History

Fold OAuth apps management into the connect modal (#932)

* 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.

Rhys Sullivan committed 5 months ago

Commit 205fe3d1 parent 72049a819 files · +634 −709Browse files

Unified · Split

19 files
  1. apps/cloud/src/routeTree.gen.ts +0 −21
  2. apps/cloud/src/routes/oauth-apps.tsx +0 −6
  3. apps/host-selfhost/web/routeTree.gen.ts +0 −21
  4. apps/host-selfhost/web/routes/oauth-apps.tsx +0 −6
  5. e2e/scenarios/openapi-source.test.ts +53 −32
  6. e2e/selfhost/oauth-app-modal.test.ts +169 −0
  7. e2e/targets/selfhost.ts +41 −7
  8. packages/app/src/routeTree.gen.ts +0 −21
  9. packages/app/src/routes/oauth-apps.tsx +0 −6
  10. packages/app/src/web/shell.tsx +0 −7
  11. packages/react/src/api/atoms.tsx +46 −0
  12. packages/react/src/components/add-account-modal.tsx +193 −49
  13. packages/react/src/components/oauth-client-form.tsx +2 −2
  14. packages/react/src/components/remove-oauth-app-dialog.tsx +77 −0
  15. packages/react/src/lib/oauth-client-usage.test.ts +1 −31
  16. packages/react/src/lib/oauth-client-usage.ts +46 −0
  17. packages/react/src/multiplayer/shell.tsx +0 −1
  18. packages/react/src/pages/oauth-apps.tsx +0 −497
  19. packages/react/src/plugins/use-effective-oauth-client.tsx +6 −2
apps/cloud/src/routeTree.gen.ts+0−21
@@ -14,7 +14,6 @@ import { Route as SetupMcpRouteImport } from './routes/setup-mcp' · 13 unmodified lines
14import { Route as SecretsRouteImport } from './routes/secrets'14import { Route as SecretsRouteImport } from './routes/secrets'
15import { Route as PoliciesRouteImport } from './routes/policies'15import { Route as PoliciesRouteImport } from './routes/policies'
16import { Route as OrgRouteImport } from './routes/org'16import { Route as OrgRouteImport } from './routes/org'
17import { Route as OauthAppsRouteImport } from './routes/oauth-apps'
18import { Route as CreateOrgRouteImport } from './routes/create-org'17import { Route as CreateOrgRouteImport } from './routes/create-org'
19import { Route as BillingRouteImport } from './routes/billing'18import { Route as BillingRouteImport } from './routes/billing'
20import { Route as ApiKeysRouteImport } from './routes/api-keys'19import { Route as ApiKeysRouteImport } from './routes/api-keys'
@@ -49,11 +48,6 @@ const OrgRoute = OrgRouteImport.update({ · 28 unmodified lines
49 path: '/org',48 path: '/org',
50 getParentRoute: () => rootRouteImport,49 getParentRoute: () => rootRouteImport,
51} as any)50} as any)
52const OauthAppsRoute = OauthAppsRouteImport.update({
53 id: '/oauth-apps',
54 path: '/oauth-apps',
55 getParentRoute: () => rootRouteImport,
56} as any)
57const CreateOrgRoute = CreateOrgRouteImport.update({51const CreateOrgRoute = CreateOrgRouteImport.update({
58 id: '/create-org',52 id: '/create-org',
59 path: '/create-org',53 path: '/create-org',
@@ -101,7 +95,6 @@ export interface FileRoutesByFullPath { · 41 unmodified lines
101 '/api-keys': typeof ApiKeysRoute95 '/api-keys': typeof ApiKeysRoute
102 '/billing': typeof BillingRoute96 '/billing': typeof BillingRoute
103 '/create-org': typeof CreateOrgRoute97 '/create-org': typeof CreateOrgRoute
104 '/oauth-apps': typeof OauthAppsRoute
105 '/org': typeof OrgRoute98 '/org': typeof OrgRoute
106 '/policies': typeof PoliciesRoute99 '/policies': typeof PoliciesRoute
107 '/secrets': typeof SecretsRoute100 '/secrets': typeof SecretsRoute
@@ -117,7 +110,6 @@ export interface FileRoutesByTo { · 9 unmodified lines
117 '/api-keys': typeof ApiKeysRoute110 '/api-keys': typeof ApiKeysRoute
118 '/billing': typeof BillingRoute111 '/billing': typeof BillingRoute
119 '/create-org': typeof CreateOrgRoute112 '/create-org': typeof CreateOrgRoute
120 '/oauth-apps': typeof OauthAppsRoute
121 '/org': typeof OrgRoute113 '/org': typeof OrgRoute
122 '/policies': typeof PoliciesRoute114 '/policies': typeof PoliciesRoute
123 '/secrets': typeof SecretsRoute115 '/secrets': typeof SecretsRoute
@@ -134,7 +126,6 @@ export interface FileRoutesById { · 10 unmodified lines
134 '/api-keys': typeof ApiKeysRoute126 '/api-keys': typeof ApiKeysRoute
135 '/billing': typeof BillingRoute127 '/billing': typeof BillingRoute
136 '/create-org': typeof CreateOrgRoute128 '/create-org': typeof CreateOrgRoute
137 '/oauth-apps': typeof OauthAppsRoute
138 '/org': typeof OrgRoute129 '/org': typeof OrgRoute
139 '/policies': typeof PoliciesRoute130 '/policies': typeof PoliciesRoute
140 '/secrets': typeof SecretsRoute131 '/secrets': typeof SecretsRoute
@@ -152,7 +143,6 @@ export interface FileRouteTypes { · 11 unmodified lines
152 | '/api-keys'143 | '/api-keys'
153 | '/billing'144 | '/billing'
154 | '/create-org'145 | '/create-org'
155 | '/oauth-apps'
156 | '/org'146 | '/org'
157 | '/policies'147 | '/policies'
158 | '/secrets'148 | '/secrets'
@@ -168,7 +158,6 @@ export interface FileRouteTypes { · 9 unmodified lines
168 | '/api-keys'158 | '/api-keys'
169 | '/billing'159 | '/billing'
170 | '/create-org'160 | '/create-org'
171 | '/oauth-apps'
172 | '/org'161 | '/org'
173 | '/policies'162 | '/policies'
174 | '/secrets'163 | '/secrets'
@@ -184,7 +173,6 @@ export interface FileRouteTypes { · 9 unmodified lines
184 | '/api-keys'173 | '/api-keys'
185 | '/billing'174 | '/billing'
186 | '/create-org'175 | '/create-org'
187 | '/oauth-apps'
188 | '/org'176 | '/org'
189 | '/policies'177 | '/policies'
190 | '/secrets'178 | '/secrets'
@@ -201,7 +189,6 @@ export interface RootRouteChildren { · 10 unmodified lines
201 ApiKeysRoute: typeof ApiKeysRoute189 ApiKeysRoute: typeof ApiKeysRoute
202 BillingRoute: typeof BillingRoute190 BillingRoute: typeof BillingRoute
203 CreateOrgRoute: typeof CreateOrgRoute191 CreateOrgRoute: typeof CreateOrgRoute
204 OauthAppsRoute: typeof OauthAppsRoute
205 OrgRoute: typeof OrgRoute192 OrgRoute: typeof OrgRoute
206 PoliciesRoute: typeof PoliciesRoute193 PoliciesRoute: typeof PoliciesRoute
207 SecretsRoute: typeof SecretsRoute194 SecretsRoute: typeof SecretsRoute
@@ -250,13 +237,6 @@ declare module '@tanstack/react-router' { · 42 unmodified lines
250 preLoaderRoute: typeof OrgRouteImport237 preLoaderRoute: typeof OrgRouteImport
251 parentRoute: typeof rootRouteImport238 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 '/create-org': {240 '/create-org': {
261 id: '/create-org'241 id: '/create-org'
262 path: '/create-org'242 path: '/create-org'
@@ -321,7 +301,6 @@ const rootRouteChildren: RootRouteChildren = { · 58 unmodified lines
321 ApiKeysRoute: ApiKeysRoute,301 ApiKeysRoute: ApiKeysRoute,
322 BillingRoute: BillingRoute,302 BillingRoute: BillingRoute,
323 CreateOrgRoute: CreateOrgRoute,303 CreateOrgRoute: CreateOrgRoute,
324 OauthAppsRoute: OauthAppsRoute,
325 OrgRoute: OrgRoute,304 OrgRoute: OrgRoute,
326 PoliciesRoute: PoliciesRoute,305 PoliciesRoute: PoliciesRoute,
327 SecretsRoute: SecretsRoute,306 SecretsRoute: SecretsRoute,
apps/cloud/src/routes/oauth-apps.tsx+0−6
@@ -1,6 +0,0 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { OAuthAppsPage } from "@executor-js/react/pages/oauth-apps";
3
4export const Route = createFileRoute("/oauth-apps")({
5 component: () => <OAuthAppsPage />,
6});
apps/host-selfhost/web/routeTree.gen.ts+0−21
@@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
12import { Route as ToolsRouteImport } from './routes/tools'12import { Route as ToolsRouteImport } from './routes/tools'
13import { Route as SecretsRouteImport } from './routes/secrets'13import { Route as SecretsRouteImport } from './routes/secrets'
14import { Route as PoliciesRouteImport } from './routes/policies'14import { Route as PoliciesRouteImport } from './routes/policies'
15import { Route as OauthAppsRouteImport } from './routes/oauth-apps'
16import { Route as ApiKeysRouteImport } from './routes/api-keys'15import { Route as ApiKeysRouteImport } from './routes/api-keys'
17import { Route as AdminRouteImport } from './routes/admin'16import { Route as AdminRouteImport } from './routes/admin'
18import { Route as IndexRouteImport } from './routes/index'17import { Route as IndexRouteImport } from './routes/index'
@@ -39,11 +38,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ · 20 unmodified lines
39 path: '/policies',38 path: '/policies',
40 getParentRoute: () => rootRouteImport,39 getParentRoute: () => rootRouteImport,
41} as any)40} as any)
42const OauthAppsRoute = OauthAppsRouteImport.update({
43 id: '/oauth-apps',
44 path: '/oauth-apps',
45 getParentRoute: () => rootRouteImport,
46} as any)
47const ApiKeysRoute = ApiKeysRouteImport.update({41const ApiKeysRoute = ApiKeysRouteImport.update({
48 id: '/api-keys',42 id: '/api-keys',
49 path: '/api-keys',43 path: '/api-keys',
@@ -100,7 +94,6 @@ export interface FileRoutesByFullPath { · 50 unmodified lines
100 '/': typeof IndexRoute94 '/': typeof IndexRoute
101 '/admin': typeof AdminRoute95 '/admin': typeof AdminRoute
102 '/api-keys': typeof ApiKeysRoute96 '/api-keys': typeof ApiKeysRoute
103 '/oauth-apps': typeof OauthAppsRoute
104 '/policies': typeof PoliciesRoute97 '/policies': typeof PoliciesRoute
105 '/secrets': typeof SecretsRoute98 '/secrets': typeof SecretsRoute
106 '/tools': typeof ToolsRoute99 '/tools': typeof ToolsRoute
@@ -116,7 +109,6 @@ export interface FileRoutesByTo { · 9 unmodified lines
116 '/': typeof IndexRoute109 '/': typeof IndexRoute
117 '/admin': typeof AdminRoute110 '/admin': typeof AdminRoute
118 '/api-keys': typeof ApiKeysRoute111 '/api-keys': typeof ApiKeysRoute
119 '/oauth-apps': typeof OauthAppsRoute
120 '/policies': typeof PoliciesRoute112 '/policies': typeof PoliciesRoute
121 '/secrets': typeof SecretsRoute113 '/secrets': typeof SecretsRoute
122 '/tools': typeof ToolsRoute114 '/tools': typeof ToolsRoute
@@ -133,7 +125,6 @@ export interface FileRoutesById { · 10 unmodified lines
133 '/': typeof IndexRoute125 '/': typeof IndexRoute
134 '/admin': typeof AdminRoute126 '/admin': typeof AdminRoute
135 '/api-keys': typeof ApiKeysRoute127 '/api-keys': typeof ApiKeysRoute
136 '/oauth-apps': typeof OauthAppsRoute
137 '/policies': typeof PoliciesRoute128 '/policies': typeof PoliciesRoute
138 '/secrets': typeof SecretsRoute129 '/secrets': typeof SecretsRoute
139 '/tools': typeof ToolsRoute130 '/tools': typeof ToolsRoute
@@ -151,7 +142,6 @@ export interface FileRouteTypes { · 11 unmodified lines
151 | '/'142 | '/'
152 | '/admin'143 | '/admin'
153 | '/api-keys'144 | '/api-keys'
154 | '/oauth-apps'
155 | '/policies'145 | '/policies'
156 | '/secrets'146 | '/secrets'
157 | '/tools'147 | '/tools'
@@ -167,7 +157,6 @@ export interface FileRouteTypes { · 9 unmodified lines
167 | '/'157 | '/'
168 | '/admin'158 | '/admin'
169 | '/api-keys'159 | '/api-keys'
170 | '/oauth-apps'
171 | '/policies'160 | '/policies'
172 | '/secrets'161 | '/secrets'
173 | '/tools'162 | '/tools'
@@ -183,7 +172,6 @@ export interface FileRouteTypes { · 9 unmodified lines
183 | '/'172 | '/'
184 | '/admin'173 | '/admin'
185 | '/api-keys'174 | '/api-keys'
186 | '/oauth-apps'
187 | '/policies'175 | '/policies'
188 | '/secrets'176 | '/secrets'
189 | '/tools'177 | '/tools'
@@ -200,7 +188,6 @@ export interface RootRouteChildren { · 10 unmodified lines
200 IndexRoute: typeof IndexRoute188 IndexRoute: typeof IndexRoute
201 AdminRoute: typeof AdminRoute189 AdminRoute: typeof AdminRoute
202 ApiKeysRoute: typeof ApiKeysRoute190 ApiKeysRoute: typeof ApiKeysRoute
203 OauthAppsRoute: typeof OauthAppsRoute
204 PoliciesRoute: typeof PoliciesRoute191 PoliciesRoute: typeof PoliciesRoute
205 SecretsRoute: typeof SecretsRoute192 SecretsRoute: typeof SecretsRoute
206 ToolsRoute: typeof ToolsRoute193 ToolsRoute: typeof ToolsRoute
@@ -236,13 +223,6 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
236 preLoaderRoute: typeof PoliciesRouteImport223 preLoaderRoute: typeof PoliciesRouteImport
237 parentRoute: typeof rootRouteImport224 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 '/api-keys': {226 '/api-keys': {
247 id: '/api-keys'227 id: '/api-keys'
248 path: '/api-keys'228 path: '/api-keys'
@@ -320,7 +300,6 @@ const rootRouteChildren: RootRouteChildren = { · 71 unmodified lines
320 IndexRoute: IndexRoute,300 IndexRoute: IndexRoute,
321 AdminRoute: AdminRoute,301 AdminRoute: AdminRoute,
322 ApiKeysRoute: ApiKeysRoute,302 ApiKeysRoute: ApiKeysRoute,
323 OauthAppsRoute: OauthAppsRoute,
324 PoliciesRoute: PoliciesRoute,303 PoliciesRoute: PoliciesRoute,
325 SecretsRoute: SecretsRoute,304 SecretsRoute: SecretsRoute,
326 ToolsRoute: ToolsRoute,305 ToolsRoute: ToolsRoute,
apps/host-selfhost/web/routes/oauth-apps.tsx+0−6
@@ -1,6 +0,0 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { OAuthAppsPage } from "@executor-js/react/pages/oauth-apps";
3
4export const Route = createFileRoute("/oauth-apps")({
5 component: OAuthAppsPage,
6});
e2e/scenarios/openapi-source.test.ts+53−32
@@ -53,42 +53,63 @@ scenario( · 52 unmodified lines
53 const slug = `openapi-scn-greet-${randomBytes(4).toString("hex")}`;53 const slug = `openapi-scn-greet-${randomBytes(4).toString("hex")}`;
54 const specBaseUrl = "http://127.0.0.1:59999"; // never contacted during registration54 const specBaseUrl = "http://127.0.0.1:59999"; // never contacted during registration
55 55
56 const added = yield* client.openapi.addSpec({56 yield* Effect.ensuring(
57 payload: {57 Effect.gen(function* () {
58 spec: { kind: "blob", value: greetSpec(specBaseUrl) },58 const added = yield* client.openapi.addSpec({
59 slug,59 payload: {
60 baseUrl: specBaseUrl,60 spec: { kind: "blob", value: greetSpec(specBaseUrl) },
61 authenticationTemplate: [61 slug,
62 {62 baseUrl: specBaseUrl,
63 slug: "apiKey",63 authenticationTemplate: [
64 type: "apiKey",64 {
65 headers: { "x-api-key": [{ type: "variable", name: "token" }] },65 slug: "apiKey",
66 type: "apiKey",
67 headers: { "x-api-key": [{ type: "variable", name: "token" }] },
68 },
69 ],
66 },70 },
67 ],71 });
68 },72 expect(added.toolCount, "the spec's operations were extracted as tools").toBeGreaterThan(
69 });73 0,
70 expect(added.toolCount, "the spec's operations were extracted as tools").toBeGreaterThan(0);74 );
71 expect(added.slug, "the integration keeps the requested slug").toBe(slug);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` provider77 // The catalog stamps tools once a connection exists; a `from` provider
74 // reference avoids any vault round-trip.78 // reference avoids any vault round-trip.
75 const providers = yield* client.providers.list();79 const providers = yield* client.providers.list();
76 expect(providers.length, "a credential provider is available").toBeGreaterThan(0);80 expect(providers.length, "a credential provider is available").toBeGreaterThan(0);
77 81
78 yield* client.connections.create({82 yield* client.connections.create({
79 payload: {83 payload: {
80 owner: "org",84 owner: "org",
81 name: ConnectionName.make("main"),85 name: ConnectionName.make("main"),
82 integration: IntegrationSlug.make(slug),86 integration: IntegrationSlug.make(slug),
83 template: AuthTemplateSlug.make("apiKey"),87 template: AuthTemplateSlug.make("apiKey"),
84 from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },88 from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
85 },89 },
86 });90 });
87 91
88 const tools = yield* client.tools.list();92 const tools = yield* client.tools.list();
89 const mine = tools.filter((tool) => String(tool.integration) === slug).map((t) => t.name);93 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(94 expect(mine.join(", "), "the spec's operation is in the tool catalog").toContain(
91 "getGreeting",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);
e2e/selfhost/oauth-app-modal.test.ts+169−0
@@ -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.
10import { randomBytes } from "node:crypto";
11
12import { expect } from "@effect/vitest";
13import { Effect } from "effect";
14import { composePluginApi } from "@executor-js/api/server";
15import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
16
17import { scenario } from "../src/scenario";
18
19const api = composePluginApi([openApiHttpPlugin()] as const);
20
21/** Minimal OpenAPI 3 spec — one operation, server never contacted. */
22const 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
38scenario(
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);
e2e/targets/selfhost.ts+41−7
@@ -17,18 +17,52 @@ export const SELFHOST_ADMIN = { · 16 unmodified lines
17 password: process.env.E2E_SELFHOST_ADMIN_PASSWORD ?? "e2e-admin-password-123",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.
25const 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
20export const selfhostTarget = (): Target => ({47export const selfhostTarget = (): Target => ({
21 name: "selfhost",48 name: "selfhost",
22 baseUrl: SELFHOST_BASE_URL,49 baseUrl: SELFHOST_BASE_URL,
23 mcpUrl: `${SELFHOST_BASE_URL}/mcp`,50 mcpUrl: `${SELFHOST_BASE_URL}/mcp`,
24 // No "billing" (no limits) and no "browser" yet (cookie injection for the51 // No "billing" (no limits). Identity is the bootstrap admin for now —
25 // Better Auth session isn't wired). Identity is the bootstrap admin for now —52 // single-tenant; per-test invite-signup isolation is the next step here, so
26 // single-tenant; per-test invite-signup isolation is the next step here.53 // browser scenarios must prefix the resources they create.
27 capabilities: new Set(["api", "mcp-oauth"]),54 capabilities: new Set(["api", "browser", "mcp-oauth"]),
28 newIdentity: () =>55 newIdentity: () =>
29 Effect.succeed<Identity>({56 Effect.promise(async (): Promise<Identity> => {
30 label: SELFHOST_ADMIN.email,57 // Sign in once and carry the session in both shapes: `headers` for the
31 credentials: SELFHOST_ADMIN,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 mcpConsent: (identity: Identity) =>67 mcpConsent: (identity: Identity) =>
34 cookieConsentStrategy({68 cookieConsentStrategy({
packages/app/src/routeTree.gen.ts+0−21
@@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
12import { Route as ToolsRouteImport } from './routes/tools'12import { Route as ToolsRouteImport } from './routes/tools'
13import { Route as SecretsRouteImport } from './routes/secrets'13import { Route as SecretsRouteImport } from './routes/secrets'
14import { Route as PoliciesRouteImport } from './routes/policies'14import { Route as PoliciesRouteImport } from './routes/policies'
15import { Route as OauthAppsRouteImport } from './routes/oauth-apps'
16import { Route as IndexRouteImport } from './routes/index'15import { Route as IndexRouteImport } from './routes/index'
17import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId'16import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId'
18import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace'17import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace'
@@ -34,11 +33,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ · 15 unmodified lines
34 path: '/policies',33 path: '/policies',
35 getParentRoute: () => rootRouteImport,34 getParentRoute: () => rootRouteImport,
36} as any)35} as any)
37const OauthAppsRoute = OauthAppsRouteImport.update({
38 id: '/oauth-apps',
39 path: '/oauth-apps',
40 getParentRoute: () => rootRouteImport,
41} as any)
42const IndexRoute = IndexRouteImport.update({36const IndexRoute = IndexRouteImport.update({
43 id: '/',37 id: '/',
44 path: '/',38 path: '/',
@@ -68,7 +62,6 @@ const IntegrationsAddPluginKeyRoute = · 23 unmodified lines
68 62
69export interface FileRoutesByFullPath {63export interface FileRoutesByFullPath {
70 '/': typeof IndexRoute64 '/': typeof IndexRoute
71 '/oauth-apps': typeof OauthAppsRoute
72 '/policies': typeof PoliciesRoute65 '/policies': typeof PoliciesRoute
73 '/secrets': typeof SecretsRoute66 '/secrets': typeof SecretsRoute
74 '/tools': typeof ToolsRoute67 '/tools': typeof ToolsRoute
@@ -79,7 +72,6 @@ export interface FileRoutesByFullPath { · 4 unmodified lines
79}72}
80export interface FileRoutesByTo {73export interface FileRoutesByTo {
81 '/': typeof IndexRoute74 '/': typeof IndexRoute
82 '/oauth-apps': typeof OauthAppsRoute
83 '/policies': typeof PoliciesRoute75 '/policies': typeof PoliciesRoute
84 '/secrets': typeof SecretsRoute76 '/secrets': typeof SecretsRoute
85 '/tools': typeof ToolsRoute77 '/tools': typeof ToolsRoute
@@ -91,7 +83,6 @@ export interface FileRoutesByTo { · 5 unmodified lines
91export interface FileRoutesById {83export interface FileRoutesById {
92 __root__: typeof rootRouteImport84 __root__: typeof rootRouteImport
93 '/': typeof IndexRoute85 '/': typeof IndexRoute
94 '/oauth-apps': typeof OauthAppsRoute
95 '/policies': typeof PoliciesRoute86 '/policies': typeof PoliciesRoute
96 '/secrets': typeof SecretsRoute87 '/secrets': typeof SecretsRoute
97 '/tools': typeof ToolsRoute88 '/tools': typeof ToolsRoute
@@ -104,7 +95,6 @@ export interface FileRouteTypes { · 6 unmodified lines
104 fileRoutesByFullPath: FileRoutesByFullPath95 fileRoutesByFullPath: FileRoutesByFullPath
105 fullPaths:96 fullPaths:
106 | '/'97 | '/'
107 | '/oauth-apps'
108 | '/policies'98 | '/policies'
109 | '/secrets'99 | '/secrets'
110 | '/tools'100 | '/tools'
@@ -115,7 +105,6 @@ export interface FileRouteTypes { · 4 unmodified lines
115 fileRoutesByTo: FileRoutesByTo105 fileRoutesByTo: FileRoutesByTo
116 to:106 to:
117 | '/'107 | '/'
118 | '/oauth-apps'
119 | '/policies'108 | '/policies'
120 | '/secrets'109 | '/secrets'
121 | '/tools'110 | '/tools'
@@ -126,7 +115,6 @@ export interface FileRouteTypes { · 4 unmodified lines
126 id:115 id:
127 | '__root__'116 | '__root__'
128 | '/'117 | '/'
129 | '/oauth-apps'
130 | '/policies'118 | '/policies'
131 | '/secrets'119 | '/secrets'
132 | '/tools'120 | '/tools'
@@ -138,7 +126,6 @@ export interface FileRouteTypes { · 5 unmodified lines
138}126}
139export interface RootRouteChildren {127export interface RootRouteChildren {
140 IndexRoute: typeof IndexRoute128 IndexRoute: typeof IndexRoute
141 OauthAppsRoute: typeof OauthAppsRoute
142 PoliciesRoute: typeof PoliciesRoute129 PoliciesRoute: typeof PoliciesRoute
143 SecretsRoute: typeof SecretsRoute130 SecretsRoute: typeof SecretsRoute
144 ToolsRoute: typeof ToolsRoute131 ToolsRoute: typeof ToolsRoute
@@ -171,13 +158,6 @@ declare module '@tanstack/react-router' { · 26 unmodified lines
171 preLoaderRoute: typeof PoliciesRouteImport158 preLoaderRoute: typeof PoliciesRouteImport
172 parentRoute: typeof rootRouteImport159 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 id: '/'162 id: '/'
183 path: '/'163 path: '/'
@@ -218,7 +198,6 @@ declare module '@tanstack/react-router' { · 34 unmodified lines
218 198
219const rootRouteChildren: RootRouteChildren = {199const rootRouteChildren: RootRouteChildren = {
220 IndexRoute: IndexRoute,200 IndexRoute: IndexRoute,
221 OauthAppsRoute: OauthAppsRoute,
222 PoliciesRoute: PoliciesRoute,201 PoliciesRoute: PoliciesRoute,
223 SecretsRoute: SecretsRoute,202 SecretsRoute: SecretsRoute,
224 ToolsRoute: ToolsRoute,203 ToolsRoute: ToolsRoute,
packages/app/src/routes/oauth-apps.tsx+0−6
@@ -1,6 +0,0 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { OAuthAppsPage } from "@executor-js/react/pages/oauth-apps";
3
4export const Route = createFileRoute("/oauth-apps")({
5 component: OAuthAppsPage,
6});
packages/app/src/web/shell.tsx+0−7
@@ -338,7 +338,6 @@ function SidebarContent(props: { · 337 unmodified lines
338}) {338}) {
339 const isHome = props.pathname === "/";339 const isHome = props.pathname === "/";
340 const isSecrets = props.pathname === "/secrets";340 const isSecrets = props.pathname === "/secrets";
341 const isOAuthApps = props.pathname === "/oauth-apps";
342 const isPolicies = props.pathname === "/policies";341 const isPolicies = props.pathname === "/policies";
343 342
344 return (343 return (
@@ -360,12 +359,6 @@ function SidebarContent(props: { · 15 unmodified lines
360 <nav className="flex flex-1 flex-col overflow-y-auto p-2">359 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
361 <NavItem to="/" label="Integrations" active={isHome} onNavigate={props.onNavigate} />360 <NavItem to="/" label="Integrations" active={isHome} onNavigate={props.onNavigate} />
362 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />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 <NavItem362 <NavItem
370 to="/policies"363 to="/policies"
371 label="Policies"364 label="Policies"
packages/react/src/api/atoms.tsx+46−0
@@ -7,6 +7,8 @@ import { · 6 unmodified lines
7 type ConnectionName,7 type ConnectionName,
8 type IntegrationSlug,8 type IntegrationSlug,
9 type OAuthClientSlug,9 type OAuthClientSlug,
10 type OAuthClientSummary,
11 type OAuthGrant,
10 type Owner,12 type Owner,
11 type ProviderItemId,13 type ProviderItemId,
12 type ToolAddress,14 type ToolAddress,
@@ -433,3 +435,47 @@ export const removeOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 420 unmodified lines
433 fn: removeOAuthClient,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. */
444export 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);
packages/react/src/components/add-account-modal.tsx+193−49
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 OAuthClientSlug,8 OAuthClientSlug,
9 ProviderItemId,9 ProviderItemId,
10 ProviderKey,10 ProviderKey,
11 type OAuthClientSummary,
11 type Owner,12 type Owner,
12} from "@executor-js/sdk/shared";13} from "@executor-js/sdk/shared";
13import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";14import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
@@ -15,10 +16,13 @@ import { toast } from "sonner"; · 1 unmodified line
15 16
16import {17import {
17 addConnectionOptimistic,18 addConnectionOptimistic,
19 connectionsAllAtom,
20 oauthClientsOptimisticAtom,
18 probeOAuth,21 probeOAuth,
19 providerItemsAtom,22 providerItemsAtom,
20 providersAtom,23 providersAtom,
21 registerDynamicOAuthClient,24 registerDynamicOAuthClient,
25 removeOAuthClientOptimistic,
22 startOAuth,26 startOAuth,
23} from "../api/atoms";27} from "../api/atoms";
24import { connectionWriteKeys, oauthClientWriteKeys } from "../api/reactivity-keys";28import { connectionWriteKeys, oauthClientWriteKeys } from "../api/reactivity-keys";
@@ -42,12 +46,20 @@ import { · 17 unmodified lines
42 type OAuthClientOption,46 type OAuthClientOption,
43} from "../plugins/use-effective-oauth-client";47} from "../plugins/use-effective-oauth-client";
44import { cn } from "../lib/utils";48import { cn } from "../lib/utils";
49import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage";
45import { OAuthClientForm } from "./oauth-client-form";50import { OAuthClientForm } from "./oauth-client-form";
51import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
46import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";52import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
47import { PlacementLine, type AuthMethod } from "../lib/auth-placements";53import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
48import { connectionIdentifier } from "../lib/connection-name";54import { connectionIdentifier } from "../lib/connection-name";
49import { Badge } from "./badge";55import { Badge } from "./badge";
50import { Button } from "./button";56import { Button } from "./button";
57import {
58 DropdownMenu,
59 DropdownMenuContent,
60 DropdownMenuItem,
61 DropdownMenuTrigger,
62} from "./dropdown-menu";
51import { PlusIcon, XIcon } from "lucide-react";63import { PlusIcon, XIcon } from "lucide-react";
52import {64import {
53 Dialog,65 Dialog,
@@ -509,6 +521,72 @@ export async function runDcrConnect( · 455 unmodified lines
509 return { kind: "started" };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// ---------------------------------------------------------------------------
531function 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
512export function AddAccountModal(props: {590export function AddAccountModal(props: {
513 readonly integration: IntegrationSlug;591 readonly integration: IntegrationSlug;
514 readonly integrationName: string;592 readonly integrationName: string;
@@ -579,6 +657,12 @@ export function AddAccountModal(props: { · 64 unmodified lines
579 // FIX 3 escape hatch: when no registered app matched the integration's657 // FIX 3 escape hatch: when no registered app matched the integration's
580 // endpoints, the unmatched apps are collapsed behind an opt-in expander.658 // endpoints, the unmatched apps are collapsed behind an opt-in expander.
581 const [showOtherApps, setShowOtherApps] = useState(false);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 const doCreate = useAtomSet(addConnectionOptimistic(owner), {667 const doCreate = useAtomSet(addConnectionOptimistic(owner), {
584 mode: "promiseExit",668 mode: "promiseExit",
@@ -588,6 +672,22 @@ export function AddAccountModal(props: { · 3 unmodified lines
588 const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, {672 const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, {
589 mode: "promiseExit",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 const method = useMemo(692 const method = useMemo(
593 () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0],693 () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0],
@@ -688,6 +788,9 @@ export function AddAccountModal(props: { · 94 unmodified lines
688 oauthEndpointMatched && oauthApps.length > 0 ? String(oauthApps[0]?.slug) : "";788 oauthEndpointMatched && oauthApps.length > 0 ? String(oauthApps[0]?.slug) : "";
689 const selectedApp = pickedApp ?? oauthDefaultApp;789 const selectedApp = pickedApp ?? oauthDefaultApp;
690 const oauthRegistering = isOAuth && registeringOAuthClient;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 const chosenClient: OAuthClientOption | null =794 const chosenClient: OAuthClientOption | null =
692 oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null;795 oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null;
693 const oauthBusy = ccBusy || oauthPopup.busy;796 const oauthBusy = ccBusy || oauthPopup.busy;
@@ -728,11 +831,44 @@ export function AddAccountModal(props: { · 34 unmodified lines
728 setDcrBusy(false);831 setDcrBusy(false);
729 setDcrFailed(false);832 setDcrFailed(false);
730 setShowOtherApps(false);833 setShowOtherApps(false);
834 setEditingClient(null);
835 setRemovingClient(null);
731 setCreatedMethods([]);836 setCreatedMethods([]);
732 setRemovedMethodIds(new Set());837 setRemovedMethodIds(new Set());
733 setAddingMethod(false);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 const selectMethod = (nextMethodId: string): void => {872 const selectMethod = (nextMethodId: string): void => {
737 setMethodId(nextMethodId);873 setMethodId(nextMethodId);
738 setValues({});874 setValues({});
@@ -957,7 +1093,7 @@ export function AddAccountModal(props: { · 218 unmodified lines
957 <DialogContent1093 <DialogContent
958 className={cn(1094 className={cn(
959 "max-h-[85vh] overflow-x-hidden overflow-y-auto",1095 "max-h-[85vh] overflow-x-hidden overflow-y-auto",
960 (addingMethod && createCustomMethod) || oauthRegistering1096 (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing
961 ? "gap-0 p-0 sm:max-w-2xl"1097 ? "gap-0 p-0 sm:max-w-2xl"
962 : "sm:max-w-xl",1098 : "sm:max-w-xl",
963 )}1099 )}
@@ -976,6 +1112,36 @@ export function AddAccountModal(props: { · 12 unmodified lines
976 onCancel={() => setAddingMethod(false)}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&apos;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 ) : oauthRegistering && method?.kind === "oauth" ? (1145 ) : oauthRegistering && method?.kind === "oauth" ? (
980 <>1146 <>
981 <DialogHeader className="border-b border-border/60 px-5 py-4">1147 <DialogHeader className="border-b border-border/60 px-5 py-4">
@@ -1166,32 +1332,14 @@ export function AddAccountModal(props: { · 184 unmodified lines
1166 className="gap-2 pt-1"1332 className="gap-2 pt-1"
1167 >1333 >
1168 {oauthOtherApps.map((app: OAuthClientOption) => (1334 {oauthOtherApps.map((app: OAuthClientOption) => (
1169 <Label1335 <OAuthAppRadioRow
1170 key={String(app.slug)}1336 key={String(app.slug)}
1171 htmlFor={`other-app-${app.slug}`}1337 app={app}
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"1338 idPrefix="other-app"
1173 >1339 variant="other"
1174 <RadioGroupItem1340 showOwnerLabel={ownerDisplay.showOwnerLabels}
1175 id={`other-app-${app.slug}`}1341 onManage={manageHandlersFor(app)}
1176 value={String(app.slug)}1342 />
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>
1195 ))}1343 ))}
1196 </RadioGroup>1344 </RadioGroup>
1197 ) : null}1345 ) : null}
@@ -1205,30 +1353,14 @@ export function AddAccountModal(props: { · 7 unmodified lines
1205 className="gap-2"1353 className="gap-2"
1206 >1354 >
1207 {oauthApps.map((app: OAuthClientOption) => (1355 {oauthApps.map((app: OAuthClientOption) => (
1208 <Label1356 <OAuthAppRadioRow
1209 key={String(app.slug)}1357 key={String(app.slug)}
1210 htmlFor={`app-${app.slug}`}1358 app={app}
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"1359 idPrefix="app"
1212 >1360 variant="matched"
1213 <RadioGroupItem1361 showOwnerLabel={ownerDisplay.showOwnerLabels}
1214 id={`app-${app.slug}`}1362 onManage={manageHandlersFor(app)}
1215 value={String(app.slug)}1363 />
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>
1232 ))}1364 ))}
1233 <Button1365 <Button
1234 type="button"1366 type="button"
@@ -1331,6 +1463,18 @@ export function AddAccountModal(props: { · 96 unmodified lines
1331 </DialogFooter>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 </DialogContent>1478 </DialogContent>
1335 </Dialog>1479 </Dialog>
1336 );1480 );
packages/react/src/components/oauth-client-form.tsx+2−2
@@ -4,7 +4,7 @@ import * as Exit from "effect/Exit"; · 3 unmodified lines
4import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared";4import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared";
5import { toast } from "sonner";5import { toast } from "sonner";
6 6
7import { createOAuthClient, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";7import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";
8import { ownerLabelForHost } from "../api/owner-display";8import { ownerLabelForHost } from "../api/owner-display";
9import { useOrganizationId } from "../api/organization-context";9import { useOrganizationId } from "../api/organization-context";
10import { oauthClientWriteKeys } from "../api/reactivity-keys";10import { oauthClientWriteKeys } from "../api/reactivity-keys";
@@ -116,7 +116,7 @@ export function OAuthClientForm(props: { · 105 unmodified lines
116 const endpointsKnown = (prefill?.tokenUrl ?? "").length > 0;116 const endpointsKnown = (prefill?.tokenUrl ?? "").length > 0;
117 const [showEndpoints, setShowEndpoints] = useState(!endpointsKnown);117 const [showEndpoints, setShowEndpoints] = useState(!endpointsKnown);
118 118
119 const doCreate = useAtomSet(createOAuthClient, { mode: "promiseExit" });119 const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" });
120 const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" });120 const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" });
121 const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, {121 const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, {
122 mode: "promiseExit",122 mode: "promiseExit",
packages/react/src/components/remove-oauth-app-dialog.tsx+77−0
@@ -0,0 +1,77 @@
1import type { Connection, OAuthClientSummary } from "@executor-js/sdk/shared";
2
3import { ownerLabel } from "../api/owner-display";
4import { Alert, AlertDescription, AlertTitle } from "./alert";
5import { Button } from "./button";
6import {
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
22export 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&apos;t delete those connections, but they&apos;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}
packages/react/src/lib/oauth-client-usage.test.tsrenamed from packages/react/src/pages/oauth-apps.test.ts+1−31
@@ -11,7 +11,7 @@ import { · 10 unmodified lines
11 type Owner,11 type Owner,
12} from "@executor-js/sdk/shared";12} from "@executor-js/sdk/shared";
13 13
14import { buildUsageMap, connectionsUsingClient, groupClientsByOwner } from "./oauth-apps";14import { buildUsageMap, connectionsUsingClient } from "./oauth-client-usage";
15 15
16// Minimal app summary builder — only the fields the helpers read matter.16// Minimal app summary builder — only the fields the helpers read matter.
17const app = (slug: string, opts?: { readonly owner?: Owner }): OAuthClientSummary => ({17const app = (slug: string, opts?: { readonly owner?: Owner }): OAuthClientSummary => ({
@@ -52,36 +52,6 @@ const connection = ( · 34 unmodified lines
52 oauthClientOwner: opts?.oauthClientOwner ?? null,52 oauthClientOwner: opts?.oauthClientOwner ?? null,
53});53});
54 54
55describe("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
85describe("buildUsageMap / connectionsUsingClient", () => {55describe("buildUsageMap / connectionsUsingClient", () => {
86 it("maps connections to the app slug that minted them", () => {56 it("maps connections to the app slug that minted them", () => {
87 const usage = buildUsageMap([57 const usage = buildUsageMap([
packages/react/src/lib/oauth-client-usage.ts+46−0
@@ -0,0 +1,46 @@
1import 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. */
20const 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. */
25export 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. */
41export 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}
packages/react/src/multiplayer/shell.tsx+0−1
@@ -42,7 +42,6 @@ export type ShellNavItem = { readonly to: string; readonly label: string }; · 41 unmodified lines
42export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [42export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [
43 { to: "/", label: "Integrations" },43 { to: "/", label: "Integrations" },
44 { to: "/secrets", label: "Providers" },44 { to: "/secrets", label: "Providers" },
45 { to: "/oauth-apps", label: "OAuth apps" },
46 { to: "/policies", label: "Policies" },45 { to: "/policies", label: "Policies" },
47];46];
48 47
packages/react/src/pages/oauth-apps.tsx+0−497
@@ -1,497 +0,0 @@
1import { useMemo, useState } from "react";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { ChevronDownIcon } from "lucide-react";
5import {
6 OAuthClientSlug,
7 type Connection,
8 type OAuthClientSummary,
9 type OAuthGrant,
10 type Owner,
11} from "@executor-js/sdk/shared";
12import { toast } from "sonner";
13
14import {
15 connectionsAllAtom,
16 oauthClientsOptimisticAtom,
17 removeOAuthClientOptimistic,
18} from "../api/atoms";
19import { oauthClientWriteKeys } from "../api/reactivity-keys";
20import { ownerLabel } from "../api/owner-display";
21import { Badge } from "../components/badge";
22import { Button } from "../components/button";
23import { CopyButton } from "../components/copy-button";
24import {
25 CardStack,
26 CardStackContent,
27 CardStackEntry,
28 CardStackEntryActions,
29 CardStackEntryContent,
30 CardStackEntryDescription,
31 CardStackEntryTitle,
32 CardStackHeader,
33} from "../components/card-stack";
34import {
35 Dialog,
36 DialogContent,
37 DialogDescription,
38 DialogFooter,
39 DialogHeader,
40 DialogTitle,
41} from "../components/dialog";
42import { Alert, AlertDescription, AlertTitle } from "../components/alert";
43import {
44 DropdownMenu,
45 DropdownMenuContent,
46 DropdownMenuItem,
47 DropdownMenuTrigger,
48} from "../components/dropdown-menu";
49import { 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.
67const OWNER_ORDER: readonly Owner[] = ["org", "user"];
68
69const GRANT_LABEL: Record<OAuthGrant, string> = {
70 authorization_code: "Authorization code",
71 client_credentials: "Client credentials",
72};
73
74const 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. */
82export 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. */
95const oauthClientUsageKey = (owner: Owner | null | undefined, slug: OAuthClientSlug): string =>
96 `${owner ?? "org"}\0${String(slug)}`;
97
98export 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. */
114export 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
125function 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
147function 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
248function 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&apos;t delete those connections, but they&apos;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
309function 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.
339type 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
349export 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}
packages/react/src/plugins/use-effective-oauth-client.tsx+6−2
@@ -3,7 +3,7 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 2 unmodified lines
3import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";3import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";
4import { getDomain } from "tldts";4import { getDomain } from "tldts";
5 5
6import { oauthClientsAtom } from "../api/atoms";6import { oauthClientsOptimisticAtom } from "../api/atoms";
7 7
8// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
9// OAuth client (registered app) selection for an integration's connect flow.9// OAuth client (registered app) selection for an integration's connect flow.
@@ -134,7 +134,11 @@ export function useOAuthClientsForIntegration(opts: { · 124 unmodified lines
134 readonly tokenUrl?: string;134 readonly tokenUrl?: string;
135 readonly authorizationUrl?: string;135 readonly authorizationUrl?: string;
136}): UseOAuthClientsResult {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 if (!AsyncResult.isSuccess(clientsResult)) {142 if (!AsyncResult.isSuccess(clientsResult)) {
139 return {143 return {
140 clients: [],144 clients: [],