History

Add the admin users console section

Rhys Sullivan committed 3 months ago

Commit 440d1941 parent b115ba021 files · +1830 −7Browse files

Unified · Split

21 files
  1. apps/cloud/src/routeTree.gen.ts +23 −0
  2. apps/cloud/src/web/shell.tsx +9 −1
  3. apps/host-cloudflare/tsr.routes.ts +5 −1
  4. apps/host-selfhost/web/routeTree.gen.ts +23 −0
  5. apps/host-selfhost/web/routes/__root.tsx +10 −1
  6. e2e/cloud/admin-users-console.test.ts +365 −0
  7. e2e/selfhost/admin-users-console.test.ts +167 −0
  8. packages/app/tsr.routes.ts +6 −4
  9. packages/core/api/src/client.ts +13 −0
  10. packages/react/src/api/admin-atoms.tsx +53 −0
  11. packages/react/src/api/admin-client.tsx +53 −0
  12. packages/react/src/api/reactivity-keys.tsx +4 −0
  13. packages/react/src/console-routes.ts +5 −0
  14. packages/react/src/lib/admin-access.test.ts +52 −0
  15. packages/react/src/lib/admin-access.ts +46 −0
  16. packages/react/src/lib/admin-users-display.test.ts +173 −0
  17. packages/react/src/lib/admin-users-display.ts +175 −0
  18. packages/react/src/multiplayer/use-admin-nav.tsx +46 −0
  19. packages/react/src/pages/admin-users.tsx +574 −0
  20. packages/react/src/routes/routeTree.gen.ts +21 −0
  21. packages/react/src/routes/users.tsx +7 −0
apps/cloud/src/routeTree.gen.ts+23−0
@@ -13,6 +13,7 @@ import { Route as SetupMcpRouteImport } from './routes/bare/setup-mcp' · 12 unmodified lines
1313 import { Route as LoginRouteImport } from './routes/bare/login'
1414 import { Route as CreateOrgRouteImport } from './routes/bare/create-org'
1515 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
16+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
1617 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
1718 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
1819 import { Route as SecretsRouteImport } from './routes/app/secrets'
@@ -48,6 +49,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 29 unmodified lines
4849 path: '/{-$orgSlug}/',
4950 getParentRoute: () => rootRouteImport,
5051 } as any)
52+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
53+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
54+ id: '/{-$orgSlug}/users',
55+ path: '/{-$orgSlug}/users',
56+ getParentRoute: () => rootRouteImport,
57+ } as any)
5158 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
5259 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
5360 id: '/{-$orgSlug}/tools',
@@ -141,6 +148,7 @@ export interface FileRoutesByFullPath { · 87 unmodified lines
141148 '/{-$orgSlug}/secrets': typeof SecretsRoute
142149 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
143150 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
151+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
144152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
145153 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
146154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -160,6 +168,7 @@ export interface FileRoutesByTo { · 13 unmodified lines
160168 '/{-$orgSlug}/secrets': typeof SecretsRoute
161169 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
162170 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
171+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
163172 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
164173 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
165174 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -180,6 +189,7 @@ export interface FileRoutesById { · 14 unmodified lines
180189 '/{-$orgSlug}/secrets': typeof SecretsRoute
181190 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
182191 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
192+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
183193 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
184194 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
185195 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -201,6 +211,7 @@ export interface FileRouteTypes { · 15 unmodified lines
201211 | '/{-$orgSlug}/secrets'
202212 | '/{-$orgSlug}/toolkits'
203213 | '/{-$orgSlug}/tools'
214+ | '/{-$orgSlug}/users'
204215 | '/{-$orgSlug}/'
205216 | '/{-$orgSlug}/billing/plans'
206217 | '/{-$orgSlug}/connect/$integrationSlug'
@@ -220,6 +231,7 @@ export interface FileRouteTypes { · 13 unmodified lines
220231 | '/{-$orgSlug}/secrets'
221232 | '/{-$orgSlug}/toolkits'
222233 | '/{-$orgSlug}/tools'
234+ | '/{-$orgSlug}/users'
223235 | '/{-$orgSlug}'
224236 | '/{-$orgSlug}/billing/plans'
225237 | '/{-$orgSlug}/connect/$integrationSlug'
@@ -239,6 +251,7 @@ export interface FileRouteTypes { · 13 unmodified lines
239251 | '/{-$orgSlug}/secrets'
240252 | '/{-$orgSlug}/toolkits'
241253 | '/{-$orgSlug}/tools'
254+ | '/{-$orgSlug}/users'
242255 | '/{-$orgSlug}/'
243256 | '/{-$orgSlug}/billing_/plans'
244257 | '/{-$orgSlug}/connect/$integrationSlug'
@@ -259,6 +272,7 @@ export interface RootRouteChildren { · 14 unmodified lines
259272 SecretsRoute: typeof SecretsRoute
260273 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
261274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
275+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
262276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
263277 Billing_DotplansRoute: typeof Billing_DotplansRoute
264278 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -297,6 +311,13 @@ declare module '@tanstack/react-router' { · 32 unmodified lines
297311 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
298312 parentRoute: typeof rootRouteImport
299313 }
314+ '/{-$orgSlug}/users': {
315+ id: '/{-$orgSlug}/users'
316+ path: '/{-$orgSlug}/users'
317+ fullPath: '/{-$orgSlug}/users'
318+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
319+ parentRoute: typeof rootRouteImport
320+ }
300321 '/{-$orgSlug}/tools': {
301322 id: '/{-$orgSlug}/tools'
302323 path: '/{-$orgSlug}/tools'
@@ -420,6 +441,8 @@ const rootRouteChildren: RootRouteChildren = { · 117 unmodified lines
420441 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
421442 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
422443 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
444+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
445+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
423446 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
424447 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
425448 Billing_DotplansRoute: Billing_DotplansRoute,
apps/cloud/src/web/shell.tsx+9−1
@@ -1,6 +1,7 @@
11 import type React from "react";
22
33 import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
4+import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
45 import { trackEvent } from "@executor-js/react/api/analytics";
56 import { AUTH_PATHS } from "../auth/api";
67 import { OrgMenuSlot } from "./components/org-menu-slot";
@@ -25,6 +26,12 @@ const navItems = [ · 18 unmodified lines
2526 { to: "/billing", label: "Billing" },
2627 ];
2728
29+// Sections only an admin of the org may open. The Users page reads the
30+// tenant-wide admin plane, which cloud gates on an admin-role membership — so
31+// a plain member is not shown a link that would only refuse them. The page
32+// still renders its own denied state if one arrives by URL.
33+const adminNavItems = [{ to: "/users", label: "Users" }];
34+
2835 // A top-level form POST, not fetch: the logout response 302s through the
2936 // WorkOS logout endpoint (ending the hosted AuthKit session) before landing
3037 // back home. A fetch would follow that chain invisibly inside the XHR and
@@ -39,10 +46,11 @@ const signOut = () => { · 8 unmodified lines
3946 };
4047
4148 export function Shell(props: { readonly content?: React.ReactNode }) {
49+ const items = useAdminNavItems(navItems, adminNavItems);
4250 return (
4351 <SharedShell
4452 onSignOut={signOut}
45− navItems={navItems}
53+ navItems={items}
4654 orgMenuSlot={<OrgMenuSlot />}
4755 supportSlot={<SupportSlot />}
4856 content={props.content}
apps/host-cloudflare/tsr.routes.ts+5−1
@@ -10,6 +10,10 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 9 unmodified lines
1010 // this app only owns its root (the Cloudflare-Access shell). To add
1111 // app-specific routes, create web/routes/app and mount it inside the org
1212 // scope via `orgScoped: [physical("", "app")]` — the directory must exist.
13+//
14+// /users is excluded: this host mounts no admin-users routes (identity is
15+// Cloudflare Access, which has no in-app role to gate on), so the page would
16+// only ever render its denied state.
1317 export const routes = rootRoute("__root.tsx", [
14− ...consoleRoutes({ dir: "../../../../packages/react/src/routes" }),
18+ ...consoleRoutes({ dir: "../../../../packages/react/src/routes", exclude: ["/users"] }),
1519 ]);
apps/host-selfhost/web/routeTree.gen.ts+23−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010
1111 import { Route as rootRouteImport } from './routes/__root'
1212 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
13+import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
1314 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
1415 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
1516 import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
@@ -30,6 +31,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 14 unmodified lines
3031 path: '/{-$orgSlug}/',
3132 getParentRoute: () => rootRouteImport,
3233 } as any)
34+const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
35+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
36+ id: '/{-$orgSlug}/users',
37+ path: '/{-$orgSlug}/users',
38+ getParentRoute: () => rootRouteImport,
39+ } as any)
3340 const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
3441 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
3542 id: '/{-$orgSlug}/tools',
@@ -127,6 +134,7 @@ export interface FileRoutesByFullPath { · 91 unmodified lines
127134 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
128135 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
129136 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
137+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
130138 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
131139 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
132140 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -143,6 +151,7 @@ export interface FileRoutesByTo { · 10 unmodified lines
143151 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
144152 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
145153 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
154+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
146155 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
147156 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
148157 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -160,6 +169,7 @@ export interface FileRoutesById { · 11 unmodified lines
160169 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
161170 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
162171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
172+ '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
163173 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
164174 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
165175 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -178,6 +188,7 @@ export interface FileRouteTypes { · 12 unmodified lines
178188 | '/{-$orgSlug}/secrets'
179189 | '/{-$orgSlug}/toolkits'
180190 | '/{-$orgSlug}/tools'
191+ | '/{-$orgSlug}/users'
181192 | '/{-$orgSlug}/'
182193 | '/{-$orgSlug}/connect/$integrationSlug'
183194 | '/{-$orgSlug}/integrations/$namespace'
@@ -194,6 +205,7 @@ export interface FileRouteTypes { · 10 unmodified lines
194205 | '/{-$orgSlug}/secrets'
195206 | '/{-$orgSlug}/toolkits'
196207 | '/{-$orgSlug}/tools'
208+ | '/{-$orgSlug}/users'
197209 | '/{-$orgSlug}'
198210 | '/{-$orgSlug}/connect/$integrationSlug'
199211 | '/{-$orgSlug}/integrations/$namespace'
@@ -210,6 +222,7 @@ export interface FileRouteTypes { · 10 unmodified lines
210222 | '/{-$orgSlug}/secrets'
211223 | '/{-$orgSlug}/toolkits'
212224 | '/{-$orgSlug}/tools'
225+ | '/{-$orgSlug}/users'
213226 | '/{-$orgSlug}/'
214227 | '/{-$orgSlug}/connect/$integrationSlug'
215228 | '/{-$orgSlug}/integrations/$namespace'
@@ -227,6 +240,7 @@ export interface RootRouteChildren { · 11 unmodified lines
227240 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
228241 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
229242 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
243+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
230244 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
231245 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
232246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -244,6 +258,13 @@ declare module '@tanstack/react-router' { · 11 unmodified lines
244258 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
245259 parentRoute: typeof rootRouteImport
246260 }
261+ '/{-$orgSlug}/users': {
262+ id: '/{-$orgSlug}/users'
263+ path: '/{-$orgSlug}/users'
264+ fullPath: '/{-$orgSlug}/users'
265+ preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
266+ parentRoute: typeof rootRouteImport
267+ }
247268 '/{-$orgSlug}/tools': {
248269 id: '/{-$orgSlug}/tools'
249270 path: '/{-$orgSlug}/tools'
@@ -365,6 +386,8 @@ const rootRouteChildren: RootRouteChildren = { · 115 unmodified lines
365386 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
366387 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
367388 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
389+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
390+ DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
368391 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
369392 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
370393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
apps/host-selfhost/web/routes/__root.tsx+10−1
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
1616 } from "@executor-js/react/components/card";
1717 import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";
1818 import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
19+import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
1920 import { plugins as clientPlugins } from "virtual:executor/plugins-client";
2021
2122 import { authClient } from "../auth-client";
@@ -46,6 +47,13 @@ const selfHostNavItems = [ · 24 unmodified lines
4647 { to: "/admin", label: "Admin" },
4748 ];
4849
50+// Sections only an owner/admin of the instance may open. Users reads the
51+// tenant-wide admin plane, gated on a Better Auth owner/admin member, so a
52+// plain member is not shown a link that would only refuse them. (The existing
53+// /admin entry predates this and stays unconditional — it is this instance's
54+// member/invite page, and its own notice covers a non-admin who opens it.)
55+const selfHostAdminNavItems = [{ to: "/users", label: "Users" }];
56+
4957 const signOut = async () => {
5058 await authClient.signOut();
5159 window.location.href = "/";
@@ -150,12 +158,13 @@ function AuthGate({ children }: { children: ReactNode }) { · 98 unmodified lines
150158 function AuthenticatedApp() {
151159 const auth = useAuth();
152160 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
161+ const navItems = useAdminNavItems(selfHostNavItems, selfHostAdminNavItems);
153162
154163 // Single-org instance: a bare URL canonicalizes onto the instance org's
155164 // slug. There's only ever one org, so no other slug is reachable.
156165 const gated = (
157166 <>
158− <Shell onSignOut={signOut} navItems={selfHostNavItems} />
167+ <Shell onSignOut={signOut} navItems={navItems} />
159168 <Toaster />
160169 </>
161170 );
e2e/cloud/admin-users-console.test.ts+365−0
@@ -0,0 +1,365 @@
1+// Cloud-only: the admin Users PAGE — the console surface over `/api/admin/users*`.
2+//
3+// The sibling `admin-users.test.ts` pins the API's guarantees. This one pins
4+// what an operator actually sees, which the API scenario cannot: that the page
5+// renders every member of the workspace, that each row's connection summary
6+// distinguishes connected from merely available, that opening a user shows that
7+// user's own connections with their health, and — the failure path that matters
8+// most here — that a plain member who reaches the URL is told they don't have
9+// access rather than shown an empty workspace.
10+//
11+// Two members are built through the REAL flows (login → create-organization →
12+// invite → accept-invitation) and each connects their own credential, so the
13+// two rows differ in what they've connected and the summary has something to
14+// be right about.
15+import { randomBytes } from "node:crypto";
16+
17+import { expect } from "@effect/vitest";
18+import { Effect } from "effect";
19+import type { HttpApiClient } from "effect/unstable/httpapi";
20+import { composePluginApi } from "@executor-js/api/server";
21+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
22+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
23+
24+import { scenario } from "../src/scenario";
25+import { Api, Browser, Target } from "../src/services";
26+import type { Identity, Target as TargetShape } from "../src/target";
27+
28+const api = composePluginApi([openApiHttpPlugin()] as const);
29+type Client = HttpApiClient.ForApi<typeof api>;
30+
31+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
32+
33+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
34+const pingSpec = JSON.stringify({
35+ openapi: "3.0.3",
36+ info: { title: "Ping API", version: "1.0.0" },
37+ paths: {
38+ "/ping": {
39+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
40+ },
41+ },
42+});
43+
44+/** Registers a fresh apiKey-authenticated integration for connections to bind to. */
45+const registerIntegration = (client: Client, label: string) =>
46+ Effect.gen(function* () {
47+ const slug = IntegrationSlug.make(`${label}-${randomBytes(4).toString("hex")}`);
48+ yield* client.openapi.addSpec({
49+ payload: {
50+ spec: { kind: "blob", value: pingSpec },
51+ slug,
52+ baseUrl: "http://127.0.0.1:59999", // never contacted during registration
53+ authenticationTemplate: [
54+ {
55+ slug: "apiKey",
56+ type: "apiKey",
57+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
58+ },
59+ ],
60+ },
61+ });
62+ return slug;
63+ });
64+
65+const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
66+
67+const cookieOf = (identity: Identity): string => identity.headers?.["cookie"] ?? "";
68+
69+/** The identity re-bound so the BROWSER carries the same session the API calls
70+ * do. `joinOrg` refreshes `headers.cookie` (the org is baked into the sealed
71+ * session) but leaves `cookies` on the pre-join value, and the browser context
72+ * is seeded from `cookies`. */
73+const forBrowser = (identity: Identity): Identity => {
74+ const cookie = cookieOf(identity);
75+ const separator = cookie.indexOf("=");
76+ if (separator < 0) throw new Error("identity carries no session cookie");
77+ return {
78+ ...identity,
79+ cookies: [{ name: cookie.slice(0, separator), value: cookie.slice(separator + 1) }],
80+ };
81+};
82+
83+const postJson = (target: TargetShape, path: string, identity: Identity, body: unknown) =>
84+ Effect.promise(async () => {
85+ const response = await fetch(new URL(path, target.baseUrl), {
86+ method: "POST",
87+ headers: {
88+ "content-type": "application/json",
89+ origin: new URL(target.baseUrl).origin,
90+ cookie: cookieOf(identity),
91+ },
92+ body: JSON.stringify(body),
93+ });
94+ if (!response.ok) {
95+ throw new Error(`${path} failed (${response.status}): ${await response.text()}`);
96+ }
97+ return response;
98+ });
99+
100+/** The identity re-bound to the refreshed session cookie a response set. */
101+const withRefreshedSession = (identity: Identity, response: Response): Identity => {
102+ const refreshed = (response.headers.getSetCookie?.() ?? [])
103+ .find((header) => header.startsWith("wos-session="))
104+ ?.split(";")[0];
105+ if (!refreshed) throw new Error("response did not refresh the session cookie");
106+ return { ...identity, headers: { cookie: refreshed } };
107+};
108+
109+/** Invite `member` into `admin`'s org and accept — the real invite flow. The
110+ * invite carries no roleSlug, so the joiner lands as a plain member: exactly
111+ * the actor the denial half of this scenario needs. */
112+const joinOrg = (target: TargetShape, admin: Identity, member: Identity) =>
113+ Effect.gen(function* () {
114+ const inviteResponse = yield* postJson(target, "/api/account/members/invite", admin, {
115+ email: member.credentials?.email,
116+ });
117+ const invitation = (yield* Effect.promise(() => inviteResponse.json())) as { id: string };
118+ const acceptResponse = yield* postJson(target, "/api/auth/accept-invitation", member, {
119+ invitationId: invitation.id,
120+ });
121+ return withRefreshedSession(member, acceptResponse);
122+ });
123+
124+/** The caller's own account id — the `externalId` the admin plane reports and
125+ * the page renders in mono. */
126+const accountIdOf = (target: TargetShape, identity: Identity) =>
127+ Effect.promise(async () => {
128+ const response = await fetch(new URL("/api/account/me", target.baseUrl), {
129+ headers: { cookie: cookieOf(identity) },
130+ });
131+ if (!response.ok) throw new Error(`/api/account/me failed (${response.status})`);
132+ const body = (await response.json()) as { user: { id: string } };
133+ return body.user.id;
134+ });
135+
136+scenario(
137+ "Admin · the Users page shows every member and what each has connected, and refuses a plain member",
138+ { timeout: 180_000 },
139+ Effect.gen(function* () {
140+ const target = yield* Target;
141+ const browser = yield* Browser;
142+ const { client: apiClient } = yield* Api;
143+
144+ const admin = yield* target.newIdentity();
145+ const invitee = yield* target.newIdentity({ org: false });
146+ const member = yield* joinOrg(target, admin, invitee);
147+
148+ const adminClient = yield* apiClient(api, admin);
149+ const memberClient = yield* apiClient(api, member);
150+ const adminId = yield* accountIdOf(target, admin);
151+ const memberId = yield* accountIdOf(target, member);
152+
153+ // Two integrations so the summary has a real available-vs-connected split:
154+ // each member connects one, so each row shows one connected and one not.
155+ const connectedIntegration = yield* registerIntegration(adminClient, "admin-ui-conn");
156+ const availableIntegration = yield* registerIntegration(adminClient, "admin-ui-avail");
157+ const adminConnection = freshConnectionName();
158+ const memberConnection = freshConnectionName();
159+
160+ yield* Effect.ensuring(
161+ Effect.gen(function* () {
162+ // Each member stores their OWN credential. Neither can see the other's
163+ // through the product plane — the admin page is the only surface that
164+ // reports both.
165+ yield* adminClient.connections.create({
166+ payload: {
167+ owner: "user",
168+ name: adminConnection,
169+ integration: connectedIntegration,
170+ template: TEMPLATE_API_KEY,
171+ value: "admin-personal-token",
172+ },
173+ });
174+ yield* memberClient.connections.create({
175+ payload: {
176+ owner: "user",
177+ name: memberConnection,
178+ integration: connectedIntegration,
179+ template: TEMPLATE_API_KEY,
180+ value: "member-personal-token",
181+ },
182+ });
183+
184+ // ── The admin's view ────────────────────────────────────────────────
185+ yield* browser.session(forBrowser(admin), async ({ page, step }) => {
186+ let slug = "";
187+
188+ await step("Land in the workspace and canonicalize onto the org slug", async () => {
189+ await page.goto("/", { waitUntil: "networkidle" });
190+ await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
191+ timeout: 30_000,
192+ });
193+ slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
194+ expect(slug, "the URL settled on an org slug").not.toBe("");
195+ });
196+
197+ await step("Open Users from the sidebar", async () => {
198+ // The section is admin-only, so an admin must actually see the link
199+ // — reaching the page by URL would not prove the nav gate opened.
200+ await page.getByRole("link", { name: "Users" }).click();
201+ await page.waitForURL((url) => url.pathname === `/${slug}/users`, { timeout: 30_000 });
202+ await page.locator("[data-slot='admin-users-table']").waitFor({ timeout: 30_000 });
203+ });
204+
205+ await step("Both members of the workspace are listed", async () => {
206+ const rows = page.locator("[data-slot='admin-user-row']");
207+ await rows.first().waitFor({ timeout: 30_000 });
208+ // The id is opaque and rendered shortened, so the row's full value
209+ // lives in the title the operator hovers for.
210+ const ids = await rows
211+ .locator("[data-slot='admin-user-id']")
212+ .evaluateAll((elements) =>
213+ elements.map((element) => element.getAttribute("title") ?? ""),
214+ );
215+ expect(ids, "the admin's own row is listed").toContain(adminId);
216+ expect(
217+ ids,
218+ "the invited member is listed too, though the admin's product view cannot see them",
219+ ).toContain(memberId);
220+ });
221+
222+ await step("Each row summarizes connected vs available integrations", async () => {
223+ const memberRow = page
224+ .locator("[data-slot='admin-user-row']")
225+ .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) });
226+ await memberRow.waitFor({ state: "visible", timeout: 30_000 });
227+ // This member connected exactly one integration. The denominator is
228+ // the tenant's whole catalog (which also carries the built-in), so
229+ // it is read rather than pinned — what this scenario owns is the
230+ // numerator and which slots are lit.
231+ const summary = memberRow.locator("[data-slot='admin-user-connection-count']");
232+ await summary.waitFor({ state: "visible", timeout: 30_000 });
233+ const [connected, total] = ((await summary.textContent()) ?? "").split("/");
234+ expect(connected, "the member is counted as having connected one integration").toBe(
235+ "1",
236+ );
237+ expect(
238+ Number(total),
239+ "the catalog denominator covers at least the two integrations this scenario registered",
240+ ).toBeGreaterThanOrEqual(2);
241+ expect(
242+ await memberRow
243+ .locator(`[data-integration='${connectedIntegration}'][data-connected='true']`)
244+ .count(),
245+ "the integration this member connected is lit in their summary",
246+ ).toBe(1);
247+ expect(
248+ await memberRow
249+ .locator(`[data-integration='${availableIntegration}'][data-connected='false']`)
250+ .count(),
251+ "the one they haven't connected still has a slot — that absence is the view",
252+ ).toBe(1);
253+ });
254+
255+ await step("Open the member's detail and read their connections", async () => {
256+ await page
257+ .locator("[data-slot='admin-user-row']")
258+ .filter({ has: page.locator(`[data-slot='admin-user-id'][title='${memberId}']`) })
259+ .click();
260+ const detail = page.getByRole("dialog");
261+ await detail.waitFor({ state: "visible", timeout: 30_000 });
262+
263+ // Their own connection, by name, with the shared health vocabulary.
264+ await detail
265+ .getByText(memberConnection, { exact: true })
266+ .waitFor({ state: "visible", timeout: 30_000 });
267+ // Never probed, so the honest verdict is Unchecked — not Healthy.
268+ expect(
269+ await detail.getByLabel("Status: Unchecked").count(),
270+ "a never-probed connection reads as unchecked, not healthy",
271+ ).toBe(1);
272+ // The other member's credential is not this member's business.
273+ expect(
274+ await detail.getByText(adminConnection, { exact: true }).count(),
275+ "one user's detail never shows another user's connection",
276+ ).toBe(0);
277+ });
278+
279+ await step("The not-connected integration offers a copyable connect link", async () => {
280+ const detail = page.getByRole("dialog");
281+ const origin = new URL(target.baseUrl).origin;
282+ // A bare /connect/<slug> URL: it opens in the RECIPIENT's session,
283+ // so it carries no org segment and is copied rather than followed.
284+ await detail
285+ .getByText(`${origin}/connect/${availableIntegration}`, { exact: true })
286+ .waitFor({ state: "visible", timeout: 30_000 });
287+ // One per not-connected integration — the catalog also carries the
288+ // built-in, so this is "at least the one we registered", not a
289+ // pinned count of the tenant's catalog.
290+ expect(
291+ await detail.getByRole("button", { name: "Copy link" }).count(),
292+ "every not-connected integration offers its link to copy",
293+ ).toBeGreaterThanOrEqual(1);
294+ });
295+ });
296+
297+ // ── The plain member's view ─────────────────────────────────────────
298+ yield* browser.session(forBrowser(member), async ({ page, step }) => {
299+ let slug = "";
300+
301+ await step("Sign in as a plain member of the same workspace", async () => {
302+ await page.goto("/", { waitUntil: "networkidle" });
303+ await page.waitForURL((url) => /^\/[a-z0-9-]+\/?$/.test(url.pathname), {
304+ timeout: 30_000,
305+ });
306+ slug = new URL(page.url()).pathname.split("/").filter(Boolean)[0] ?? "";
307+ expect(slug, "the member landed in the workspace").not.toBe("");
308+ });
309+
310+ await step("The Users section is not offered to them", async () => {
311+ await page
312+ .getByRole("link", { name: "Integrations" })
313+ .first()
314+ .waitFor({ state: "visible", timeout: 30_000 });
315+ expect(
316+ await page.getByRole("link", { name: "Users" }).count(),
317+ "a plain member is not shown a section that would only refuse them",
318+ ).toBe(0);
319+ });
320+
321+ await step(
322+ "Reaching the URL directly says no access, not an empty workspace",
323+ async () => {
324+ await page.goto(`/${slug}/users`, { waitUntil: "networkidle" });
325+ // The denial is the assertion: an empty table here would misreport
326+ // a populated workspace as having no users.
327+ await page
328+ .getByText("You don't have access to this workspace's users")
329+ .waitFor({ state: "visible", timeout: 30_000 });
330+ expect(
331+ await page.locator("[data-slot='admin-users-table']").count(),
332+ "the refusal replaces the table rather than rendering it empty",
333+ ).toBe(0);
334+ expect(
335+ await page.getByText(adminId, { exact: true }).count(),
336+ "no other member's identity leaks to a refused viewer",
337+ ).toBe(0);
338+ },
339+ );
340+ });
341+ }),
342+ Effect.all(
343+ [
344+ adminClient.connections
345+ .remove({
346+ params: { owner: "user", integration: connectedIntegration, name: adminConnection },
347+ })
348+ .pipe(Effect.ignore),
349+ memberClient.connections
350+ .remove({
351+ params: { owner: "user", integration: connectedIntegration, name: memberConnection },
352+ })
353+ .pipe(Effect.ignore),
354+ adminClient.openapi
355+ .removeSpec({ params: { slug: connectedIntegration } })
356+ .pipe(Effect.ignore),
357+ adminClient.openapi
358+ .removeSpec({ params: { slug: availableIntegration } })
359+ .pipe(Effect.ignore),
360+ ],
361+ { discard: true },
362+ ),
363+ );
364+ }),
365+);
e2e/selfhost/admin-users-console.test.ts+167−0
@@ -0,0 +1,167 @@
1+// Selfhost-only (browser): the admin Users page against the OTHER auth model.
2+//
3+// Cloud gates this plane on a WorkOS `admin` membership; self-host gates it on
4+// a Better Auth `owner`/`admin` member. The console is shared, so the same page
5+// must open for the instance owner and refuse an invited plain member — that
6+// second role vocabulary is exactly what the cloud scenario cannot cover.
7+//
8+// Selfhost identities are shared across scenarios (one bootstrap admin, one
9+// org), so this asserts "contains mine" rather than global counts, and every
10+// resource it creates is prefixed.
11+import { randomBytes } from "node:crypto";
12+
13+import { expect } from "@effect/vitest";
14+import { Effect } from "effect";
15+import type { HttpApiClient } from "effect/unstable/httpapi";
16+import { composePluginApi } from "@executor-js/api/server";
17+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
18+import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
19+
20+import { scenario } from "../src/scenario";
21+import { createInvitedIdentity } from "../targets/selfhost";
22+import { Api, Browser, Target } from "../src/services";
23+
24+const api = composePluginApi([openApiHttpPlugin()] as const);
25+type Client = HttpApiClient.ForApi<typeof api>;
26+
27+const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
28+
29+/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
30+const pingSpec = JSON.stringify({
31+ openapi: "3.0.3",
32+ info: { title: "Ping API", version: "1.0.0" },
33+ paths: {
34+ "/ping": {
35+ get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } },
36+ },
37+ },
38+});
39+
40+const registerIntegration = (client: Client) =>
41+ Effect.gen(function* () {
42+ const slug = IntegrationSlug.make(`admin-ui-sh-${randomBytes(4).toString("hex")}`);
43+ yield* client.openapi.addSpec({
44+ payload: {
45+ spec: { kind: "blob", value: pingSpec },
46+ slug,
47+ baseUrl: "http://127.0.0.1:59999", // never contacted during registration
48+ authenticationTemplate: [
49+ {
50+ slug: "apiKey",
51+ type: "apiKey",
52+ headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
53+ },
54+ ],
55+ },
56+ });
57+ return slug;
58+ });
59+
60+scenario(
61+ "Admin · a self-hosted owner sees the workspace's users; an invited member is refused",
62+ { timeout: 180_000 },
63+ Effect.gen(function* () {
64+ const target = yield* Target;
65+ const browser = yield* Browser;
66+ const { client: apiClient } = yield* Api;
67+
68+ // The bootstrap identity owns the instance; the invited one is a plain
69+ // member, which is the Better Auth role the admin plane refuses.
70+ const owner = yield* target.newIdentity();
71+ const member = yield* Effect.promise(() =>
72+ createInvitedIdentity(target.baseUrl, owner, {
73+ role: "member",
74+ emailPrefix: "admin-ui-member",
75+ }),
76+ );
77+
78+ const ownerClient = yield* apiClient(api, owner);
79+ const memberClient = yield* apiClient(api, member);
80+ const integration = yield* registerIntegration(ownerClient);
81+ const memberConnection = ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
82+
83+ yield* Effect.ensuring(
84+ Effect.gen(function* () {
85+ // The member stores their own credential, so the owner's view has
86+ // something to report that the owner's product view cannot see.
87+ yield* memberClient.connections.create({
88+ payload: {
89+ owner: "user",
90+ name: memberConnection,
91+ integration,
92+ template: TEMPLATE_API_KEY,
93+ value: "member-personal-token",
94+ },
95+ });
96+
97+ yield* browser.session(owner, async ({ page, step }) => {
98+ await step("Open Users from the sidebar as the instance owner", async () => {
99+ await page.goto("/", { waitUntil: "networkidle" });
100+ await page
101+ .locator("nav")
102+ .getByRole("link", { name: "Users" })
103+ .first()
104+ .click({ timeout: 30_000 });
105+ await page
106+ .locator("[data-slot='admin-users-table']")
107+ .waitFor({ state: "visible", timeout: 30_000 });
108+ });
109+
110+ await step("The invited member's connection is attributed to them", async () => {
111+ // Selfhost shares one org across scenarios, so this asserts the
112+ // member's own row exists — never a count of the whole instance.
113+ const row = page
114+ .locator("[data-slot='admin-user-row']")
115+ .filter({
116+ has: page.locator(`[data-integration='${integration}'][data-connected='true']`),
117+ })
118+ .first();
119+ await row.waitFor({ state: "visible", timeout: 30_000 });
120+ await row.click();
121+
122+ const detail = page.getByRole("dialog");
123+ await detail.waitFor({ state: "visible", timeout: 30_000 });
124+ await detail
125+ .getByText(memberConnection, { exact: true })
126+ .waitFor({ state: "visible", timeout: 30_000 });
127+ });
128+ });
129+
130+ yield* browser.session(member, async ({ page, step }) => {
131+ await step("A plain member is not offered the section", async () => {
132+ await page.goto("/", { waitUntil: "networkidle" });
133+ await page
134+ .locator("nav")
135+ .getByRole("link", { name: "Integrations" })
136+ .first()
137+ .waitFor({ state: "visible", timeout: 30_000 });
138+ expect(
139+ await page.locator("nav").getByRole("link", { name: "Users" }).count(),
140+ "a plain member is not shown a section that would only refuse them",
141+ ).toBe(0);
142+ });
143+
144+ await step("And reaching the URL directly is refused, not silently empty", async () => {
145+ await page.goto("/users", { waitUntil: "networkidle" });
146+ await page
147+ .getByText("You don't have access to this workspace's users")
148+ .waitFor({ state: "visible", timeout: 30_000 });
149+ expect(
150+ await page.locator("[data-slot='admin-users-table']").count(),
151+ "the refusal replaces the table rather than rendering it empty",
152+ ).toBe(0);
153+ });
154+ });
155+ }),
156+ Effect.all(
157+ [
158+ memberClient.connections
159+ .remove({ params: { owner: "user", integration, name: memberConnection } })
160+ .pipe(Effect.ignore),
161+ ownerClient.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore),
162+ ],
163+ { discard: true },
164+ ),
165+ );
166+ }),
167+);
packages/app/tsr.routes.ts+6−4
@@ -7,13 +7,15 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 6 unmodified lines
77 // Shared console routes come from @executor-js/react (see its
88 // console-routes.ts); this app owns its root and the local-specific routes
99 // under src/routes/app. /secrets is excluded: the local app's variant shows
10−// credential-provider info. App routes mount INSIDE the shared `{-$orgSlug}`
11−// scope (local never sets the param, so URLs stay bare) so the override masks
12−// the shared route id.
10+// credential-provider info. /users is excluded because local serves no account
11+// or admin API at all (see apps/local/src/app.ts) — there is no tenant to
12+// operate and no endpoint behind the page. App routes mount INSIDE the shared
13+// `{-$orgSlug}` scope (local never sets the param, so URLs stay bare) so the
14+// override masks the shared route id.
1315 export const routes = rootRoute("__root.tsx", [
1416 ...consoleRoutes({
1517 dir: "../../../react/src/routes",
16− exclude: ["/secrets"],
18+ exclude: ["/secrets", "/users"],
1719 orgScoped: [physical("", "app")],
1820 }),
1921 ]);
packages/core/api/src/client.ts+13−0
@@ -14,3 +14,16 @@ export { · 13 unmodified lines
1414 AccountNoOrganization,
1515 AccountUnauthorized,
1616 } from "./account/api";
17+export {
18+ AdminUsersApi,
19+ AdminUsersHttpApi,
20+ AdminUsersError,
21+ AdminUsersForbidden,
22+ AdminUsersUnauthorized,
23+ AdminUser,
24+ AdminUserConnection,
25+ AdminUserWithConnections,
26+ AdminUsersResponse,
27+ AdminUserConnectionsResponse,
28+ AdminUsersWithConnectionsResponse,
29+} from "./admin/api";
packages/react/src/api/admin-atoms.tsx+53−0
@@ -0,0 +1,53 @@
1+import * as Atom from "effect/unstable/reactivity/Atom";
2+
3+import { AdminApiClient } from "./admin-client";
4+import { ReactivityKey } from "./reactivity-keys";
5+
6+// ---------------------------------------------------------------------------
7+// Admin atoms — the tenant-wide users view behind `/admin/users*`.
8+//
9+// Read-only by construction: the admin plane serves GETs only (the owner policy
10+// rejects writes at tenant reach), so there are no mutations here and every
11+// atom carries the same reactivity key.
12+//
13+// Paging is part of the atom identity, so each page is its own cache entry and
14+// stepping back to a visited page is instant. `Atom.family` (not a bare arrow)
15+// because the page component re-derives the key object on every render — a
16+// fresh atom per render would refetch in a loop.
17+// ---------------------------------------------------------------------------
18+
19+/** How many users one page of the list shows. Well inside the contract's
20+ * 1..500 bound; the joined endpoint reads per-user connections, so a modest
21+ * page keeps that join cheap. */
22+export const ADMIN_USERS_PAGE_SIZE = 25;
23+
24+export interface AdminUsersPage {
25+ readonly limit: number;
26+ readonly offset: number;
27+}
28+
29+/**
30+ * One page of users joined with their connections — what the list renders.
31+ *
32+ * Asks for one row MORE than the page size so the pager can tell "there is a
33+ * next page" from "this is the last one" without a count endpoint (the contract
34+ * returns no total). The page component drops the extra row.
35+ */
36+export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) =>
37+ AdminApiClient.query("adminUsers", "listUsersWithConnections", {
38+ query: { limit: page.limit + 1, offset: page.offset },
39+ timeToLive: "30 seconds",
40+ reactivityKeys: [ReactivityKey.adminUsers],
41+ }),
42+);
43+
44+/** One user's connections, for the detail pane. A separate read rather than a
45+ * slice of the list so opening a user is correct even when the list page it
46+ * came from has since gone stale. */
47+export const adminUserConnectionsAtom = Atom.family((externalId: string) =>
48+ AdminApiClient.query("adminUsers", "listUserConnections", {
49+ params: { externalId },
50+ timeToLive: "30 seconds",
51+ reactivityKeys: [ReactivityKey.adminUsers],
52+ }),
53+);
packages/react/src/api/admin-client.tsx+53−0
@@ -0,0 +1,53 @@
1+import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3+import { AdminUsersHttpApi } from "@executor-js/api/client";
4+import * as Effect from "effect/Effect";
5+
6+import { reportApiClientInfrastructureCause } from "./client";
7+import {
8+ EXECUTOR_ORG_HEADER,
9+ getActiveOrgSlug,
10+ getExecutorApiBaseUrl,
11+ getExecutorServerAuthorizationHeader,
12+} from "./server-connection";
13+
14+// ---------------------------------------------------------------------------
15+// Shared admin client — the tenant-wide `/admin/users*` operator surface.
16+//
17+// A third AtomHttpApi service alongside `ExecutorApiClient` (the product plane:
18+// "what can I reach") and `AccountApiClient` (identity + org membership). This
19+// one answers the OWNER's question: who are my users and what have they
20+// connected. Its paths are `/admin/*` with no `/api` — `transformClient`
21+// prepends the API base, exactly like the account client.
22+//
23+// Both hosts that serve it (cloud: an org API key or an admin-role session;
24+// self-host: a Better Auth owner/admin) implement identical routes, so one
25+// client works for both. Requests carry the same session cookie the browser
26+// sends anyway; the org header scopes cloud to the org the console URL is on.
27+//
28+// `reportApiClientInfrastructureCause` (not `handleApiClientCause`) on purpose:
29+// a 401 here means "not an admin of this tenant", which must render a denied
30+// state on the page — it must NOT pop the local-auth gate the core client
31+// raises when the whole product plane is unreachable.
32+// ---------------------------------------------------------------------------
33+
34+const AdminApiClient = AtomHttpApi.Service<"AdminApiClient">()("AdminApiClient", {
35+ api: AdminUsersHttpApi,
36+ httpClient: FetchHttpClient.layer,
37+ transformClient: HttpClient.mapRequest((request) => {
38+ let next = HttpClientRequest.prependUrl(request, getExecutorApiBaseUrl());
39+ const authorization = getExecutorServerAuthorizationHeader();
40+ if (authorization) {
41+ next = HttpClientRequest.setHeader(next, "authorization", authorization);
42+ }
43+ // Scope to the org the console URL is on (see server-connection).
44+ const orgSlug = getActiveOrgSlug();
45+ if (orgSlug) {
46+ next = HttpClientRequest.setHeader(next, EXECUTOR_ORG_HEADER, orgSlug);
47+ }
48+ return next;
49+ }),
50+ transformResponse: (effect) => Effect.tapCause(effect, reportApiClientInfrastructureCause),
51+});
52+
53+export { AdminApiClient };
packages/react/src/api/reactivity-keys.tsx+4−0
@@ -36,6 +36,10 @@ export const ReactivityKey = { · 35 unmodified lines
3636 orgInfo: "org:info",
3737 apiKeys: "api-keys",
3838 auth: "auth",
39+ /** The tenant-wide admin users view. Read-only today (the admin plane has no
40+ * writes), so nothing invalidates it — it exists so the pages refresh
41+ * together and so a future lifecycle mutation has a key to publish. */
42+ adminUsers: "admin:users",
3943 } as const;
4044
4145 /** Mutations that add/remove/refresh an integration also affect tool listings. */
packages/react/src/console-routes.ts+5−0
@@ -40,6 +40,7 @@ export const CONSOLE_ROUTE_PATHS = [ · 39 unmodified lines
4040 "/policies",
4141 "/secrets",
4242 "/tools",
43+ "/users",
4344 "/toolkits",
4445 "/toolkits/$toolkitSlug",
4546 "/resume/$executionId",
@@ -78,6 +79,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 32 unmodified lines
7879 ["/policies", route("/policies", file("policies.tsx"))],
7980 ["/secrets", route("/secrets", file("secrets.tsx"))],
8081 ["/tools", route("/tools", file("tools.tsx"))],
82+ // The admin users view. Hosts that don't serve `/admin/users*` (local /
83+ // desktop, cloudflare) exclude it — the page would only ever render its
84+ // denied state there.
85+ ["/users", route("/users", file("users.tsx"))],
8186 ["/toolkits", route("/toolkits", file("toolkits.tsx"))],
8287 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],
8388 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],
packages/react/src/lib/admin-access.test.ts+52−0
@@ -0,0 +1,52 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { isTenantAdminMember, type TenantMemberRow } from "./admin-access";
4+
5+const member = (overrides: Partial<TenantMemberRow> = {}): TenantMemberRow => ({
6+ role: "member",
7+ status: "active",
8+ isCurrentUser: false,
9+ ...overrides,
10+});
11+
12+describe("isTenantAdminMember", () => {
13+ it("an active admin of the tenant may see the admin sections", () => {
14+ expect(isTenantAdminMember([member({ role: "admin", isCurrentUser: true })])).toBe(true);
15+ });
16+
17+ // Self-host (Better Auth) issues `owner` where cloud (WorkOS) issues `admin`;
18+ // both hosts' admin middleware accept either.
19+ it("self-host's owner role counts too", () => {
20+ expect(isTenantAdminMember([member({ role: "owner", isCurrentUser: true })])).toBe(true);
21+ });
22+
23+ it("a plain member does not", () => {
24+ expect(isTenantAdminMember([member({ role: "member", isCurrentUser: true })])).toBe(false);
25+ });
26+
27+ // A pending invite carries a role but no access yet — treating it as admin
28+ // would show a section the server refuses.
29+ it("a pending admin invite is not yet an admin", () => {
30+ expect(
31+ isTenantAdminMember([member({ role: "admin", status: "pending", isCurrentUser: true })]),
32+ ).toBe(false);
33+ });
34+
35+ it("another member's admin role says nothing about the viewer", () => {
36+ expect(
37+ isTenantAdminMember([
38+ member({ role: "admin", isCurrentUser: false }),
39+ member({ role: "member", isCurrentUser: true }),
40+ ]),
41+ ).toBe(false);
42+ });
43+
44+ it("fails closed when the list has no row for the viewer", () => {
45+ expect(isTenantAdminMember([])).toBe(false);
46+ expect(isTenantAdminMember([member({ role: "admin" })])).toBe(false);
47+ });
48+
49+ it("an unknown role is not an admin", () => {
50+ expect(isTenantAdminMember([member({ role: "billing", isCurrentUser: true })])).toBe(false);
51+ });
52+});
packages/react/src/lib/admin-access.ts+46−0
@@ -0,0 +1,46 @@
1+// ---------------------------------------------------------------------------
2+// Who may see the admin sections.
3+//
4+// There are two independent gates and both are load-bearing:
5+//
6+// 1. NAV VISIBILITY (this file). The console has no role on the session —
7+// `AccountMeResponse` carries a user and an organization, not a role — so
8+// the only client-side role signal available today is the member list:
9+// find the row flagged `isCurrentUser` and read its role. Cloud's org page
10+// already derives its danger zone this way; this generalizes it.
11+// 2. ACCESS ITSELF (the page). The server decides. Every admin endpoint is
12+// gated per host (cloud: an org API key or an admin-role session;
13+// self-host: a Better Auth owner/admin), and the page renders a denied
14+// state on 401/403 regardless of what this file concluded.
15+//
16+// The nav gate is a convenience, not security: it exists so a plain member
17+// isn't shown a section that would only refuse them. A member who guesses the
18+// URL hits gate 2, which is the real one.
19+// ---------------------------------------------------------------------------
20+
21+/** Roles that may read the tenant-wide admin plane. Cloud (WorkOS) issues
22+ * `admin` / `member`; self-host (Better Auth) issues `owner` / `admin` /
23+ * `member`. Both hosts' admin middleware accept exactly this set. */
24+const TENANT_ADMIN_ROLES: ReadonlySet<string> = new Set(["admin", "owner"]);
25+
26+/** A member row as `/account/members` returns it, typed structurally. */
27+export interface TenantMemberRow {
28+ readonly role: string;
29+ readonly status: string;
30+ readonly isCurrentUser: boolean;
31+}
32+
33+/**
34+ * Whether the current member may read the admin plane, per the member list.
35+ *
36+ * Only an ACTIVE membership counts: a pending invite carries a role but no
37+ * access yet, and treating it as admin would show a section the server refuses.
38+ * A list with no `isCurrentUser` row (self-host before the org is populated, a
39+ * host that doesn't serve members) is not an admin — the safe default, since
40+ * the page's own denied state is the honest surface for that case.
41+ */
42+export const isTenantAdminMember = (members: readonly TenantMemberRow[]): boolean =>
43+ members.some(
44+ (member) =>
45+ member.isCurrentUser && member.status === "active" && TENANT_ADMIN_ROLES.has(member.role),
46+ );
packages/react/src/lib/admin-users-display.test.ts+173−0
@@ -0,0 +1,173 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import type { IntegrationSlug } from "@executor-js/sdk/shared";
3+
4+import {
5+ connectionHealthStatus,
6+ connectLinkUrl,
7+ formatLastSeen,
8+ integrationConnectionStates,
9+ isLocalSubject,
10+ lastSeenTitle,
11+ pageNumber,
12+ shortenExternalId,
13+ splitPage,
14+ type AdminConnectionRow,
15+} from "./admin-users-display";
16+
17+const slug = (value: string): IntegrationSlug => value as IntegrationSlug;
18+
19+const connection = (overrides: Partial<AdminConnectionRow> = {}): AdminConnectionRow => ({
20+ owner: "user",
21+ integration: slug("acme"),
22+ name: "default",
23+ oauthScope: null,
24+ lastHealth: null,
25+ ...overrides,
26+});
27+
28+describe("formatLastSeen", () => {
29+ const now = Date.UTC(2026, 6, 27, 12, 0, 0);
30+
31+ it("never seen reads as Never, not as a stale timestamp", () => {
32+ expect(formatLastSeen(null, now)).toBe("Never");
33+ });
34+
35+ // The writer throttles lastSeenAt to ~1h, so anything inside that window is
36+ // one bucket. Reporting minutes would claim precision the field doesn't have.
37+ it("collapses everything inside the throttle window to one honest bucket", () => {
38+ expect(formatLastSeen(now - 60_000, now)).toBe("Within the hour");
39+ expect(formatLastSeen(now - 45 * 60_000, now)).toBe("Within the hour");
40+ expect(formatLastSeen(now, now)).toBe("Within the hour");
41+ });
42+
43+ it("counts hours and days above the window, hedged with About", () => {
44+ expect(formatLastSeen(now - 60 * 60_000, now)).toBe("About 1 hour ago");
45+ expect(formatLastSeen(now - 5 * 60 * 60_000, now)).toBe("About 5 hours ago");
46+ expect(formatLastSeen(now - 24 * 60 * 60_000, now)).toBe("About 1 day ago");
47+ expect(formatLastSeen(now - 9 * 24 * 60 * 60_000, now)).toBe("About 9 days ago");
48+ });
49+
50+ it("falls back to an absolute date once relative wording stops being useful", () => {
51+ expect(formatLastSeen(now - 200 * 24 * 60 * 60_000, now)).not.toContain("ago");
52+ });
53+
54+ it("the tooltip carries the exact instant and the coarseness caveat", () => {
55+ expect(lastSeenTitle(null)).toBe("Never seen on a request");
56+ expect(lastSeenTitle(now)).toContain("at most once an hour");
57+ });
58+});
59+
60+describe("externalId display", () => {
61+ it("recognizes the single-player host sentinel", () => {
62+ expect(isLocalSubject("local")).toBe(true);
63+ expect(isLocalSubject("user_01JABCDEF")).toBe(false);
64+ });
65+
66+ it("leaves short ids exactly as the server sent them", () => {
67+ expect(shortenExternalId("user_01JAB")).toBe("user_01JAB");
68+ });
69+
70+ // Truncation is visual only — the id is opaque, so both ends are kept and two
71+ // ids differing in the middle stay distinguishable.
72+ it("shortens long ids from the middle, keeping both ends", () => {
73+ const long = "user_01JABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
74+ const short = shortenExternalId(long, 21);
75+ expect(short.length).toBe(21);
76+ expect(short.startsWith("user_01JAB")).toBe(true);
77+ expect(short.endsWith("0123456789")).toBe(true);
78+ expect(short).toContain("…");
79+ });
80+});
81+
82+describe("connectionHealthStatus", () => {
83+ it("reads the stored verdict", () => {
84+ expect(connectionHealthStatus(connection({ lastHealth: { status: "expired" } }))).toBe(
85+ "expired",
86+ );
87+ });
88+
89+ it("a never-probed connection is unchecked, not healthy", () => {
90+ expect(connectionHealthStatus(connection({ lastHealth: null }))).toBe("unknown");
91+ });
92+});
93+
94+describe("integrationConnectionStates", () => {
95+ it("shows the whole catalog, marking what this user has connected", () => {
96+ const states = integrationConnectionStates(
97+ [slug("acme"), slug("beta"), slug("gamma")],
98+ [connection({ integration: slug("beta") })],
99+ );
100+ expect(states.map((state) => state.integration)).toEqual(["beta", "acme", "gamma"]);
101+ expect(states.map((state) => state.connected)).toEqual([true, false, false]);
102+ });
103+
104+ it("keeps an available integration listed even when nobody connected it", () => {
105+ const states = integrationConnectionStates([slug("acme")], []);
106+ expect(states).toEqual([{ integration: "acme", connected: false, connections: [] }]);
107+ });
108+
109+ // Dropping the user's own credential because the catalog read missed it would
110+ // under-report what they actually hold.
111+ it("still reports a connection whose integration is missing from the catalog", () => {
112+ const states = integrationConnectionStates([], [connection({ integration: slug("ghost") })]);
113+ expect(states.map((state) => state.integration)).toEqual(["ghost"]);
114+ expect(states[0]?.connected).toBe(true);
115+ });
116+
117+ it("groups every connection a user holds on one integration", () => {
118+ const states = integrationConnectionStates(
119+ [slug("acme")],
120+ [
121+ connection({ integration: slug("acme"), name: "work" }),
122+ connection({ integration: slug("acme"), name: "personal" }),
123+ ],
124+ );
125+ expect(states[0]?.connections.map((row) => row.name)).toEqual(["work", "personal"]);
126+ });
127+
128+ it("orders connected first, then alphabetically, so the order is stable per user", () => {
129+ const states = integrationConnectionStates(
130+ [slug("zulu"), slug("alpha"), slug("mike")],
131+ [connection({ integration: slug("zulu") })],
132+ );
133+ expect(states.map((state) => state.integration)).toEqual(["zulu", "alpha", "mike"]);
134+ });
135+});
136+
137+describe("connectLinkUrl", () => {
138+ // Bare /connect/<slug>: the link is for the recipient's own session, which
139+ // resolves their org at the auth gate — an admin's org segment would be wrong.
140+ it("builds an absolute, org-free connect URL for this deployment", () => {
141+ expect(connectLinkUrl(slug("acme"), "https://console.example.com")).toBe(
142+ "https://console.example.com/connect/acme",
143+ );
144+ });
145+
146+ it("does not double the separator when the origin carries a trailing slash", () => {
147+ expect(connectLinkUrl(slug("acme"), "https://console.example.com/")).toBe(
148+ "https://console.example.com/connect/acme",
149+ );
150+ });
151+});
152+
153+describe("paging", () => {
154+ // The contract returns no total, so the page over-fetches by one row and the
155+ // extra row IS the answer to "is there a next page".
156+ it("an over-fetched page yields the page's rows plus a next-page signal", () => {
157+ expect(splitPage([1, 2, 3, 4], 3)).toEqual({ rows: [1, 2, 3], hasNext: true });
158+ });
159+
160+ it("a page that came back short is the last page", () => {
161+ expect(splitPage([1, 2], 3)).toEqual({ rows: [1, 2], hasNext: false });
162+ });
163+
164+ it("an exactly-full page without the extra row is also the last page", () => {
165+ expect(splitPage([1, 2, 3], 3)).toEqual({ rows: [1, 2, 3], hasNext: false });
166+ });
167+
168+ it("numbers pages from one", () => {
169+ expect(pageNumber(0, 25)).toBe(1);
170+ expect(pageNumber(25, 25)).toBe(2);
171+ expect(pageNumber(75, 25)).toBe(4);
172+ });
173+});
packages/react/src/lib/admin-users-display.ts+175−0
@@ -0,0 +1,175 @@
1+import type { HealthStatus, IntegrationSlug, Owner } from "@executor-js/sdk/shared";
2+
3+// ---------------------------------------------------------------------------
4+// Pure display logic for the admin Users section.
5+//
6+// Everything here is a plain function over wire data so it can be tested
7+// without mounting the console (the package's testing doctrine: extract the
8+// logic, test the logic). The components below it only arrange the results.
9+// ---------------------------------------------------------------------------
10+
11+/** The connection shape the admin plane returns (`AdminUserConnection`), typed
12+ * structurally so these helpers don't depend on the schema module. */
13+export interface AdminConnectionRow {
14+ readonly owner: Owner;
15+ readonly integration: IntegrationSlug;
16+ readonly name: string;
17+ readonly oauthScope: string | null;
18+ readonly lastHealth: { readonly status: HealthStatus } | null;
19+}
20+
21+// ── Last seen ───────────────────────────────────────────────────────────────
22+
23+const MINUTE = 60_000;
24+const HOUR = 60 * MINUTE;
25+const DAY = 24 * HOUR;
26+
27+/**
28+ * Relative "last seen", phrased no more precisely than the field deserves.
29+ *
30+ * The writer throttles `lastSeenAt` to roughly an hour, so a sub-hour delta is
31+ * NOT evidence the user was here five minutes ago — it only means the sighting
32+ * landed within the last throttle window. Everything under an hour therefore
33+ * collapses to one honest bucket instead of counting minutes, and `null` (never
34+ * seen on a request — a user earns a row at connection-create, before any
35+ * sighting) reads as "Never", not "unknown".
36+ */
37+export const formatLastSeen = (lastSeenAt: number | null, now: number = Date.now()): string => {
38+ if (lastSeenAt === null) return "Never";
39+ const delta = now - lastSeenAt;
40+ if (delta < HOUR) return "Within the hour";
41+ if (delta < DAY) {
42+ const hours = Math.floor(delta / HOUR);
43+ return hours === 1 ? "About 1 hour ago" : `About ${hours} hours ago`;
44+ }
45+ const days = Math.floor(delta / DAY);
46+ if (days === 1) return "About 1 day ago";
47+ if (days < 30) return `About ${days} days ago`;
48+ return new Intl.DateTimeFormat(undefined, {
49+ month: "short",
50+ day: "numeric",
51+ year: "numeric",
52+ }).format(new Date(lastSeenAt));
53+};
54+
55+/** The tooltip behind {@link formatLastSeen}: the exact recorded instant plus
56+ * the coarseness caveat, so an operator reading "Within the hour" can see what
57+ * the row actually stores. */
58+export const lastSeenTitle = (lastSeenAt: number | null): string =>
59+ lastSeenAt === null
60+ ? "Never seen on a request"
61+ : `Recorded ${new Date(lastSeenAt).toLocaleString()} — updated at most once an hour`;
62+
63+/** Absolute date, for the created column. */
64+export const formatAdminDate = (epochMs: number): string =>
65+ new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(
66+ new Date(epochMs),
67+ );
68+
69+// ── Identity ────────────────────────────────────────────────────────────────
70+
71+/**
72+ * How a user's opaque `externalId` is shown.
73+ *
74+ * The id is opaque by contract — nothing may parse it — so it is rendered whole,
75+ * in mono, and only visually shortened when it is long enough to break the
76+ * column. The full value stays available via the title attribute and the copy
77+ * button. `"local"` is a host sentinel for the single-player subject and reads
78+ * better spelled out.
79+ */
80+export const LOCAL_SUBJECT_ID = "local";
81+
82+export const isLocalSubject = (externalId: string): boolean => externalId === LOCAL_SUBJECT_ID;
83+
84+/** Visual truncation only — never a parse. Keeps both ends so two ids that
85+ * differ in the middle stay distinguishable. */
86+export const shortenExternalId = (externalId: string, max = 28): string => {
87+ if (externalId.length <= max) return externalId;
88+ const head = Math.ceil((max - 1) / 2);
89+ const tail = Math.floor((max - 1) / 2);
90+ return `${externalId.slice(0, head)}…${externalId.slice(externalId.length - tail)}`;
91+};
92+
93+// ── Connections ─────────────────────────────────────────────────────────────
94+
95+/** The health status a row displays. A connection that was never probed carries
96+ * no verdict, which is `unknown` in the shared vocabulary. */
97+export const connectionHealthStatus = (connection: AdminConnectionRow): HealthStatus =>
98+ connection.lastHealth?.status ?? "unknown";
99+
100+/**
101+ * The available-vs-connected view for one user: every integration in the
102+ * tenant's catalog, marked with whether this user has connected it.
103+ *
104+ * "Available" is the catalog, so an integration nobody has connected still gets
105+ * a (greyed) slot — that absence is the point of the view. Sorted connected
106+ * first so a dense grid leads with signal, then by slug for a stable order
107+ * across users.
108+ *
109+ * Org-owned connections are deliberately excluded from the API's per-user
110+ * results, so anything appearing here is genuinely this user's own.
111+ */
112+export interface IntegrationConnectionState {
113+ readonly integration: IntegrationSlug;
114+ readonly connected: boolean;
115+ readonly connections: readonly AdminConnectionRow[];
116+}
117+
118+export const integrationConnectionStates = (
119+ catalog: readonly IntegrationSlug[],
120+ connections: readonly AdminConnectionRow[],
121+): readonly IntegrationConnectionState[] => {
122+ const byIntegration = new Map<IntegrationSlug, AdminConnectionRow[]>();
123+ for (const connection of connections) {
124+ const existing = byIntegration.get(connection.integration);
125+ if (existing) existing.push(connection);
126+ else byIntegration.set(connection.integration, [connection]);
127+ }
128+ // A user can hold a connection on an integration the catalog read didn't
129+ // return (a catalog the viewer can't see, or one removed since). Showing it
130+ // as connected is more honest than dropping the user's own credential.
131+ const slugs = new Set<IntegrationSlug>([...catalog, ...byIntegration.keys()]);
132+ return [...slugs]
133+ .map((integration) => ({
134+ integration,
135+ connected: byIntegration.has(integration),
136+ connections: byIntegration.get(integration) ?? [],
137+ }))
138+ .sort((a, b) => {
139+ if (a.connected !== b.connected) return a.connected ? -1 : 1;
140+ return a.integration.localeCompare(b.integration);
141+ });
142+};
143+
144+// ── Connect links ───────────────────────────────────────────────────────────
145+
146+/**
147+ * The connect deep link for an integration, as an absolute URL for THIS
148+ * deployment — something an admin can paste to a user who needs to connect.
149+ *
150+ * Bare `/connect/<slug>`: no org segment. The link is for a person who may not
151+ * be signed in yet, and the deployment resolves their org at the auth gate. It
152+ * is rendered as a copyable URL rather than an in-console link because it
153+ * targets the recipient's session, not the admin's.
154+ */
155+export const connectLinkUrl = (integration: IntegrationSlug, origin: string): string =>
156+ `${origin.replace(/\/+$/, "")}/connect/${integration}`;
157+
158+// ── Paging ──────────────────────────────────────────────────────────────────
159+
160+/**
161+ * Split an over-fetched page into the rows to render plus whether another page
162+ * exists. The contract returns no total count, so "is there a next page" is
163+ * answered by asking for one row more than the page holds.
164+ */
165+export const splitPage = <A>(
166+ rows: readonly A[],
167+ pageSize: number,
168+): { readonly rows: readonly A[]; readonly hasNext: boolean } => ({
169+ rows: rows.slice(0, pageSize),
170+ hasNext: rows.length > pageSize,
171+});
172+
173+/** The 1-based page number an offset lands on, for the mono pager label. */
174+export const pageNumber = (offset: number, pageSize: number): number =>
175+ Math.floor(offset / pageSize) + 1;
packages/react/src/multiplayer/use-admin-nav.tsx+46−0
@@ -0,0 +1,46 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3+
4+import { orgMembersAtom } from "../api/account-atoms";
5+import { isTenantAdminMember, type TenantMemberRow } from "../lib/admin-access";
6+import type { ShellNavItem } from "./shell";
7+
8+// ---------------------------------------------------------------------------
9+// Admin-only nav items.
10+//
11+// The console has no role on the session (`/account/me` returns a user and an
12+// organization, no role), so the current member's role is read off the member
13+// list — the same derivation cloud's org page already uses for its danger zone.
14+// Hosts pass their admin-only entries through this hook, which drops them for
15+// anyone the list doesn't show as an active admin/owner.
16+//
17+// This hides a section; it does not protect one. The page behind each item
18+// renders a denied state off the server's own 401/403, which is the gate that
19+// matters. Failing closed here is deliberate: while the member list is loading
20+// or unavailable, showing nothing is better than flashing a section that will
21+// refuse.
22+// ---------------------------------------------------------------------------
23+
24+/** Whether the current member reads as an admin/owner of this tenant. */
25+export const useIsTenantAdmin = (): boolean => {
26+ const members = useAtomValue(orgMembersAtom);
27+ return AsyncResult.match(members, {
28+ onInitial: () => false,
29+ onFailure: () => false,
30+ onSuccess: ({ value }) => isTenantAdminMember(value.members as readonly TenantMemberRow[]),
31+ });
32+};
33+
34+/**
35+ * Append admin-only nav items to a host's nav, for admins only.
36+ *
37+ * Takes both lists so hosts keep one nav definition and the ordering stays
38+ * theirs — the admin entries land wherever the host places the call.
39+ */
40+export const useAdminNavItems = (
41+ base: ReadonlyArray<ShellNavItem>,
42+ adminOnly: ReadonlyArray<ShellNavItem>,
43+): ReadonlyArray<ShellNavItem> => {
44+ const isAdmin = useIsTenantAdmin();
45+ return isAdmin ? [...base, ...adminOnly] : base;
46+};
packages/react/src/pages/admin-users.tsx+574−0
@@ -0,0 +1,574 @@
1+import { useState } from "react";
2+import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
3+import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+import * as Cause from "effect/Cause";
5+import * as Option from "effect/Option";
6+import * as Predicate from "effect/Predicate";
7+import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sdk/shared";
8+import { useIntegrationPlugins } from "@executor-js/sdk/client";
9+
10+import {
11+ ADMIN_USERS_PAGE_SIZE,
12+ adminUserConnectionsAtom,
13+ adminUsersWithConnectionsAtom,
14+} from "../api/admin-atoms";
15+import { integrationsOptimisticAtom } from "../api/atoms";
16+import { ownerLabel } from "../api/owner-display";
17+import { Button } from "../components/button";
18+import { CopyButton } from "../components/copy-button";
19+import { ErrorState } from "../components/error-state";
20+import {
21+ IntegrationFavicon,
22+ integrationInferredUrl,
23+ integrationPresetIconUrl,
24+} from "../components/integration-favicon";
25+import { PageContainer, PageHeader } from "../components/page";
26+import {
27+ Sheet,
28+ SheetContent,
29+ SheetDescription,
30+ SheetHeader,
31+ SheetTitle,
32+} from "../components/sheet";
33+import { Skeleton } from "../components/skeleton";
34+import {
35+ connectionHealthStatus,
36+ connectLinkUrl,
37+ formatAdminDate,
38+ formatLastSeen,
39+ integrationConnectionStates,
40+ isLocalSubject,
41+ lastSeenTitle,
42+ pageNumber,
43+ shortenExternalId,
44+ splitPage,
45+ type AdminConnectionRow,
46+} from "../lib/admin-users-display";
47+import {
48+ HEALTH_INDICATOR_COLOR,
49+ HEALTH_STATUS_LABEL,
50+ HEALTH_TEXT_CLASS,
51+} from "../lib/health-display";
52+import { isAsyncResultLoading } from "../lib/async-result";
53+import { useExecutorDocumentTitle } from "../lib/document-title";
54+
55+// ---------------------------------------------------------------------------
56+// Admin · Users — the tenant-wide operator view.
57+//
58+// The product plane answers "what can I reach"; this page answers the owner's
59+// question: who are my users, and what has each of them connected. It reads the
60+// admin plane only (`/admin/users*`), which is read-only by construction, so
61+// there is nothing to mutate here — every affordance is a read or a copy.
62+//
63+// ACCESS: the server is the gate. A plain member's request comes back 403 (401
64+// with no session), and the page renders an explicit denial rather than an
65+// empty table, so "you may not see this" never reads as "there is nobody here".
66+// The nav item is separately hidden from non-admins (see lib/admin-access), but
67+// that is convenience — this is the surface that actually refuses.
68+//
69+// The row is a button rather than a link: a user has no page of its own, and a
70+// detail sheet keeps the list's paging position while an operator scans several
71+// users in a row.
72+// ---------------------------------------------------------------------------
73+
74+type AdminUserRow = {
75+ readonly externalId: string;
76+ readonly createdAt: number;
77+ readonly lastSeenAt: number | null;
78+ readonly status: string | null;
79+ readonly connections: readonly AdminConnectionRow[];
80+};
81+
82+/** 401/403 from the admin plane. Both mean "not an operator here" and get the
83+ * same denial: the distinction (no session vs wrong role) is the server's, and
84+ * a signed-in member reading "unauthorized" would only confuse. */
85+const isAccessDenied = (cause: Cause.Cause<unknown>): boolean =>
86+ Option.match(Cause.findErrorOption(cause), {
87+ onNone: () => false,
88+ onSome: (error) =>
89+ Predicate.isTagged(error, "AdminUsersForbidden") ||
90+ Predicate.isTagged(error, "AdminUsersUnauthorized"),
91+ });
92+
93+// ── Access denied ───────────────────────────────────────────────────────────
94+
95+function AccessDenied() {
96+ return (
97+ <div className="rounded-lg border border-border bg-card p-8">
98+ <p className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
99+ Admin only
100+ </p>
101+ <h2 className="mt-2 text-base font-semibold text-foreground">
102+ You don&apos;t have access to this workspace&apos;s users
103+ </h2>
104+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
105+ Reading every user and what they&apos;ve connected requires an admin role. Ask an admin of
106+ this workspace if you need it.
107+ </p>
108+ </div>
109+ );
110+}
111+
112+// ── Health ──────────────────────────────────────────────────────────────────
113+
114+/** The shared health vocabulary, rendered from a stored verdict.
115+ *
116+ * Deliberately NOT `IntegrationHealthSummary`: that component probes upstream
117+ * on mount through the product plane, which is both the wrong plane here (an
118+ * admin is not the credential's owner) and the wrong behavior (opening an
119+ * admin view must not fire traffic at every user's provider). This reads the
120+ * `lastHealth` the admin payload already carries. */
121+function HealthIndicator(props: { readonly status: HealthStatus }) {
122+ const label = HEALTH_STATUS_LABEL[props.status];
123+ return (
124+ <span className="flex shrink-0 items-center gap-1.5" title={`Status: ${label}`}>
125+ <span
126+ className={`font-mono text-[11px] font-medium uppercase tracking-[0.08em] ${HEALTH_TEXT_CLASS[props.status]}`}
127+ >
128+ {label}
129+ </span>
130+ <span
131+ aria-label={`Status: ${label}`}
132+ className={`size-2 rounded-full ${HEALTH_INDICATOR_COLOR[props.status].dot}`}
133+ />
134+ </span>
135+ );
136+}
137+
138+// ── Integration icons ───────────────────────────────────────────────────────
139+
140+/** The catalog joined into a favicon lookup. The admin payload carries only a
141+ * slug per connection (no kind, no display URL — the platform view hands out
142+ * nothing it doesn't have to), so brand marks come from the tenant's own
143+ * integration catalog, which the admin can read on the product plane. */
144+type IconLookup = (integration: IntegrationSlug) => {
145+ readonly icon: string | null;
146+ readonly url: string | undefined;
147+ readonly name: string;
148+};
149+
150+const useIntegrationIcons = (): IconLookup => {
151+ const catalog = useAtomValue(integrationsOptimisticAtom);
152+ const integrationPlugins = useIntegrationPlugins();
153+ const integrations = Option.getOrElse(
154+ AsyncResult.value(catalog),
155+ (): readonly Integration[] => [],
156+ );
157+
158+ return (integration) => {
159+ const slug = String(integration);
160+ const found = integrations.find((row) => String(row.slug) === slug);
161+ const name = found?.name || slug;
162+ return {
163+ icon: found
164+ ? integrationPresetIconUrl(
165+ { id: slug, kind: found.kind, name, url: found.displayUrl },
166+ integrationPlugins,
167+ )
168+ : null,
169+ url: found?.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined,
170+ name,
171+ };
172+ };
173+};
174+
175+/** The catalog slugs, for the available-vs-connected view. Empty while the
176+ * catalog loads, which degrades to "connected only" rather than blocking the
177+ * users table on a second request. */
178+const useCatalogSlugs = (): readonly IntegrationSlug[] => {
179+ const catalog = useAtomValue(integrationsOptimisticAtom);
180+ return Option.match(AsyncResult.value(catalog), {
181+ onNone: () => [],
182+ onSome: (integrations: readonly Integration[]) => integrations.map((row) => row.slug),
183+ });
184+};
185+
186+/**
187+ * The connection summary: one slot per integration in the catalog, lit when
188+ * this user has connected it and greyed when they haven't.
189+ *
190+ * Showing the not-connected slots is the point — the grid answers "what is
191+ * available and what has this person actually set up", which a list of only
192+ * their connections cannot.
193+ */
194+function ConnectionGrid(props: {
195+ readonly catalog: readonly IntegrationSlug[];
196+ readonly connections: readonly AdminConnectionRow[];
197+ readonly icons: IconLookup;
198+}) {
199+ const states = integrationConnectionStates(props.catalog, props.connections);
200+ if (states.length === 0) {
201+ return <span className="font-mono text-[11px] text-muted-foreground">No integrations</span>;
202+ }
203+
204+ const connectedCount = states.filter((state) => state.connected).length;
205+
206+ return (
207+ <div className="flex min-w-0 items-center gap-1.5">
208+ <div className="flex min-w-0 flex-wrap items-center gap-1">
209+ {states.slice(0, 8).map((state) => {
210+ const { icon, url, name } = props.icons(state.integration);
211+ return (
212+ <span
213+ key={state.integration}
214+ // State on data-* (the repo convention) so the connected/not-
215+ // connected distinction is readable without parsing classes.
216+ data-slot="admin-user-integration"
217+ data-integration={String(state.integration)}
218+ data-connected={state.connected ? "true" : "false"}
219+ title={`${name} — ${state.connected ? "connected" : "not connected"}`}
220+ className={
221+ state.connected
222+ ? "flex size-5 items-center justify-center"
223+ : "flex size-5 items-center justify-center opacity-25 grayscale"
224+ }
225+ >
226+ <IntegrationFavicon
227+ icon={icon}
228+ integrationId={String(state.integration)}
229+ url={url}
230+ size={16}
231+ />
232+ </span>
233+ );
234+ })}
235+ </div>
236+ <span
237+ data-slot="admin-user-connection-count"
238+ className="shrink-0 font-mono text-[11px] text-muted-foreground"
239+ title={`${connectedCount} of ${states.length} integrations connected`}
240+ >
241+ {connectedCount}/{states.length}
242+ </span>
243+ </div>
244+ );
245+}
246+
247+// ── Detail sheet ────────────────────────────────────────────────────────────
248+
249+function UserDetail(props: {
250+ readonly user: AdminUserRow;
251+ readonly catalog: readonly IntegrationSlug[];
252+ readonly icons: IconLookup;
253+}) {
254+ const result = useAtomValue(adminUserConnectionsAtom(props.user.externalId));
255+ const refresh = useAtomRefresh(adminUserConnectionsAtom(props.user.externalId));
256+ // The connect link targets the recipient's own session, so it is built for
257+ // this deployment's origin and copied out — never navigated to from here.
258+ const origin = globalThis.window?.location?.origin ?? "";
259+
260+ if (isAsyncResultLoading(result)) {
261+ return (
262+ <div className="flex flex-col gap-2 p-6">
263+ {[0, 1, 2].map((row) => (
264+ <Skeleton key={row} className="h-12" />
265+ ))}
266+ </div>
267+ );
268+ }
269+
270+ return AsyncResult.match(result, {
271+ onInitial: () => (
272+ <div className="flex flex-col gap-2 p-6">
273+ {[0, 1, 2].map((row) => (
274+ <Skeleton key={row} className="h-12" />
275+ ))}
276+ </div>
277+ ),
278+ onFailure: (failure) =>
279+ isAccessDenied(failure.cause) ? (
280+ <div className="p-6">
281+ <AccessDenied />
282+ </div>
283+ ) : (
284+ <div className="p-6">
285+ <ErrorState message="Couldn't load this user's connections" onRetry={refresh} />
286+ </div>
287+ ),
288+ onSuccess: ({ value }) => {
289+ const states = integrationConnectionStates(props.catalog, value.connections);
290+ const connected = states.filter((state) => state.connected);
291+ const available = states.filter((state) => !state.connected);
292+
293+ return (
294+ <div className="flex flex-col gap-8 overflow-y-auto p-6">
295+ <section>
296+ <h3 className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
297+ Connected · {connected.length}
298+ </h3>
299+ {connected.length === 0 ? (
300+ <p className="mt-3 text-sm leading-6 text-muted-foreground">
301+ This user hasn&apos;t connected anything yet. Send them a connect link below to get
302+ started.
303+ </p>
304+ ) : (
305+ <div className="mt-3 overflow-hidden rounded-lg border border-border bg-card">
306+ {connected.flatMap((state) =>
307+ state.connections.map((connection) => {
308+ const { icon, url, name } = props.icons(connection.integration);
309+ return (
310+ <div
311+ key={`${connection.integration}/${connection.owner}/${connection.name}`}
312+ className="flex items-start gap-3 border-b border-border px-4 py-3 last:border-b-0"
313+ >
314+ <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center">
315+ <IntegrationFavicon
316+ icon={icon}
317+ integrationId={String(connection.integration)}
318+ url={url}
319+ size={16}
320+ />
321+ </span>
322+ <div className="min-w-0 flex-1">
323+ <div className="flex items-center gap-2">
324+ <p className="truncate text-sm font-medium text-foreground">{name}</p>
325+ <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
326+ {ownerLabel(connection.owner)}
327+ </span>
328+ </div>
329+ <p className="mt-1 truncate font-mono text-xs text-muted-foreground">
330+ {connection.name}
331+ </p>
332+ {connection.oauthScope ? (
333+ <p
334+ className="mt-1.5 break-words font-mono text-[11px] leading-5 text-muted-foreground"
335+ title={connection.oauthScope}
336+ >
337+ {connection.oauthScope}
338+ </p>
339+ ) : (
340+ <p className="mt-1.5 font-mono text-[11px] text-muted-foreground">
341+ No OAuth scope — static credential
342+ </p>
343+ )}
344+ </div>
345+ <HealthIndicator status={connectionHealthStatus(connection)} />
346+ </div>
347+ );
348+ }),
349+ )}
350+ </div>
351+ )}
352+ </section>
353+
354+ <section>
355+ <h3 className="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground">
356+ Not connected · {available.length}
357+ </h3>
358+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
359+ Copy a link and send it to this user — it opens the connect flow in their own session.
360+ </p>
361+ {available.length === 0 ? (
362+ <p className="mt-3 text-sm leading-6 text-muted-foreground">
363+ This user has connected every integration in the catalog.
364+ </p>
365+ ) : (
366+ <div className="mt-3 overflow-hidden rounded-lg border border-border bg-card">
367+ {available.map((state) => {
368+ const { icon, url, name } = props.icons(state.integration);
369+ const link = connectLinkUrl(state.integration, origin);
370+ return (
371+ <div
372+ key={state.integration}
373+ className="flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0"
374+ >
375+ <span className="flex size-5 shrink-0 items-center justify-center opacity-40 grayscale">
376+ <IntegrationFavicon
377+ icon={icon}
378+ integrationId={String(state.integration)}
379+ url={url}
380+ size={16}
381+ />
382+ </span>
383+ <div className="min-w-0 flex-1">
384+ <p className="truncate text-sm text-foreground">{name}</p>
385+ <p className="truncate font-mono text-[11px] text-muted-foreground">
386+ {link}
387+ </p>
388+ </div>
389+ <CopyButton value={link} label="Copy link" />
390+ </div>
391+ );
392+ })}
393+ </div>
394+ )}
395+ </section>
396+ </div>
397+ );
398+ },
399+ });
400+}
401+
402+// ── Page ────────────────────────────────────────────────────────────────────
403+
404+export function AdminUsersPage() {
405+ useExecutorDocumentTitle("Users");
406+ const [offset, setOffset] = useState(0);
407+ const [selected, setSelected] = useState<AdminUserRow | null>(null);
408+
409+ const page = { limit: ADMIN_USERS_PAGE_SIZE, offset };
410+ const result = useAtomValue(adminUsersWithConnectionsAtom(page));
411+ const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page));
412+ const catalog = useCatalogSlugs();
413+ const icons = useIntegrationIcons();
414+
415+ const header = (
416+ <PageHeader
417+ title="Users"
418+ description="Everyone who has used this workspace, and what each of them has connected."
419+ />
420+ );
421+
422+ const loading = (
423+ <div className="flex flex-col gap-2">
424+ {[0, 1, 2, 3].map((row) => (
425+ <Skeleton key={row} className="h-14" />
426+ ))}
427+ </div>
428+ );
429+
430+ return (
431+ <PageContainer>
432+ {header}
433+
434+ {isAsyncResultLoading(result)
435+ ? loading
436+ : AsyncResult.match(result, {
437+ onInitial: () => loading,
438+ onFailure: (failure) =>
439+ isAccessDenied(failure.cause) ? (
440+ <AccessDenied />
441+ ) : (
442+ <ErrorState message="Couldn't load users" onRetry={refresh} />
443+ ),
444+ onSuccess: ({ value }) => {
445+ const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE);
446+
447+ if (rows.length === 0) {
448+ return (
449+ <div className="rounded-lg border border-dashed border-border bg-card p-8">
450+ <h2 className="text-base font-semibold text-foreground">
451+ {offset === 0 ? "No users yet" : "No users on this page"}
452+ </h2>
453+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
454+ {offset === 0
455+ ? "A user appears here the first time they reach this workspace or connect an account."
456+ : "Go back a page to see this workspace's users."}
457+ </p>
458+ {offset > 0 && (
459+ <Button
460+ variant="outline"
461+ size="sm"
462+ className="mt-4"
463+ onClick={() => setOffset(Math.max(0, offset - ADMIN_USERS_PAGE_SIZE))}
464+ >
465+ Previous
466+ </Button>
467+ )}
468+ </div>
469+ );
470+ }
471+
472+ return (
473+ <>
474+ <div
475+ className="overflow-hidden rounded-lg border border-border bg-card"
476+ data-slot="admin-users-table"
477+ >
478+ <div className="grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground md:grid-cols-[1.6fr_0.8fr_0.9fr_1.1fr]">
479+ <span>User</span>
480+ <span className="hidden md:block">Created</span>
481+ <span className="hidden md:block">Last seen</span>
482+ <span className="text-right md:text-left">Connections</span>
483+ </div>
484+ {rows.map((user: AdminUserRow) => (
485+ // oxlint-disable-next-line react/forbid-elements
486+ <button
487+ key={user.externalId}
488+ type="button"
489+ onClick={() => setSelected(user)}
490+ data-slot="admin-user-row"
491+ className="grid w-full grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 text-left transition-colors last:border-b-0 hover:bg-accent md:grid-cols-[1.6fr_0.8fr_0.9fr_1.1fr]"
492+ >
493+ <span
494+ data-slot="admin-user-id"
495+ className="min-w-0 truncate font-mono text-sm text-foreground"
496+ title={user.externalId}
497+ >
498+ {isLocalSubject(user.externalId)
499+ ? "local"
500+ : shortenExternalId(user.externalId)}
501+ </span>
502+ <span className="hidden font-mono text-xs text-muted-foreground md:block">
503+ {formatAdminDate(user.createdAt)}
504+ </span>
505+ <span
506+ className="hidden font-mono text-xs text-muted-foreground md:block"
507+ title={lastSeenTitle(user.lastSeenAt)}
508+ >
509+ {formatLastSeen(user.lastSeenAt)}
510+ </span>
511+ <span className="justify-self-end md:justify-self-start">
512+ <ConnectionGrid
513+ catalog={catalog}
514+ connections={user.connections}
515+ icons={icons}
516+ />
517+ </span>
518+ </button>
519+ ))}
520+ </div>
521+
522+ {(hasNext || offset > 0) && (
523+ <div className="mt-4 flex items-center justify-between">
524+ <span className="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
525+ Page {pageNumber(offset, ADMIN_USERS_PAGE_SIZE)}
526+ </span>
527+ <div className="flex items-center gap-2">
528+ <Button
529+ variant="outline"
530+ size="sm"
531+ disabled={offset === 0}
532+ onClick={() => setOffset(Math.max(0, offset - ADMIN_USERS_PAGE_SIZE))}
533+ >
534+ Previous
535+ </Button>
536+ <Button
537+ variant="outline"
538+ size="sm"
539+ disabled={!hasNext}
540+ onClick={() => setOffset(offset + ADMIN_USERS_PAGE_SIZE)}
541+ >
542+ Next
543+ </Button>
544+ </div>
545+ </div>
546+ )}
547+ </>
548+ );
549+ },
550+ })}
551+
552+ <Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
553+ <SheetContent className="w-full gap-0 p-0 sm:max-w-xl">
554+ {selected && (
555+ <>
556+ <SheetHeader className="border-b border-border">
557+ <SheetTitle className="truncate font-mono text-sm" title={selected.externalId}>
558+ {selected.externalId}
559+ </SheetTitle>
560+ <SheetDescription>
561+ Created {formatAdminDate(selected.createdAt)} · last seen{" "}
562+ <span title={lastSeenTitle(selected.lastSeenAt)}>
563+ {formatLastSeen(selected.lastSeenAt).toLowerCase()}
564+ </span>
565+ </SheetDescription>
566+ </SheetHeader>
567+ <UserDetail user={selected} catalog={catalog} icons={icons} />
568+ </>
569+ )}
570+ </SheetContent>
571+ </Sheet>
572+ </PageContainer>
573+ );
574+}
packages/react/src/routes/routeTree.gen.ts+21−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010
1111 import { Route as rootRouteImport } from './__root'
1212 import { Route as DotIndexRouteImport } from './index'
13+import { Route as DotUsersRouteImport } from './users'
1314 import { Route as DotToolsRouteImport } from './tools'
1415 import { Route as DotToolkitsRouteImport } from './toolkits'
1516 import { Route as DotSecretsRouteImport } from './secrets'
@@ -26,6 +27,11 @@ const DotIndexRoute = DotIndexRouteImport.update({ · 10 unmodified lines
2627 path: '/{-$orgSlug}/',
2728 getParentRoute: () => rootRouteImport,
2829 } as any)
30+const DotUsersRoute = DotUsersRouteImport.update({
31+ id: '/{-$orgSlug}/users',
32+ path: '/{-$orgSlug}/users',
33+ getParentRoute: () => rootRouteImport,
34+} as any)
2935 const DotToolsRoute = DotToolsRouteImport.update({
3036 id: '/{-$orgSlug}/tools',
3137 path: '/{-$orgSlug}/tools',
@@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { · 55 unmodified lines
8793 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
8894 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
8995 '/{-$orgSlug}/tools': typeof DotToolsRoute
96+ '/{-$orgSlug}/users': typeof DotUsersRoute
9097 '/{-$orgSlug}/': typeof DotIndexRoute
9198 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
9299 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
@@ -100,6 +107,7 @@ export interface FileRoutesByTo { · 7 unmodified lines
100107 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
101108 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
102109 '/{-$orgSlug}/tools': typeof DotToolsRoute
110+ '/{-$orgSlug}/users': typeof DotUsersRoute
103111 '/{-$orgSlug}': typeof DotIndexRoute
104112 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
105113 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
@@ -114,6 +122,7 @@ export interface FileRoutesById { · 8 unmodified lines
114122 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
115123 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
116124 '/{-$orgSlug}/tools': typeof DotToolsRoute
125+ '/{-$orgSlug}/users': typeof DotUsersRoute
117126 '/{-$orgSlug}/': typeof DotIndexRoute
118127 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
119128 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
@@ -129,6 +138,7 @@ export interface FileRouteTypes { · 9 unmodified lines
129138 | '/{-$orgSlug}/secrets'
130139 | '/{-$orgSlug}/toolkits'
131140 | '/{-$orgSlug}/tools'
141+ | '/{-$orgSlug}/users'
132142 | '/{-$orgSlug}/'
133143 | '/{-$orgSlug}/connect/$integrationSlug'
134144 | '/{-$orgSlug}/integrations/$namespace'
@@ -142,6 +152,7 @@ export interface FileRouteTypes { · 7 unmodified lines
142152 | '/{-$orgSlug}/secrets'
143153 | '/{-$orgSlug}/toolkits'
144154 | '/{-$orgSlug}/tools'
155+ | '/{-$orgSlug}/users'
145156 | '/{-$orgSlug}'
146157 | '/{-$orgSlug}/connect/$integrationSlug'
147158 | '/{-$orgSlug}/integrations/$namespace'
@@ -155,6 +166,7 @@ export interface FileRouteTypes { · 7 unmodified lines
155166 | '/{-$orgSlug}/secrets'
156167 | '/{-$orgSlug}/toolkits'
157168 | '/{-$orgSlug}/tools'
169+ | '/{-$orgSlug}/users'
158170 | '/{-$orgSlug}/'
159171 | '/{-$orgSlug}/connect/$integrationSlug'
160172 | '/{-$orgSlug}/integrations/$namespace'
@@ -169,6 +181,7 @@ export interface RootRouteChildren { · 8 unmodified lines
169181 DotSecretsRoute: typeof DotSecretsRoute
170182 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
171183 DotToolsRoute: typeof DotToolsRoute
184+ DotUsersRoute: typeof DotUsersRoute
172185 DotIndexRoute: typeof DotIndexRoute
173186 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
174187 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
@@ -186,6 +199,13 @@ declare module '@tanstack/react-router' { · 11 unmodified lines
186199 preLoaderRoute: typeof DotIndexRouteImport
187200 parentRoute: typeof rootRouteImport
188201 }
202+ '/{-$orgSlug}/users': {
203+ id: '/{-$orgSlug}/users'
204+ path: '/{-$orgSlug}/users'
205+ fullPath: '/{-$orgSlug}/users'
206+ preLoaderRoute: typeof DotUsersRouteImport
207+ parentRoute: typeof rootRouteImport
208+ }
189209 '/{-$orgSlug}/tools': {
190210 id: '/{-$orgSlug}/tools'
191211 path: '/{-$orgSlug}/tools'
@@ -276,6 +296,7 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines
276296 DotSecretsRoute: DotSecretsRoute,
277297 DotToolkitsRoute: DotToolkitsRouteWithChildren,
278298 DotToolsRoute: DotToolsRoute,
299+ DotUsersRoute: DotUsersRoute,
279300 DotIndexRoute: DotIndexRoute,
280301 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
281302 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
packages/react/src/routes/users.tsx+7−0
@@ -0,0 +1,7 @@
1+import { createFileRoute } from "@tanstack/react-router";
2+
3+import { AdminUsersPage } from "../pages/admin-users";
4+
5+export const Route = createFileRoute("/{-$orgSlug}/users")({
6+ component: () => <AdminUsersPage />,
7+});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t