Unified · Split
| @@ -13,6 +13,7 @@ import { Route as SetupMcpRouteImport } from './routes/bare/setup-mcp' · 12 unmodified lines | |||
| 13 | import { Route as LoginRouteImport } from './routes/bare/login' | 13 | import { Route as LoginRouteImport } from './routes/bare/login' |
| 14 | import { Route as CreateOrgRouteImport } from './routes/bare/create-org' | 14 | import { Route as CreateOrgRouteImport } from './routes/bare/create-org' |
| 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' | ||
| 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' | 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 17 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | 18 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' |
| 18 | import { Route as SecretsRouteImport } from './routes/app/secrets' | 19 | import { Route as SecretsRouteImport } from './routes/app/secrets' |
| @@ -48,6 +49,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 29 unmodified lines | |||
| 48 | path: '/{-$orgSlug}/', | 49 | path: '/{-$orgSlug}/', |
| 49 | getParentRoute: () => rootRouteImport, | 50 | getParentRoute: () => rootRouteImport, |
| 50 | } as any) | 51 | } as any) |
| 52 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute = | ||
| 53 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({ | ||
| 54 | id: '/{-$orgSlug}/users', | ||
| 55 | path: '/{-$orgSlug}/users', | ||
| 56 | getParentRoute: () => rootRouteImport, | ||
| 57 | } as any) | ||
| 51 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = | 58 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = |
| 52 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ | 59 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ |
| 53 | id: '/{-$orgSlug}/tools', | 60 | id: '/{-$orgSlug}/tools', |
| @@ -141,6 +148,7 @@ export interface FileRoutesByFullPath { · 87 unmodified lines | |||
| 141 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 148 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 142 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 149 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 143 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 150 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 151 | '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 144 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 152 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 145 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute | 153 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 146 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 154 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| @@ -160,6 +168,7 @@ export interface FileRoutesByTo { · 13 unmodified lines | |||
| 160 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 168 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 161 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 169 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 162 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 170 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 171 | '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 163 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 172 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 164 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute | 173 | '/{-$orgSlug}/billing/plans': typeof Billing_DotplansRoute |
| 165 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 174 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| @@ -180,6 +189,7 @@ export interface FileRoutesById { · 14 unmodified lines | |||
| 180 | '/{-$orgSlug}/secrets': typeof SecretsRoute | 189 | '/{-$orgSlug}/secrets': typeof SecretsRoute |
| 181 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 190 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 182 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 191 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 192 | '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 183 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 193 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 184 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute | 194 | '/{-$orgSlug}/billing_/plans': typeof Billing_DotplansRoute |
| 185 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 195 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| @@ -201,6 +211,7 @@ export interface FileRouteTypes { · 15 unmodified lines | |||
| 201 | | '/{-$orgSlug}/secrets' | 211 | | '/{-$orgSlug}/secrets' |
| 202 | | '/{-$orgSlug}/toolkits' | 212 | | '/{-$orgSlug}/toolkits' |
| 203 | | '/{-$orgSlug}/tools' | 213 | | '/{-$orgSlug}/tools' |
| 214 | | '/{-$orgSlug}/users' | ||
| 204 | | '/{-$orgSlug}/' | 215 | | '/{-$orgSlug}/' |
| 205 | | '/{-$orgSlug}/billing/plans' | 216 | | '/{-$orgSlug}/billing/plans' |
| 206 | | '/{-$orgSlug}/connect/$integrationSlug' | 217 | | '/{-$orgSlug}/connect/$integrationSlug' |
| @@ -220,6 +231,7 @@ export interface FileRouteTypes { · 13 unmodified lines | |||
| 220 | | '/{-$orgSlug}/secrets' | 231 | | '/{-$orgSlug}/secrets' |
| 221 | | '/{-$orgSlug}/toolkits' | 232 | | '/{-$orgSlug}/toolkits' |
| 222 | | '/{-$orgSlug}/tools' | 233 | | '/{-$orgSlug}/tools' |
| 234 | | '/{-$orgSlug}/users' | ||
| 223 | | '/{-$orgSlug}' | 235 | | '/{-$orgSlug}' |
| 224 | | '/{-$orgSlug}/billing/plans' | 236 | | '/{-$orgSlug}/billing/plans' |
| 225 | | '/{-$orgSlug}/connect/$integrationSlug' | 237 | | '/{-$orgSlug}/connect/$integrationSlug' |
| @@ -239,6 +251,7 @@ export interface FileRouteTypes { · 13 unmodified lines | |||
| 239 | | '/{-$orgSlug}/secrets' | 251 | | '/{-$orgSlug}/secrets' |
| 240 | | '/{-$orgSlug}/toolkits' | 252 | | '/{-$orgSlug}/toolkits' |
| 241 | | '/{-$orgSlug}/tools' | 253 | | '/{-$orgSlug}/tools' |
| 254 | | '/{-$orgSlug}/users' | ||
| 242 | | '/{-$orgSlug}/' | 255 | | '/{-$orgSlug}/' |
| 243 | | '/{-$orgSlug}/billing_/plans' | 256 | | '/{-$orgSlug}/billing_/plans' |
| 244 | | '/{-$orgSlug}/connect/$integrationSlug' | 257 | | '/{-$orgSlug}/connect/$integrationSlug' |
| @@ -259,6 +272,7 @@ export interface RootRouteChildren { · 14 unmodified lines | |||
| 259 | SecretsRoute: typeof SecretsRoute | 272 | SecretsRoute: typeof SecretsRoute |
| 260 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 273 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 261 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 274 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 275 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 262 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 276 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 263 | Billing_DotplansRoute: typeof Billing_DotplansRoute | 277 | Billing_DotplansRoute: typeof Billing_DotplansRoute |
| 264 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 278 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| @@ -297,6 +311,13 @@ declare module '@tanstack/react-router' { · 32 unmodified lines | |||
| 297 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport | 311 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport |
| 298 | parentRoute: typeof rootRouteImport | 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 | '/{-$orgSlug}/tools': { | 321 | '/{-$orgSlug}/tools': { |
| 301 | id: '/{-$orgSlug}/tools' | 322 | id: '/{-$orgSlug}/tools' |
| 302 | path: '/{-$orgSlug}/tools' | 323 | path: '/{-$orgSlug}/tools' |
| @@ -420,6 +441,8 @@ const rootRouteChildren: RootRouteChildren = { · 117 unmodified lines | |||
| 420 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, | 441 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, |
| 421 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: | 442 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: |
| 422 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, | 443 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 444 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: | ||
| 445 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute, | ||
| 423 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: | 446 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 424 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, | 447 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 425 | Billing_DotplansRoute: Billing_DotplansRoute, | 448 | Billing_DotplansRoute: Billing_DotplansRoute, |
| @@ -1,6 +1,7 @@ | |||
| 1 | import type React from "react"; | 1 | import type React from "react"; |
| 2 | 2 | ||
| 3 | import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; | 3 | import { Shell as SharedShell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; |
| 4 | import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; | ||
| 4 | import { trackEvent } from "@executor-js/react/api/analytics"; | 5 | import { trackEvent } from "@executor-js/react/api/analytics"; |
| 5 | import { AUTH_PATHS } from "../auth/api"; | 6 | import { AUTH_PATHS } from "../auth/api"; |
| 6 | import { OrgMenuSlot } from "./components/org-menu-slot"; | 7 | import { OrgMenuSlot } from "./components/org-menu-slot"; |
| @@ -25,6 +26,12 @@ const navItems = [ · 18 unmodified lines | |||
| 25 | { to: "/billing", label: "Billing" }, | 26 | { to: "/billing", label: "Billing" }, |
| 26 | ]; | 27 | ]; |
| 27 | 28 | ||
| 29 | // Sections only an admin of the org may open. The Users page reads the | ||
| 30 | // tenant-wide admin plane, which cloud gates on an admin-role membership — so | ||
| 31 | // a plain member is not shown a link that would only refuse them. The page | ||
| 32 | // still renders its own denied state if one arrives by URL. | ||
| 33 | const adminNavItems = [{ to: "/users", label: "Users" }]; | ||
| 34 | |||
| 28 | // A top-level form POST, not fetch: the logout response 302s through the | 35 | // A top-level form POST, not fetch: the logout response 302s through the |
| 29 | // WorkOS logout endpoint (ending the hosted AuthKit session) before landing | 36 | // WorkOS logout endpoint (ending the hosted AuthKit session) before landing |
| 30 | // back home. A fetch would follow that chain invisibly inside the XHR and | 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 | export function Shell(props: { readonly content?: React.ReactNode }) { | 48 | export function Shell(props: { readonly content?: React.ReactNode }) { |
| 49 | const items = useAdminNavItems(navItems, adminNavItems); | ||
| 42 | return ( | 50 | return ( |
| 43 | <SharedShell | 51 | <SharedShell |
| 44 | onSignOut={signOut} | 52 | onSignOut={signOut} |
| 45 | navItems={navItems} | 53 | navItems={items} |
| 46 | orgMenuSlot={<OrgMenuSlot />} | 54 | orgMenuSlot={<OrgMenuSlot />} |
| 47 | supportSlot={<SupportSlot />} | 55 | supportSlot={<SupportSlot />} |
| 48 | content={props.content} | 56 | content={props.content} |
| @@ -10,6 +10,10 @@ import { consoleRoutes } from "@executor-js/react/console-routes"; · 9 unmodified lines | |||
| 10 | // this app only owns its root (the Cloudflare-Access shell). To add | 10 | // this app only owns its root (the Cloudflare-Access shell). To add |
| 11 | // app-specific routes, create web/routes/app and mount it inside the org | 11 | // app-specific routes, create web/routes/app and mount it inside the org |
| 12 | // scope via `orgScoped: [physical("", "app")]` — the directory must exist. | 12 | // scope via `orgScoped: [physical("", "app")]` — the directory must exist. |
| 13 | // | ||
| 14 | // /users is excluded: this host mounts no admin-users routes (identity is | ||
| 15 | // Cloudflare Access, which has no in-app role to gate on), so the page would | ||
| 16 | // only ever render its denied state. | ||
| 13 | export const routes = rootRoute("__root.tsx", [ | 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 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' | 12 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport } from './../../../packages/react/src/routes/index' |
| 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport } from './../../../packages/react/src/routes/users' | ||
| 13 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' | 14 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport } from './../../../packages/react/src/routes/tools' |
| 14 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' | 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteImport } from './../../../packages/react/src/routes/toolkits' |
| 15 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' | 16 | import { Route as DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRouteImport } from './../../../packages/react/src/routes/secrets' |
| @@ -30,6 +31,12 @@ const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute = · 14 unmodified lines | |||
| 30 | path: '/{-$orgSlug}/', | 31 | path: '/{-$orgSlug}/', |
| 31 | getParentRoute: () => rootRouteImport, | 32 | getParentRoute: () => rootRouteImport, |
| 32 | } as any) | 33 | } as any) |
| 34 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute = | ||
| 35 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRouteImport.update({ | ||
| 36 | id: '/{-$orgSlug}/users', | ||
| 37 | path: '/{-$orgSlug}/users', | ||
| 38 | getParentRoute: () => rootRouteImport, | ||
| 39 | } as any) | ||
| 33 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = | 40 | const DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute = |
| 34 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ | 41 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRouteImport.update({ |
| 35 | id: '/{-$orgSlug}/tools', | 42 | id: '/{-$orgSlug}/tools', |
| @@ -127,6 +134,7 @@ export interface FileRoutesByFullPath { · 91 unmodified lines | |||
| 127 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 134 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 128 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 135 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 129 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 136 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 137 | '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 130 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 138 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 131 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 139 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 132 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 140 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -143,6 +151,7 @@ export interface FileRoutesByTo { · 10 unmodified lines | |||
| 143 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 151 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 144 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 152 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 145 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 153 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 154 | '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 146 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 155 | '/{-$orgSlug}': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 147 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 156 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 148 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 157 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -160,6 +169,7 @@ export interface FileRoutesById { · 11 unmodified lines | |||
| 160 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 169 | '/{-$orgSlug}/secrets': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 161 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 170 | '/{-$orgSlug}/toolkits': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 162 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 171 | '/{-$orgSlug}/tools': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 172 | '/{-$orgSlug}/users': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 163 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 173 | '/{-$orgSlug}/': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 164 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 174 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 165 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 175 | '/{-$orgSlug}/integrations/$namespace': typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -178,6 +188,7 @@ export interface FileRouteTypes { · 12 unmodified lines | |||
| 178 | | '/{-$orgSlug}/secrets' | 188 | | '/{-$orgSlug}/secrets' |
| 179 | | '/{-$orgSlug}/toolkits' | 189 | | '/{-$orgSlug}/toolkits' |
| 180 | | '/{-$orgSlug}/tools' | 190 | | '/{-$orgSlug}/tools' |
| 191 | | '/{-$orgSlug}/users' | ||
| 181 | | '/{-$orgSlug}/' | 192 | | '/{-$orgSlug}/' |
| 182 | | '/{-$orgSlug}/connect/$integrationSlug' | 193 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 183 | | '/{-$orgSlug}/integrations/$namespace' | 194 | | '/{-$orgSlug}/integrations/$namespace' |
| @@ -194,6 +205,7 @@ export interface FileRouteTypes { · 10 unmodified lines | |||
| 194 | | '/{-$orgSlug}/secrets' | 205 | | '/{-$orgSlug}/secrets' |
| 195 | | '/{-$orgSlug}/toolkits' | 206 | | '/{-$orgSlug}/toolkits' |
| 196 | | '/{-$orgSlug}/tools' | 207 | | '/{-$orgSlug}/tools' |
| 208 | | '/{-$orgSlug}/users' | ||
| 197 | | '/{-$orgSlug}' | 209 | | '/{-$orgSlug}' |
| 198 | | '/{-$orgSlug}/connect/$integrationSlug' | 210 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 199 | | '/{-$orgSlug}/integrations/$namespace' | 211 | | '/{-$orgSlug}/integrations/$namespace' |
| @@ -210,6 +222,7 @@ export interface FileRouteTypes { · 10 unmodified lines | |||
| 210 | | '/{-$orgSlug}/secrets' | 222 | | '/{-$orgSlug}/secrets' |
| 211 | | '/{-$orgSlug}/toolkits' | 223 | | '/{-$orgSlug}/toolkits' |
| 212 | | '/{-$orgSlug}/tools' | 224 | | '/{-$orgSlug}/tools' |
| 225 | | '/{-$orgSlug}/users' | ||
| 213 | | '/{-$orgSlug}/' | 226 | | '/{-$orgSlug}/' |
| 214 | | '/{-$orgSlug}/connect/$integrationSlug' | 227 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 215 | | '/{-$orgSlug}/integrations/$namespace' | 228 | | '/{-$orgSlug}/integrations/$namespace' |
| @@ -227,6 +240,7 @@ export interface RootRouteChildren { · 11 unmodified lines | |||
| 227 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute | 240 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesSecretsRoute |
| 228 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren | 241 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren |
| 229 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute | 242 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute |
| 243 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute | ||
| 230 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute | 244 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute |
| 231 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute | 245 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute |
| 232 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute | 246 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIntegrationsDotnamespaceRoute |
| @@ -244,6 +258,13 @@ declare module '@tanstack/react-router' { · 11 unmodified lines | |||
| 244 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport | 258 | preLoaderRoute: typeof DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRouteImport |
| 245 | parentRoute: typeof rootRouteImport | 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 | '/{-$orgSlug}/tools': { | 268 | '/{-$orgSlug}/tools': { |
| 248 | id: '/{-$orgSlug}/tools' | 269 | id: '/{-$orgSlug}/tools' |
| 249 | path: '/{-$orgSlug}/tools' | 270 | path: '/{-$orgSlug}/tools' |
| @@ -365,6 +386,8 @@ const rootRouteChildren: RootRouteChildren = { · 115 unmodified lines | |||
| 365 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, | 386 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolkitsRouteWithChildren, |
| 366 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: | 387 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute: |
| 367 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, | 388 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesToolsRoute, |
| 389 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute: | ||
| 390 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesUsersRoute, | ||
| 368 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: | 391 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute: |
| 369 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, | 392 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesIndexRoute, |
| 370 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: | 393 | DotDotDotDotDotDotDotDotPackagesReactSrcRoutesConnectDotintegrationSlugRoute: |
| @@ -16,6 +16,7 @@ import { · 15 unmodified lines | |||
| 16 | } from "@executor-js/react/components/card"; | 16 | } from "@executor-js/react/components/card"; |
| 17 | import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; | 17 | import { AuthProvider, useAuth } from "@executor-js/react/multiplayer/auth-context"; |
| 18 | import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; | 18 | import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shell"; |
| 19 | import { useAdminNavItems } from "@executor-js/react/multiplayer/use-admin-nav"; | ||
| 19 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; | 20 | import { plugins as clientPlugins } from "virtual:executor/plugins-client"; |
| 20 | 21 | ||
| 21 | import { authClient } from "../auth-client"; | 22 | import { authClient } from "../auth-client"; |
| @@ -46,6 +47,13 @@ const selfHostNavItems = [ · 24 unmodified lines | |||
| 46 | { to: "/admin", label: "Admin" }, | 47 | { to: "/admin", label: "Admin" }, |
| 47 | ]; | 48 | ]; |
| 48 | 49 | ||
| 50 | // Sections only an owner/admin of the instance may open. Users reads the | ||
| 51 | // tenant-wide admin plane, gated on a Better Auth owner/admin member, so a | ||
| 52 | // plain member is not shown a link that would only refuse them. (The existing | ||
| 53 | // /admin entry predates this and stays unconditional — it is this instance's | ||
| 54 | // member/invite page, and its own notice covers a non-admin who opens it.) | ||
| 55 | const selfHostAdminNavItems = [{ to: "/users", label: "Users" }]; | ||
| 56 | |||
| 49 | const signOut = async () => { | 57 | const signOut = async () => { |
| 50 | await authClient.signOut(); | 58 | await authClient.signOut(); |
| 51 | window.location.href = "/"; | 59 | window.location.href = "/"; |
| @@ -150,12 +158,13 @@ function AuthGate({ children }: { children: ReactNode }) { · 98 unmodified lines | |||
| 150 | function AuthenticatedApp() { | 158 | function AuthenticatedApp() { |
| 151 | const auth = useAuth(); | 159 | const auth = useAuth(); |
| 152 | const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; | 160 | const organization = auth.status === "authenticated" ? (auth.organization ?? null) : null; |
| 161 | const navItems = useAdminNavItems(selfHostNavItems, selfHostAdminNavItems); | ||
| 153 | 162 | ||
| 154 | // Single-org instance: a bare URL canonicalizes onto the instance org's | 163 | // Single-org instance: a bare URL canonicalizes onto the instance org's |
| 155 | // slug. There's only ever one org, so no other slug is reachable. | 164 | // slug. There's only ever one org, so no other slug is reachable. |
| 156 | const gated = ( | 165 | const gated = ( |
| 157 | <> | 166 | <> |
| 158 | <Shell onSignOut={signOut} navItems={selfHostNavItems} /> | 167 | <Shell onSignOut={signOut} navItems={navItems} /> |
| 159 | <Toaster /> | 168 | <Toaster /> |
| 160 | </> | 169 | </> |
| 161 | ); | 170 | ); |
| @@ -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 | // Shared console routes come from @executor-js/react (see its | 7 | // Shared console routes come from @executor-js/react (see its |
| 8 | // console-routes.ts); this app owns its root and the local-specific routes | 8 | // console-routes.ts); this app owns its root and the local-specific routes |
| 9 | // under src/routes/app. /secrets is excluded: the local app's variant shows | 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}` | 10 | // credential-provider info. /users is excluded because local serves no account |
| 11 | // scope (local never sets the param, so URLs stay bare) so the override masks | 11 | // or admin API at all (see apps/local/src/app.ts) — there is no tenant to |
| 12 | // the shared route id. | 12 | // operate and no endpoint behind the page. App routes mount INSIDE the shared |
| 13 | // `{-$orgSlug}` scope (local never sets the param, so URLs stay bare) so the | ||
| 14 | // override masks the shared route id. | ||
| 13 | export const routes = rootRoute("__root.tsx", [ | 15 | export const routes = rootRoute("__root.tsx", [ |
| 14 | ...consoleRoutes({ | 16 | ...consoleRoutes({ |
| 15 | dir: "../../../react/src/routes", | 17 | dir: "../../../react/src/routes", |
| 16 | exclude: ["/secrets"], | 18 | exclude: ["/secrets", "/users"], |
| 17 | orgScoped: [physical("", "app")], | 19 | orgScoped: [physical("", "app")], |
| 18 | }), | 20 | }), |
| 19 | ]); | 21 | ]); |
| @@ -14,3 +14,16 @@ export { · 13 unmodified lines | |||
| 14 | AccountNoOrganization, | 14 | AccountNoOrganization, |
| 15 | AccountUnauthorized, | 15 | AccountUnauthorized, |
| 16 | } from "./account/api"; | 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 | orgInfo: "org:info", | 36 | orgInfo: "org:info", |
| 37 | apiKeys: "api-keys", | 37 | apiKeys: "api-keys", |
| 38 | auth: "auth", | 38 | auth: "auth", |
| 39 | /** The tenant-wide admin users view. Read-only today (the admin plane has no | ||
| 40 | * writes), so nothing invalidates it — it exists so the pages refresh | ||
| 41 | * together and so a future lifecycle mutation has a key to publish. */ | ||
| 42 | adminUsers: "admin:users", | ||
| 39 | } as const; | 43 | } as const; |
| 40 | 44 | ||
| 41 | /** Mutations that add/remove/refresh an integration also affect tool listings. */ | 45 | /** Mutations that add/remove/refresh an integration also affect tool listings. */ |
| @@ -40,6 +40,7 @@ export const CONSOLE_ROUTE_PATHS = [ · 39 unmodified lines | |||
| 40 | "/policies", | 40 | "/policies", |
| 41 | "/secrets", | 41 | "/secrets", |
| 42 | "/tools", | 42 | "/tools", |
| 43 | "/users", | ||
| 43 | "/toolkits", | 44 | "/toolkits", |
| 44 | "/toolkits/$toolkitSlug", | 45 | "/toolkits/$toolkitSlug", |
| 45 | "/resume/$executionId", | 46 | "/resume/$executionId", |
| @@ -78,6 +79,10 @@ export const consoleRoutes = (options: ConsoleRoutesOptions): Array<VirtualRoute · 32 unmodified lines | |||
| 78 | ["/policies", route("/policies", file("policies.tsx"))], | 79 | ["/policies", route("/policies", file("policies.tsx"))], |
| 79 | ["/secrets", route("/secrets", file("secrets.tsx"))], | 80 | ["/secrets", route("/secrets", file("secrets.tsx"))], |
| 80 | ["/tools", route("/tools", file("tools.tsx"))], | 81 | ["/tools", route("/tools", file("tools.tsx"))], |
| 82 | // The admin users view. Hosts that don't serve `/admin/users*` (local / | ||
| 83 | // desktop, cloudflare) exclude it — the page would only ever render its | ||
| 84 | // denied state there. | ||
| 85 | ["/users", route("/users", file("users.tsx"))], | ||
| 81 | ["/toolkits", route("/toolkits", file("toolkits.tsx"))], | 86 | ["/toolkits", route("/toolkits", file("toolkits.tsx"))], |
| 82 | ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))], | 87 | ["/toolkits/$toolkitSlug", route("/toolkits/$toolkitSlug", file("toolkits.$toolkitSlug.tsx"))], |
| 83 | ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))], | 88 | ["/resume/$executionId", route("/resume/$executionId", file("resume.$executionId.tsx"))], |
| @@ -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 | import { Route as rootRouteImport } from './__root' | 11 | import { Route as rootRouteImport } from './__root' |
| 12 | import { Route as DotIndexRouteImport } from './index' | 12 | import { Route as DotIndexRouteImport } from './index' |
| 13 | import { Route as DotUsersRouteImport } from './users' | ||
| 13 | import { Route as DotToolsRouteImport } from './tools' | 14 | import { Route as DotToolsRouteImport } from './tools' |
| 14 | import { Route as DotToolkitsRouteImport } from './toolkits' | 15 | import { Route as DotToolkitsRouteImport } from './toolkits' |
| 15 | import { Route as DotSecretsRouteImport } from './secrets' | 16 | import { Route as DotSecretsRouteImport } from './secrets' |
| @@ -26,6 +27,11 @@ const DotIndexRoute = DotIndexRouteImport.update({ · 10 unmodified lines | |||
| 26 | path: '/{-$orgSlug}/', | 27 | path: '/{-$orgSlug}/', |
| 27 | getParentRoute: () => rootRouteImport, | 28 | getParentRoute: () => rootRouteImport, |
| 28 | } as any) | 29 | } as any) |
| 30 | const DotUsersRoute = DotUsersRouteImport.update({ | ||
| 31 | id: '/{-$orgSlug}/users', | ||
| 32 | path: '/{-$orgSlug}/users', | ||
| 33 | getParentRoute: () => rootRouteImport, | ||
| 34 | } as any) | ||
| 29 | const DotToolsRoute = DotToolsRouteImport.update({ | 35 | const DotToolsRoute = DotToolsRouteImport.update({ |
| 30 | id: '/{-$orgSlug}/tools', | 36 | id: '/{-$orgSlug}/tools', |
| 31 | path: '/{-$orgSlug}/tools', | 37 | path: '/{-$orgSlug}/tools', |
| @@ -87,6 +93,7 @@ export interface FileRoutesByFullPath { · 55 unmodified lines | |||
| 87 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute | 93 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 88 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | 94 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 89 | '/{-$orgSlug}/tools': typeof DotToolsRoute | 95 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 96 | '/{-$orgSlug}/users': typeof DotUsersRoute | ||
| 90 | '/{-$orgSlug}/': typeof DotIndexRoute | 97 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 91 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | 98 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 92 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 99 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| @@ -100,6 +107,7 @@ export interface FileRoutesByTo { · 7 unmodified lines | |||
| 100 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute | 107 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 101 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | 108 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 102 | '/{-$orgSlug}/tools': typeof DotToolsRoute | 109 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 110 | '/{-$orgSlug}/users': typeof DotUsersRoute | ||
| 103 | '/{-$orgSlug}': typeof DotIndexRoute | 111 | '/{-$orgSlug}': typeof DotIndexRoute |
| 104 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | 112 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 105 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 113 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| @@ -114,6 +122,7 @@ export interface FileRoutesById { · 8 unmodified lines | |||
| 114 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute | 122 | '/{-$orgSlug}/secrets': typeof DotSecretsRoute |
| 115 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren | 123 | '/{-$orgSlug}/toolkits': typeof DotToolkitsRouteWithChildren |
| 116 | '/{-$orgSlug}/tools': typeof DotToolsRoute | 124 | '/{-$orgSlug}/tools': typeof DotToolsRoute |
| 125 | '/{-$orgSlug}/users': typeof DotUsersRoute | ||
| 117 | '/{-$orgSlug}/': typeof DotIndexRoute | 126 | '/{-$orgSlug}/': typeof DotIndexRoute |
| 118 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute | 127 | '/{-$orgSlug}/connect/$integrationSlug': typeof DotConnectDotintegrationSlugRoute |
| 119 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute | 128 | '/{-$orgSlug}/integrations/$namespace': typeof DotIntegrationsDotnamespaceRoute |
| @@ -129,6 +138,7 @@ export interface FileRouteTypes { · 9 unmodified lines | |||
| 129 | | '/{-$orgSlug}/secrets' | 138 | | '/{-$orgSlug}/secrets' |
| 130 | | '/{-$orgSlug}/toolkits' | 139 | | '/{-$orgSlug}/toolkits' |
| 131 | | '/{-$orgSlug}/tools' | 140 | | '/{-$orgSlug}/tools' |
| 141 | | '/{-$orgSlug}/users' | ||
| 132 | | '/{-$orgSlug}/' | 142 | | '/{-$orgSlug}/' |
| 133 | | '/{-$orgSlug}/connect/$integrationSlug' | 143 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 134 | | '/{-$orgSlug}/integrations/$namespace' | 144 | | '/{-$orgSlug}/integrations/$namespace' |
| @@ -142,6 +152,7 @@ export interface FileRouteTypes { · 7 unmodified lines | |||
| 142 | | '/{-$orgSlug}/secrets' | 152 | | '/{-$orgSlug}/secrets' |
| 143 | | '/{-$orgSlug}/toolkits' | 153 | | '/{-$orgSlug}/toolkits' |
| 144 | | '/{-$orgSlug}/tools' | 154 | | '/{-$orgSlug}/tools' |
| 155 | | '/{-$orgSlug}/users' | ||
| 145 | | '/{-$orgSlug}' | 156 | | '/{-$orgSlug}' |
| 146 | | '/{-$orgSlug}/connect/$integrationSlug' | 157 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 147 | | '/{-$orgSlug}/integrations/$namespace' | 158 | | '/{-$orgSlug}/integrations/$namespace' |
| @@ -155,6 +166,7 @@ export interface FileRouteTypes { · 7 unmodified lines | |||
| 155 | | '/{-$orgSlug}/secrets' | 166 | | '/{-$orgSlug}/secrets' |
| 156 | | '/{-$orgSlug}/toolkits' | 167 | | '/{-$orgSlug}/toolkits' |
| 157 | | '/{-$orgSlug}/tools' | 168 | | '/{-$orgSlug}/tools' |
| 169 | | '/{-$orgSlug}/users' | ||
| 158 | | '/{-$orgSlug}/' | 170 | | '/{-$orgSlug}/' |
| 159 | | '/{-$orgSlug}/connect/$integrationSlug' | 171 | | '/{-$orgSlug}/connect/$integrationSlug' |
| 160 | | '/{-$orgSlug}/integrations/$namespace' | 172 | | '/{-$orgSlug}/integrations/$namespace' |
| @@ -169,6 +181,7 @@ export interface RootRouteChildren { · 8 unmodified lines | |||
| 169 | DotSecretsRoute: typeof DotSecretsRoute | 181 | DotSecretsRoute: typeof DotSecretsRoute |
| 170 | DotToolkitsRoute: typeof DotToolkitsRouteWithChildren | 182 | DotToolkitsRoute: typeof DotToolkitsRouteWithChildren |
| 171 | DotToolsRoute: typeof DotToolsRoute | 183 | DotToolsRoute: typeof DotToolsRoute |
| 184 | DotUsersRoute: typeof DotUsersRoute | ||
| 172 | DotIndexRoute: typeof DotIndexRoute | 185 | DotIndexRoute: typeof DotIndexRoute |
| 173 | DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute | 186 | DotConnectDotintegrationSlugRoute: typeof DotConnectDotintegrationSlugRoute |
| 174 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute | 187 | DotIntegrationsDotnamespaceRoute: typeof DotIntegrationsDotnamespaceRoute |
| @@ -186,6 +199,13 @@ declare module '@tanstack/react-router' { · 11 unmodified lines | |||
| 186 | preLoaderRoute: typeof DotIndexRouteImport | 199 | preLoaderRoute: typeof DotIndexRouteImport |
| 187 | parentRoute: typeof rootRouteImport | 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 | '/{-$orgSlug}/tools': { | 209 | '/{-$orgSlug}/tools': { |
| 190 | id: '/{-$orgSlug}/tools' | 210 | id: '/{-$orgSlug}/tools' |
| 191 | path: '/{-$orgSlug}/tools' | 211 | path: '/{-$orgSlug}/tools' |
| @@ -276,6 +296,7 @@ const rootRouteChildren: RootRouteChildren = { · 84 unmodified lines | |||
| 276 | DotSecretsRoute: DotSecretsRoute, | 296 | DotSecretsRoute: DotSecretsRoute, |
| 277 | DotToolkitsRoute: DotToolkitsRouteWithChildren, | 297 | DotToolkitsRoute: DotToolkitsRouteWithChildren, |
| 278 | DotToolsRoute: DotToolsRoute, | 298 | DotToolsRoute: DotToolsRoute, |
| 299 | DotUsersRoute: DotUsersRoute, | ||
| 279 | DotIndexRoute: DotIndexRoute, | 300 | DotIndexRoute: DotIndexRoute, |
| 280 | DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, | 301 | DotConnectDotintegrationSlugRoute: DotConnectDotintegrationSlugRoute, |
| 281 | DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, | 302 | DotIntegrationsDotnamespaceRoute: DotIntegrationsDotnamespaceRoute, |
| @@ -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 | }); | ||