* Fold OAuth apps management into the add-account modal Remove the standalone /oauth-apps page and surface app edit/remove from the OAuth app picker in the add-account modal. Extract the connection-usage helpers and the remove-confirm dialog into shared modules. * e2e: record OAuth app management in the connect modal (selfhost browser) Wire the browser capability into the selfhost target (inject the Better Auth session cookie into Playwright) and add a selfhost browser scenario that registers, edits, and removes a bring-your-own OAuth app entirely from the add-connection modal — the journey that replaced the standalone OAuth apps page. Also give the openapi-source scenario an Effect.ensuring finalizer so it stops leaking a connection into the shared selfhost admin. * Make the OAuth app picker optimistic on register/edit/remove The picker read the non-optimistic clients list while the management menu read the optimistic one, so registering, editing, or removing an app flashed the stale list until the refetch landed. Add an optimistic upsert create (mirroring createClient's owner+slug upsert) and point the picker hook at the optimistic atom, so all three actions paint immediately and stay consistent with the menu.
Unified · Split
| @@ -14,7 +14,6 @@ import { Route as SetupMcpRouteImport } from './routes/setup-mcp' · 13 unmodified lines | ||
| 14 | 14 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 15 | 15 | import { Route as PoliciesRouteImport } from './routes/policies' |
| 16 | 16 | import { Route as OrgRouteImport } from './routes/org' |
| 17 | −import { Route as OauthAppsRouteImport } from './routes/oauth-apps' | |
| 18 | 17 | import { Route as CreateOrgRouteImport } from './routes/create-org' |
| 19 | 18 | import { Route as BillingRouteImport } from './routes/billing' |
| 20 | 19 | import { Route as ApiKeysRouteImport } from './routes/api-keys' |
| @@ -49,11 +48,6 @@ const OrgRoute = OrgRouteImport.update({ · 28 unmodified lines | ||
| 49 | 48 | path: '/org', |
| 50 | 49 | getParentRoute: () => rootRouteImport, |
| 51 | 50 | } as any) |
| 52 | −const OauthAppsRoute = OauthAppsRouteImport.update({ | |
| 53 | − id: '/oauth-apps', | |
| 54 | − path: '/oauth-apps', | |
| 55 | − getParentRoute: () => rootRouteImport, | |
| 56 | −} as any) | |
| 57 | 51 | const CreateOrgRoute = CreateOrgRouteImport.update({ |
| 58 | 52 | id: '/create-org', |
| 59 | 53 | path: '/create-org', |
| @@ -101,7 +95,6 @@ export interface FileRoutesByFullPath { · 41 unmodified lines | ||
| 101 | 95 | '/api-keys': typeof ApiKeysRoute |
| 102 | 96 | '/billing': typeof BillingRoute |
| 103 | 97 | '/create-org': typeof CreateOrgRoute |
| 104 | − '/oauth-apps': typeof OauthAppsRoute | |
| 105 | 98 | '/org': typeof OrgRoute |
| 106 | 99 | '/policies': typeof PoliciesRoute |
| 107 | 100 | '/secrets': typeof SecretsRoute |
| @@ -117,7 +110,6 @@ export interface FileRoutesByTo { · 9 unmodified lines | ||
| 117 | 110 | '/api-keys': typeof ApiKeysRoute |
| 118 | 111 | '/billing': typeof BillingRoute |
| 119 | 112 | '/create-org': typeof CreateOrgRoute |
| 120 | − '/oauth-apps': typeof OauthAppsRoute | |
| 121 | 113 | '/org': typeof OrgRoute |
| 122 | 114 | '/policies': typeof PoliciesRoute |
| 123 | 115 | '/secrets': typeof SecretsRoute |
| @@ -134,7 +126,6 @@ export interface FileRoutesById { · 10 unmodified lines | ||
| 134 | 126 | '/api-keys': typeof ApiKeysRoute |
| 135 | 127 | '/billing': typeof BillingRoute |
| 136 | 128 | '/create-org': typeof CreateOrgRoute |
| 137 | − '/oauth-apps': typeof OauthAppsRoute | |
| 138 | 129 | '/org': typeof OrgRoute |
| 139 | 130 | '/policies': typeof PoliciesRoute |
| 140 | 131 | '/secrets': typeof SecretsRoute |
| @@ -152,7 +143,6 @@ export interface FileRouteTypes { · 11 unmodified lines | ||
| 152 | 143 | | '/api-keys' |
| 153 | 144 | | '/billing' |
| 154 | 145 | | '/create-org' |
| 155 | − | '/oauth-apps' | |
| 156 | 146 | | '/org' |
| 157 | 147 | | '/policies' |
| 158 | 148 | | '/secrets' |
| @@ -168,7 +158,6 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 168 | 158 | | '/api-keys' |
| 169 | 159 | | '/billing' |
| 170 | 160 | | '/create-org' |
| 171 | − | '/oauth-apps' | |
| 172 | 161 | | '/org' |
| 173 | 162 | | '/policies' |
| 174 | 163 | | '/secrets' |
| @@ -184,7 +173,6 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 184 | 173 | | '/api-keys' |
| 185 | 174 | | '/billing' |
| 186 | 175 | | '/create-org' |
| 187 | − | '/oauth-apps' | |
| 188 | 176 | | '/org' |
| 189 | 177 | | '/policies' |
| 190 | 178 | | '/secrets' |
| @@ -201,7 +189,6 @@ export interface RootRouteChildren { · 10 unmodified lines | ||
| 201 | 189 | ApiKeysRoute: typeof ApiKeysRoute |
| 202 | 190 | BillingRoute: typeof BillingRoute |
| 203 | 191 | CreateOrgRoute: typeof CreateOrgRoute |
| 204 | − OauthAppsRoute: typeof OauthAppsRoute | |
| 205 | 192 | OrgRoute: typeof OrgRoute |
| 206 | 193 | PoliciesRoute: typeof PoliciesRoute |
| 207 | 194 | SecretsRoute: typeof SecretsRoute |
| @@ -250,13 +237,6 @@ declare module '@tanstack/react-router' { · 42 unmodified lines | ||
| 250 | 237 | preLoaderRoute: typeof OrgRouteImport |
| 251 | 238 | parentRoute: typeof rootRouteImport |
| 252 | 239 | } |
| 253 | − '/oauth-apps': { | |
| 254 | − id: '/oauth-apps' | |
| 255 | − path: '/oauth-apps' | |
| 256 | − fullPath: '/oauth-apps' | |
| 257 | − preLoaderRoute: typeof OauthAppsRouteImport | |
| 258 | − parentRoute: typeof rootRouteImport | |
| 259 | − } | |
| 260 | 240 | '/create-org': { |
| 261 | 241 | id: '/create-org' |
| 262 | 242 | path: '/create-org' |
| @@ -321,7 +301,6 @@ const rootRouteChildren: RootRouteChildren = { · 58 unmodified lines | ||
| 321 | 301 | ApiKeysRoute: ApiKeysRoute, |
| 322 | 302 | BillingRoute: BillingRoute, |
| 323 | 303 | CreateOrgRoute: CreateOrgRoute, |
| 324 | − OauthAppsRoute: OauthAppsRoute, | |
| 325 | 304 | OrgRoute: OrgRoute, |
| 326 | 305 | PoliciesRoute: PoliciesRoute, |
| 327 | 306 | SecretsRoute: SecretsRoute, |
| @@ -1,6 +0,0 @@ | ||
| 1 | −import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | −import { OAuthAppsPage } from "@executor-js/react/pages/oauth-apps"; | |
| 3 | − | |
| 4 | −export const Route = createFileRoute("/oauth-apps")({ | |
| 5 | − component: () => <OAuthAppsPage />, | |
| 6 | −}); | |
| @@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines | ||
| 12 | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | 14 | import { Route as PoliciesRouteImport } from './routes/policies' |
| 15 | −import { Route as OauthAppsRouteImport } from './routes/oauth-apps' | |
| 16 | 15 | import { Route as ApiKeysRouteImport } from './routes/api-keys' |
| 17 | 16 | import { Route as AdminRouteImport } from './routes/admin' |
| 18 | 17 | import { Route as IndexRouteImport } from './routes/index' |
| @@ -39,11 +38,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ · 20 unmodified lines | ||
| 39 | 38 | path: '/policies', |
| 40 | 39 | getParentRoute: () => rootRouteImport, |
| 41 | 40 | } as any) |
| 42 | −const OauthAppsRoute = OauthAppsRouteImport.update({ | |
| 43 | − id: '/oauth-apps', | |
| 44 | − path: '/oauth-apps', | |
| 45 | − getParentRoute: () => rootRouteImport, | |
| 46 | −} as any) | |
| 47 | 41 | const ApiKeysRoute = ApiKeysRouteImport.update({ |
| 48 | 42 | id: '/api-keys', |
| 49 | 43 | path: '/api-keys', |
| @@ -100,7 +94,6 @@ export interface FileRoutesByFullPath { · 50 unmodified lines | ||
| 100 | 94 | '/': typeof IndexRoute |
| 101 | 95 | '/admin': typeof AdminRoute |
| 102 | 96 | '/api-keys': typeof ApiKeysRoute |
| 103 | − '/oauth-apps': typeof OauthAppsRoute | |
| 104 | 97 | '/policies': typeof PoliciesRoute |
| 105 | 98 | '/secrets': typeof SecretsRoute |
| 106 | 99 | '/tools': typeof ToolsRoute |
| @@ -116,7 +109,6 @@ export interface FileRoutesByTo { · 9 unmodified lines | ||
| 116 | 109 | '/': typeof IndexRoute |
| 117 | 110 | '/admin': typeof AdminRoute |
| 118 | 111 | '/api-keys': typeof ApiKeysRoute |
| 119 | − '/oauth-apps': typeof OauthAppsRoute | |
| 120 | 112 | '/policies': typeof PoliciesRoute |
| 121 | 113 | '/secrets': typeof SecretsRoute |
| 122 | 114 | '/tools': typeof ToolsRoute |
| @@ -133,7 +125,6 @@ export interface FileRoutesById { · 10 unmodified lines | ||
| 133 | 125 | '/': typeof IndexRoute |
| 134 | 126 | '/admin': typeof AdminRoute |
| 135 | 127 | '/api-keys': typeof ApiKeysRoute |
| 136 | − '/oauth-apps': typeof OauthAppsRoute | |
| 137 | 128 | '/policies': typeof PoliciesRoute |
| 138 | 129 | '/secrets': typeof SecretsRoute |
| 139 | 130 | '/tools': typeof ToolsRoute |
| @@ -151,7 +142,6 @@ export interface FileRouteTypes { · 11 unmodified lines | ||
| 151 | 142 | | '/' |
| 152 | 143 | | '/admin' |
| 153 | 144 | | '/api-keys' |
| 154 | − | '/oauth-apps' | |
| 155 | 145 | | '/policies' |
| 156 | 146 | | '/secrets' |
| 157 | 147 | | '/tools' |
| @@ -167,7 +157,6 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 167 | 157 | | '/' |
| 168 | 158 | | '/admin' |
| 169 | 159 | | '/api-keys' |
| 170 | − | '/oauth-apps' | |
| 171 | 160 | | '/policies' |
| 172 | 161 | | '/secrets' |
| 173 | 162 | | '/tools' |
| @@ -183,7 +172,6 @@ export interface FileRouteTypes { · 9 unmodified lines | ||
| 183 | 172 | | '/' |
| 184 | 173 | | '/admin' |
| 185 | 174 | | '/api-keys' |
| 186 | − | '/oauth-apps' | |
| 187 | 175 | | '/policies' |
| 188 | 176 | | '/secrets' |
| 189 | 177 | | '/tools' |
| @@ -200,7 +188,6 @@ export interface RootRouteChildren { · 10 unmodified lines | ||
| 200 | 188 | IndexRoute: typeof IndexRoute |
| 201 | 189 | AdminRoute: typeof AdminRoute |
| 202 | 190 | ApiKeysRoute: typeof ApiKeysRoute |
| 203 | − OauthAppsRoute: typeof OauthAppsRoute | |
| 204 | 191 | PoliciesRoute: typeof PoliciesRoute |
| 205 | 192 | SecretsRoute: typeof SecretsRoute |
| 206 | 193 | ToolsRoute: typeof ToolsRoute |
| @@ -236,13 +223,6 @@ declare module '@tanstack/react-router' { · 29 unmodified lines | ||
| 236 | 223 | preLoaderRoute: typeof PoliciesRouteImport |
| 237 | 224 | parentRoute: typeof rootRouteImport |
| 238 | 225 | } |
| 239 | − '/oauth-apps': { | |
| 240 | − id: '/oauth-apps' | |
| 241 | − path: '/oauth-apps' | |
| 242 | − fullPath: '/oauth-apps' | |
| 243 | − preLoaderRoute: typeof OauthAppsRouteImport | |
| 244 | − parentRoute: typeof rootRouteImport | |
| 245 | − } | |
| 246 | 226 | '/api-keys': { |
| 247 | 227 | id: '/api-keys' |
| 248 | 228 | path: '/api-keys' |
| @@ -320,7 +300,6 @@ const rootRouteChildren: RootRouteChildren = { · 71 unmodified lines | ||
| 320 | 300 | IndexRoute: IndexRoute, |
| 321 | 301 | AdminRoute: AdminRoute, |
| 322 | 302 | ApiKeysRoute: ApiKeysRoute, |
| 323 | − OauthAppsRoute: OauthAppsRoute, | |
| 324 | 303 | PoliciesRoute: PoliciesRoute, |
| 325 | 304 | SecretsRoute: SecretsRoute, |
| 326 | 305 | ToolsRoute: ToolsRoute, |
| @@ -1,6 +0,0 @@ | ||
| 1 | −import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | −import { OAuthAppsPage } from "@executor-js/react/pages/oauth-apps"; | |
| 3 | − | |
| 4 | −export const Route = createFileRoute("/oauth-apps")({ | |
| 5 | − component: OAuthAppsPage, | |
| 6 | −}); | |
| @@ -53,42 +53,63 @@ scenario( · 52 unmodified lines | ||
| 53 | 53 | const slug = `openapi-scn-greet-${randomBytes(4).toString("hex")}`; |
| 54 | 54 | const specBaseUrl = "http://127.0.0.1:59999"; // never contacted during registration |
| 55 | 55 | |
| 56 | − const added = yield* client.openapi.addSpec({ | |
| 57 | − payload: { | |
| 58 | − spec: { kind: "blob", value: greetSpec(specBaseUrl) }, | |
| 59 | − slug, | |
| 60 | − baseUrl: specBaseUrl, | |
| 61 | − authenticationTemplate: [ | |
| 62 | − { | |
| 63 | − slug: "apiKey", | |
| 64 | − type: "apiKey", | |
| 65 | − headers: { "x-api-key": [{ type: "variable", name: "token" }] }, | |
| 56 | + yield* Effect.ensuring( | |
| 57 | + Effect.gen(function* () { | |
| 58 | + const added = yield* client.openapi.addSpec({ | |
| 59 | + payload: { | |
| 60 | + spec: { kind: "blob", value: greetSpec(specBaseUrl) }, | |
| 61 | + slug, | |
| 62 | + baseUrl: specBaseUrl, | |
| 63 | + authenticationTemplate: [ | |
| 64 | + { | |
| 65 | + slug: "apiKey", | |
| 66 | + type: "apiKey", | |
| 67 | + headers: { "x-api-key": [{ type: "variable", name: "token" }] }, | |
| 68 | + }, | |
| 69 | + ], | |
| 66 | 70 | }, |
| 67 | − ], | |
| 68 | − }, | |
| 69 | − }); | |
| 70 | − expect(added.toolCount, "the spec's operations were extracted as tools").toBeGreaterThan(0); | |
| 71 | − expect(added.slug, "the integration keeps the requested slug").toBe(slug); | |
| 71 | + }); | |
| 72 | + expect(added.toolCount, "the spec's operations were extracted as tools").toBeGreaterThan( | |
| 73 | + 0, | |
| 74 | + ); | |
| 75 | + expect(added.slug, "the integration keeps the requested slug").toBe(slug); | |
| 72 | 76 | |
| 73 | − // The catalog stamps tools once a connection exists; a `from` provider | |
| 74 | − // reference avoids any vault round-trip. | |
| 75 | − const providers = yield* client.providers.list(); | |
| 76 | − expect(providers.length, "a credential provider is available").toBeGreaterThan(0); | |
| 77 | + // The catalog stamps tools once a connection exists; a `from` provider | |
| 78 | + // reference avoids any vault round-trip. | |
| 79 | + const providers = yield* client.providers.list(); | |
| 80 | + expect(providers.length, "a credential provider is available").toBeGreaterThan(0); | |
| 77 | 81 | |
| 78 | − yield* client.connections.create({ | |
| 79 | − payload: { | |
| 80 | − owner: "org", | |
| 81 | − name: ConnectionName.make("main"), | |
| 82 | − integration: IntegrationSlug.make(slug), | |
| 83 | − template: AuthTemplateSlug.make("apiKey"), | |
| 84 | − from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, | |
| 85 | − }, | |
| 86 | − }); | |
| 82 | + yield* client.connections.create({ | |
| 83 | + payload: { | |
| 84 | + owner: "org", | |
| 85 | + name: ConnectionName.make("main"), | |
| 86 | + integration: IntegrationSlug.make(slug), | |
| 87 | + template: AuthTemplateSlug.make("apiKey"), | |
| 88 | + from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) }, | |
| 89 | + }, | |
| 90 | + }); | |
| 87 | 91 | |
| 88 | − const tools = yield* client.tools.list(); | |
| 89 | − const mine = tools.filter((tool) => String(tool.integration) === slug).map((t) => t.name); | |
| 90 | − expect(mine.join(", "), "the spec's operation is in the tool catalog").toContain( | |
| 91 | − "getGreeting", | |
| 92 | + const tools = yield* client.tools.list(); | |
| 93 | + const mine = tools.filter((tool) => String(tool.integration) === slug).map((t) => t.name); | |
| 94 | + expect(mine.join(", "), "the spec's operation is in the tool catalog").toContain( | |
| 95 | + "getGreeting", | |
| 96 | + ); | |
| 97 | + }), | |
| 98 | + // Selfhost shares one bootstrap admin, so this scenario must not leak | |
| 99 | + // its connection or integration — otherwise a cross-target guarantee | |
| 100 | + // like "a fresh identity starts with zero connections" would see it. | |
| 101 | + Effect.gen(function* () { | |
| 102 | + yield* client.connections | |
| 103 | + .remove({ | |
| 104 | + params: { | |
| 105 | + owner: "org", | |
| 106 | + integration: IntegrationSlug.make(slug), | |
| 107 | + name: ConnectionName.make("main"), | |
| 108 | + }, | |
| 109 | + }) | |
| 110 | + .pipe(Effect.ignore); | |
| 111 | + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); | |
| 112 | + }), | |
| 92 | 113 | ); |
| 93 | 114 | }), |
| 94 | 115 | ); |
| @@ -0,0 +1,169 @@ | ||
| 1 | +// Selfhost (browser): a registered OAuth app is managed entirely from the | |
| 2 | +// Add-connection modal — there is no separate "OAuth apps" page. The user | |
| 3 | +// registers a bring-your-own app for an integration, edits its stored client | |
| 4 | +// id, and removes it, all from the OAuth app picker inside the modal. | |
| 5 | +// | |
| 6 | +// The integration only needs to DECLARE an OAuth method for the picker to show; | |
| 7 | +// registering/editing/removing an app touches stored credentials only and never | |
| 8 | +// calls the authorization/token endpoints, so a static issuer is enough and no | |
| 9 | +// OAuth provider is started. | |
| 10 | +import { randomBytes } from "node:crypto"; | |
| 11 | + | |
| 12 | +import { expect } from "@effect/vitest"; | |
| 13 | +import { Effect } from "effect"; | |
| 14 | +import { composePluginApi } from "@executor-js/api/server"; | |
| 15 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 16 | + | |
| 17 | +import { scenario } from "../src/scenario"; | |
| 18 | + | |
| 19 | +const api = composePluginApi([openApiHttpPlugin()] as const); | |
| 20 | + | |
| 21 | +/** Minimal OpenAPI 3 spec — one operation, server never contacted. */ | |
| 22 | +const greetSpec = (baseUrl: string): string => | |
| 23 | + JSON.stringify({ | |
| 24 | + openapi: "3.0.3", | |
| 25 | + info: { title: "OAuth Modal API", version: "1.0.0" }, | |
| 26 | + servers: [{ url: baseUrl }], | |
| 27 | + paths: { | |
| 28 | + "/greet": { | |
| 29 | + get: { | |
| 30 | + operationId: "getGreeting", | |
| 31 | + summary: "Return a greeting", | |
| 32 | + responses: { "200": { description: "A greeting" } }, | |
| 33 | + }, | |
| 34 | + }, | |
| 35 | + }, | |
| 36 | + }); | |
| 37 | + | |
| 38 | +scenario( | |
| 39 | + "OAuth apps · a registered app is edited and removed from the connect modal", | |
| 40 | + { needs: ["browser"], timeout: 180_000 }, | |
| 41 | + (ctx) => | |
| 42 | + Effect.gen(function* () { | |
| 43 | + const identity = yield* ctx.target.newIdentity(); | |
| 44 | + const client = yield* ctx.api.client(api, identity); | |
| 45 | + | |
| 46 | + // Selfhost shares the bootstrap-admin identity, so prefix every resource | |
| 47 | + // with a per-run id to stay out of parallel/repeated runs' way. | |
| 48 | + const id = randomBytes(4).toString("hex"); | |
| 49 | + const integration = `oauth-modal-scn-${id}`; | |
| 50 | + const appName = `oauthmodalapp${id}`; // lowercase+digits → slug === appName | |
| 51 | + // The picker humanizes the slug for display ("oauthmodalappab12" → | |
| 52 | + // "Oauthmodalappab12"); used to assert the row is gone after removal. | |
| 53 | + const appDisplayName = appName.charAt(0).toUpperCase() + appName.slice(1); | |
| 54 | + const specBaseUrl = "http://127.0.0.1:59998"; // never contacted | |
| 55 | + | |
| 56 | + // Stand up an integration that declares a bring-your-own OAuth2 method. | |
| 57 | + // The endpoints are inert — app management never calls them. | |
| 58 | + yield* client.openapi.addSpec({ | |
| 59 | + payload: { | |
| 60 | + spec: { kind: "blob", value: greetSpec(specBaseUrl) }, | |
| 61 | + slug: integration, | |
| 62 | + baseUrl: specBaseUrl, | |
| 63 | + authenticationTemplate: [ | |
| 64 | + { | |
| 65 | + slug: "oauth", | |
| 66 | + type: "oauth", | |
| 67 | + authorizationUrl: "https://auth.example/authorize", | |
| 68 | + tokenUrl: "https://auth.example/token", | |
| 69 | + scopes: [], | |
| 70 | + }, | |
| 71 | + ], | |
| 72 | + }, | |
| 73 | + }); | |
| 74 | + | |
| 75 | + yield* Effect.ensuring( | |
| 76 | + Effect.gen(function* () { | |
| 77 | + yield* ctx.browser.session(identity, async ({ page, step }) => { | |
| 78 | + const actions = page.getByRole("button", { name: `Actions for ${appName}` }); | |
| 79 | + | |
| 80 | + await step("Open the integration and start a new connection", async () => { | |
| 81 | + await page.goto(`/integrations/${integration}`, { waitUntil: "networkidle" }); | |
| 82 | + await page.getByRole("button", { name: "Add connection" }).click(); | |
| 83 | + // OAuth2 is the integration's only method, so the modal opens on | |
| 84 | + // the OAuth app step with nothing registered yet. (`exact` avoids | |
| 85 | + // the "Register app for help" tooltip button in the form.) | |
| 86 | + await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + await step("Register a bring-your-own OAuth app", async () => { | |
| 90 | + await page.getByRole("button", { name: "Register app", exact: true }).click(); | |
| 91 | + await page.locator("#oauth-app-name").fill(appName); | |
| 92 | + await page.locator("#oauth-client-id").fill("client-one"); | |
| 93 | + await page.locator("#oauth-client-secret").fill("secret-one"); | |
| 94 | + await page.getByRole("button", { name: "Register app", exact: true }).click(); | |
| 95 | + // Back on the picker, the new app is selectable AND manageable — | |
| 96 | + // the per-app actions menu is what replaced the old apps page. | |
| 97 | + await actions.waitFor(); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + await step("Edit opens the app prefilled with its stored client id", async () => { | |
| 101 | + await actions.click(); | |
| 102 | + await page.getByRole("menuitem", { name: "Edit" }).click(); | |
| 103 | + await page.getByText(`Edit ${appName}`).waitFor(); | |
| 104 | + expect( | |
| 105 | + await page.locator("#oauth-client-id").inputValue(), | |
| 106 | + "the edit form prefills the stored client id", | |
| 107 | + ).toBe("client-one"); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + await step("Change the client id and save the edit", async () => { | |
| 111 | + await page.locator("#oauth-client-id").fill("client-two"); | |
| 112 | + await page.locator("#oauth-client-secret").fill("secret-two"); | |
| 113 | + await page.getByRole("button", { name: "Register app", exact: true }).click(); | |
| 114 | + await actions.waitFor(); | |
| 115 | + }); | |
| 116 | + | |
| 117 | + await step("Reopening the app shows the saved client id", async () => { | |
| 118 | + await actions.click(); | |
| 119 | + await page.getByRole("menuitem", { name: "Edit" }).click(); | |
| 120 | + await page.getByText(`Edit ${appName}`).waitFor(); | |
| 121 | + expect( | |
| 122 | + await page.locator("#oauth-client-id").inputValue(), | |
| 123 | + "the edit persisted to the saved app", | |
| 124 | + ).toBe("client-two"); | |
| 125 | + await page.getByRole("button", { name: "Cancel" }).click(); | |
| 126 | + await actions.waitFor(); | |
| 127 | + }); | |
| 128 | + | |
| 129 | + await step("Remove the app and confirm", async () => { | |
| 130 | + await actions.click(); | |
| 131 | + await page.getByRole("menuitem", { name: "Remove" }).click(); | |
| 132 | + await page.getByRole("button", { name: "Remove app" }).click(); | |
| 133 | + // The row (and its actions menu) is gone from the picker, and the | |
| 134 | + // empty-state register CTA is back — no app left for this method. | |
| 135 | + await actions.waitFor({ state: "detached" }); | |
| 136 | + await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); | |
| 137 | + // Scope to the modal so the "Removed …" success toast (which | |
| 138 | + // echoes the slug) doesn't count as a lingering picker row. | |
| 139 | + expect( | |
| 140 | + await page.getByRole("dialog").getByText(appDisplayName).count(), | |
| 141 | + "the removed app no longer appears in the picker", | |
| 142 | + ).toBe(0); | |
| 143 | + }); | |
| 144 | + }); | |
| 145 | + | |
| 146 | + // The removal is real, not just visual: the app is gone from the API | |
| 147 | + // (asserted before the finalizer, which would also remove it). | |
| 148 | + const remaining = yield* client.oauth.listClients(); | |
| 149 | + expect( | |
| 150 | + remaining.map((c) => String(c.slug)), | |
| 151 | + "the removed app is gone from the OAuth client catalog", | |
| 152 | + ).not.toContain(appName); | |
| 153 | + }), | |
| 154 | + // Finalizer: never leak the integration or a half-removed app into the | |
| 155 | + // shared selfhost instance, even if a step above failed mid-flow. | |
| 156 | + Effect.gen(function* () { | |
| 157 | + const clients = yield* client.oauth.listClients().pipe(Effect.orElseSucceed(() => [])); | |
| 158 | + for (const c of clients) { | |
| 159 | + if (String(c.slug) === appName) { | |
| 160 | + yield* client.oauth | |
| 161 | + .removeClient({ params: { slug: c.slug }, payload: { owner: c.owner } }) | |
| 162 | + .pipe(Effect.ignore); | |
| 163 | + } | |
| 164 | + } | |
| 165 | + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); | |
| 166 | + }), | |
| 167 | + ); | |
| 168 | + }), | |
| 169 | +); | |
| @@ -17,18 +17,52 @@ export const SELFHOST_ADMIN = { · 16 unmodified lines | ||
| 17 | 17 | password: process.env.E2E_SELFHOST_ADMIN_PASSWORD ?? "e2e-admin-password-123", |
| 18 | 18 | }; |
| 19 | 19 | |
| 20 | +// Sign the bootstrap admin in via Better Auth email and return the session | |
| 21 | +// cookie in both shapes we need: the `Cookie` header the API surface attaches, | |
| 22 | +// and the {name,value} list Playwright injects into a browser context. The | |
| 23 | +// `origin` header is required — Better Auth rejects state-changing requests | |
| 24 | +// without it. | |
| 25 | +const signInSession = async ( | |
| 26 | + baseUrl: string, | |
| 27 | + credentials: { readonly email: string; readonly password: string }, | |
| 28 | +): Promise<{ | |
| 29 | + readonly cookieHeader: string; | |
| 30 | + readonly cookies: ReadonlyArray<{ readonly name: string; readonly value: string }>; | |
| 31 | +}> => { | |
| 32 | + const response = await fetch(new URL("/api/auth/sign-in/email", baseUrl), { | |
| 33 | + method: "POST", | |
| 34 | + headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, | |
| 35 | + body: JSON.stringify(credentials), | |
| 36 | + redirect: "manual", | |
| 37 | + }); | |
| 38 | + const pairs = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]!.trim()); | |
| 39 | + if (pairs.length === 0) throw new Error(`selfhost: sign-in set no cookie (${response.status})`); | |
| 40 | + const cookies = pairs.map((pair) => { | |
| 41 | + const eq = pair.indexOf("="); | |
| 42 | + return { name: pair.slice(0, eq), value: pair.slice(eq + 1) }; | |
| 43 | + }); | |
| 44 | + return { cookieHeader: pairs.join("; "), cookies }; | |
| 45 | +}; | |
| 46 | + | |
| 20 | 47 | export const selfhostTarget = (): Target => ({ |
| 21 | 48 | name: "selfhost", |
| 22 | 49 | baseUrl: SELFHOST_BASE_URL, |
| 23 | 50 | mcpUrl: `${SELFHOST_BASE_URL}/mcp`, |
| 24 | − // No "billing" (no limits) and no "browser" yet (cookie injection for the | |
| 25 | − // Better Auth session isn't wired). Identity is the bootstrap admin for now — | |
| 26 | − // single-tenant; per-test invite-signup isolation is the next step here. | |
| 27 | − capabilities: new Set(["api", "mcp-oauth"]), | |
| 51 | + // No "billing" (no limits). Identity is the bootstrap admin for now — | |
| 52 | + // single-tenant; per-test invite-signup isolation is the next step here, so | |
| 53 | + // browser scenarios must prefix the resources they create. | |
| 54 | + capabilities: new Set(["api", "browser", "mcp-oauth"]), | |
| 28 | 55 | newIdentity: () => |
| 29 | − Effect.succeed<Identity>({ | |
| 30 | − label: SELFHOST_ADMIN.email, | |
| 31 | − credentials: SELFHOST_ADMIN, | |
| 56 | + Effect.promise(async (): Promise<Identity> => { | |
| 57 | + // Sign in once and carry the session in both shapes: `headers` for the | |
| 58 | + // API surface, `cookies` for an injectable logged-in browser context. | |
| 59 | + const { cookieHeader, cookies } = await signInSession(SELFHOST_BASE_URL, SELFHOST_ADMIN); | |
| 60 | + return { | |
| 61 | + label: SELFHOST_ADMIN.email, | |
| 62 | + credentials: SELFHOST_ADMIN, | |
| 63 | + headers: { cookie: cookieHeader }, | |
| 64 | + cookies, | |
| 65 | + }; | |
| 32 | 66 | }), |
| 33 | 67 | mcpConsent: (identity: Identity) => |
| 34 | 68 | cookieConsentStrategy({ |
| @@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines | ||
| 12 | 12 | import { Route as ToolsRouteImport } from './routes/tools' |
| 13 | 13 | import { Route as SecretsRouteImport } from './routes/secrets' |
| 14 | 14 | import { Route as PoliciesRouteImport } from './routes/policies' |
| 15 | −import { Route as OauthAppsRouteImport } from './routes/oauth-apps' | |
| 16 | 15 | import { Route as IndexRouteImport } from './routes/index' |
| 17 | 16 | import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId' |
| 18 | 17 | import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace' |
| @@ -34,11 +33,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ · 15 unmodified lines | ||
| 34 | 33 | path: '/policies', |
| 35 | 34 | getParentRoute: () => rootRouteImport, |
| 36 | 35 | } as any) |
| 37 | −const OauthAppsRoute = OauthAppsRouteImport.update({ | |
| 38 | − id: '/oauth-apps', | |
| 39 | − path: '/oauth-apps', | |
| 40 | − getParentRoute: () => rootRouteImport, | |
| 41 | −} as any) | |
| 42 | 36 | const IndexRoute = IndexRouteImport.update({ |
| 43 | 37 | id: '/', |
| 44 | 38 | path: '/', |
| @@ -68,7 +62,6 @@ const IntegrationsAddPluginKeyRoute = · 23 unmodified lines | ||
| 68 | 62 | |
| 69 | 63 | export interface FileRoutesByFullPath { |
| 70 | 64 | '/': typeof IndexRoute |
| 71 | − '/oauth-apps': typeof OauthAppsRoute | |
| 72 | 65 | '/policies': typeof PoliciesRoute |
| 73 | 66 | '/secrets': typeof SecretsRoute |
| 74 | 67 | '/tools': typeof ToolsRoute |
| @@ -79,7 +72,6 @@ export interface FileRoutesByFullPath { · 4 unmodified lines | ||
| 79 | 72 | } |
| 80 | 73 | export interface FileRoutesByTo { |
| 81 | 74 | '/': typeof IndexRoute |
| 82 | − '/oauth-apps': typeof OauthAppsRoute | |
| 83 | 75 | '/policies': typeof PoliciesRoute |
| 84 | 76 | '/secrets': typeof SecretsRoute |
| 85 | 77 | '/tools': typeof ToolsRoute |
| @@ -91,7 +83,6 @@ export interface FileRoutesByTo { · 5 unmodified lines | ||
| 91 | 83 | export interface FileRoutesById { |
| 92 | 84 | __root__: typeof rootRouteImport |
| 93 | 85 | '/': typeof IndexRoute |
| 94 | − '/oauth-apps': typeof OauthAppsRoute | |
| 95 | 86 | '/policies': typeof PoliciesRoute |
| 96 | 87 | '/secrets': typeof SecretsRoute |
| 97 | 88 | '/tools': typeof ToolsRoute |
| @@ -104,7 +95,6 @@ export interface FileRouteTypes { · 6 unmodified lines | ||
| 104 | 95 | fileRoutesByFullPath: FileRoutesByFullPath |
| 105 | 96 | fullPaths: |
| 106 | 97 | | '/' |
| 107 | − | '/oauth-apps' | |
| 108 | 98 | | '/policies' |
| 109 | 99 | | '/secrets' |
| 110 | 100 | | '/tools' |
| @@ -115,7 +105,6 @@ export interface FileRouteTypes { · 4 unmodified lines | ||
| 115 | 105 | fileRoutesByTo: FileRoutesByTo |
| 116 | 106 | to: |
| 117 | 107 | | '/' |
| 118 | − | '/oauth-apps' | |
| 119 | 108 | | '/policies' |
| 120 | 109 | | '/secrets' |
| 121 | 110 | | '/tools' |
| @@ -126,7 +115,6 @@ export interface FileRouteTypes { · 4 unmodified lines | ||
| 126 | 115 | id: |
| 127 | 116 | | '__root__' |
| 128 | 117 | | '/' |
| 129 | − | '/oauth-apps' | |
| 130 | 118 | | '/policies' |
| 131 | 119 | | '/secrets' |
| 132 | 120 | | '/tools' |
| @@ -138,7 +126,6 @@ export interface FileRouteTypes { · 5 unmodified lines | ||
| 138 | 126 | } |
| 139 | 127 | export interface RootRouteChildren { |
| 140 | 128 | IndexRoute: typeof IndexRoute |
| 141 | − OauthAppsRoute: typeof OauthAppsRoute | |
| 142 | 129 | PoliciesRoute: typeof PoliciesRoute |
| 143 | 130 | SecretsRoute: typeof SecretsRoute |
| 144 | 131 | ToolsRoute: typeof ToolsRoute |
| @@ -171,13 +158,6 @@ declare module '@tanstack/react-router' { · 26 unmodified lines | ||
| 171 | 158 | preLoaderRoute: typeof PoliciesRouteImport |
| 172 | 159 | parentRoute: typeof rootRouteImport |
| 173 | 160 | } |
| 174 | − '/oauth-apps': { | |
| 175 | − id: '/oauth-apps' | |
| 176 | − path: '/oauth-apps' | |
| 177 | − fullPath: '/oauth-apps' | |
| 178 | − preLoaderRoute: typeof OauthAppsRouteImport | |
| 179 | − parentRoute: typeof rootRouteImport | |
| 180 | − } | |
| 181 | 161 | '/': { |
| 182 | 162 | id: '/' |
| 183 | 163 | path: '/' |
| @@ -218,7 +198,6 @@ declare module '@tanstack/react-router' { · 34 unmodified lines | ||
| 218 | 198 | |
| 219 | 199 | const rootRouteChildren: RootRouteChildren = { |
| 220 | 200 | IndexRoute: IndexRoute, |
| 221 | − OauthAppsRoute: OauthAppsRoute, | |
| 222 | 201 | PoliciesRoute: PoliciesRoute, |
| 223 | 202 | SecretsRoute: SecretsRoute, |
| 224 | 203 | ToolsRoute: ToolsRoute, |
| @@ -1,6 +0,0 @@ | ||
| 1 | −import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | −import { OAuthAppsPage } from "@executor-js/react/pages/oauth-apps"; | |
| 3 | − | |
| 4 | −export const Route = createFileRoute("/oauth-apps")({ | |
| 5 | − component: OAuthAppsPage, | |
| 6 | −}); | |
| @@ -338,7 +338,6 @@ function SidebarContent(props: { · 337 unmodified lines | ||
| 338 | 338 | }) { |
| 339 | 339 | const isHome = props.pathname === "/"; |
| 340 | 340 | const isSecrets = props.pathname === "/secrets"; |
| 341 | − const isOAuthApps = props.pathname === "/oauth-apps"; | |
| 342 | 341 | const isPolicies = props.pathname === "/policies"; |
| 343 | 342 | |
| 344 | 343 | return ( |
| @@ -360,12 +359,6 @@ function SidebarContent(props: { · 15 unmodified lines | ||
| 360 | 359 | <nav className="flex flex-1 flex-col overflow-y-auto p-2"> |
| 361 | 360 | <NavItem to="/" label="Integrations" active={isHome} onNavigate={props.onNavigate} /> |
| 362 | 361 | <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} /> |
| 363 | − <NavItem | |
| 364 | − to="/oauth-apps" | |
| 365 | − label="OAuth Connections" | |
| 366 | − active={isOAuthApps} | |
| 367 | − onNavigate={props.onNavigate} | |
| 368 | − /> | |
| 369 | 362 | <NavItem |
| 370 | 363 | to="/policies" |
| 371 | 364 | label="Policies" |
| @@ -7,6 +7,8 @@ import { · 6 unmodified lines | ||
| 7 | 7 | type ConnectionName, |
| 8 | 8 | type IntegrationSlug, |
| 9 | 9 | type OAuthClientSlug, |
| 10 | + type OAuthClientSummary, | |
| 11 | + type OAuthGrant, | |
| 10 | 12 | type Owner, |
| 11 | 13 | type ProviderItemId, |
| 12 | 14 | type ToolAddress, |
| @@ -433,3 +435,47 @@ export const removeOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 420 unmodified lines | ||
| 433 | 435 | fn: removeOAuthClient, |
| 434 | 436 | }), |
| 435 | 437 | ); |
| 438 | + | |
| 439 | +/** Register (or edit) an OAuth app and paint it into the list immediately. | |
| 440 | + * `createClient` upserts by `(owner, slug)` server-side, so the reducer mirrors | |
| 441 | + * that: an existing row is replaced (edit), a new slug is appended (register). | |
| 442 | + * The client secret is never part of the summary, so it is dropped. The | |
| 443 | + * post-commit refresh (`oauthClientWriteKeys`) reconciles with the server. */ | |
| 444 | +export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( | |
| 445 | + Atom.optimisticFn({ | |
| 446 | + reducer: ( | |
| 447 | + current, | |
| 448 | + arg: { | |
| 449 | + readonly payload: { | |
| 450 | + readonly owner: Owner; | |
| 451 | + readonly slug: OAuthClientSlug; | |
| 452 | + readonly authorizationUrl: string; | |
| 453 | + readonly tokenUrl: string; | |
| 454 | + readonly grant: OAuthGrant; | |
| 455 | + readonly clientId: string; | |
| 456 | + readonly resource?: string | null; | |
| 457 | + }; | |
| 458 | + }, | |
| 459 | + ) => | |
| 460 | + AsyncResult.map(current, (rows) => { | |
| 461 | + const summary: OAuthClientSummary = { | |
| 462 | + owner: arg.payload.owner, | |
| 463 | + slug: arg.payload.slug, | |
| 464 | + grant: arg.payload.grant, | |
| 465 | + authorizationUrl: arg.payload.authorizationUrl, | |
| 466 | + tokenUrl: arg.payload.tokenUrl, | |
| 467 | + resource: arg.payload.resource ?? null, | |
| 468 | + clientId: arg.payload.clientId, | |
| 469 | + origin: { kind: "manual" }, | |
| 470 | + }; | |
| 471 | + const index = rows.findIndex( | |
| 472 | + (client) => client.owner === summary.owner && client.slug === summary.slug, | |
| 473 | + ); | |
| 474 | + if (index === -1) return [...rows, summary]; | |
| 475 | + const next = rows.slice(); | |
| 476 | + next[index] = summary; | |
| 477 | + return next; | |
| 478 | + }), | |
| 479 | + fn: createOAuthClient, | |
| 480 | + }), | |
| 481 | +); | |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | OAuthClientSlug, |
| 9 | 9 | ProviderItemId, |
| 10 | 10 | ProviderKey, |
| 11 | + type OAuthClientSummary, | |
| 11 | 12 | type Owner, |
| 12 | 13 | } from "@executor-js/sdk/shared"; |
| 13 | 14 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| @@ -15,10 +16,13 @@ import { toast } from "sonner"; · 1 unmodified line | ||
| 15 | 16 | |
| 16 | 17 | import { |
| 17 | 18 | addConnectionOptimistic, |
| 19 | + connectionsAllAtom, | |
| 20 | + oauthClientsOptimisticAtom, | |
| 18 | 21 | probeOAuth, |
| 19 | 22 | providerItemsAtom, |
| 20 | 23 | providersAtom, |
| 21 | 24 | registerDynamicOAuthClient, |
| 25 | + removeOAuthClientOptimistic, | |
| 22 | 26 | startOAuth, |
| 23 | 27 | } from "../api/atoms"; |
| 24 | 28 | import { connectionWriteKeys, oauthClientWriteKeys } from "../api/reactivity-keys"; |
| @@ -42,12 +46,20 @@ import { · 17 unmodified lines | ||
| 42 | 46 | type OAuthClientOption, |
| 43 | 47 | } from "../plugins/use-effective-oauth-client"; |
| 44 | 48 | import { cn } from "../lib/utils"; |
| 49 | +import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage"; | |
| 45 | 50 | import { OAuthClientForm } from "./oauth-client-form"; |
| 51 | +import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; | |
| 46 | 52 | import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; |
| 47 | 53 | import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; |
| 48 | 54 | import { connectionIdentifier } from "../lib/connection-name"; |
| 49 | 55 | import { Badge } from "./badge"; |
| 50 | 56 | import { Button } from "./button"; |
| 57 | +import { | |
| 58 | + DropdownMenu, | |
| 59 | + DropdownMenuContent, | |
| 60 | + DropdownMenuItem, | |
| 61 | + DropdownMenuTrigger, | |
| 62 | +} from "./dropdown-menu"; | |
| 51 | 63 | import { PlusIcon, XIcon } from "lucide-react"; |
| 52 | 64 | import { |
| 53 | 65 | Dialog, |
| @@ -509,6 +521,72 @@ export async function runDcrConnect( · 455 unmodified lines | ||
| 509 | 521 | return { kind: "started" }; |
| 510 | 522 | } |
| 511 | 523 | |
| 524 | +// --------------------------------------------------------------------------- | |
| 525 | +// One row in the OAuth app picker: a radio-select Label plus an actions menu | |
| 526 | +// (Edit / Remove) so the registered app can be managed inline. The page that | |
| 527 | +// used to own this management was removed in favour of doing it here, next to | |
| 528 | +// where the app is picked. The menu lives OUTSIDE the <label> so clicking it | |
| 529 | +// never toggles the radio. | |
| 530 | +// --------------------------------------------------------------------------- | |
| 531 | +function OAuthAppRadioRow(props: { | |
| 532 | + readonly app: OAuthClientOption; | |
| 533 | + readonly idPrefix: string; | |
| 534 | + readonly variant: "matched" | "other"; | |
| 535 | + readonly showOwnerLabel: boolean; | |
| 536 | + readonly onManage?: { readonly onEdit: () => void; readonly onRemove: () => void }; | |
| 537 | +}) { | |
| 538 | + const { app, idPrefix, variant, showOwnerLabel, onManage } = props; | |
| 539 | + return ( | |
| 540 | + <div className="flex items-center gap-1"> | |
| 541 | + <Label | |
| 542 | + htmlFor={`${idPrefix}-${app.slug}`} | |
| 543 | + className={cn( | |
| 544 | + "flex flex-1 cursor-pointer items-center gap-3 rounded-lg border border-border/60 px-3 py-2.5 font-normal has-[:checked]:border-ring has-[:checked]:bg-accent/40", | |
| 545 | + variant === "matched" ? "bg-muted/30" : "bg-background/40", | |
| 546 | + )} | |
| 547 | + > | |
| 548 | + <RadioGroupItem id={`${idPrefix}-${app.slug}`} value={String(app.slug)} /> | |
| 549 | + <span className="min-w-0 flex-1"> | |
| 550 | + <span className="block text-sm font-medium">{clientDisplayName(String(app.slug))}</span> | |
| 551 | + <span className="block truncate text-xs text-muted-foreground"> | |
| 552 | + {clientHost(app.tokenUrl)} ·{" "} | |
| 553 | + {app.grant === "client_credentials" ? "app-to-app" : "you'll sign in"} | |
| 554 | + </span> | |
| 555 | + </span> | |
| 556 | + {showOwnerLabel ? <Badge variant="outline">{ownerLabel(app.owner)}</Badge> : null} | |
| 557 | + </Label> | |
| 558 | + {onManage ? ( | |
| 559 | + <DropdownMenu> | |
| 560 | + <DropdownMenuTrigger asChild> | |
| 561 | + <Button | |
| 562 | + type="button" | |
| 563 | + variant="ghost" | |
| 564 | + size="icon" | |
| 565 | + className="size-8 shrink-0 text-muted-foreground" | |
| 566 | + aria-label={`Actions for ${String(app.slug)}`} | |
| 567 | + > | |
| 568 | + <svg viewBox="0 0 16 16" className="size-3.5"> | |
| 569 | + <circle cx="8" cy="3" r="1.2" fill="currentColor" /> | |
| 570 | + <circle cx="8" cy="8" r="1.2" fill="currentColor" /> | |
| 571 | + <circle cx="8" cy="13" r="1.2" fill="currentColor" /> | |
| 572 | + </svg> | |
| 573 | + </Button> | |
| 574 | + </DropdownMenuTrigger> | |
| 575 | + <DropdownMenuContent align="end" className="w-40"> | |
| 576 | + <DropdownMenuItem onClick={onManage.onEdit}>Edit</DropdownMenuItem> | |
| 577 | + <DropdownMenuItem | |
| 578 | + className="text-destructive focus:text-destructive text-sm" | |
| 579 | + onClick={onManage.onRemove} | |
| 580 | + > | |
| 581 | + Remove | |
| 582 | + </DropdownMenuItem> | |
| 583 | + </DropdownMenuContent> | |
| 584 | + </DropdownMenu> | |
| 585 | + ) : null} | |
| 586 | + </div> | |
| 587 | + ); | |
| 588 | +} | |
| 589 | + | |
| 512 | 590 | export function AddAccountModal(props: { |
| 513 | 591 | readonly integration: IntegrationSlug; |
| 514 | 592 | readonly integrationName: string; |
| @@ -579,6 +657,12 @@ export function AddAccountModal(props: { · 64 unmodified lines | ||
| 579 | 657 | // FIX 3 escape hatch: when no registered app matched the integration's |
| 580 | 658 | // endpoints, the unmatched apps are collapsed behind an opt-in expander. |
| 581 | 659 | const [showOtherApps, setShowOtherApps] = useState(false); |
| 660 | + // Inline OAuth app management — edit re-opens the registration form for an | |
| 661 | + // existing app (upsert by owner+slug); remove confirms before deleting. Both | |
| 662 | + // hold the FULL app summary (with endpoints + resource) so the edit prefill | |
| 663 | + // and the remove warning have everything they need. | |
| 664 | + const [editingClient, setEditingClient] = useState<OAuthClientSummary | null>(null); | |
| 665 | + const [removingClient, setRemovingClient] = useState<OAuthClientSummary | null>(null); | |
| 582 | 666 | |
| 583 | 667 | const doCreate = useAtomSet(addConnectionOptimistic(owner), { |
| 584 | 668 | mode: "promiseExit", |
| @@ -588,6 +672,22 @@ export function AddAccountModal(props: { · 3 unmodified lines | ||
| 588 | 672 | const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, { |
| 589 | 673 | mode: "promiseExit", |
| 590 | 674 | }); |
| 675 | + const doRemoveOAuthClient = useAtomSet(removeOAuthClientOptimistic, { mode: "promise" }); | |
| 676 | + | |
| 677 | + // Full registered-app summaries (carry endpoints + resource the picker's | |
| 678 | + // lightweight options omit) and the connection→app usage map that powers the | |
| 679 | + // remove warning. Both are secondary reads — a not-yet-loaded list just means | |
| 680 | + // no management affordance / no usage badge until it arrives. | |
| 681 | + const allClientsResult = useAtomValue(oauthClientsOptimisticAtom); | |
| 682 | + const connectionsResult = useAtomValue(connectionsAllAtom); | |
| 683 | + const clientSummaries = useMemo<readonly OAuthClientSummary[]>( | |
| 684 | + () => (AsyncResult.isSuccess(allClientsResult) ? allClientsResult.value : []), | |
| 685 | + [allClientsResult], | |
| 686 | + ); | |
| 687 | + const usage = useMemo( | |
| 688 | + () => buildUsageMap(AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []), | |
| 689 | + [connectionsResult], | |
| 690 | + ); | |
| 591 | 691 | |
| 592 | 692 | const method = useMemo( |
| 593 | 693 | () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0], |
| @@ -688,6 +788,9 @@ export function AddAccountModal(props: { · 94 unmodified lines | ||
| 688 | 788 | oauthEndpointMatched && oauthApps.length > 0 ? String(oauthApps[0]?.slug) : ""; |
| 689 | 789 | const selectedApp = pickedApp ?? oauthDefaultApp; |
| 690 | 790 | const oauthRegistering = isOAuth && registeringOAuthClient; |
| 791 | + // Editing reuses the registration form (createClient upserts by owner+slug), | |
| 792 | + // so it occupies the same full-bleed sub-view as registering. | |
| 793 | + const oauthEditing = isOAuth && editingClient !== null; | |
| 691 | 794 | const chosenClient: OAuthClientOption | null = |
| 692 | 795 | oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null; |
| 693 | 796 | const oauthBusy = ccBusy || oauthPopup.busy; |
| @@ -728,11 +831,44 @@ export function AddAccountModal(props: { · 34 unmodified lines | ||
| 728 | 831 | setDcrBusy(false); |
| 729 | 832 | setDcrFailed(false); |
| 730 | 833 | setShowOtherApps(false); |
| 834 | + setEditingClient(null); | |
| 835 | + setRemovingClient(null); | |
| 731 | 836 | setCreatedMethods([]); |
| 732 | 837 | setRemovedMethodIds(new Set()); |
| 733 | 838 | setAddingMethod(false); |
| 734 | 839 | }; |
| 735 | 840 | |
| 841 | + // Build the picker row's Edit/Remove menu for an app, but only once its full | |
| 842 | + // summary has loaded (the picker option lacks endpoints/resource). Until then | |
| 843 | + // the row shows no actions menu rather than a broken one. | |
| 844 | + const manageHandlersFor = ( | |
| 845 | + appOption: OAuthClientOption, | |
| 846 | + ): { readonly onEdit: () => void; readonly onRemove: () => void } | undefined => { | |
| 847 | + const summary = clientSummaries.find( | |
| 848 | + (c: OAuthClientSummary) => | |
| 849 | + c.owner === appOption.owner && String(c.slug) === String(appOption.slug), | |
| 850 | + ); | |
| 851 | + if (!summary) return undefined; | |
| 852 | + return { | |
| 853 | + onEdit: () => setEditingClient(summary), | |
| 854 | + onRemove: () => setRemovingClient(summary), | |
| 855 | + }; | |
| 856 | + }; | |
| 857 | + | |
| 858 | + // Remove a registered app. Removal never cascades into its connections (they | |
| 859 | + // reconnect at their next refresh); clear the picked app if it was the one | |
| 860 | + // removed so the connect button doesn't point at a gone slug. | |
| 861 | + const handleRemoveApp = async (client: OAuthClientSummary): Promise<void> => { | |
| 862 | + setRemovingClient(null); | |
| 863 | + await doRemoveOAuthClient({ | |
| 864 | + params: { slug: client.slug }, | |
| 865 | + payload: { owner: client.owner }, | |
| 866 | + reactivityKeys: oauthClientWriteKeys, | |
| 867 | + }); | |
| 868 | + toast.success(`Removed ${String(client.slug)}`); | |
| 869 | + if (pickedApp === String(client.slug)) setPickedApp(null); | |
| 870 | + }; | |
| 871 | + | |
| 736 | 872 | const selectMethod = (nextMethodId: string): void => { |
| 737 | 873 | setMethodId(nextMethodId); |
| 738 | 874 | setValues({}); |
| @@ -957,7 +1093,7 @@ export function AddAccountModal(props: { · 218 unmodified lines | ||
| 957 | 1093 | <DialogContent |
| 958 | 1094 | className={cn( |
| 959 | 1095 | "max-h-[85vh] overflow-x-hidden overflow-y-auto", |
| 960 | − (addingMethod && createCustomMethod) || oauthRegistering | |
| 1096 | + (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing | |
| 961 | 1097 | ? "gap-0 p-0 sm:max-w-2xl" |
| 962 | 1098 | : "sm:max-w-xl", |
| 963 | 1099 | )} |
| @@ -976,6 +1112,36 @@ export function AddAccountModal(props: { · 12 unmodified lines | ||
| 976 | 1112 | onCancel={() => setAddingMethod(false)} |
| 977 | 1113 | /> |
| 978 | 1114 | </> |
| 1115 | + ) : oauthEditing && editingClient ? ( | |
| 1116 | + <> | |
| 1117 | + <DialogHeader className="border-b border-border/60 px-5 py-4"> | |
| 1118 | + <DialogTitle className="text-base">Edit {String(editingClient.slug)}</DialogTitle> | |
| 1119 | + <DialogDescription className="text-sm"> | |
| 1120 | + Update this {ownerLabel(editingClient.owner).toLowerCase()} app's client | |
| 1121 | + credentials or endpoints. Re-enter the client secret to save. | |
| 1122 | + </DialogDescription> | |
| 1123 | + </DialogHeader> | |
| 1124 | + <div className="px-5 py-5"> | |
| 1125 | + <OAuthClientForm | |
| 1126 | + integrationName={integrationName} | |
| 1127 | + existingSlugs={[...oauthApps, ...oauthOtherApps].map((app: OAuthClientOption) => | |
| 1128 | + String(app.slug), | |
| 1129 | + )} | |
| 1130 | + fixedSlug={editingClient.slug} | |
| 1131 | + fixedOwner={editingClient.owner} | |
| 1132 | + prefill={{ | |
| 1133 | + authorizationUrl: editingClient.authorizationUrl, | |
| 1134 | + tokenUrl: editingClient.tokenUrl, | |
| 1135 | + resource: editingClient.resource ?? null, | |
| 1136 | + grant: editingClient.grant, | |
| 1137 | + clientId: editingClient.clientId, | |
| 1138 | + }} | |
| 1139 | + onCreated={() => setEditingClient(null)} | |
| 1140 | + onCancel={() => setEditingClient(null)} | |
| 1141 | + surface="plain" | |
| 1142 | + /> | |
| 1143 | + </div> | |
| 1144 | + </> | |
| 979 | 1145 | ) : oauthRegistering && method?.kind === "oauth" ? ( |
| 980 | 1146 | <> |
| 981 | 1147 | <DialogHeader className="border-b border-border/60 px-5 py-4"> |
| @@ -1166,32 +1332,14 @@ export function AddAccountModal(props: { · 184 unmodified lines | ||
| 1166 | 1332 | className="gap-2 pt-1" |
| 1167 | 1333 | > |
| 1168 | 1334 | {oauthOtherApps.map((app: OAuthClientOption) => ( |
| 1169 | − <Label | |
| 1335 | + <OAuthAppRadioRow | |
| 1170 | 1336 | key={String(app.slug)} |
| 1171 | − htmlFor={`other-app-${app.slug}`} | |
| 1172 | − className="flex cursor-pointer items-center gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 font-normal has-[:checked]:border-ring has-[:checked]:bg-accent/40" | |
| 1173 | − > | |
| 1174 | − <RadioGroupItem | |
| 1175 | − id={`other-app-${app.slug}`} | |
| 1176 | − value={String(app.slug)} | |
| 1177 | − /> | |
| 1178 | − <span className="min-w-0 flex-1"> | |
| 1179 | − <span className="block text-sm font-medium"> | |
| 1180 | − {clientDisplayName(String(app.slug))} | |
| 1181 | − </span> | |
| 1182 | − <span className="block truncate text-xs text-muted-foreground"> | |
| 1183 | − {clientHost(app.tokenUrl)} ·{" "} | |
| 1184 | − {app.grant === "client_credentials" | |
| 1185 | − ? "app-to-app" | |
| 1186 | − : "you'll sign in"} | |
| 1187 | − </span> | |
| 1188 | − </span> | |
| 1189 | − {ownerDisplay.showOwnerLabels ? ( | |
| 1190 | − <Badge variant="outline"> | |
| 1191 | − {ownerLabel(app.owner)} | |
| 1192 | − </Badge> | |
| 1193 | − ) : null} | |
| 1194 | − </Label> | |
| 1337 | + app={app} | |
| 1338 | + idPrefix="other-app" | |
| 1339 | + variant="other" | |
| 1340 | + showOwnerLabel={ownerDisplay.showOwnerLabels} | |
| 1341 | + onManage={manageHandlersFor(app)} | |
| 1342 | + /> | |
| 1195 | 1343 | ))} |
| 1196 | 1344 | </RadioGroup> |
| 1197 | 1345 | ) : null} |
| @@ -1205,30 +1353,14 @@ export function AddAccountModal(props: { · 7 unmodified lines | ||
| 1205 | 1353 | className="gap-2" |
| 1206 | 1354 | > |
| 1207 | 1355 | {oauthApps.map((app: OAuthClientOption) => ( |
| 1208 | − <Label | |
| 1356 | + <OAuthAppRadioRow | |
| 1209 | 1357 | key={String(app.slug)} |
| 1210 | − htmlFor={`app-${app.slug}`} | |
| 1211 | − className="flex cursor-pointer items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 font-normal has-[:checked]:border-ring has-[:checked]:bg-accent/40" | |
| 1212 | − > | |
| 1213 | − <RadioGroupItem | |
| 1214 | − id={`app-${app.slug}`} | |
| 1215 | − value={String(app.slug)} | |
| 1216 | − /> | |
| 1217 | − <span className="min-w-0 flex-1"> | |
| 1218 | − <span className="block text-sm font-medium"> | |
| 1219 | − {clientDisplayName(String(app.slug))} | |
| 1220 | − </span> | |
| 1221 | − <span className="block truncate text-xs text-muted-foreground"> | |
| 1222 | − {clientHost(app.tokenUrl)} ·{" "} | |
| 1223 | − {app.grant === "client_credentials" | |
| 1224 | − ? "app-to-app" | |
| 1225 | − : "you'll sign in"} | |
| 1226 | − </span> | |
| 1227 | − </span> | |
| 1228 | − {ownerDisplay.showOwnerLabels ? ( | |
| 1229 | − <Badge variant="outline">{ownerLabel(app.owner)}</Badge> | |
| 1230 | − ) : null} | |
| 1231 | − </Label> | |
| 1358 | + app={app} | |
| 1359 | + idPrefix="app" | |
| 1360 | + variant="matched" | |
| 1361 | + showOwnerLabel={ownerDisplay.showOwnerLabels} | |
| 1362 | + onManage={manageHandlersFor(app)} | |
| 1363 | + /> | |
| 1232 | 1364 | ))} |
| 1233 | 1365 | <Button |
| 1234 | 1366 | type="button" |
| @@ -1331,6 +1463,18 @@ export function AddAccountModal(props: { · 96 unmodified lines | ||
| 1331 | 1463 | </DialogFooter> |
| 1332 | 1464 | </> |
| 1333 | 1465 | )} |
| 1466 | + | |
| 1467 | + {/* Remove-app confirmation — layered over the picker. Renders into its | |
| 1468 | + own portal, so it sits cleanly above the modal regardless of which | |
| 1469 | + sub-view is active. */} | |
| 1470 | + {removingClient ? ( | |
| 1471 | + <RemoveOAuthAppDialog | |
| 1472 | + client={removingClient} | |
| 1473 | + connections={connectionsUsingClient(usage, removingClient)} | |
| 1474 | + onConfirm={() => void handleRemoveApp(removingClient)} | |
| 1475 | + onClose={() => setRemovingClient(null)} | |
| 1476 | + /> | |
| 1477 | + ) : null} | |
| 1334 | 1478 | </DialogContent> |
| 1335 | 1479 | </Dialog> |
| 1336 | 1480 | ); |
| @@ -4,7 +4,7 @@ import * as Exit from "effect/Exit"; · 3 unmodified lines | ||
| 4 | 4 | import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared"; |
| 5 | 5 | import { toast } from "sonner"; |
| 6 | 6 | |
| 7 | −import { createOAuthClient, probeOAuth, registerDynamicOAuthClient } from "../api/atoms"; | |
| 7 | +import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms"; | |
| 8 | 8 | import { ownerLabelForHost } from "../api/owner-display"; |
| 9 | 9 | import { useOrganizationId } from "../api/organization-context"; |
| 10 | 10 | import { oauthClientWriteKeys } from "../api/reactivity-keys"; |
| @@ -116,7 +116,7 @@ export function OAuthClientForm(props: { · 105 unmodified lines | ||
| 116 | 116 | const endpointsKnown = (prefill?.tokenUrl ?? "").length > 0; |
| 117 | 117 | const [showEndpoints, setShowEndpoints] = useState(!endpointsKnown); |
| 118 | 118 | |
| 119 | − const doCreate = useAtomSet(createOAuthClient, { mode: "promiseExit" }); | |
| 119 | + const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" }); | |
| 120 | 120 | const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" }); |
| 121 | 121 | const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, { |
| 122 | 122 | mode: "promiseExit", |
| @@ -0,0 +1,77 @@ | ||
| 1 | +import type { Connection, OAuthClientSummary } from "@executor-js/sdk/shared"; | |
| 2 | + | |
| 3 | +import { ownerLabel } from "../api/owner-display"; | |
| 4 | +import { Alert, AlertDescription, AlertTitle } from "./alert"; | |
| 5 | +import { Button } from "./button"; | |
| 6 | +import { | |
| 7 | + Dialog, | |
| 8 | + DialogContent, | |
| 9 | + DialogDescription, | |
| 10 | + DialogFooter, | |
| 11 | + DialogHeader, | |
| 12 | + DialogTitle, | |
| 13 | +} from "./dialog"; | |
| 14 | + | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | +// Remove-confirm dialog for a registered OAuth app — warns (does not block) | |
| 17 | +// when the app still backs connections. Removal never cascades into those | |
| 18 | +// connections; they keep their stored slug and surface a reconnect prompt at | |
| 19 | +// their next token refresh. | |
| 20 | +// --------------------------------------------------------------------------- | |
| 21 | + | |
| 22 | +export function RemoveOAuthAppDialog(props: { | |
| 23 | + readonly client: OAuthClientSummary; | |
| 24 | + readonly connections: readonly Connection[]; | |
| 25 | + readonly onConfirm: () => void; | |
| 26 | + readonly onClose: () => void; | |
| 27 | +}) { | |
| 28 | + const { client, connections } = props; | |
| 29 | + const inUse = connections.length > 0; | |
| 30 | + return ( | |
| 31 | + <Dialog open onOpenChange={(open: boolean) => (open ? undefined : props.onClose())}> | |
| 32 | + <DialogContent> | |
| 33 | + <DialogHeader> | |
| 34 | + <DialogTitle>Remove {String(client.slug)}?</DialogTitle> | |
| 35 | + <DialogDescription> | |
| 36 | + This permanently removes the {ownerLabel(client.owner).toLowerCase()} OAuth app and its | |
| 37 | + stored client credentials. | |
| 38 | + </DialogDescription> | |
| 39 | + </DialogHeader> | |
| 40 | + | |
| 41 | + {inUse ? ( | |
| 42 | + <Alert variant="destructive"> | |
| 43 | + <AlertTitle> | |
| 44 | + This app backs {connections.length} connection | |
| 45 | + {connections.length === 1 ? "" : "s"} | |
| 46 | + </AlertTitle> | |
| 47 | + <AlertDescription> | |
| 48 | + <p> | |
| 49 | + Removing it won't delete those connections, but they'll need to reconnect | |
| 50 | + at their next token refresh. | |
| 51 | + </p> | |
| 52 | + <ul className="mt-1 flex flex-col gap-0.5"> | |
| 53 | + {connections.map((connection: Connection) => ( | |
| 54 | + <li | |
| 55 | + key={`${connection.owner}.${String(connection.integration)}.${String(connection.name)}`} | |
| 56 | + className="font-mono text-xs" | |
| 57 | + > | |
| 58 | + {String(connection.integration)} / {String(connection.name)} | |
| 59 | + </li> | |
| 60 | + ))} | |
| 61 | + </ul> | |
| 62 | + </AlertDescription> | |
| 63 | + </Alert> | |
| 64 | + ) : null} | |
| 65 | + | |
| 66 | + <DialogFooter> | |
| 67 | + <Button type="button" variant="ghost" onClick={props.onClose}> | |
| 68 | + Cancel | |
| 69 | + </Button> | |
| 70 | + <Button type="button" variant="destructive" onClick={props.onConfirm}> | |
| 71 | + Remove app | |
| 72 | + </Button> | |
| 73 | + </DialogFooter> | |
| 74 | + </DialogContent> | |
| 75 | + </Dialog> | |
| 76 | + ); | |
| 77 | +} | |
| @@ -11,7 +11,7 @@ import { · 10 unmodified lines | ||
| 11 | 11 | type Owner, |
| 12 | 12 | } from "@executor-js/sdk/shared"; |
| 13 | 13 | |
| 14 | −import { buildUsageMap, connectionsUsingClient, groupClientsByOwner } from "./oauth-apps"; | |
| 14 | +import { buildUsageMap, connectionsUsingClient } from "./oauth-client-usage"; | |
| 15 | 15 | |
| 16 | 16 | // Minimal app summary builder — only the fields the helpers read matter. |
| 17 | 17 | const app = (slug: string, opts?: { readonly owner?: Owner }): OAuthClientSummary => ({ |
| @@ -52,36 +52,6 @@ const connection = ( · 34 unmodified lines | ||
| 52 | 52 | oauthClientOwner: opts?.oauthClientOwner ?? null, |
| 53 | 53 | }); |
| 54 | 54 | |
| 55 | −describe("groupClientsByOwner", () => { | |
| 56 | − it("orders Workspace (org) before Personal (user) and drops empty groups", () => { | |
| 57 | − const groups = groupClientsByOwner([ | |
| 58 | − app("personal-app", { owner: "user" }), | |
| 59 | − app("workspace-app", { owner: "org" }), | |
| 60 | − ]); | |
| 61 | − expect(groups.map((g) => g.owner)).toEqual(["org", "user"]); | |
| 62 | − expect(groups[0]!.clients.map((c: OAuthClientSummary) => String(c.slug))).toEqual([ | |
| 63 | − "workspace-app", | |
| 64 | − ]); | |
| 65 | − expect(groups[1]!.clients.map((c: OAuthClientSummary) => String(c.slug))).toEqual([ | |
| 66 | − "personal-app", | |
| 67 | − ]); | |
| 68 | − }); | |
| 69 | − | |
| 70 | − it("omits an owner group entirely when it has no apps", () => { | |
| 71 | − const groups = groupClientsByOwner([app("only-personal", { owner: "user" })]); | |
| 72 | − expect(groups.map((g) => g.owner)).toEqual(["user"]); | |
| 73 | − }); | |
| 74 | − | |
| 75 | − it("returns no groups for an empty list", () => { | |
| 76 | − expect(groupClientsByOwner([])).toEqual([]); | |
| 77 | − }); | |
| 78 | − | |
| 79 | − it("preserves original order within a group", () => { | |
| 80 | − const groups = groupClientsByOwner([app("b", { owner: "org" }), app("a", { owner: "org" })]); | |
| 81 | − expect(groups[0]!.clients.map((c: OAuthClientSummary) => String(c.slug))).toEqual(["b", "a"]); | |
| 82 | − }); | |
| 83 | −}); | |
| 84 | − | |
| 85 | 55 | describe("buildUsageMap / connectionsUsingClient", () => { |
| 86 | 56 | it("maps connections to the app slug that minted them", () => { |
| 87 | 57 | const usage = buildUsageMap([ |
| @@ -0,0 +1,46 @@ | ||
| 1 | +import type { | |
| 2 | + Connection, | |
| 3 | + OAuthClientSlug, | |
| 4 | + OAuthClientSummary, | |
| 5 | + Owner, | |
| 6 | +} from "@executor-js/sdk/shared"; | |
| 7 | + | |
| 8 | +// --------------------------------------------------------------------------- | |
| 9 | +// OAuth client (registered app) usage — which connections a given app backs. | |
| 10 | +// | |
| 11 | +// Connections that were minted through an OAuth flow carry the slug of the app | |
| 12 | +// that minted them (`oauthClient`) plus that app's owner (`oauthClientOwner`). | |
| 13 | +// Removing an app NEVER cascades into its connections — an orphaned connection | |
| 14 | +// keeps its stored slug and surfaces a reconnect prompt at its next token | |
| 15 | +// refresh — so the remove flow only WARNS, using this map to list what breaks. | |
| 16 | +// --------------------------------------------------------------------------- | |
| 17 | + | |
| 18 | +/** owner+slug key — a connection can reference either a personal or workspace | |
| 19 | + * app with the same slug, so owner is part of the key. */ | |
| 20 | +const oauthClientUsageKey = (owner: Owner | null | undefined, slug: OAuthClientSlug): string => | |
| 21 | + `${owner ?? "org"}\0${String(slug)}`; | |
| 22 | + | |
| 23 | +/** Build an owner+slug → connections map so each app can show what it backs. | |
| 24 | + * Static connections (null `oauthClient`) are skipped. */ | |
| 25 | +export function buildUsageMap( | |
| 26 | + connections: readonly Connection[], | |
| 27 | +): ReadonlyMap<string, readonly Connection[]> { | |
| 28 | + const map = new Map<string, Connection[]>(); | |
| 29 | + for (const connection of connections) { | |
| 30 | + const slug = connection.oauthClient; | |
| 31 | + if (slug == null) continue; | |
| 32 | + const key = oauthClientUsageKey(connection.oauthClientOwner, slug); | |
| 33 | + const existing = map.get(key); | |
| 34 | + if (existing) existing.push(connection); | |
| 35 | + else map.set(key, [connection]); | |
| 36 | + } | |
| 37 | + return map; | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** Connections backing one app, or an empty array. */ | |
| 41 | +export function connectionsUsingClient( | |
| 42 | + usage: ReadonlyMap<string, readonly Connection[]>, | |
| 43 | + client: Pick<OAuthClientSummary, "owner" | "slug">, | |
| 44 | +): readonly Connection[] { | |
| 45 | + return usage.get(oauthClientUsageKey(client.owner, client.slug)) ?? []; | |
| 46 | +} | |
| @@ -42,7 +42,6 @@ export type ShellNavItem = { readonly to: string; readonly label: string }; · 41 unmodified lines | ||
| 42 | 42 | export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [ |
| 43 | 43 | { to: "/", label: "Integrations" }, |
| 44 | 44 | { to: "/secrets", label: "Providers" }, |
| 45 | − { to: "/oauth-apps", label: "OAuth apps" }, | |
| 46 | 45 | { to: "/policies", label: "Policies" }, |
| 47 | 46 | ]; |
| 48 | 47 | |
| @@ -1,497 +0,0 @@ | ||
| 1 | −import { useMemo, useState } from "react"; | |
| 2 | −import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | −import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | −import { ChevronDownIcon } from "lucide-react"; | |
| 5 | −import { | |
| 6 | − OAuthClientSlug, | |
| 7 | − type Connection, | |
| 8 | − type OAuthClientSummary, | |
| 9 | − type OAuthGrant, | |
| 10 | − type Owner, | |
| 11 | −} from "@executor-js/sdk/shared"; | |
| 12 | −import { toast } from "sonner"; | |
| 13 | − | |
| 14 | −import { | |
| 15 | − connectionsAllAtom, | |
| 16 | − oauthClientsOptimisticAtom, | |
| 17 | − removeOAuthClientOptimistic, | |
| 18 | −} from "../api/atoms"; | |
| 19 | −import { oauthClientWriteKeys } from "../api/reactivity-keys"; | |
| 20 | −import { ownerLabel } from "../api/owner-display"; | |
| 21 | −import { Badge } from "../components/badge"; | |
| 22 | −import { Button } from "../components/button"; | |
| 23 | −import { CopyButton } from "../components/copy-button"; | |
| 24 | −import { | |
| 25 | − CardStack, | |
| 26 | − CardStackContent, | |
| 27 | − CardStackEntry, | |
| 28 | − CardStackEntryActions, | |
| 29 | − CardStackEntryContent, | |
| 30 | − CardStackEntryDescription, | |
| 31 | − CardStackEntryTitle, | |
| 32 | − CardStackHeader, | |
| 33 | −} from "../components/card-stack"; | |
| 34 | −import { | |
| 35 | − Dialog, | |
| 36 | − DialogContent, | |
| 37 | − DialogDescription, | |
| 38 | − DialogFooter, | |
| 39 | − DialogHeader, | |
| 40 | − DialogTitle, | |
| 41 | −} from "../components/dialog"; | |
| 42 | −import { Alert, AlertDescription, AlertTitle } from "../components/alert"; | |
| 43 | −import { | |
| 44 | − DropdownMenu, | |
| 45 | − DropdownMenuContent, | |
| 46 | − DropdownMenuItem, | |
| 47 | − DropdownMenuTrigger, | |
| 48 | −} from "../components/dropdown-menu"; | |
| 49 | −import { OAuthClientForm, type OAuthClientFormPrefill } from "../components/oauth-client-form"; | |
| 50 | − | |
| 51 | −// --------------------------------------------------------------------------- | |
| 52 | −// OAuth apps page (v2). | |
| 53 | −// | |
| 54 | −// Registered OAuth clients ("apps") are the credentials behind OAuth-minted | |
| 55 | −// connections: each app holds the client id/secret + endpoints/scopes a flow | |
| 56 | −// runs through. This page lists them grouped per owner (Workspace vs Personal), | |
| 57 | −// shows each app's endpoints/scopes/grant/client id, the connections it backs, | |
| 58 | −// and lets the user register, edit, or remove one. | |
| 59 | −// | |
| 60 | −// Removal NEVER cascades into connections — an orphaned connection keeps its | |
| 61 | −// stored slug and surfaces a reconnect prompt at its next token refresh. The | |
| 62 | −// remove dialog warns (does not block) when an app is still in use. | |
| 63 | −// --------------------------------------------------------------------------- | |
| 64 | − | |
| 65 | −// Owner ordering: Workspace (org) first, then Personal (user). Mirrors the | |
| 66 | −// policies page grouping so the two surfaces read the same. | |
| 67 | −const OWNER_ORDER: readonly Owner[] = ["org", "user"]; | |
| 68 | − | |
| 69 | −const GRANT_LABEL: Record<OAuthGrant, string> = { | |
| 70 | − authorization_code: "Authorization code", | |
| 71 | − client_credentials: "Client credentials", | |
| 72 | −}; | |
| 73 | − | |
| 74 | −const grantLabel = (grant: OAuthGrant): string => GRANT_LABEL[grant]; | |
| 75 | − | |
| 76 | −// --------------------------------------------------------------------------- | |
| 77 | −// Pure helpers (exported for unit tests) — usage map + per-owner grouping. | |
| 78 | −// --------------------------------------------------------------------------- | |
| 79 | − | |
| 80 | −/** Group apps by owner, preserving `OWNER_ORDER` and dropping empty groups. | |
| 81 | − * Within a group, original list order is kept. */ | |
| 82 | −export function groupClientsByOwner( | |
| 83 | − clients: readonly OAuthClientSummary[], | |
| 84 | −): ReadonlyArray<{ readonly owner: Owner; readonly clients: readonly OAuthClientSummary[] }> { | |
| 85 | − return OWNER_ORDER.flatMap((owner: Owner) => { | |
| 86 | − const group = clients.filter((client: OAuthClientSummary) => client.owner === owner); | |
| 87 | − return group.length === 0 ? [] : [{ owner, clients: group }]; | |
| 88 | − }); | |
| 89 | −} | |
| 90 | − | |
| 91 | −/** Build an owner+slug → connections map so each app can show what it backs. | |
| 92 | − * Connection rows can reference either a personal or workspace app with the | |
| 93 | − * same slug, so owner is part of the key. Static connections (null | |
| 94 | − * `oauthClient`) are skipped. */ | |
| 95 | −const oauthClientUsageKey = (owner: Owner | null | undefined, slug: OAuthClientSlug): string => | |
| 96 | − `${owner ?? "org"}\0${String(slug)}`; | |
| 97 | − | |
| 98 | −export function buildUsageMap( | |
| 99 | − connections: readonly Connection[], | |
| 100 | −): ReadonlyMap<string, readonly Connection[]> { | |
| 101 | − const map = new Map<string, Connection[]>(); | |
| 102 | − for (const connection of connections) { | |
| 103 | − const slug = connection.oauthClient; | |
| 104 | − if (slug == null) continue; | |
| 105 | − const key = oauthClientUsageKey(connection.oauthClientOwner, slug); | |
| 106 | − const existing = map.get(key); | |
| 107 | − if (existing) existing.push(connection); | |
| 108 | − else map.set(key, [connection]); | |
| 109 | − } | |
| 110 | − return map; | |
| 111 | −} | |
| 112 | − | |
| 113 | −/** Connections backing one app, or an empty array. */ | |
| 114 | −export function connectionsUsingClient( | |
| 115 | − usage: ReadonlyMap<string, readonly Connection[]>, | |
| 116 | − client: Pick<OAuthClientSummary, "owner" | "slug">, | |
| 117 | −): readonly Connection[] { | |
| 118 | − return usage.get(oauthClientUsageKey(client.owner, client.slug)) ?? []; | |
| 119 | −} | |
| 120 | − | |
| 121 | −// --------------------------------------------------------------------------- | |
| 122 | −// Field row — a labeled, monospace, copyable value. | |
| 123 | −// --------------------------------------------------------------------------- | |
| 124 | − | |
| 125 | −function MetaField(props: { | |
| 126 | − readonly label: string; | |
| 127 | − readonly value: string; | |
| 128 | − readonly copy?: boolean; | |
| 129 | −}) { | |
| 130 | − return ( | |
| 131 | − <div className="flex min-w-0 flex-col gap-0.5"> | |
| 132 | − <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> | |
| 133 | − {props.label} | |
| 134 | − </span> | |
| 135 | − <div className="flex min-w-0 items-center gap-1"> | |
| 136 | − <span className="min-w-0 truncate font-mono text-xs text-foreground">{props.value}</span> | |
| 137 | − {props.copy ? <CopyButton value={props.value} /> : null} | |
| 138 | − </div> | |
| 139 | − </div> | |
| 140 | − ); | |
| 141 | −} | |
| 142 | − | |
| 143 | −// --------------------------------------------------------------------------- | |
| 144 | −// App card | |
| 145 | −// --------------------------------------------------------------------------- | |
| 146 | − | |
| 147 | −function OAuthAppCard(props: { | |
| 148 | − readonly client: OAuthClientSummary; | |
| 149 | − readonly connections: readonly Connection[]; | |
| 150 | − readonly onEdit: () => void; | |
| 151 | − readonly onRemove: () => void; | |
| 152 | −}) { | |
| 153 | − const { client, connections } = props; | |
| 154 | − const [expanded, setExpanded] = useState(false); | |
| 155 | − const inUse = connections.length > 0; | |
| 156 | − | |
| 157 | − return ( | |
| 158 | − <CardStackEntry className="flex-col items-stretch gap-3"> | |
| 159 | − <div className="flex w-full items-start gap-3"> | |
| 160 | − <CardStackEntryContent> | |
| 161 | − <CardStackEntryTitle className="flex items-center gap-2 font-mono text-sm"> | |
| 162 | − <span className="truncate">{String(client.slug)}</span> | |
| 163 | − <Badge variant="outline" className="font-sans text-[10px]"> | |
| 164 | − {grantLabel(client.grant)} | |
| 165 | − </Badge> | |
| 166 | − </CardStackEntryTitle> | |
| 167 | − <CardStackEntryDescription>{ownerLabel(client.owner)} app</CardStackEntryDescription> | |
| 168 | − </CardStackEntryContent> | |
| 169 | − <CardStackEntryActions> | |
| 170 | − <DropdownMenu> | |
| 171 | − <DropdownMenuTrigger asChild> | |
| 172 | − <Button | |
| 173 | − variant="ghost" | |
| 174 | − size="icon" | |
| 175 | − className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100" | |
| 176 | − aria-label={`Actions for ${String(client.slug)}`} | |
| 177 | − > | |
| 178 | − <svg viewBox="0 0 16 16" className="size-3"> | |
| 179 | − <circle cx="8" cy="3" r="1.2" fill="currentColor" /> | |
| 180 | − <circle cx="8" cy="8" r="1.2" fill="currentColor" /> | |
| 181 | − <circle cx="8" cy="13" r="1.2" fill="currentColor" /> | |
| 182 | − </svg> | |
| 183 | − </Button> | |
| 184 | − </DropdownMenuTrigger> | |
| 185 | − <DropdownMenuContent align="end" className="w-40"> | |
| 186 | − <DropdownMenuItem onClick={props.onEdit}>Edit</DropdownMenuItem> | |
| 187 | − <DropdownMenuItem | |
| 188 | − className="text-destructive focus:text-destructive text-sm" | |
| 189 | − onClick={props.onRemove} | |
| 190 | − > | |
| 191 | − Remove | |
| 192 | − </DropdownMenuItem> | |
| 193 | − </DropdownMenuContent> | |
| 194 | − </DropdownMenu> | |
| 195 | − </CardStackEntryActions> | |
| 196 | − </div> | |
| 197 | − | |
| 198 | − <div className="grid gap-3 sm:grid-cols-2"> | |
| 199 | − <MetaField label="Client ID" value={client.clientId} copy /> | |
| 200 | − {client.grant === "authorization_code" ? ( | |
| 201 | − <MetaField label="Authorization URL" value={client.authorizationUrl} copy /> | |
| 202 | − ) : null} | |
| 203 | − <MetaField label="Token URL" value={client.tokenUrl} copy /> | |
| 204 | − {client.resource ? <MetaField label="Resource" value={client.resource} copy /> : null} | |
| 205 | − </div> | |
| 206 | − | |
| 207 | − <div className="flex flex-col gap-1.5"> | |
| 208 | − <Button | |
| 209 | − type="button" | |
| 210 | − variant="ghost" | |
| 211 | − size="xs" | |
| 212 | − onClick={() => setExpanded((prev: boolean) => !prev)} | |
| 213 | − disabled={!inUse} | |
| 214 | − className="h-auto w-fit gap-1 px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground disabled:opacity-100" | |
| 215 | − > | |
| 216 | − {inUse ? ( | |
| 217 | − <ChevronDownIcon | |
| 218 | − className={`size-3 transition-transform ${expanded ? "rotate-180" : ""}`} | |
| 219 | − /> | |
| 220 | − ) : null} | |
| 221 | − Used by {connections.length} connection{connections.length === 1 ? "" : "s"} | |
| 222 | − </Button> | |
| 223 | − {inUse && expanded ? ( | |
| 224 | − <ul className="flex flex-col gap-1 border-l border-border/60 pl-3"> | |
| 225 | − {connections.map((connection: Connection) => ( | |
| 226 | − <li | |
| 227 | − key={`${connection.owner}.${String(connection.integration)}.${String(connection.name)}`} | |
| 228 | − className="flex items-center gap-2 text-xs text-muted-foreground" | |
| 229 | − > | |
| 230 | − <span className="font-mono text-foreground">{String(connection.integration)}</span> | |
| 231 | − <span className="truncate">{String(connection.name)}</span> | |
| 232 | − <span className="ml-auto shrink-0 rounded border border-border px-1 py-0.5 text-[10px] leading-none"> | |
| 233 | − {ownerLabel(connection.owner)} | |
| 234 | − </span> | |
| 235 | − </li> | |
| 236 | − ))} | |
| 237 | − </ul> | |
| 238 | − ) : null} | |
| 239 | − </div> | |
| 240 | − </CardStackEntry> | |
| 241 | − ); | |
| 242 | −} | |
| 243 | − | |
| 244 | −// --------------------------------------------------------------------------- | |
| 245 | −// Remove-confirm dialog — warns (does not block) when the app is in use. | |
| 246 | −// --------------------------------------------------------------------------- | |
| 247 | − | |
| 248 | −function RemoveAppDialog(props: { | |
| 249 | − readonly client: OAuthClientSummary; | |
| 250 | − readonly connections: readonly Connection[]; | |
| 251 | − readonly onConfirm: () => void; | |
| 252 | − readonly onClose: () => void; | |
| 253 | −}) { | |
| 254 | − const { client, connections } = props; | |
| 255 | − const inUse = connections.length > 0; | |
| 256 | − return ( | |
| 257 | − <Dialog open onOpenChange={(open: boolean) => (open ? undefined : props.onClose())}> | |
| 258 | − <DialogContent> | |
| 259 | − <DialogHeader> | |
| 260 | − <DialogTitle>Remove {String(client.slug)}?</DialogTitle> | |
| 261 | − <DialogDescription> | |
| 262 | − This permanently removes the {ownerLabel(client.owner).toLowerCase()} OAuth app and its | |
| 263 | − stored client credentials. | |
| 264 | − </DialogDescription> | |
| 265 | − </DialogHeader> | |
| 266 | − | |
| 267 | − {inUse ? ( | |
| 268 | − <Alert variant="destructive"> | |
| 269 | − <AlertTitle> | |
| 270 | − This app backs {connections.length} connection | |
| 271 | − {connections.length === 1 ? "" : "s"} | |
| 272 | − </AlertTitle> | |
| 273 | − <AlertDescription> | |
| 274 | − <p> | |
| 275 | − Removing it won't delete those connections, but they'll need to reconnect | |
| 276 | − at their next token refresh. | |
| 277 | − </p> | |
| 278 | − <ul className="mt-1 flex flex-col gap-0.5"> | |
| 279 | − {connections.map((connection: Connection) => ( | |
| 280 | − <li | |
| 281 | − key={`${connection.owner}.${String(connection.integration)}.${String(connection.name)}`} | |
| 282 | − className="font-mono text-xs" | |
| 283 | − > | |
| 284 | − {String(connection.integration)} / {String(connection.name)} | |
| 285 | − </li> | |
| 286 | − ))} | |
| 287 | − </ul> | |
| 288 | − </AlertDescription> | |
| 289 | − </Alert> | |
| 290 | − ) : null} | |
| 291 | − | |
| 292 | − <DialogFooter> | |
| 293 | − <Button type="button" variant="ghost" onClick={props.onClose}> | |
| 294 | − Cancel | |
| 295 | − </Button> | |
| 296 | − <Button type="button" variant="destructive" onClick={props.onConfirm}> | |
| 297 | − Remove app | |
| 298 | − </Button> | |
| 299 | − </DialogFooter> | |
| 300 | − </DialogContent> | |
| 301 | − </Dialog> | |
| 302 | − ); | |
| 303 | −} | |
| 304 | − | |
| 305 | −// --------------------------------------------------------------------------- | |
| 306 | −// Add / edit dialog — wraps the shared `OAuthClientForm`. | |
| 307 | −// --------------------------------------------------------------------------- | |
| 308 | − | |
| 309 | −function AppFormDialog(props: { | |
| 310 | − readonly title: string; | |
| 311 | − readonly existingSlugs: readonly string[]; | |
| 312 | − readonly fixedSlug?: OAuthClientSlug; | |
| 313 | − readonly fixedOwner?: Owner; | |
| 314 | − readonly prefill?: OAuthClientFormPrefill; | |
| 315 | − readonly onClose: () => void; | |
| 316 | −}) { | |
| 317 | − return ( | |
| 318 | − <Dialog open onOpenChange={(open: boolean) => (open ? undefined : props.onClose())}> | |
| 319 | − <DialogContent className="sm:max-w-xl"> | |
| 320 | − <DialogHeader> | |
| 321 | − <DialogTitle>{props.title}</DialogTitle> | |
| 322 | − </DialogHeader> | |
| 323 | − <OAuthClientForm | |
| 324 | − integrationName="OAuth app" | |
| 325 | − existingSlugs={props.existingSlugs} | |
| 326 | − fixedSlug={props.fixedSlug} | |
| 327 | − fixedOwner={props.fixedOwner} | |
| 328 | − prefill={props.prefill} | |
| 329 | − onCreated={() => props.onClose()} | |
| 330 | − onCancel={props.onClose} | |
| 331 | − /> | |
| 332 | − </DialogContent> | |
| 333 | − </Dialog> | |
| 334 | − ); | |
| 335 | −} | |
| 336 | − | |
| 337 | −// What the page is currently editing/removing/adding. A discriminated union so | |
| 338 | −// only one dialog is ever open. | |
| 339 | −type OpenDialog = | |
| 340 | − | { readonly kind: "none" } | |
| 341 | − | { readonly kind: "add" } | |
| 342 | − | { readonly kind: "edit"; readonly client: OAuthClientSummary } | |
| 343 | − | { readonly kind: "remove"; readonly client: OAuthClientSummary }; | |
| 344 | − | |
| 345 | −// --------------------------------------------------------------------------- | |
| 346 | −// Page | |
| 347 | −// --------------------------------------------------------------------------- | |
| 348 | − | |
| 349 | −export function OAuthAppsPage() { | |
| 350 | − const clients = useAtomValue(oauthClientsOptimisticAtom); | |
| 351 | − const connections = useAtomValue(connectionsAllAtom); | |
| 352 | − const doRemove = useAtomSet(removeOAuthClientOptimistic, { mode: "promise" }); | |
| 353 | − const [dialog, setDialog] = useState<OpenDialog>({ kind: "none" }); | |
| 354 | − | |
| 355 | − // Connections list is a secondary read; treat a not-yet-loaded list as empty | |
| 356 | − // rather than blocking the page on it. The usage badges fill in on its load. | |
| 357 | − const connectionRows = useMemo( | |
| 358 | − () => (AsyncResult.isSuccess(connections) ? connections.value : []), | |
| 359 | − [connections], | |
| 360 | − ); | |
| 361 | − const usage = useMemo(() => buildUsageMap(connectionRows), [connectionRows]); | |
| 362 | − | |
| 363 | − const handleRemove = async (client: OAuthClientSummary) => { | |
| 364 | − setDialog({ kind: "none" }); | |
| 365 | − await doRemove({ | |
| 366 | − params: { slug: client.slug }, | |
| 367 | − payload: { owner: client.owner }, | |
| 368 | − reactivityKeys: oauthClientWriteKeys, | |
| 369 | − }); | |
| 370 | − toast.success(`Removed ${String(client.slug)}`); | |
| 371 | − }; | |
| 372 | − | |
| 373 | − return ( | |
| 374 | − <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 375 | − <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14"> | |
| 376 | − <div className="mb-10 flex items-end justify-between gap-4"> | |
| 377 | − <div> | |
| 378 | − <h1 className="font-display text-[2rem] leading-none tracking-tight text-foreground"> | |
| 379 | − OAuth apps | |
| 380 | − </h1> | |
| 381 | − <p className="mt-2 text-sm leading-relaxed text-muted-foreground"> | |
| 382 | − Registered OAuth clients — the client id/secret, endpoints, and scopes that back your | |
| 383 | − OAuth connections. Workspace apps are shared with everyone; personal apps are yours | |
| 384 | − only. Each person still mints their own connection against a shared app. | |
| 385 | − </p> | |
| 386 | − </div> | |
| 387 | − <Button type="button" onClick={() => setDialog({ kind: "add" })} className="shrink-0"> | |
| 388 | − Add OAuth app | |
| 389 | − </Button> | |
| 390 | − </div> | |
| 391 | − | |
| 392 | − {AsyncResult.match(clients, { | |
| 393 | − onInitial: () => ( | |
| 394 | − <div className="flex items-center gap-2 py-8"> | |
| 395 | − <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" /> | |
| 396 | − <p className="text-sm text-muted-foreground">Loading OAuth apps…</p> | |
| 397 | − </div> | |
| 398 | − ), | |
| 399 | − onFailure: () => ( | |
| 400 | − <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3"> | |
| 401 | − <p className="text-sm text-destructive">Failed to load OAuth apps</p> | |
| 402 | − </div> | |
| 403 | − ), | |
| 404 | − onSuccess: ({ value }) => { | |
| 405 | − const visibleClients = value.filter( | |
| 406 | − (client: OAuthClientSummary) => client.origin.kind !== "dynamic_client_registration", | |
| 407 | − ); | |
| 408 | − const groups = groupClientsByOwner(visibleClients); | |
| 409 | − const existingSlugs = value.map((client: OAuthClientSummary) => String(client.slug)); | |
| 410 | − // Dialogs render regardless of how many apps exist — in particular | |
| 411 | − // the "add" dialog must be reachable from the empty state so a fresh | |
| 412 | − // workspace can register its first app. | |
| 413 | − const dialogs = ( | |
| 414 | − <> | |
| 415 | − {dialog.kind === "add" ? ( | |
| 416 | − <AppFormDialog | |
| 417 | − title="Register an OAuth app" | |
| 418 | − existingSlugs={existingSlugs} | |
| 419 | − onClose={() => setDialog({ kind: "none" })} | |
| 420 | − /> | |
| 421 | − ) : null} | |
| 422 | − | |
| 423 | − {dialog.kind === "edit" ? ( | |
| 424 | − <AppFormDialog | |
| 425 | − title={`Edit ${String(dialog.client.slug)}`} | |
| 426 | − existingSlugs={existingSlugs} | |
| 427 | − fixedSlug={dialog.client.slug} | |
| 428 | − fixedOwner={dialog.client.owner} | |
| 429 | − prefill={{ | |
| 430 | − authorizationUrl: dialog.client.authorizationUrl, | |
| 431 | − tokenUrl: dialog.client.tokenUrl, | |
| 432 | − resource: dialog.client.resource ?? null, | |
| 433 | − grant: dialog.client.grant, | |
| 434 | − clientId: dialog.client.clientId, | |
| 435 | − }} | |
| 436 | − onClose={() => setDialog({ kind: "none" })} | |
| 437 | − /> | |
| 438 | − ) : null} | |
| 439 | − | |
| 440 | − {dialog.kind === "remove" ? ( | |
| 441 | − <RemoveAppDialog | |
| 442 | − client={dialog.client} | |
| 443 | − connections={connectionsUsingClient(usage, dialog.client)} | |
| 444 | − onConfirm={() => void handleRemove(dialog.client)} | |
| 445 | − onClose={() => setDialog({ kind: "none" })} | |
| 446 | − /> | |
| 447 | − ) : null} | |
| 448 | − </> | |
| 449 | − ); | |
| 450 | − if (groups.length === 0) { | |
| 451 | − return ( | |
| 452 | − <div className="flex flex-col gap-6"> | |
| 453 | − <CardStack> | |
| 454 | − <CardStackHeader>Registered apps</CardStackHeader> | |
| 455 | − <CardStackContent> | |
| 456 | − <CardStackEntry> | |
| 457 | − <CardStackEntryContent> | |
| 458 | − <CardStackEntryDescription> | |
| 459 | − No OAuth apps yet. Register one to back OAuth connections with your own | |
| 460 | − client id/secret. | |
| 461 | − </CardStackEntryDescription> | |
| 462 | − </CardStackEntryContent> | |
| 463 | − </CardStackEntry> | |
| 464 | − </CardStackContent> | |
| 465 | − </CardStack> | |
| 466 | − {dialogs} | |
| 467 | − </div> | |
| 468 | − ); | |
| 469 | − } | |
| 470 | − return ( | |
| 471 | − <div className="flex flex-col gap-6"> | |
| 472 | − {groups.map((group) => ( | |
| 473 | − <CardStack key={group.owner}> | |
| 474 | − <CardStackHeader>{ownerLabel(group.owner)}</CardStackHeader> | |
| 475 | − <CardStackContent> | |
| 476 | − {group.clients.map((client: OAuthClientSummary) => ( | |
| 477 | − <OAuthAppCard | |
| 478 | − key={`${client.owner}.${String(client.slug)}`} | |
| 479 | − client={client} | |
| 480 | − connections={connectionsUsingClient(usage, client)} | |
| 481 | − onEdit={() => setDialog({ kind: "edit", client })} | |
| 482 | − onRemove={() => setDialog({ kind: "remove", client })} | |
| 483 | − /> | |
| 484 | − ))} | |
| 485 | − </CardStackContent> | |
| 486 | − </CardStack> | |
| 487 | − ))} | |
| 488 | − | |
| 489 | − {dialogs} | |
| 490 | − </div> | |
| 491 | − ); | |
| 492 | − }, | |
| 493 | − })} | |
| 494 | − </div> | |
| 495 | − </div> | |
| 496 | − ); | |
| 497 | −} | |
| @@ -3,7 +3,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 2 unmodified lines | ||
| 3 | 3 | import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; |
| 4 | 4 | import { getDomain } from "tldts"; |
| 5 | 5 | |
| 6 | −import { oauthClientsAtom } from "../api/atoms"; | |
| 6 | +import { oauthClientsOptimisticAtom } from "../api/atoms"; | |
| 7 | 7 | |
| 8 | 8 | // --------------------------------------------------------------------------- |
| 9 | 9 | // OAuth client (registered app) selection for an integration's connect flow. |
| @@ -134,7 +134,11 @@ export function useOAuthClientsForIntegration(opts: { · 124 unmodified lines | ||
| 134 | 134 | readonly tokenUrl?: string; |
| 135 | 135 | readonly authorizationUrl?: string; |
| 136 | 136 | }): UseOAuthClientsResult { |
| 137 | − const clientsResult = useAtomValue(oauthClientsAtom); | |
| 137 | + // Read the optimistic list so a just-registered/edited/removed app paints | |
| 138 | + // immediately, instead of flashing the stale server list until the refetch | |
| 139 | + // lands. The modal's management menu reads the same optimistic atom, so the | |
| 140 | + // picker rows and their actions stay consistent. | |
| 141 | + const clientsResult = useAtomValue(oauthClientsOptimisticAtom); | |
| 138 | 142 | if (!AsyncResult.isSuccess(clientsResult)) { |
| 139 | 143 | return { |
| 140 | 144 | clients: [], |