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