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
13import { Route as LoginRouteImport } from './routes/bare/login'13import { Route as LoginRouteImport } from './routes/bare/login'
14import { Route as CreateOrgRouteImport } from './routes/bare/create-org'14import { Route as CreateOrgRouteImport } from './routes/bare/create-org'
15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
17import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'18import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
18import { Route as SecretsRouteImport } from './routes/app/secrets'19import { Route as SecretsRouteImport } from './routes/app/secrets'
@@ -48,6 +49,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 29 unmodified lines
48 path: '/{-$orgSlug}/',49 path: '/{-$orgSlug}/',
49 getParentRoute: () => rootRouteImport,50 getParentRoute: () => rootRouteImport,
50 } as any)51 } as any)
52const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
53 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
54 id: '/{-$orgSlug}/users',
55 path: '/{-$orgSlug}/users',
56 getParentRoute: () => rootRouteImport,
57 } as any)
51const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =58const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
52 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({59 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
53 id: '/{-$orgSlug}/tools',60 id: '/{-$orgSlug}/tools',
@@ -141,6 +148,7 @@ export interface FileRoutesByFullPath { · 87 unmodified lines
141 '/{-$orgSlug}/secrets': typeof SecretsRoute148 '/{-$orgSlug}/secrets': typeof SecretsRoute
142 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren149 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
143 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute150 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
151 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
144 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute152 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
145 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute153 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
146 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute154 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -160,6 +168,7 @@ export interface FileRoutesByTo { · 13 unmodified lines
160 '/{-$orgSlug}/secrets': typeof SecretsRoute168 '/{-$orgSlug}/secrets': typeof SecretsRoute
161 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren169 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
162 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute170 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
171 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
163 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute172 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
164 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute173 '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute
165 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute174 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -180,6 +189,7 @@ export interface FileRoutesById { · 14 unmodified lines
180 '/{-$orgSlug}/secrets': typeof SecretsRoute189 '/{-$orgSlug}/secrets': typeof SecretsRoute
181 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren190 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
182 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute191 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
192 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
183 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute193 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
184 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute194 '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute
185 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute195 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -201,6 +211,7 @@ export interface FileRouteTypes { · 15 unmodified lines
201 | '/{-$orgSlug}/secrets'211 | '/{-$orgSlug}/secrets'
202 | '/{-$orgSlug}/toolkits'212 | '/{-$orgSlug}/toolkits'
203 | '/{-$orgSlug}/tools'213 | '/{-$orgSlug}/tools'
214 | '/{-$orgSlug}/users'
204 | '/{-$orgSlug}/'215 | '/{-$orgSlug}/'
205 | '/{-$orgSlug}/billing/plans'216 | '/{-$orgSlug}/billing/plans'
206 | '/{-$orgSlug}/connect/$integrationSlug'217 | '/{-$orgSlug}/connect/$integrationSlug'
@@ -220,6 +231,7 @@ export interface FileRouteTypes { · 13 unmodified lines
220 | '/{-$orgSlug}/secrets'231 | '/{-$orgSlug}/secrets'
221 | '/{-$orgSlug}/toolkits'232 | '/{-$orgSlug}/toolkits'
222 | '/{-$orgSlug}/tools'233 | '/{-$orgSlug}/tools'
234 | '/{-$orgSlug}/users'
223 | '/{-$orgSlug}'235 | '/{-$orgSlug}'
224 | '/{-$orgSlug}/billing/plans'236 | '/{-$orgSlug}/billing/plans'
225 | '/{-$orgSlug}/connect/$integrationSlug'237 | '/{-$orgSlug}/connect/$integrationSlug'
@@ -239,6 +251,7 @@ export interface FileRouteTypes { · 13 unmodified lines
239 | '/{-$orgSlug}/secrets'251 | '/{-$orgSlug}/secrets'
240 | '/{-$orgSlug}/toolkits'252 | '/{-$orgSlug}/toolkits'
241 | '/{-$orgSlug}/tools'253 | '/{-$orgSlug}/tools'
254 | '/{-$orgSlug}/users'
242 | '/{-$orgSlug}/'255 | '/{-$orgSlug}/'
243 | '/{-$orgSlug}/billing_/plans'256 | '/{-$orgSlug}/billing_/plans'
244 | '/{-$orgSlug}/connect/$integrationSlug'257 | '/{-$orgSlug}/connect/$integrationSlug'
@@ -259,6 +272,7 @@ export interface RootRouteChildren { · 14 unmodified lines
259 SecretsRoute: typeof SecretsRoute272 SecretsRoute: typeof SecretsRoute
260 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren273 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
261 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute274 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
275 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
262 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute276 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
263 Billing_DotplansRoute: typeof Billing_DotplansRoute277 Billing_DotplansRoute: typeof Billing_DotplansRoute
264 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute278 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
@@ -297,6 +311,13 @@ declare module '@tanstack/react-router' { · 32 unmodified lines
297 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport311 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
298 parentRoute: typeof rootRouteImport312 parentRoute: typeof rootRouteImport
299 }313 }
314 '/{-$orgSlug}/users': {
315 id: '/{-$orgSlug}/users'
316 path: '/{-$orgSlug}/users'
317 fullPath: '/{-$orgSlug}/users'
318 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
319 parentRoute: typeof rootRouteImport
320 }
300 '/{-$orgSlug}/tools': {321 '/{-$orgSlug}/tools': {
301 id: '/{-$orgSlug}/tools'322 id: '/{-$orgSlug}/tools'
302 path: '/{-$orgSlug}/tools'323 path: '/{-$orgSlug}/tools'
@@ -420,6 +441,8 @@ const rootRouteChildren: RootRouteChildren = { · 117 unmodified lines
420 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,441 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
421 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:442 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
422 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,443 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
444 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
445 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
423 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:446 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
424 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,447 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
425 Billing_DotplansRoute: Billing_DotplansRoute,448 Billing_DotplansRoute: Billing_DotplansRoute,
apps/cloud/src/web/shell.tsx+9−1
@@ -1,6 +1,7 @@
1import type React from "react";1import type React from "react";
2 2
3import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";3import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
4import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
4import { trackEvent } from "@executor-js/react/api/analytics";5import { trackEvent } from "@executor-js/react/api/analytics";
5import { AUTH_PATHS } from "../auth/api";6import { AUTH_PATHS } from "../auth/api";
6import { OrgMenuSlot } from "./components/org-menu-slot";7import { OrgMenuSlot } from "./components/org-menu-slot";
@@ -25,6 +26,12 @@ const navItems = [ · 18 unmodified lines
25 { to: "/billing", label: "Billing" },26 { to: "/billing", label: "Billing" },
26];27];
27 28
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.
33const adminNavItems = [{ to: "/users", label: "Users" }];
34
28// A top-level form POST, not fetch: the logout response 302s through the35// A top-level form POST, not fetch: the logout response 302s through the
29// WorkOS logout endpoint (ending the hosted AuthKit session) before landing36// WorkOS logout endpoint (ending the hosted AuthKit session) before landing
30// back home. A fetch would follow that chain invisibly inside the XHR and37// back home. A fetch would follow that chain invisibly inside the XHR and
@@ -39,10 +46,11 @@ const signOut = () => { · 8 unmodified lines
39};46};
40 47
41export function Shell(props: { readonly content?: React.ReactNode }) {48export function Shell(props: { readonly content?: React.ReactNode }) {
49 const items = useAdminNavItems(navItems, adminNavItems);
42 return (50 return (
43 <SharedShell51 <SharedShell
44 onSignOut={signOut}52 onSignOut={signOut}
45 navItems={navItems}53 navItems={items}
46 orgMenuSlot={<OrgMenuSlot />}54 orgMenuSlot={<OrgMenuSlot />}
47 supportSlot={<SupportSlot />}55 supportSlot={<SupportSlot />}
48 content={props.content}56 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
10// this app only owns its root (the Cloudflare-Access shell). To add10// this app only owns its root (the Cloudflare-Access shell). To add
11// app-specific routes, create web/routes/app and mount it inside the org11// app-specific routes, create web/routes/app and mount it inside the org
12// scope via `orgScoped: [physical("", "app")]` — the directory must exist.12// 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.
13export const routes = rootRoute("__root.tsx", [17export const routes = rootRoute("__root.tsx", [
14 ...consoleRoutes({ dir: "../../../../packages/react/src/routes" }),18 ...consoleRoutes({ dir: "../../../../packages/react/src/routes", exclude: ["/users"] }),
15]);19]);
apps/host-selfhost/web/routeTree.gen.ts+23−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 10
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'12import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index'
13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users'
13import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools'
14import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits'
15import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'16import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets'
@@ -30,6 +31,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 14 unmodified lines
30 path: '/{-$orgSlug}/',31 path: '/{-$orgSlug}/',
31 getParentRoute: () => rootRouteImport,32 getParentRoute: () => rootRouteImport,
32 } as any)33 } as any)
34const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute =
35 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({
36 id: '/{-$orgSlug}/users',
37 path: '/{-$orgSlug}/users',
38 getParentRoute: () => rootRouteImport,
39 } as any)
33const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =40const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute =
34 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({41 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({
35 id: '/{-$orgSlug}/tools',42 id: '/{-$orgSlug}/tools',
@@ -127,6 +134,7 @@ export interface FileRoutesByFullPath { · 91 unmodified lines
127 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute134 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
128 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren135 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
129 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute136 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
137 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
130 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute138 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
131 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute139 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
132 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute140 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -143,6 +151,7 @@ export interface FileRoutesByTo { · 10 unmodified lines
143 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute151 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
144 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren152 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
145 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute153 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
154 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
146 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute155 '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
147 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute156 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
148 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute157 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -160,6 +169,7 @@ export interface FileRoutesById { · 11 unmodified lines
160 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute169 '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
161 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren170 '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
162 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute171 '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
172 '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
163 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute173 '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
164 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute174 '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
165 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute175 '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -178,6 +188,7 @@ export interface FileRouteTypes { · 12 unmodified lines
178 | '/{-$orgSlug}/secrets'188 | '/{-$orgSlug}/secrets'
179 | '/{-$orgSlug}/toolkits'189 | '/{-$orgSlug}/toolkits'
180 | '/{-$orgSlug}/tools'190 | '/{-$orgSlug}/tools'
191 | '/{-$orgSlug}/users'
181 | '/{-$orgSlug}/'192 | '/{-$orgSlug}/'
182 | '/{-$orgSlug}/connect/$integrationSlug'193 | '/{-$orgSlug}/connect/$integrationSlug'
183 | '/{-$orgSlug}/integrations/$namespace'194 | '/{-$orgSlug}/integrations/$namespace'
@@ -194,6 +205,7 @@ export interface FileRouteTypes { · 10 unmodified lines
194 | '/{-$orgSlug}/secrets'205 | '/{-$orgSlug}/secrets'
195 | '/{-$orgSlug}/toolkits'206 | '/{-$orgSlug}/toolkits'
196 | '/{-$orgSlug}/tools'207 | '/{-$orgSlug}/tools'
208 | '/{-$orgSlug}/users'
197 | '/{-$orgSlug}'209 | '/{-$orgSlug}'
198 | '/{-$orgSlug}/connect/$integrationSlug'210 | '/{-$orgSlug}/connect/$integrationSlug'
199 | '/{-$orgSlug}/integrations/$namespace'211 | '/{-$orgSlug}/integrations/$namespace'
@@ -210,6 +222,7 @@ export interface FileRouteTypes { · 10 unmodified lines
210 | '/{-$orgSlug}/secrets'222 | '/{-$orgSlug}/secrets'
211 | '/{-$orgSlug}/toolkits'223 | '/{-$orgSlug}/toolkits'
212 | '/{-$orgSlug}/tools'224 | '/{-$orgSlug}/tools'
225 | '/{-$orgSlug}/users'
213 | '/{-$orgSlug}/'226 | '/{-$orgSlug}/'
214 | '/{-$orgSlug}/connect/$integrationSlug'227 | '/{-$orgSlug}/connect/$integrationSlug'
215 | '/{-$orgSlug}/integrations/$namespace'228 | '/{-$orgSlug}/integrations/$namespace'
@@ -227,6 +240,7 @@ export interface RootRouteChildren { · 11 unmodified lines
227 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute240 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute
228 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren241 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren
229 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute242 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute
243 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute
230 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute244 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute
231 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute245 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute
232 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute246 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute
@@ -244,6 +258,13 @@ declare module '@tanstack/react-router' { · 11 unmodified lines
244 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport258 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport
245 parentRoute: typeof rootRouteImport259 parentRoute: typeof rootRouteImport
246 }260 }
261 '/{-$orgSlug}/users': {
262 id: '/{-$orgSlug}/users'
263 path: '/{-$orgSlug}/users'
264 fullPath: '/{-$orgSlug}/users'
265 preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport
266 parentRoute: typeof rootRouteImport
267 }
247 '/{-$orgSlug}/tools': {268 '/{-$orgSlug}/tools': {
248 id: '/{-$orgSlug}/tools'269 id: '/{-$orgSlug}/tools'
249 path: '/{-$orgSlug}/tools'270 path: '/{-$orgSlug}/tools'
@@ -365,6 +386,8 @@ const rootRouteChildren: RootRouteChildren = { · 115 unmodified lines
365 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,386 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren,
366 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:387 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute:
367 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,388 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute,
389 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute:
390 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute,
368 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:391 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute:
369 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,392 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute,
370 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:393 DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute:
apps/host-selfhost/web/routes/__root.tsx+10−1
@@ -16,6 +16,7 @@ import { · 15 unmodified lines
16} from "@executor-js/react/components/card";16} from "@executor-js/react/components/card";
17import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";17import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context";
18import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";18import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell";
19import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav";
19import { plugins as clientPlugins } from "virtual:executor/plugins-client";20import { plugins as clientPlugins } from "virtual:executor/plugins-client";
20 21
21import { authClient } from "../auth-client";22import { authClient } from "../auth-client";
@@ -46,6 +47,13 @@ const selfHostNavItems = [ · 24 unmodified lines
46 { to: "/admin", label: "Admin" },47 { to: "/admin", label: "Admin" },
47];48];
48 49
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.)
55const selfHostAdminNavItems = [{ to: "/users", label: "Users" }];
56
49const signOut = async () => {57const signOut = async () => {
50 await authClient.signOut();58 await authClient.signOut();
51 window.location.href = "/";59 window.location.href = "/";
@@ -150,12 +158,13 @@ function AuthGate({ children }: { children: ReactNode }) { · 98 unmodified lines
150function AuthenticatedApp() {158function AuthenticatedApp() {
151 const auth = useAuth();159 const auth = useAuth();
152 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;160 const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null;
161 const navItems = useAdminNavItems(selfHostNavItems, selfHostAdminNavItems);
153 162
154 // Single-org instance: a bare URL canonicalizes onto the instance org's163 // Single-org instance: a bare URL canonicalizes onto the instance org's
155 // slug. There's only ever one org, so no other slug is reachable.164 // slug. There's only ever one org, so no other slug is reachable.
156 const gated = (165 const gated = (
157 <>166 <>
158 <Shell onSignOut={signOut} navItems={selfHostNavItems} />167 <Shell onSignOut={signOut} navItems={navItems} />
159 <Toaster />168 <Toaster />
160 </>169 </>
161 );170 );
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.
15import { randomBytes } from "node:crypto";
16
17import { expect } from "@effect/vitest";
18import { Effect } from "effect";
19import type { HttpApiClient } from "effect/unstable/httpapi";
20import { composePluginApi } from "@executor-js/api/server";
21import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
22import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
23
24import { scenario } from "../src/scenario";
25import { Api, Browser, Target } from "../src/services";
26import type { Identity, Target as TargetShape } from "../src/target";
27
28const api = composePluginApi([openApiHttpPlugin()] as const);
29type Client = HttpApiClient.ForApi<typeof api>;
30
31const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
32
33/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
34const 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. */
45const 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
65const freshConnectionName = () => ConnectionName.make(`conn${randomBytes(4).toString("hex")}`);
66
67const 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`. */
73const 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
83const 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. */
101const 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. */
112const 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. */
126const 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
136scenario(
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.
11import { randomBytes } from "node:crypto";
12
13import { expect } from "@effect/vitest";
14import { Effect } from "effect";
15import type { HttpApiClient } from "effect/unstable/httpapi";
16import { composePluginApi } from "@executor-js/api/server";
17import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
18import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
19
20import { scenario } from "../src/scenario";
21import { createInvitedIdentity } from "../targets/selfhost";
22import { Api, Browser, Target } from "../src/services";
23
24const api = composePluginApi([openApiHttpPlugin()] as const);
25type Client = HttpApiClient.ForApi<typeof api>;
26
27const TEMPLATE_API_KEY = AuthTemplateSlug.make("apiKey");
28
29/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */
30const 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
40const 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
60scenario(
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
7// Shared console routes come from @executor-js/react (see its7// Shared console routes come from @executor-js/react (see its
8// console-routes.ts); this app owns its root and the local-specific routes8// console-routes.ts); this app owns its root and the local-specific routes
9// under src/routes/app. /secrets is excluded: the local app's variant shows9// under src/routes/app. /secrets is excluded: the local app's variant shows
10// credential-provider info. App routes mount INSIDE the shared `{-$orgSlug}`10// credential-provider info. /users is excluded because local serves no account
11// scope (local never sets the param, so URLs stay bare) so the override masks11// or admin API at all (see apps/local/src/app.ts) — there is no tenant to
12// the shared route id.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.
13export const routes = rootRoute("__root.tsx", [15export const routes = rootRoute("__root.tsx", [
14 ...consoleRoutes({16 ...consoleRoutes({
15 dir: "../../../react/src/routes",17 dir: "../../../react/src/routes",
16 exclude: ["/secrets"],18 exclude: ["/secrets", "/users"],
17 orgScoped: [physical("", "app")],19 orgScoped: [physical("", "app")],
18 }),20 }),
19]);21]);
packages/core/api/src/client.ts+13−0
@@ -14,3 +14,16 @@ export { · 13 unmodified lines
14 AccountNoOrganization,14 AccountNoOrganization,
15 AccountUnauthorized,15 AccountUnauthorized,
16} from "./account/api";16} from "./account/api";
17export {
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 @@
1import * as Atom from "effect/unstable/reactivity/Atom";
2
3import { AdminApiClient } from "./admin-client";
4import { 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. */
22export const ADMIN_USERS_PAGE_SIZE = 25;
23
24export 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 */
36export 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. */
47export 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 @@
1import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3import { AdminUsersHttpApi } from "@executor-js/api/client";
4import * as Effect from "effect/Effect";
5
6import { reportApiClientInfrastructureCause } from "./client";
7import {
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
34const 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
53export { AdminApiClient };
packages/react/src/api/reactivity-keys.tsx+4−0
@@ -36,6 +36,10 @@ export const ReactivityKey = { · 35 unmodified lines
36 orgInfo: "org:info",36 orgInfo: "org:info",
37 apiKeys: "api-keys",37 apiKeys: "api-keys",
38 auth: "auth",38 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",
39} as const;43} as const;
40 44
41/** Mutations that add/remove/refresh an integration also affect tool listings. */45/** 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
40 "/policies",40 "/policies",
41 "/secrets",41 "/secrets",
42 "/tools",42 "/tools",
43 "/users",
43 "/toolkits",44 "/toolkits",
44 "/toolkits/$toolkitSlug",45 "/toolkits/$toolkitSlug",
45 "/resume/$executionId",46 "/resume/$executionId",
@@ -78,6 +79,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 32 unmodified lines
78 ["/policies", route("/policies", file("policies.tsx"))],79 ["/policies", route("/policies", file("policies.tsx"))],
79 ["/secrets", route("/secrets", file("secrets.tsx"))],80 ["/secrets", route("/secrets", file("secrets.tsx"))],
80 ["/tools", route("/tools", file("tools.tsx"))],81 ["/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"))],
81 ["/toolkits", route("/toolkits", file("toolkits.tsx"))],86 ["/toolkits", route("/toolkits", file("toolkits.tsx"))],
82 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],87 ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))],
83 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],88 ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))],
packages/react/src/lib/admin-access.test.ts+52−0
@@ -0,0 +1,52 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { isTenantAdminMember, type TenantMemberRow } from "./admin-access";
4
5const member = (overrides: Partial<TenantMemberRow> = {}): TenantMemberRow => ({
6 role: "member",
7 status: "active",
8 isCurrentUser: false,
9 ...overrides,
10});
11
12describe("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. */
24const TENANT_ADMIN_ROLES: ReadonlySet<string> = new Set(["admin", "owner"]);
25
26/** A member row as `/account/members` returns it, typed structurally. */
27export 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 */
42export 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 @@
1import { describe, expect, it } from "@effect/vitest";
2import type { IntegrationSlug } from "@executor-js/sdk/shared";
3
4import {
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
17const slug = (value: string): IntegrationSlug => value as IntegrationSlug;
18
19const 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
28describe("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
60describe("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
82describe("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
94describe("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
137describe("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
153describe("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 @@
1import 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. */
13export 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
23const MINUTE = 60_000;
24const HOUR = 60 * MINUTE;
25const 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 */
37export 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. */
58export 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. */
64export 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 */
80export const LOCAL_SUBJECT_ID = "local";
81
82export 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. */
86export 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. */
97export 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 */
112export interface IntegrationConnectionState {
113 readonly integration: IntegrationSlug;
114 readonly connected: boolean;
115 readonly connections: readonly AdminConnectionRow[];
116}
117
118export 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 */
155export 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 */
165export 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. */
174export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3
4import { orgMembersAtom } from "../api/account-atoms";
5import { isTenantAdminMember, type TenantMemberRow } from "../lib/admin-access";
6import 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. */
25export 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 */
40export 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 @@
1import { useState } from "react";
2import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Cause from "effect/Cause";
5import * as Option from "effect/Option";
6import * as Predicate from "effect/Predicate";
7import type { HealthStatus, Integration, IntegrationSlug } from "@executor-js/sdk/shared";
8import { useIntegrationPlugins } from "@executor-js/sdk/client";
9
10import {
11 ADMIN_USERS_PAGE_SIZE,
12 adminUserConnectionsAtom,
13 adminUsersWithConnectionsAtom,
14} from "../api/admin-atoms";
15import { integrationsOptimisticAtom } from "../api/atoms";
16import { ownerLabel } from "../api/owner-display";
17import { Button } from "../components/button";
18import { CopyButton } from "../components/copy-button";
19import { ErrorState } from "../components/error-state";
20import {
21 IntegrationFavicon,
22 integrationInferredUrl,
23 integrationPresetIconUrl,
24} from "../components/integration-favicon";
25import { PageContainer, PageHeader } from "../components/page";
26import {
27 Sheet,
28 SheetContent,
29 SheetDescription,
30 SheetHeader,
31 SheetTitle,
32} from "../components/sheet";
33import { Skeleton } from "../components/skeleton";
34import {
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";
47import {
48 HEALTH_INDICATOR_COLOR,
49 HEALTH_STATUS_LABEL,
50 HEALTH_TEXT_CLASS,
51} from "../lib/health-display";
52import { isAsyncResultLoading } from "../lib/async-result";
53import { 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
74type 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. */
85const 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
95function 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. */
121function 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. */
144type IconLookup = (integration: IntegrationSlug) => {
145 readonly icon: string | null;
146 readonly url: string | undefined;
147 readonly name: string;
148};
149
150const 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. */
178const 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 */
194function 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
249function 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
404export 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
10 10
11import { Route as rootRouteImport } from './__root'11import { Route as rootRouteImport } from './__root'
12import { Route as DotIndexRouteImport } from './index'12import { Route as DotIndexRouteImport } from './index'
13import { Route as DotUsersRouteImport } from './users'
13import { Route as DotToolsRouteImport } from './tools'14import { Route as DotToolsRouteImport } from './tools'
14import { Route as DotToolkitsRouteImport } from './toolkits'15import { Route as DotToolkitsRouteImport } from './toolkits'
15import { Route as DotSecretsRouteImport } from './secrets'16import { Route as DotSecretsRouteImport } from './secrets'
@@ -26,6 +27,11 @@ const DotIndexRoute = DotIndexRouteImport.update({ · 10 unmodified lines
26 path: '/{-$orgSlug}/',27 path: '/{-$orgSlug}/',
27 getParentRoute: () => rootRouteImport,28 getParentRoute: () => rootRouteImport,
28} as any)29} as any)
30const DotUsersRoute = DotUsersRouteImport.update({
31 id: '/{-$orgSlug}/users',
32 path: '/{-$orgSlug}/users',
33 getParentRoute: () => rootRouteImport,
34} as any)
29const DotToolsRoute = DotToolsRouteImport.update({35const DotToolsRoute = DotToolsRouteImport.update({
30 id: '/{-$orgSlug}/tools',36 id: '/{-$orgSlug}/tools',
31 path: '/{-$orgSlug}/tools',37 path: '/{-$orgSlug}/tools',
@@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { · 55 unmodified lines
87 '/{-$orgSlug}/secrets': typeof DotSecretsRoute93 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
88 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren94 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
89 '/{-$orgSlug}/tools': typeof DotToolsRoute95 '/{-$orgSlug}/tools': typeof DotToolsRoute
96 '/{-$orgSlug}/users': typeof DotUsersRoute
90 '/{-$orgSlug}/': typeof DotIndexRoute97 '/{-$orgSlug}/': typeof DotIndexRoute
91 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute98 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
92 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute99 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
@@ -100,6 +107,7 @@ export interface FileRoutesByTo { · 7 unmodified lines
100 '/{-$orgSlug}/secrets': typeof DotSecretsRoute107 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
101 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren108 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
102 '/{-$orgSlug}/tools': typeof DotToolsRoute109 '/{-$orgSlug}/tools': typeof DotToolsRoute
110 '/{-$orgSlug}/users': typeof DotUsersRoute
103 '/{-$orgSlug}': typeof DotIndexRoute111 '/{-$orgSlug}': typeof DotIndexRoute
104 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute112 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
105 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute113 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
@@ -114,6 +122,7 @@ export interface FileRoutesById { · 8 unmodified lines
114 '/{-$orgSlug}/secrets': typeof DotSecretsRoute122 '/{-$orgSlug}/secrets': typeof DotSecretsRoute
115 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren123 '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren
116 '/{-$orgSlug}/tools': typeof DotToolsRoute124 '/{-$orgSlug}/tools': typeof DotToolsRoute
125 '/{-$orgSlug}/users': typeof DotUsersRoute
117 '/{-$orgSlug}/': typeof DotIndexRoute126 '/{-$orgSlug}/': typeof DotIndexRoute
118 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute127 '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute
119 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute128 '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute
@@ -129,6 +138,7 @@ export interface FileRouteTypes { · 9 unmodified lines
129 | '/{-$orgSlug}/secrets'138 | '/{-$orgSlug}/secrets'
130 | '/{-$orgSlug}/toolkits'139 | '/{-$orgSlug}/toolkits'
131 | '/{-$orgSlug}/tools'140 | '/{-$orgSlug}/tools'
141 | '/{-$orgSlug}/users'
132 | '/{-$orgSlug}/'142 | '/{-$orgSlug}/'
133 | '/{-$orgSlug}/connect/$integrationSlug'143 | '/{-$orgSlug}/connect/$integrationSlug'
134 | '/{-$orgSlug}/integrations/$namespace'144 | '/{-$orgSlug}/integrations/$namespace'
@@ -142,6 +152,7 @@ export interface FileRouteTypes { · 7 unmodified lines
142 | '/{-$orgSlug}/secrets'152 | '/{-$orgSlug}/secrets'
143 | '/{-$orgSlug}/toolkits'153 | '/{-$orgSlug}/toolkits'
144 | '/{-$orgSlug}/tools'154 | '/{-$orgSlug}/tools'
155 | '/{-$orgSlug}/users'
145 | '/{-$orgSlug}'156 | '/{-$orgSlug}'
146 | '/{-$orgSlug}/connect/$integrationSlug'157 | '/{-$orgSlug}/connect/$integrationSlug'
147 | '/{-$orgSlug}/integrations/$namespace'158 | '/{-$orgSlug}/integrations/$namespace'
@@ -155,6 +166,7 @@ export interface FileRouteTypes { · 7 unmodified lines
155 | '/{-$orgSlug}/secrets'166 | '/{-$orgSlug}/secrets'
156 | '/{-$orgSlug}/toolkits'167 | '/{-$orgSlug}/toolkits'
157 | '/{-$orgSlug}/tools'168 | '/{-$orgSlug}/tools'
169 | '/{-$orgSlug}/users'
158 | '/{-$orgSlug}/'170 | '/{-$orgSlug}/'
159 | '/{-$orgSlug}/connect/$integrationSlug'171 | '/{-$orgSlug}/connect/$integrationSlug'
160 | '/{-$orgSlug}/integrations/$namespace'172 | '/{-$orgSlug}/integrations/$namespace'
@@ -169,6 +181,7 @@ export interface RootRouteChildren { · 8 unmodified lines
169 DotSecretsRoute: typeof DotSecretsRoute181 DotSecretsRoute: typeof DotSecretsRoute
170 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren182 DotToolkitsRoute: typeof DotToolkitsRouteWithChildren
171 DotToolsRoute: typeof DotToolsRoute183 DotToolsRoute: typeof DotToolsRoute
184 DotUsersRoute: typeof DotUsersRoute
172 DotIndexRoute: typeof DotIndexRoute185 DotIndexRoute: typeof DotIndexRoute
173 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute186 DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute
174 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute187 DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute
@@ -186,6 +199,13 @@ declare module '@tanstack/react-router' { · 11 unmodified lines
186 preLoaderRoute: typeof DotIndexRouteImport199 preLoaderRoute: typeof DotIndexRouteImport
187 parentRoute: typeof rootRouteImport200 parentRoute: typeof rootRouteImport
188 }201 }
202 '/{-$orgSlug}/users': {
203 id: '/{-$orgSlug}/users'
204 path: '/{-$orgSlug}/users'
205 fullPath: '/{-$orgSlug}/users'
206 preLoaderRoute: typeof DotUsersRouteImport
207 parentRoute: typeof rootRouteImport
208 }
189 '/{-$orgSlug}/tools': {209 '/{-$orgSlug}/tools': {
190 id: '/{-$orgSlug}/tools'210 id: '/{-$orgSlug}/tools'
191 path: '/{-$orgSlug}/tools'211 path: '/{-$orgSlug}/tools'
@@ -276,6 +296,7 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines
276 DotSecretsRoute: DotSecretsRoute,296 DotSecretsRoute: DotSecretsRoute,
277 DotToolkitsRoute: DotToolkitsRouteWithChildren,297 DotToolkitsRoute: DotToolkitsRouteWithChildren,
278 DotToolsRoute: DotToolsRoute,298 DotToolsRoute: DotToolsRoute,
299 DotUsersRoute: DotUsersRoute,
279 DotIndexRoute: DotIndexRoute,300 DotIndexRoute: DotIndexRoute,
280 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,301 DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute,
281 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,302 DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute,
packages/react/src/routes/users.tsx+7−0
@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { AdminUsersPage } from "../pages/admin-users";
4
5export 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