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
1414 import { Route as SecretsRouteImport } from './routes/secrets'
1515 import { Route as PoliciesRouteImport } from './routes/policies'
1616 import { Route as OrgRouteImport } from './routes/org'
17−import { Route as OauthAppsRouteImport } from './routes/oauth-apps'
1817 import { Route as CreateOrgRouteImport } from './routes/create-org'
1918 import { Route as BillingRouteImport } from './routes/billing'
2019 import { Route as ApiKeysRouteImport } from './routes/api-keys'
@@ -49,11 +48,6 @@ const OrgRoute = OrgRouteImport.update({ · 28 unmodified lines
4948 path: '/org',
5049 getParentRoute: () => rootRouteImport,
5150 } as any)
52−const OauthAppsRoute = OauthAppsRouteImport.update({
53− id: '/oauth-apps',
54− path: '/oauth-apps',
55− getParentRoute: () => rootRouteImport,
56−} as any)
5751 const CreateOrgRoute = CreateOrgRouteImport.update({
5852 id: '/create-org',
5953 path: '/create-org',
@@ -101,7 +95,6 @@ export interface FileRoutesByFullPath { · 41 unmodified lines
10195 '/api-keys': typeof ApiKeysRoute
10296 '/billing': typeof BillingRoute
10397 '/create-org': typeof CreateOrgRoute
104− '/oauth-apps': typeof OauthAppsRoute
10598 '/org': typeof OrgRoute
10699 '/policies': typeof PoliciesRoute
107100 '/secrets': typeof SecretsRoute
@@ -117,7 +110,6 @@ export interface FileRoutesByTo { · 9 unmodified lines
117110 '/api-keys': typeof ApiKeysRoute
118111 '/billing': typeof BillingRoute
119112 '/create-org': typeof CreateOrgRoute
120− '/oauth-apps': typeof OauthAppsRoute
121113 '/org': typeof OrgRoute
122114 '/policies': typeof PoliciesRoute
123115 '/secrets': typeof SecretsRoute
@@ -134,7 +126,6 @@ export interface FileRoutesById { · 10 unmodified lines
134126 '/api-keys': typeof ApiKeysRoute
135127 '/billing': typeof BillingRoute
136128 '/create-org': typeof CreateOrgRoute
137− '/oauth-apps': typeof OauthAppsRoute
138129 '/org': typeof OrgRoute
139130 '/policies': typeof PoliciesRoute
140131 '/secrets': typeof SecretsRoute
@@ -152,7 +143,6 @@ export interface FileRouteTypes { · 11 unmodified lines
152143 | '/api-keys'
153144 | '/billing'
154145 | '/create-org'
155− | '/oauth-apps'
156146 | '/org'
157147 | '/policies'
158148 | '/secrets'
@@ -168,7 +158,6 @@ export interface FileRouteTypes { · 9 unmodified lines
168158 | '/api-keys'
169159 | '/billing'
170160 | '/create-org'
171− | '/oauth-apps'
172161 | '/org'
173162 | '/policies'
174163 | '/secrets'
@@ -184,7 +173,6 @@ export interface FileRouteTypes { · 9 unmodified lines
184173 | '/api-keys'
185174 | '/billing'
186175 | '/create-org'
187− | '/oauth-apps'
188176 | '/org'
189177 | '/policies'
190178 | '/secrets'
@@ -201,7 +189,6 @@ export interface RootRouteChildren { · 10 unmodified lines
201189 ApiKeysRoute: typeof ApiKeysRoute
202190 BillingRoute: typeof BillingRoute
203191 CreateOrgRoute: typeof CreateOrgRoute
204− OauthAppsRoute: typeof OauthAppsRoute
205192 OrgRoute: typeof OrgRoute
206193 PoliciesRoute: typeof PoliciesRoute
207194 SecretsRoute: typeof SecretsRoute
@@ -250,13 +237,6 @@ declare module '@tanstack/react-router' { · 42 unmodified lines
250237 preLoaderRoute: typeof OrgRouteImport
251238 parentRoute: typeof rootRouteImport
252239 }
253− '/oauth-apps': {
254− id: '/oauth-apps'
255− path: '/oauth-apps'
256− fullPath: '/oauth-apps'
257− preLoaderRoute: typeof OauthAppsRouteImport
258− parentRoute: typeof rootRouteImport
259− }
260240 '/create-org': {
261241 id: '/create-org'
262242 path: '/create-org'
@@ -321,7 +301,6 @@ const rootRouteChildren: RootRouteChildren = { · 58 unmodified lines
321301 ApiKeysRoute: ApiKeysRoute,
322302 BillingRoute: BillingRoute,
323303 CreateOrgRoute: CreateOrgRoute,
324− OauthAppsRoute: OauthAppsRoute,
325304 OrgRoute: OrgRoute,
326305 PoliciesRoute: PoliciesRoute,
327306 SecretsRoute: SecretsRoute,
apps/cloud/src/routes/oauth-apps.tsx+0−6
@@ -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−});
apps/host-selfhost/web/routeTree.gen.ts+0−21
@@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
1212 import { Route as ToolsRouteImport } from './routes/tools'
1313 import { Route as SecretsRouteImport } from './routes/secrets'
1414 import { Route as PoliciesRouteImport } from './routes/policies'
15−import { Route as OauthAppsRouteImport } from './routes/oauth-apps'
1615 import { Route as ApiKeysRouteImport } from './routes/api-keys'
1716 import { Route as AdminRouteImport } from './routes/admin'
1817 import { Route as IndexRouteImport } from './routes/index'
@@ -39,11 +38,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ · 20 unmodified lines
3938 path: '/policies',
4039 getParentRoute: () => rootRouteImport,
4140 } as any)
42−const OauthAppsRoute = OauthAppsRouteImport.update({
43− id: '/oauth-apps',
44− path: '/oauth-apps',
45− getParentRoute: () => rootRouteImport,
46−} as any)
4741 const ApiKeysRoute = ApiKeysRouteImport.update({
4842 id: '/api-keys',
4943 path: '/api-keys',
@@ -100,7 +94,6 @@ export interface FileRoutesByFullPath { · 50 unmodified lines
10094 '/': typeof IndexRoute
10195 '/admin': typeof AdminRoute
10296 '/api-keys': typeof ApiKeysRoute
103− '/oauth-apps': typeof OauthAppsRoute
10497 '/policies': typeof PoliciesRoute
10598 '/secrets': typeof SecretsRoute
10699 '/tools': typeof ToolsRoute
@@ -116,7 +109,6 @@ export interface FileRoutesByTo { · 9 unmodified lines
116109 '/': typeof IndexRoute
117110 '/admin': typeof AdminRoute
118111 '/api-keys': typeof ApiKeysRoute
119− '/oauth-apps': typeof OauthAppsRoute
120112 '/policies': typeof PoliciesRoute
121113 '/secrets': typeof SecretsRoute
122114 '/tools': typeof ToolsRoute
@@ -133,7 +125,6 @@ export interface FileRoutesById { · 10 unmodified lines
133125 '/': typeof IndexRoute
134126 '/admin': typeof AdminRoute
135127 '/api-keys': typeof ApiKeysRoute
136− '/oauth-apps': typeof OauthAppsRoute
137128 '/policies': typeof PoliciesRoute
138129 '/secrets': typeof SecretsRoute
139130 '/tools': typeof ToolsRoute
@@ -151,7 +142,6 @@ export interface FileRouteTypes { · 11 unmodified lines
151142 | '/'
152143 | '/admin'
153144 | '/api-keys'
154− | '/oauth-apps'
155145 | '/policies'
156146 | '/secrets'
157147 | '/tools'
@@ -167,7 +157,6 @@ export interface FileRouteTypes { · 9 unmodified lines
167157 | '/'
168158 | '/admin'
169159 | '/api-keys'
170− | '/oauth-apps'
171160 | '/policies'
172161 | '/secrets'
173162 | '/tools'
@@ -183,7 +172,6 @@ export interface FileRouteTypes { · 9 unmodified lines
183172 | '/'
184173 | '/admin'
185174 | '/api-keys'
186− | '/oauth-apps'
187175 | '/policies'
188176 | '/secrets'
189177 | '/tools'
@@ -200,7 +188,6 @@ export interface RootRouteChildren { · 10 unmodified lines
200188 IndexRoute: typeof IndexRoute
201189 AdminRoute: typeof AdminRoute
202190 ApiKeysRoute: typeof ApiKeysRoute
203− OauthAppsRoute: typeof OauthAppsRoute
204191 PoliciesRoute: typeof PoliciesRoute
205192 SecretsRoute: typeof SecretsRoute
206193 ToolsRoute: typeof ToolsRoute
@@ -236,13 +223,6 @@ declare module '@tanstack/react-router' { · 29 unmodified lines
236223 preLoaderRoute: typeof PoliciesRouteImport
237224 parentRoute: typeof rootRouteImport
238225 }
239− '/oauth-apps': {
240− id: '/oauth-apps'
241− path: '/oauth-apps'
242− fullPath: '/oauth-apps'
243− preLoaderRoute: typeof OauthAppsRouteImport
244− parentRoute: typeof rootRouteImport
245− }
246226 '/api-keys': {
247227 id: '/api-keys'
248228 path: '/api-keys'
@@ -320,7 +300,6 @@ const rootRouteChildren: RootRouteChildren = { · 71 unmodified lines
320300 IndexRoute: IndexRoute,
321301 AdminRoute: AdminRoute,
322302 ApiKeysRoute: ApiKeysRoute,
323− OauthAppsRoute: OauthAppsRoute,
324303 PoliciesRoute: PoliciesRoute,
325304 SecretsRoute: SecretsRoute,
326305 ToolsRoute: ToolsRoute,
apps/host-selfhost/web/routes/oauth-apps.tsx+0−6
@@ -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−});
e2e/scenarios/openapi-source.test.ts+53−32
@@ -53,42 +53,63 @@ scenario( · 52 unmodified lines
5353 const slug = `openapi-scn-greet-${randomBytes(4).toString("hex")}`;
5454 const specBaseUrl = "http://127.0.0.1:59999"; // never contacted during registration
5555
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+ ],
6670 },
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);
7276
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);
7781
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+ });
8791
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+ }),
92113 );
93114 }),
94115 );
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.
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+);
e2e/targets/selfhost.ts+41−7
@@ -17,18 +17,52 @@ export const SELFHOST_ADMIN = { · 16 unmodified lines
1717 password: process.env.E2E_SELFHOST_ADMIN_PASSWORD ?? "e2e-admin-password-123",
1818 };
1919
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+
2047 export const selfhostTarget = (): Target => ({
2148 name: "selfhost",
2249 baseUrl: SELFHOST_BASE_URL,
2350 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"]),
2855 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+ };
3266 }),
3367 mcpConsent: (identity: Identity) =>
3468 cookieConsentStrategy({
packages/app/src/routeTree.gen.ts+0−21
@@ -12,7 +12,6 @@ import { Route as rootRouteImport } from './routes/__root' · 11 unmodified lines
1212 import { Route as ToolsRouteImport } from './routes/tools'
1313 import { Route as SecretsRouteImport } from './routes/secrets'
1414 import { Route as PoliciesRouteImport } from './routes/policies'
15−import { Route as OauthAppsRouteImport } from './routes/oauth-apps'
1615 import { Route as IndexRouteImport } from './routes/index'
1716 import { Route as ResumeExecutionIdRouteImport } from './routes/resume.$executionId'
1817 import { Route as IntegrationsNamespaceRouteImport } from './routes/integrations.$namespace'
@@ -34,11 +33,6 @@ const PoliciesRoute = PoliciesRouteImport.update({ · 15 unmodified lines
3433 path: '/policies',
3534 getParentRoute: () => rootRouteImport,
3635 } as any)
37−const OauthAppsRoute = OauthAppsRouteImport.update({
38− id: '/oauth-apps',
39− path: '/oauth-apps',
40− getParentRoute: () => rootRouteImport,
41−} as any)
4236 const IndexRoute = IndexRouteImport.update({
4337 id: '/',
4438 path: '/',
@@ -68,7 +62,6 @@ const IntegrationsAddPluginKeyRoute = · 23 unmodified lines
6862
6963 export interface FileRoutesByFullPath {
7064 '/': typeof IndexRoute
71− '/oauth-apps': typeof OauthAppsRoute
7265 '/policies': typeof PoliciesRoute
7366 '/secrets': typeof SecretsRoute
7467 '/tools': typeof ToolsRoute
@@ -79,7 +72,6 @@ export interface FileRoutesByFullPath { · 4 unmodified lines
7972 }
8073 export interface FileRoutesByTo {
8174 '/': typeof IndexRoute
82− '/oauth-apps': typeof OauthAppsRoute
8375 '/policies': typeof PoliciesRoute
8476 '/secrets': typeof SecretsRoute
8577 '/tools': typeof ToolsRoute
@@ -91,7 +83,6 @@ export interface FileRoutesByTo { · 5 unmodified lines
9183 export interface FileRoutesById {
9284 __root__: typeof rootRouteImport
9385 '/': typeof IndexRoute
94− '/oauth-apps': typeof OauthAppsRoute
9586 '/policies': typeof PoliciesRoute
9687 '/secrets': typeof SecretsRoute
9788 '/tools': typeof ToolsRoute
@@ -104,7 +95,6 @@ export interface FileRouteTypes { · 6 unmodified lines
10495 fileRoutesByFullPath: FileRoutesByFullPath
10596 fullPaths:
10697 | '/'
107− | '/oauth-apps'
10898 | '/policies'
10999 | '/secrets'
110100 | '/tools'
@@ -115,7 +105,6 @@ export interface FileRouteTypes { · 4 unmodified lines
115105 fileRoutesByTo: FileRoutesByTo
116106 to:
117107 | '/'
118− | '/oauth-apps'
119108 | '/policies'
120109 | '/secrets'
121110 | '/tools'
@@ -126,7 +115,6 @@ export interface FileRouteTypes { · 4 unmodified lines
126115 id:
127116 | '__root__'
128117 | '/'
129− | '/oauth-apps'
130118 | '/policies'
131119 | '/secrets'
132120 | '/tools'
@@ -138,7 +126,6 @@ export interface FileRouteTypes { · 5 unmodified lines
138126 }
139127 export interface RootRouteChildren {
140128 IndexRoute: typeof IndexRoute
141− OauthAppsRoute: typeof OauthAppsRoute
142129 PoliciesRoute: typeof PoliciesRoute
143130 SecretsRoute: typeof SecretsRoute
144131 ToolsRoute: typeof ToolsRoute
@@ -171,13 +158,6 @@ declare module '@tanstack/react-router' { · 26 unmodified lines
171158 preLoaderRoute: typeof PoliciesRouteImport
172159 parentRoute: typeof rootRouteImport
173160 }
174− '/oauth-apps': {
175− id: '/oauth-apps'
176− path: '/oauth-apps'
177− fullPath: '/oauth-apps'
178− preLoaderRoute: typeof OauthAppsRouteImport
179− parentRoute: typeof rootRouteImport
180− }
181161 '/': {
182162 id: '/'
183163 path: '/'
@@ -218,7 +198,6 @@ declare module '@tanstack/react-router' { · 34 unmodified lines
218198
219199 const rootRouteChildren: RootRouteChildren = {
220200 IndexRoute: IndexRoute,
221− OauthAppsRoute: OauthAppsRoute,
222201 PoliciesRoute: PoliciesRoute,
223202 SecretsRoute: SecretsRoute,
224203 ToolsRoute: ToolsRoute,
packages/app/src/routes/oauth-apps.tsx+0−6
@@ -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−});
packages/app/src/web/shell.tsx+0−7
@@ -338,7 +338,6 @@ function SidebarContent(props: { · 337 unmodified lines
338338 }) {
339339 const isHome = props.pathname === "/";
340340 const isSecrets = props.pathname === "/secrets";
341− const isOAuthApps = props.pathname === "/oauth-apps";
342341 const isPolicies = props.pathname === "/policies";
343342
344343 return (
@@ -360,12 +359,6 @@ function SidebarContent(props: { · 15 unmodified lines
360359 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
361360 <NavItem to="/" label="Integrations" active={isHome} onNavigate={props.onNavigate} />
362361 <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− />
369362 <NavItem
370363 to="/policies"
371364 label="Policies"
packages/react/src/api/atoms.tsx+46−0
@@ -7,6 +7,8 @@ import { · 6 unmodified lines
77 type ConnectionName,
88 type IntegrationSlug,
99 type OAuthClientSlug,
10+ type OAuthClientSummary,
11+ type OAuthGrant,
1012 type Owner,
1113 type ProviderItemId,
1214 type ToolAddress,
@@ -433,3 +435,47 @@ export const removeOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( · 420 unmodified lines
433435 fn: removeOAuthClient,
434436 }),
435437 );
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+);
packages/react/src/components/add-account-modal.tsx+193−49
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 OAuthClientSlug,
99 ProviderItemId,
1010 ProviderKey,
11+ type OAuthClientSummary,
1112 type Owner,
1213 } from "@executor-js/sdk/shared";
1314 import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
@@ -15,10 +16,13 @@ import { toast } from "sonner"; · 1 unmodified line
1516
1617 import {
1718 addConnectionOptimistic,
19+ connectionsAllAtom,
20+ oauthClientsOptimisticAtom,
1821 probeOAuth,
1922 providerItemsAtom,
2023 providersAtom,
2124 registerDynamicOAuthClient,
25+ removeOAuthClientOptimistic,
2226 startOAuth,
2327 } from "../api/atoms";
2428 import { connectionWriteKeys, oauthClientWriteKeys } from "../api/reactivity-keys";
@@ -42,12 +46,20 @@ import { · 17 unmodified lines
4246 type OAuthClientOption,
4347 } from "../plugins/use-effective-oauth-client";
4448 import { cn } from "../lib/utils";
49+import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage";
4550 import { OAuthClientForm } from "./oauth-client-form";
51+import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
4652 import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
4753 import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
4854 import { connectionIdentifier } from "../lib/connection-name";
4955 import { Badge } from "./badge";
5056 import { Button } from "./button";
57+import {
58+ DropdownMenu,
59+ DropdownMenuContent,
60+ DropdownMenuItem,
61+ DropdownMenuTrigger,
62+} from "./dropdown-menu";
5163 import { PlusIcon, XIcon } from "lucide-react";
5264 import {
5365 Dialog,
@@ -509,6 +521,72 @@ export async function runDcrConnect( · 455 unmodified lines
509521 return { kind: "started" };
510522 }
511523
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+
512590 export function AddAccountModal(props: {
513591 readonly integration: IntegrationSlug;
514592 readonly integrationName: string;
@@ -579,6 +657,12 @@ export function AddAccountModal(props: { · 64 unmodified lines
579657 // FIX 3 escape hatch: when no registered app matched the integration's
580658 // endpoints, the unmatched apps are collapsed behind an opt-in expander.
581659 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);
582666
583667 const doCreate = useAtomSet(addConnectionOptimistic(owner), {
584668 mode: "promiseExit",
@@ -588,6 +672,22 @@ export function AddAccountModal(props: { · 3 unmodified lines
588672 const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, {
589673 mode: "promiseExit",
590674 });
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+ );
591691
592692 const method = useMemo(
593693 () => allMethods.find((m: AuthMethod) => m.id === methodId) ?? allMethods[0],
@@ -688,6 +788,9 @@ export function AddAccountModal(props: { · 94 unmodified lines
688788 oauthEndpointMatched && oauthApps.length > 0 ? String(oauthApps[0]?.slug) : "";
689789 const selectedApp = pickedApp ?? oauthDefaultApp;
690790 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;
691794 const chosenClient: OAuthClientOption | null =
692795 oauthApps.find((c: OAuthClientOption) => String(c.slug) === selectedApp) ?? null;
693796 const oauthBusy = ccBusy || oauthPopup.busy;
@@ -728,11 +831,44 @@ export function AddAccountModal(props: { · 34 unmodified lines
728831 setDcrBusy(false);
729832 setDcrFailed(false);
730833 setShowOtherApps(false);
834+ setEditingClient(null);
835+ setRemovingClient(null);
731836 setCreatedMethods([]);
732837 setRemovedMethodIds(new Set());
733838 setAddingMethod(false);
734839 };
735840
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+
736872 const selectMethod = (nextMethodId: string): void => {
737873 setMethodId(nextMethodId);
738874 setValues({});
@@ -957,7 +1093,7 @@ export function AddAccountModal(props: { · 218 unmodified lines
9571093 <DialogContent
9581094 className={cn(
9591095 "max-h-[85vh] overflow-x-hidden overflow-y-auto",
960− (addingMethod && createCustomMethod) || oauthRegistering
1096+ (addingMethod && createCustomMethod) || oauthRegistering || oauthEditing
9611097 ? "gap-0 p-0 sm:max-w-2xl"
9621098 : "sm:max-w-xl",
9631099 )}
@@ -976,6 +1112,36 @@ export function AddAccountModal(props: { · 12 unmodified lines
9761112 onCancel={() => setAddingMethod(false)}
9771113 />
9781114 </>
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+ </>
9791145 ) : oauthRegistering && method?.kind === "oauth" ? (
9801146 <>
9811147 <DialogHeader className="border-b border-border/60 px-5 py-4">
@@ -1166,32 +1332,14 @@ export function AddAccountModal(props: { · 184 unmodified lines
11661332 className="gap-2 pt-1"
11671333 >
11681334 {oauthOtherApps.map((app: OAuthClientOption) => (
1169− <Label
1335+ <OAuthAppRadioRow
11701336 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+ />
11951343 ))}
11961344 </RadioGroup>
11971345 ) : null}
@@ -1205,30 +1353,14 @@ export function AddAccountModal(props: { · 7 unmodified lines
12051353 className="gap-2"
12061354 >
12071355 {oauthApps.map((app: OAuthClientOption) => (
1208− <Label
1356+ <OAuthAppRadioRow
12091357 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+ />
12321364 ))}
12331365 <Button
12341366 type="button"
@@ -1331,6 +1463,18 @@ export function AddAccountModal(props: { · 96 unmodified lines
13311463 </DialogFooter>
13321464 </>
13331465 )}
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}
13341478 </DialogContent>
13351479 </Dialog>
13361480 );
packages/react/src/components/oauth-client-form.tsx+2−2
@@ -4,7 +4,7 @@ import * as Exit from "effect/Exit"; · 3 unmodified lines
44 import { OAuthClientSlug, type OAuthGrant, type Owner } from "@executor-js/sdk/shared";
55 import { toast } from "sonner";
66
7−import { createOAuthClient, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";
7+import { createOAuthClientOptimistic, probeOAuth, registerDynamicOAuthClient } from "../api/atoms";
88 import { ownerLabelForHost } from "../api/owner-display";
99 import { useOrganizationId } from "../api/organization-context";
1010 import { oauthClientWriteKeys } from "../api/reactivity-keys";
@@ -116,7 +116,7 @@ export function OAuthClientForm(props: { · 105 unmodified lines
116116 const endpointsKnown = (prefill?.tokenUrl ?? "").length > 0;
117117 const [showEndpoints, setShowEndpoints] = useState(!endpointsKnown);
118118
119− const doCreate = useAtomSet(createOAuthClient, { mode: "promiseExit" });
119+ const doCreate = useAtomSet(createOAuthClientOptimistic, { mode: "promiseExit" });
120120 const doProbe = useAtomSet(probeOAuth, { mode: "promiseExit" });
121121 const doRegisterDynamic = useAtomSet(registerDynamicOAuthClient, {
122122 mode: "promiseExit",
packages/react/src/components/remove-oauth-app-dialog.tsx+77−0
@@ -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&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
1111 type Owner,
1212 } from "@executor-js/sdk/shared";
1313
14−import { buildUsageMap, connectionsUsingClient, groupClientsByOwner } from "./oauth-apps";
14+import { buildUsageMap, connectionsUsingClient } from "./oauth-client-usage";
1515
1616 // Minimal app summary builder — only the fields the helpers read matter.
1717 const app = (slug: string, opts?: { readonly owner?: Owner }): OAuthClientSummary => ({
@@ -52,36 +52,6 @@ const connection = ( · 34 unmodified lines
5252 oauthClientOwner: opts?.oauthClientOwner ?? null,
5353 });
5454
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−
8555 describe("buildUsageMap / connectionsUsingClient", () => {
8656 it("maps connections to the app slug that minted them", () => {
8757 const usage = buildUsageMap([
packages/react/src/lib/oauth-client-usage.ts+46−0
@@ -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+}
packages/react/src/multiplayer/shell.tsx+0−1
@@ -42,7 +42,6 @@ export type ShellNavItem = { readonly to: string; readonly label: string }; · 41 unmodified lines
4242 export const defaultShellNavItems: ReadonlyArray<ShellNavItem> = [
4343 { to: "/", label: "Integrations" },
4444 { to: "/secrets", label: "Providers" },
45− { to: "/oauth-apps", label: "OAuth apps" },
4645 { to: "/policies", label: "Policies" },
4746 ];
4847
packages/react/src/pages/oauth-apps.tsx+0−497
@@ -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&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−
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−}
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
33 import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";
44 import { getDomain } from "tldts";
55
6−import { oauthClientsAtom } from "../api/atoms";
6+import { oauthClientsOptimisticAtom } from "../api/atoms";
77
88 // ---------------------------------------------------------------------------
99 // OAuth client (registered app) selection for an integration's connect flow.
@@ -134,7 +134,11 @@ export function useOAuthClientsForIntegration(opts: { · 124 unmodified lines
134134 readonly tokenUrl?: string;
135135 readonly authorizationUrl?: string;
136136 }): 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);
138142 if (!AsyncResult.isSuccess(clientsResult)) {
139143 return {
140144 clients: [],