From 57394ec6f4962bdbfac150bfb65c1056d84cdffa Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:38:50 -0700 Subject: [PATCH 01/12] feat(cloud): add team management page with WorkOS org members Custom team page using WorkOS API for listing members, inviting, removing, and changing roles. Admin-gated write operations with server-side role checks. Uses Effect API layer pattern with typed endpoints and effect-atom for client-side state. Also fixes Sonner toaster to use system theme detection instead of next-themes, removes next-themes dependency. --- apps/cloud/package.json | 3 +- apps/cloud/src/api.ts | 52 +++ apps/cloud/src/auth/workos.ts | 59 +++- apps/cloud/src/routeTree.gen.ts | 303 +++++++++-------- apps/cloud/src/routes/__root.tsx | 2 + apps/cloud/src/routes/team.tsx | 411 +++++++++++++++++++++++ apps/cloud/src/team/api.ts | 92 +++++ apps/cloud/src/team/compose.ts | 6 + apps/cloud/src/team/handlers.test.ts | 244 ++++++++++++++ apps/cloud/src/team/handlers.ts | 97 ++++++ apps/cloud/src/web/client.tsx | 5 +- apps/cloud/src/web/shell.tsx | 2 + apps/cloud/src/web/team-atoms.ts | 15 + bun.lock | 4 +- packages/react/package.json | 1 - packages/react/src/components/sonner.tsx | 5 +- 16 files changed, 1148 insertions(+), 153 deletions(-) create mode 100644 apps/cloud/src/routes/team.tsx create mode 100644 apps/cloud/src/team/api.ts create mode 100644 apps/cloud/src/team/compose.ts create mode 100644 apps/cloud/src/team/handlers.test.ts create mode 100644 apps/cloud/src/team/handlers.ts create mode 100644 apps/cloud/src/web/team-atoms.ts diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 76b76b45ce..5131ee9575 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -46,7 +46,8 @@ "jose": "^5.6.3", "postgres": "^3.4.9", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "sonner": "^2.0.7" }, "devDependencies": { "@cloudflare/vitest-pool-workers": "^0.14.3", diff --git a/apps/cloud/src/api.ts b/apps/cloud/src/api.ts index aacbe0cca3..dd77b2ad16 100644 --- a/apps/cloud/src/api.ts +++ b/apps/cloud/src/api.ts @@ -46,6 +46,8 @@ import { import { WorkOSAuth } from "./auth/workos"; import { DbService } from "./services/db"; import { createOrgExecutor } from "./services/executor"; +import { TeamOrgApi } from "./team/compose"; +import { TeamHandlers } from "./team/handlers"; import { server } from "./env"; // --------------------------------------------------------------------------- @@ -90,6 +92,11 @@ const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( Layer.provideMerge(SessionAuthLive), ); +const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe( + Layer.provide(TeamHandlers), + Layer.provideMerge(OrgAuthLive), +); + // --------------------------------------------------------------------------- // Public auth web handler // --------------------------------------------------------------------------- @@ -113,6 +120,12 @@ const createNonProtectedHandler = () => { middleware: HttpMiddleware.logger }, ); +const createTeamHandler = () => + HttpApiBuilder.toWebHandler( + TeamApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)), + { middleware: HttpMiddleware.logger }, + ); + // --------------------------------------------------------------------------- // Protected handler — must be built per-request because the executor varies // --------------------------------------------------------------------------- @@ -158,6 +171,7 @@ const buildProtectedHandler = ( const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/"); const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/"); +const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/"); const isExecutionPath = (pathname: string): boolean => pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname); @@ -241,9 +255,47 @@ const handleAutumnRequest = async (request: Request): Promise => { ); }; +// --------------------------------------------------------------------------- +// Widget token endpoint — returns a WorkOS widget token for the session user +// --------------------------------------------------------------------------- + +const handleWidgetTokenRequest = async (request: Request): Promise => { + const program = Effect.gen(function* () { + const workos = yield* WorkOSAuth; + const result = yield* workos.authenticateRequest(request); + + if (!result || !result.organizationId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const token = yield* workos.getWidgetToken(result.userId, result.organizationId); + return Response.json({ token }); + }); + + return Effect.runPromise(program.pipe(Effect.provide(SharedServices), Effect.scoped)).catch( + (err) => { + console.error("[widget-token] request failed:", err instanceof Error ? err.stack : err); + return Response.json({ error: "Internal server error" }, { status: 500 }); + }, + ); +}; + export const handleApiRequest = async (request: Request): Promise => { const pathname = new URL(request.url).pathname; + if (pathname === "/widget-token") { + return handleWidgetTokenRequest(request); + } + + if (isTeamPath(pathname)) { + const handler = createTeamHandler(); + try { + return await handler.handler(request); + } finally { + await handler.dispose(); + } + } + if (isAutumnPath(pathname)) { return handleAutumnRequest(request); } diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index cfee30ed80..43d546e51f 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -138,10 +138,67 @@ const make = Effect.gen(function* () { if (!sessionData) return null; return yield* authenticateSealedSession(sessionData); }), + + /** Generate a widget token for the UsersManagement widget. */ + getWidgetToken: (userId: string, organizationId: string) => + use((wos) => + wos.widgets.getToken({ + userId, + organizationId, + scopes: ["widgets:users-table:manage"], + }), + ), + + /** List organization memberships with user details. */ + listOrgMembers: (organizationId: string) => + use((wos) => + wos.userManagement.listOrganizationMemberships({ + organizationId, + statuses: ["active", "pending"], + }), + ), + + /** Get a user by ID. */ + getUser: (userId: string) => + use((wos) => wos.userManagement.getUser(userId)), + + /** Send an organization invitation. */ + sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => + use((wos) => + wos.userManagement.sendInvitation({ + email: params.email, + organizationId: params.organizationId, + roleSlug: params.roleSlug, + }), + ), + + /** Remove an organization membership. */ + deleteOrgMembership: (membershipId: string) => + use((wos) => + wos.userManagement.deleteOrganizationMembership(membershipId), + ), + + /** Get the role for a membership. */ + getOrgMembership: (membershipId: string) => + use((wos) => + wos.userManagement.getOrganizationMembership(membershipId), + ), + + /** Update a membership's role. */ + updateOrgMembershipRole: (membershipId: string, roleSlug: string) => + use((wos) => + wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug }), + ), + + /** List available roles for an organization. */ + listOrgRoles: (organizationId: string) => + use((wos) => + wos.organizations.listOrganizationRoles({ organizationId }), + ), }; }); -type WorkOSAuthService = Effect.Effect.Success; +export type WorkOSAuthService = Effect.Effect.Success; export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")< WorkOSAuth, diff --git a/apps/cloud/src/routeTree.gen.ts b/apps/cloud/src/routeTree.gen.ts index 2ec0acaa56..c380583184 100644 --- a/apps/cloud/src/routeTree.gen.ts +++ b/apps/cloud/src/routeTree.gen.ts @@ -8,170 +8,190 @@ // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. -import { Route as rootRouteImport } from "./routes/__root"; -import { Route as ToolsRouteImport } from "./routes/tools"; -import { Route as SecretsRouteImport } from "./routes/secrets"; -import { Route as BillingRouteImport } from "./routes/billing"; -import { Route as IndexRouteImport } from "./routes/index"; -import { Route as SourcesNamespaceRouteImport } from "./routes/sources.$namespace"; -import { Route as BillingPlansRouteImport } from "./routes/billing_.plans"; -import { Route as SourcesAddPluginKeyRouteImport } from "./routes/sources.add.$pluginKey"; +import { Route as rootRouteImport } from './routes/__root' +import { Route as ToolsRouteImport } from './routes/tools' +import { Route as TeamRouteImport } from './routes/team' +import { Route as SecretsRouteImport } from './routes/secrets' +import { Route as BillingRouteImport } from './routes/billing' +import { Route as IndexRouteImport } from './routes/index' +import { Route as SourcesNamespaceRouteImport } from './routes/sources.$namespace' +import { Route as BillingPlansRouteImport } from './routes/billing_.plans' +import { Route as SourcesAddPluginKeyRouteImport } from './routes/sources.add.$pluginKey' const ToolsRoute = ToolsRouteImport.update({ - id: "/tools", - path: "/tools", + id: '/tools', + path: '/tools', getParentRoute: () => rootRouteImport, -} as any); +} as any) +const TeamRoute = TeamRouteImport.update({ + id: '/team', + path: '/team', + getParentRoute: () => rootRouteImport, +} as any) const SecretsRoute = SecretsRouteImport.update({ - id: "/secrets", - path: "/secrets", + id: '/secrets', + path: '/secrets', getParentRoute: () => rootRouteImport, -} as any); +} as any) const BillingRoute = BillingRouteImport.update({ - id: "/billing", - path: "/billing", + id: '/billing', + path: '/billing', getParentRoute: () => rootRouteImport, -} as any); +} as any) const IndexRoute = IndexRouteImport.update({ - id: "/", - path: "/", + id: '/', + path: '/', getParentRoute: () => rootRouteImport, -} as any); +} as any) const SourcesNamespaceRoute = SourcesNamespaceRouteImport.update({ - id: "/sources/$namespace", - path: "/sources/$namespace", + id: '/sources/$namespace', + path: '/sources/$namespace', getParentRoute: () => rootRouteImport, -} as any); +} as any) const BillingPlansRoute = BillingPlansRouteImport.update({ - id: "/billing_/plans", - path: "/billing/plans", + id: '/billing_/plans', + path: '/billing/plans', getParentRoute: () => rootRouteImport, -} as any); +} as any) const SourcesAddPluginKeyRoute = SourcesAddPluginKeyRouteImport.update({ - id: "/sources/add/$pluginKey", - path: "/sources/add/$pluginKey", + id: '/sources/add/$pluginKey', + path: '/sources/add/$pluginKey', getParentRoute: () => rootRouteImport, -} as any); +} as any) export interface FileRoutesByFullPath { - "/": typeof IndexRoute; - "/billing": typeof BillingRoute; - "/secrets": typeof SecretsRoute; - "/tools": typeof ToolsRoute; - "/billing/plans": typeof BillingPlansRoute; - "/sources/$namespace": typeof SourcesNamespaceRoute; - "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; + '/': typeof IndexRoute + '/billing': typeof BillingRoute + '/secrets': typeof SecretsRoute + '/team': typeof TeamRoute + '/tools': typeof ToolsRoute + '/billing/plans': typeof BillingPlansRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } export interface FileRoutesByTo { - "/": typeof IndexRoute; - "/billing": typeof BillingRoute; - "/secrets": typeof SecretsRoute; - "/tools": typeof ToolsRoute; - "/billing/plans": typeof BillingPlansRoute; - "/sources/$namespace": typeof SourcesNamespaceRoute; - "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; + '/': typeof IndexRoute + '/billing': typeof BillingRoute + '/secrets': typeof SecretsRoute + '/team': typeof TeamRoute + '/tools': typeof ToolsRoute + '/billing/plans': typeof BillingPlansRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } export interface FileRoutesById { - __root__: typeof rootRouteImport; - "/": typeof IndexRoute; - "/billing": typeof BillingRoute; - "/secrets": typeof SecretsRoute; - "/tools": typeof ToolsRoute; - "/billing_/plans": typeof BillingPlansRoute; - "/sources/$namespace": typeof SourcesNamespaceRoute; - "/sources/add/$pluginKey": typeof SourcesAddPluginKeyRoute; + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/billing': typeof BillingRoute + '/secrets': typeof SecretsRoute + '/team': typeof TeamRoute + '/tools': typeof ToolsRoute + '/billing_/plans': typeof BillingPlansRoute + '/sources/$namespace': typeof SourcesNamespaceRoute + '/sources/add/$pluginKey': typeof SourcesAddPluginKeyRoute } export interface FileRouteTypes { - fileRoutesByFullPath: FileRoutesByFullPath; + fileRoutesByFullPath: FileRoutesByFullPath fullPaths: - | "/" - | "/billing" - | "/secrets" - | "/tools" - | "/billing/plans" - | "/sources/$namespace" - | "/sources/add/$pluginKey"; - fileRoutesByTo: FileRoutesByTo; + | '/' + | '/billing' + | '/secrets' + | '/team' + | '/tools' + | '/billing/plans' + | '/sources/$namespace' + | '/sources/add/$pluginKey' + fileRoutesByTo: FileRoutesByTo to: - | "/" - | "/billing" - | "/secrets" - | "/tools" - | "/billing/plans" - | "/sources/$namespace" - | "/sources/add/$pluginKey"; + | '/' + | '/billing' + | '/secrets' + | '/team' + | '/tools' + | '/billing/plans' + | '/sources/$namespace' + | '/sources/add/$pluginKey' id: - | "__root__" - | "/" - | "/billing" - | "/secrets" - | "/tools" - | "/billing_/plans" - | "/sources/$namespace" - | "/sources/add/$pluginKey"; - fileRoutesById: FileRoutesById; + | '__root__' + | '/' + | '/billing' + | '/secrets' + | '/team' + | '/tools' + | '/billing_/plans' + | '/sources/$namespace' + | '/sources/add/$pluginKey' + fileRoutesById: FileRoutesById } export interface RootRouteChildren { - IndexRoute: typeof IndexRoute; - BillingRoute: typeof BillingRoute; - SecretsRoute: typeof SecretsRoute; - ToolsRoute: typeof ToolsRoute; - BillingPlansRoute: typeof BillingPlansRoute; - SourcesNamespaceRoute: typeof SourcesNamespaceRoute; - SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute; + IndexRoute: typeof IndexRoute + BillingRoute: typeof BillingRoute + SecretsRoute: typeof SecretsRoute + TeamRoute: typeof TeamRoute + ToolsRoute: typeof ToolsRoute + BillingPlansRoute: typeof BillingPlansRoute + SourcesNamespaceRoute: typeof SourcesNamespaceRoute + SourcesAddPluginKeyRoute: typeof SourcesAddPluginKeyRoute } -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/tools": { - id: "/tools"; - path: "/tools"; - fullPath: "/tools"; - preLoaderRoute: typeof ToolsRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/secrets": { - id: "/secrets"; - path: "/secrets"; - fullPath: "/secrets"; - preLoaderRoute: typeof SecretsRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/billing": { - id: "/billing"; - path: "/billing"; - fullPath: "/billing"; - preLoaderRoute: typeof BillingRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/sources/$namespace": { - id: "/sources/$namespace"; - path: "/sources/$namespace"; - fullPath: "/sources/$namespace"; - preLoaderRoute: typeof SourcesNamespaceRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/billing_/plans": { - id: "/billing_/plans"; - path: "/billing/plans"; - fullPath: "/billing/plans"; - preLoaderRoute: typeof BillingPlansRouteImport; - parentRoute: typeof rootRouteImport; - }; - "/sources/add/$pluginKey": { - id: "/sources/add/$pluginKey"; - path: "/sources/add/$pluginKey"; - fullPath: "/sources/add/$pluginKey"; - preLoaderRoute: typeof SourcesAddPluginKeyRouteImport; - parentRoute: typeof rootRouteImport; - }; + '/tools': { + id: '/tools' + path: '/tools' + fullPath: '/tools' + preLoaderRoute: typeof ToolsRouteImport + parentRoute: typeof rootRouteImport + } + '/team': { + id: '/team' + path: '/team' + fullPath: '/team' + preLoaderRoute: typeof TeamRouteImport + parentRoute: typeof rootRouteImport + } + '/secrets': { + id: '/secrets' + path: '/secrets' + fullPath: '/secrets' + preLoaderRoute: typeof SecretsRouteImport + parentRoute: typeof rootRouteImport + } + '/billing': { + id: '/billing' + path: '/billing' + fullPath: '/billing' + preLoaderRoute: typeof BillingRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/sources/$namespace': { + id: '/sources/$namespace' + path: '/sources/$namespace' + fullPath: '/sources/$namespace' + preLoaderRoute: typeof SourcesNamespaceRouteImport + parentRoute: typeof rootRouteImport + } + '/billing_/plans': { + id: '/billing_/plans' + path: '/billing/plans' + fullPath: '/billing/plans' + preLoaderRoute: typeof BillingPlansRouteImport + parentRoute: typeof rootRouteImport + } + '/sources/add/$pluginKey': { + id: '/sources/add/$pluginKey' + path: '/sources/add/$pluginKey' + fullPath: '/sources/add/$pluginKey' + preLoaderRoute: typeof SourcesAddPluginKeyRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -179,21 +199,22 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BillingRoute: BillingRoute, SecretsRoute: SecretsRoute, + TeamRoute: TeamRoute, ToolsRoute: ToolsRoute, BillingPlansRoute: BillingPlansRoute, SourcesNamespaceRoute: SourcesNamespaceRoute, SourcesAddPluginKeyRoute: SourcesAddPluginKeyRoute, -}; +} export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes(); + ._addFileTypes() -import type { getRouter } from "./router.tsx"; -import type { startInstance } from "./start.ts"; -declare module "@tanstack/react-start" { +import type { getRouter } from './router.tsx' +import type { startInstance } from './start.ts' +declare module '@tanstack/react-start' { interface Register { - ssr: true; - router: Awaited>; - config: Awaited>; + ssr: true + router: Awaited> + config: Awaited> } } diff --git a/apps/cloud/src/routes/__root.tsx b/apps/cloud/src/routes/__root.tsx index 3bd8fd460b..e6476ede0f 100644 --- a/apps/cloud/src/routes/__root.tsx +++ b/apps/cloud/src/routes/__root.tsx @@ -2,6 +2,7 @@ import React from "react"; import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"; import { AutumnProvider } from "autumn-js/react"; import { ExecutorProvider } from "@executor/react/api/provider"; +import { Toaster } from "@executor/react/components/sonner"; import { AuthProvider, useAuth } from "../web/auth"; import { LoginPage } from "../web/pages/login"; import { Shell } from "../web/shell"; @@ -70,6 +71,7 @@ function AuthGate() { + ); diff --git a/apps/cloud/src/routes/team.tsx b/apps/cloud/src/routes/team.tsx new file mode 100644 index 0000000000..00bf39840d --- /dev/null +++ b/apps/cloud/src/routes/team.tsx @@ -0,0 +1,411 @@ +import { useReducer, useState } from "react"; +import { Exit } from "effect"; +import { createFileRoute } from "@tanstack/react-router"; +import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogClose, +} from "@executor/react/components/dialog"; +import { Button } from "@executor/react/components/button"; +import { Input } from "@executor/react/components/input"; +import { Label } from "@executor/react/components/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@executor/react/components/select"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@executor/react/components/dropdown-menu"; +import { + teamMembersAtom, + teamRolesAtom, + inviteMember, + removeMember, + updateMemberRole, +} from "../web/team-atoms"; + +export const Route = createFileRoute("/team")({ + component: TeamPage, +}); + +type InviteState = { + email: string; + roleSlug: string; + status: "idle" | "sending" | "error"; + error: string | null; +}; + +const initialInviteState: InviteState = { + email: "", + roleSlug: "", + status: "idle", + error: null, +}; + +type InviteAction = + | { type: "setEmail"; email: string } + | { type: "setRole"; roleSlug: string } + | { type: "send" } + | { type: "error"; message: string } + | { type: "reset" }; + +function inviteReducer(state: InviteState, action: InviteAction): InviteState { + switch (action.type) { + case "setEmail": return { ...state, email: action.email }; + case "setRole": return { ...state, roleSlug: action.roleSlug }; + case "send": return { ...state, status: "sending", error: null }; + case "error": return { ...state, status: "error", error: action.message }; + case "reset": return initialInviteState; + } +} + +function formatLastActive(lastActiveAt: string | null): string { + if (!lastActiveAt) return "\u2014"; + const date = new Date(lastActiveAt); + const diffMs = Date.now() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} + +function TeamPage() { + const membersResult = useAtomValue(teamMembersAtom); + const rolesResult = useAtomValue(teamRolesAtom); + const refreshMembers = useAtomRefresh(teamMembersAtom); + const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); + const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); + const [inviteOpen, setInviteOpen] = useState(false); + const [search, setSearch] = useState(""); + + const roles = Result.match(rolesResult, { + onInitial: () => [] as readonly { slug: string; name: string }[], + onFailure: () => [] as readonly { slug: string; name: string }[], + onSuccess: ({ value }) => value.roles, + }); + + const handleRemove = async (membershipId: string, name: string) => { + const exit = await doRemove({ path: { membershipId } }); + if (Exit.isSuccess(exit)) { + toast.success(`Removed ${name}`); + refreshMembers(); + } else { + toast.error("Failed to remove member"); + } + }; + + const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { + const exit = await doUpdateRole({ path: { membershipId }, payload: { roleSlug } }); + if (Exit.isSuccess(exit)) { + toast.success(`Role changed to ${roleName}`); + refreshMembers(); + } else { + toast.error("Failed to change role"); + } + }; + + return ( +
+
+
+

+ Team +

+ +
+ + {/* Search */} +
+ setSearch((e.target as HTMLInputElement).value)} + className="text-[0.8125rem] h-9" + /> +
+ + {/* Members */} + {Result.match(membersResult, { + onInitial: () => ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ), + onFailure: () => ( +
+

Failed to load team members

+
+ ), + onSuccess: ({ value }) => { + const members = value.members; + const filtered = search + ? members.filter( + (m) => + m.email.toLowerCase().includes(search.toLowerCase()) || + (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), + ) + : members; + + if (filtered.length === 0) { + return ( +

+ {search ? "No matching members" : "No team members yet"} +

+ ); + } + + return ( +
+ {filtered.map((member) => ( +
+ {/* Avatar */} + {member.avatarUrl ? ( + + ) : ( +
+ {member.name + ? member.name.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase() + : member.email[0]!.toUpperCase()} +
+ )} + + {/* Name + email */} +
+
+

+ {member.name ?? member.email} +

+ {member.isCurrentUser && ( + + You + + )} + {member.status === "pending" && ( + + Invited + + )} +
+ {member.name && ( +

+ {member.email} +

+ )} +
+ + {/* Role */} +

+ {member.role} +

+ + {/* Last active */} +

+ {formatLastActive(member.lastActiveAt)} +

+ + {/* Actions */} + {!member.isCurrentUser ? ( + + + + + + {roles.length > 0 && ( + <> + + + Change role + + + {roles.map((role) => ( + handleChangeRole(member.id, role.slug, role.name)} + > + {role.name} + {role.slug === member.role && ( + + + + + + )} + + ))} + + + + + )} + handleRemove(member.id, member.name ?? member.email)} + > + Remove member + + + + ) :
} +
+ ))} +
+ ); + }, + })} + + +
+
+ ); +} + +function InviteDialog(props: { + open: boolean; + onOpenChange: (v: boolean) => void; + onInvited: () => void; + roles: readonly { slug: string; name: string }[]; +}) { + const [state, dispatch] = useReducer(inviteReducer, initialInviteState); + const doInvite = useAtomSet(inviteMember, { mode: "promiseExit" }); + + const handleInvite = async () => { + if (!state.email.trim()) return; + dispatch({ type: "send" }); + + const exit = await doInvite({ + payload: { + email: state.email.trim(), + ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), + }, + }); + + if (Exit.isSuccess(exit)) { + toast.success(`Invitation sent to ${state.email.trim()}`); + dispatch({ type: "reset" }); + props.onOpenChange(false); + props.onInvited(); + } else { + dispatch({ type: "error", message: "Failed to send invitation" }); + } + }; + + return ( + { + if (!v) dispatch({ type: "reset" }); + props.onOpenChange(v); + }} + > + + + Invite member + + Send an email invitation to join your team. + + + +
+
+ + dispatch({ type: "setEmail", email: (e.target as HTMLInputElement).value })} + onKeyDown={(e) => { if (e.key === "Enter") handleInvite(); }} + className="text-[0.8125rem] h-9" + /> +
+ + {props.roles.length > 0 && ( +
+ + +
+ )} + + {state.status === "error" && state.error && ( +
+

{state.error}

+
+ )} +
+ + + + + + + +
+
+ ); +} diff --git a/apps/cloud/src/team/api.ts b/apps/cloud/src/team/api.ts new file mode 100644 index 0000000000..7ef62c2fbb --- /dev/null +++ b/apps/cloud/src/team/api.ts @@ -0,0 +1,92 @@ +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; +import { Schema } from "effect"; +import { WorkOSError } from "../auth/errors"; + +export class Forbidden extends Schema.TaggedError()( + "Forbidden", + {}, + HttpApiSchema.annotations({ status: 403 }), +) {} + +const TeamMember = Schema.Struct({ + id: Schema.String, + userId: Schema.String, + email: Schema.String, + name: Schema.NullOr(Schema.String), + avatarUrl: Schema.NullOr(Schema.String), + role: Schema.String, + status: Schema.String, + lastActiveAt: Schema.NullOr(Schema.String), + isCurrentUser: Schema.Boolean, +}); + +const TeamMembersResponse = Schema.Struct({ + members: Schema.Array(TeamMember), +}); + +const TeamRole = Schema.Struct({ + slug: Schema.String, + name: Schema.String, +}); + +const TeamRolesResponse = Schema.Struct({ + roles: Schema.Array(TeamRole), +}); + +const InviteBody = Schema.Struct({ + email: Schema.String, + roleSlug: Schema.optional(Schema.String), +}); + +const InviteResponse = Schema.Struct({ + id: Schema.String, + email: Schema.String, +}); + +const membershipIdParam = HttpApiSchema.param("membershipId", Schema.String); + +const RemoveResponse = Schema.Struct({ + success: Schema.Boolean, +}); + +const UpdateRoleBody = Schema.Struct({ + roleSlug: Schema.String, +}); + +const UpdateRoleResponse = Schema.Struct({ + success: Schema.Boolean, +}); + +export { TeamMember, TeamMembersResponse }; + +export class TeamApi extends HttpApiGroup.make("team") + .add( + HttpApiEndpoint.get("listMembers")`/team/members` + .addSuccess(TeamMembersResponse) + .addError(WorkOSError), + ) + .add( + HttpApiEndpoint.get("listRoles")`/team/roles` + .addSuccess(TeamRolesResponse) + .addError(WorkOSError), + ) + .add( + HttpApiEndpoint.post("invite")`/team/invite` + .setPayload(InviteBody) + .addSuccess(InviteResponse) + .addError(WorkOSError) + .addError(Forbidden), + ) + .add( + HttpApiEndpoint.del("removeMember")`/team/members/${membershipIdParam}` + .addSuccess(RemoveResponse) + .addError(WorkOSError) + .addError(Forbidden), + ) + .add( + HttpApiEndpoint.patch("updateMemberRole")`/team/members/${membershipIdParam}/role` + .setPayload(UpdateRoleBody) + .addSuccess(UpdateRoleResponse) + .addError(WorkOSError) + .addError(Forbidden), + ) {} diff --git a/apps/cloud/src/team/compose.ts b/apps/cloud/src/team/compose.ts new file mode 100644 index 0000000000..cf19d88fef --- /dev/null +++ b/apps/cloud/src/team/compose.ts @@ -0,0 +1,6 @@ +import { HttpApi } from "@effect/platform"; +import { OrgAuth } from "../auth/middleware"; +import { TeamApi } from "./api"; + +/** Team API with org-level auth — requires authenticated session with an org. */ +export const TeamOrgApi = HttpApi.make("teamOrg").add(TeamApi).middleware(OrgAuth); diff --git a/apps/cloud/src/team/handlers.test.ts b/apps/cloud/src/team/handlers.test.ts new file mode 100644 index 0000000000..25574c2811 --- /dev/null +++ b/apps/cloud/src/team/handlers.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { AuthContext } from "../auth/middleware"; +import { WorkOSAuth } from "../auth/workos"; +import { Forbidden } from "./api"; + +// --------------------------------------------------------------------------- +// Stub factory — only implement what each test calls +// --------------------------------------------------------------------------- + +type StubOverrides = { + listOrgMembers?: (...args: any[]) => Effect.Effect; + getUser?: (...args: any[]) => Effect.Effect; + sendInvitation?: (...args: any[]) => Effect.Effect; + deleteOrgMembership?: (...args: any[]) => Effect.Effect; + updateOrgMembershipRole?: (...args: any[]) => Effect.Effect; + listOrgRoles?: (...args: any[]) => Effect.Effect; +}; + +const stubWorkOS = (overrides: StubOverrides = {}) => + Layer.succeed(WorkOSAuth, new Proxy({} as WorkOSAuth["Type"], { + get: (_target, prop) => { + if (prop in overrides) return (overrides as Record)[prop as string]; + return () => { throw new Error(`WorkOSAuth.${String(prop)} not stubbed`); }; + }, + })); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const adminAuth = { + accountId: "user_admin", + organizationId: "org_1", + email: "admin@test.com", + name: "Admin", + avatarUrl: null, +}; + +const memberAuth = { + accountId: "user_member", + organizationId: "org_1", + email: "member@test.com", + name: "Member", + avatarUrl: null, +}; + +const fakeMemberships = [ + { id: "mem_admin", userId: "user_admin", status: "active", role: { slug: "admin" } }, + { id: "mem_member", userId: "user_member", status: "active", role: { slug: "member" } }, +]; + +const fakeUsers: Record = { + user_admin: { email: "admin@test.com", firstName: "Admin", lastName: null, profilePictureUrl: null, lastSignInAt: "2026-04-09T00:00:00Z" }, + user_member: { email: "member@test.com", firstName: "Member", lastName: null, profilePictureUrl: null, lastSignInAt: null }, +}; + +const fakeRoles = [ + { slug: "admin", name: "Admin" }, + { slug: "member", name: "Member" }, +]; + +// --------------------------------------------------------------------------- +// The admin guard — mirrors handlers.ts +// --------------------------------------------------------------------------- + +const requireAdmin = Effect.gen(function* () { + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + const memberships = yield* workos.listOrgMembers(auth.organizationId); + const current = memberships.data.find((m: any) => m.userId === auth.accountId); + if (!current || current.role?.slug !== "admin") { + return yield* new Forbidden(); + } +}); + +const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => + Layer.mergeAll( + Layer.succeed(AuthContext, auth), + stubWorkOS(workosOverrides), + ); + +const withMembers: StubOverrides = { + listOrgMembers: () => Effect.succeed({ data: fakeMemberships }), +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("Team handlers", () => { + describe("listMembers", () => { + it.effect("returns members with isCurrentUser set correctly", () => + Effect.gen(function* () { + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + const result = yield* workos.listOrgMembers(auth.organizationId); + const members = yield* Effect.all( + result.data.map((m: any) => + Effect.gen(function* () { + const user = yield* workos.getUser(m.userId); + return { + id: m.id, + email: user.email, + role: m.role?.slug ?? "member", + isCurrentUser: m.userId === auth.accountId, + }; + }), + ), + ); + + expect(members).toHaveLength(2); + expect(members[0]).toMatchObject({ email: "admin@test.com", isCurrentUser: true }); + expect(members[1]).toMatchObject({ email: "member@test.com", isCurrentUser: false }); + }).pipe( + Effect.provide(provide(adminAuth, { + ...withMembers, + getUser: (id: string) => Effect.succeed(fakeUsers[id]), + })), + ), + ); + }); + + describe("listRoles", () => { + it.effect("returns available roles", () => + Effect.gen(function* () { + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + const result = yield* workos.listOrgRoles(auth.organizationId); + const roles = result.data.map((r: any) => ({ slug: r.slug, name: r.name })); + + expect(roles).toEqual(fakeRoles); + }).pipe( + Effect.provide(provide(adminAuth, { + listOrgRoles: () => Effect.succeed({ data: fakeRoles }), + })), + ), + ); + }); + + describe("requireAdmin", () => { + it.effect("passes for admin user", () => + requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))), + ); + + it.effect("rejects non-admin with Forbidden", () => + Effect.gen(function* () { + const error = yield* Effect.flip(requireAdmin); + expect(error).toBeInstanceOf(Forbidden); + }).pipe(Effect.provide(provide(memberAuth, withMembers))), + ); + }); + + describe("invite (admin-gated)", () => { + it.effect("admin can invite", () => + Effect.gen(function* () { + yield* requireAdmin; + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + const result = yield* workos.sendInvitation({ + email: "new@test.com", + organizationId: auth.organizationId, + }); + + expect(result.email).toBe("new@test.com"); + }).pipe( + Effect.provide(provide(adminAuth, { + ...withMembers, + sendInvitation: (p: any) => Effect.succeed({ id: "inv_1", email: p.email }), + })), + ), + ); + + it.effect("member cannot invite", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Effect.gen(function* () { + yield* requireAdmin; + const workos = yield* WorkOSAuth; + yield* workos.sendInvitation({ email: "x", organizationId: "org_1" }); + }), + ); + expect(error).toBeInstanceOf(Forbidden); + }).pipe(Effect.provide(provide(memberAuth, withMembers))), + ); + }); + + describe("removeMember (admin-gated)", () => { + it.effect("admin can remove", () => + Effect.gen(function* () { + yield* requireAdmin; + const workos = yield* WorkOSAuth; + yield* workos.deleteOrgMembership("mem_member"); + }).pipe( + Effect.provide(provide(adminAuth, { + ...withMembers, + deleteOrgMembership: () => Effect.void, + })), + ), + ); + + it.effect("member cannot remove", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Effect.gen(function* () { + yield* requireAdmin; + const workos = yield* WorkOSAuth; + yield* workos.deleteOrgMembership("mem_admin"); + }), + ); + expect(error).toBeInstanceOf(Forbidden); + }).pipe(Effect.provide(provide(memberAuth, withMembers))), + ); + }); + + describe("updateMemberRole (admin-gated)", () => { + it.effect("admin can change role", () => + Effect.gen(function* () { + yield* requireAdmin; + const workos = yield* WorkOSAuth; + yield* workos.updateOrgMembershipRole("mem_member", "admin"); + }).pipe( + Effect.provide(provide(adminAuth, { + ...withMembers, + updateOrgMembershipRole: () => Effect.void, + })), + ), + ); + + it.effect("member cannot change role", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Effect.gen(function* () { + yield* requireAdmin; + const workos = yield* WorkOSAuth; + yield* workos.updateOrgMembershipRole("mem_admin", "member"); + }), + ); + expect(error).toBeInstanceOf(Forbidden); + }).pipe(Effect.provide(provide(memberAuth, withMembers))), + ); + }); +}); diff --git a/apps/cloud/src/team/handlers.ts b/apps/cloud/src/team/handlers.ts new file mode 100644 index 0000000000..1ab884aea9 --- /dev/null +++ b/apps/cloud/src/team/handlers.ts @@ -0,0 +1,97 @@ +import { HttpApiBuilder } from "@effect/platform"; +import { Effect } from "effect"; + +import { AuthContext } from "../auth/middleware"; +import { WorkOSAuth } from "../auth/workos"; +import { TeamOrgApi } from "./compose"; +import { Forbidden } from "./api"; + +const requireAdmin = Effect.gen(function* () { + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + const memberships = yield* workos.listOrgMembers(auth.organizationId); + const currentMembership = memberships.data.find((m) => m.userId === auth.accountId); + if (!currentMembership || currentMembership.role?.slug !== "admin") { + return yield* new Forbidden(); + } +}); + +export const TeamHandlers = HttpApiBuilder.group(TeamOrgApi, "team", (handlers) => + handlers + .handle("listMembers", () => + Effect.gen(function* () { + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + + const memberships = yield* workos.listOrgMembers(auth.organizationId); + + const members = yield* Effect.all( + memberships.data.map((m) => + Effect.gen(function* () { + const user = yield* workos.getUser(m.userId); + return { + id: m.id, + userId: m.userId, + email: user.email, + name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, + avatarUrl: user.profilePictureUrl ?? null, + role: m.role?.slug ?? "member", + status: m.status, + lastActiveAt: user.lastSignInAt ?? null, + isCurrentUser: m.userId === auth.accountId, + }; + }), + ), + { concurrency: 5 }, + ); + + return { members }; + }), + ) + .handle("listRoles", () => + Effect.gen(function* () { + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + + const result = yield* workos.listOrgRoles(auth.organizationId); + + return { + roles: result.data.map((r) => ({ + slug: r.slug, + name: r.name, + })), + }; + }), + ) + .handle("invite", ({ payload }) => + Effect.gen(function* () { + yield* requireAdmin; + const auth = yield* AuthContext; + const workos = yield* WorkOSAuth; + + const invitation = yield* workos.sendInvitation({ + email: payload.email, + organizationId: auth.organizationId, + roleSlug: payload.roleSlug, + }); + + return { id: invitation.id, email: invitation.email }; + }), + ) + .handle("removeMember", ({ path }) => + Effect.gen(function* () { + yield* requireAdmin; + const workos = yield* WorkOSAuth; + yield* workos.deleteOrgMembership(path.membershipId); + return { success: true }; + }), + ) + .handle("updateMemberRole", ({ path, payload }) => + Effect.gen(function* () { + yield* requireAdmin; + const workos = yield* WorkOSAuth; + yield* workos.updateOrgMembershipRole(path.membershipId, payload.roleSlug); + return { success: true }; + }), + ), +); diff --git a/apps/cloud/src/web/client.tsx b/apps/cloud/src/web/client.tsx index 2a3836d8b6..bae58893e1 100644 --- a/apps/cloud/src/web/client.tsx +++ b/apps/cloud/src/web/client.tsx @@ -3,12 +3,13 @@ import { FetchHttpClient } from "@effect/platform"; import { addGroup } from "@executor/api"; import { getBaseUrl } from "@executor/react/api/base-url"; import { CloudAuthApi } from "../auth/api"; +import { TeamApi } from "../team/api"; // --------------------------------------------------------------------------- -// Cloud API client — core API + cloud auth +// Cloud API client — core API + cloud auth + team // --------------------------------------------------------------------------- -const CloudApi = addGroup(CloudAuthApi); +const CloudApi = addGroup(CloudAuthApi).add(TeamApi); class CloudApiClient extends AtomHttpApi.Tag()("CloudApiClient", { api: CloudApi, diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 4586fae377..bab66c82ba 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -136,6 +136,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show const isHome = props.pathname === "/"; const isSecrets = props.pathname === "/secrets"; const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/"); + const isTeam = props.pathname === "/team"; return ( <> @@ -150,6 +151,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show