History

feat(cloud): add team management page (#157)

* 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. * style: format * chore: remove unused widget token endpoint and getWidgetToken * fix: remove any types from team handler tests * style: format * fix: add @types/node to storage-postgres for tsgo compat * fix: resolve merge conflict in test file * style: format * fix: resolve tsgo typecheck errors in billing and test files * fix: add explicit Plan types for tsgo compatibility * style: format * fix: cast empty array for tsgo type inference

Rhys Sullivan committed 7 months ago

Commit ec8d8861 parent 261c7b219 files · +1079 −16Browse files

Showing the changes to apps (15 of 19 files). Show all

Unified · Split

15 files
  1. apps/cloud/package.json +2 −1
  2. apps/cloud/src/api.ts +27 −0
  3. apps/cloud/src/auth/workos.ts +39 −1
  4. apps/cloud/src/routeTree.gen.ts +21 −0
  5. apps/cloud/src/routes/__root.tsx +2 −0
  6. apps/cloud/src/routes/billing.tsx +5 −3
  7. apps/cloud/src/routes/billing_.plans.tsx +5 −1
  8. apps/cloud/src/routes/team.tsx +446 −0
  9. apps/cloud/src/team/api.ts +92 −0
  10. apps/cloud/src/team/compose.ts +6 −0
  11. apps/cloud/src/team/handlers.test.ts +309 −0
  12. apps/cloud/src/team/handlers.ts +97 −0
  13. apps/cloud/src/web/client.tsx +3 −2
  14. apps/cloud/src/web/shell.tsx +2 −0
  15. apps/cloud/src/web/team-atoms.ts +15 −0
apps/cloud/package.json+2−1
@@ -46,7 +46,8 @@ · 45 unmodified lines
4646 "jose": "^5.6.3",
4747 "postgres": "^3.4.9",
4848 "react": "catalog:",
49− "react-dom": "catalog:"
49+ "react-dom": "catalog:",
50+ "sonner": "^2.0.7"
5051 },
5152 "devDependencies": {
5253 "@cloudflare/vitest-pool-workers": "^0.14.3",
apps/cloud/src/api.ts+27−0
@@ -46,6 +46,8 @@ import { · 45 unmodified lines
4646 import { WorkOSAuth } from "./auth/workos";
4747 import { DbService } from "./services/db";
4848 import { createOrgExecutor } from "./services/executor";
49+import { TeamOrgApi } from "./team/compose";
50+import { TeamHandlers } from "./team/handlers";
4951 import { server } from "./env";
5052
5153 // ---------------------------------------------------------------------------
@@ -90,6 +92,11 @@ const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( · 38 unmodified lines
9092 Layer.provideMerge(SessionAuthLive),
9193 );
9294
95+const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe(
96+ Layer.provide(TeamHandlers),
97+ Layer.provideMerge(OrgAuthLive),
98+);
99+
93100 // ---------------------------------------------------------------------------
94101 // Public auth web handler
95102 // ---------------------------------------------------------------------------
@@ -113,6 +120,12 @@ const createNonProtectedHandler = () => · 17 unmodified lines
113120 { middleware: HttpMiddleware.logger },
114121 );
115122
123+const createTeamHandler = () =>
124+ HttpApiBuilder.toWebHandler(
125+ TeamApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)),
126+ { middleware: HttpMiddleware.logger },
127+ );
128+
116129 // ---------------------------------------------------------------------------
117130 // Protected handler — must be built per-request because the executor varies
118131 // ---------------------------------------------------------------------------
@@ -158,6 +171,7 @@ const buildProtectedHandler = ( · 39 unmodified lines
158171
159172 const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/");
160173 const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/");
174+const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/");
161175 const isExecutionPath = (pathname: string): boolean =>
162176 pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname);
163177
@@ -241,9 +255,22 @@ const handleAutumnRequest = async (request: Request): Promise<Response> => { · 77 unmodified lines
241255 );
242256 };
243257
258+// ---------------------------------------------------------------------------
259+// Widget token endpoint — returns a WorkOS widget token for the session user
260+// ---------------------------------------------------------------------------
261+
244262 export const handleApiRequest = async (request: Request): Promise<Response> => {
245263 const pathname = new URL(request.url).pathname;
246264
265+ if (isTeamPath(pathname)) {
266+ const handler = createTeamHandler();
267+ try {
268+ return await handler.handler(request);
269+ } finally {
270+ await handler.dispose();
271+ }
272+ }
273+
247274 if (isAutumnPath(pathname)) {
248275 return handleAutumnRequest(request);
249276 }
apps/cloud/src/auth/workos.ts+39−1
@@ -138,10 +138,48 @@ const make = Effect.gen(function* () { · 137 unmodified lines
138138 if (!sessionData) return null;
139139 return yield* authenticateSealedSession(sessionData);
140140 }),
141+
142+ /** List organization memberships with user details. */
143+ listOrgMembers: (organizationId: string) =>
144+ use((wos) =>
145+ wos.userManagement.listOrganizationMemberships({
146+ organizationId,
147+ statuses: ["active", "pending"],
148+ }),
149+ ),
150+
151+ /** Get a user by ID. */
152+ getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)),
153+
154+ /** Send an organization invitation. */
155+ sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) =>
156+ use((wos) =>
157+ wos.userManagement.sendInvitation({
158+ email: params.email,
159+ organizationId: params.organizationId,
160+ roleSlug: params.roleSlug,
161+ }),
162+ ),
163+
164+ /** Remove an organization membership. */
165+ deleteOrgMembership: (membershipId: string) =>
166+ use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)),
167+
168+ /** Get the role for a membership. */
169+ getOrgMembership: (membershipId: string) =>
170+ use((wos) => wos.userManagement.getOrganizationMembership(membershipId)),
171+
172+ /** Update a membership's role. */
173+ updateOrgMembershipRole: (membershipId: string, roleSlug: string) =>
174+ use((wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug })),
175+
176+ /** List available roles for an organization. */
177+ listOrgRoles: (organizationId: string) =>
178+ use((wos) => wos.organizations.listOrganizationRoles({ organizationId })),
141179 };
142180 });
143181
144−type WorkOSAuthService = Effect.Effect.Success<typeof make>;
182+export type WorkOSAuthService = Effect.Effect.Success<typeof make>;
145183
146184 export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")<
147185 WorkOSAuth,
apps/cloud/src/routeTree.gen.ts+21−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
1010
1111 import { Route as rootRouteImport } from "./routes/__root";
1212 import { Route as ToolsRouteImport } from "./routes/tools";
13+import { Route as TeamRouteImport } from "./routes/team";
1314 import { Route as SecretsRouteImport } from "./routes/secrets";
1415 import { Route as BillingRouteImport } from "./routes/billing";
1516 import { Route as IndexRouteImport } from "./routes/index";
@@ -22,6 +23,11 @@ const ToolsRoute = ToolsRouteImport.update({ · 6 unmodified lines
2223 path: "/tools",
2324 getParentRoute: () => rootRouteImport,
2425 } as any);
26+const TeamRoute = TeamRouteImport.update({
27+ id: "/team",
28+ path: "/team",
29+ getParentRoute: () => rootRouteImport,
30+} as any);
2531 const SecretsRoute = SecretsRouteImport.update({
2632 id: "/secrets",
2733 path: "/secrets",
@@ -57,6 +63,7 @@ export interface FileRoutesByFullPath { · 29 unmodified lines
5763 "/": typeof IndexRoute;
5864 "/billing": typeof BillingRoute;
5965 "/secrets": typeof SecretsRoute;
66+ "/team": typeof TeamRoute;
6067 "/tools": typeof ToolsRoute;
6168 "/billing/plans": typeof BillingPlansRoute;
6269 "/sources/$namespace": typeof SourcesNamespaceRoute;
@@ -66,6 +73,7 @@ export interface FileRoutesByTo { · 3 unmodified lines
6673 "/": typeof IndexRoute;
6774 "/billing": typeof BillingRoute;
6875 "/secrets": typeof SecretsRoute;
76+ "/team": typeof TeamRoute;
6977 "/tools": typeof ToolsRoute;
7078 "/billing/plans": typeof BillingPlansRoute;
7179 "/sources/$namespace": typeof SourcesNamespaceRoute;
@@ -76,6 +84,7 @@ export interface FileRoutesById { · 4 unmodified lines
7684 "/": typeof IndexRoute;
7785 "/billing": typeof BillingRoute;
7886 "/secrets": typeof SecretsRoute;
87+ "/team": typeof TeamRoute;
7988 "/tools": typeof ToolsRoute;
8089 "/billing_/plans": typeof BillingPlansRoute;
8190 "/sources/$namespace": typeof SourcesNamespaceRoute;
@@ -87,6 +96,7 @@ export interface FileRouteTypes { · 5 unmodified lines
8796 | "/"
8897 | "/billing"
8998 | "/secrets"
99+ | "/team"
90100 | "/tools"
91101 | "/billing/plans"
92102 | "/sources/$namespace"
@@ -96,6 +106,7 @@ export interface FileRouteTypes { · 3 unmodified lines
96106 | "/"
97107 | "/billing"
98108 | "/secrets"
109+ | "/team"
99110 | "/tools"
100111 | "/billing/plans"
101112 | "/sources/$namespace"
@@ -105,6 +116,7 @@ export interface FileRouteTypes { · 3 unmodified lines
105116 | "/"
106117 | "/billing"
107118 | "/secrets"
119+ | "/team"
108120 | "/tools"
109121 | "/billing_/plans"
110122 | "/sources/$namespace"
@@ -115,6 +127,7 @@ export interface RootRouteChildren { · 4 unmodified lines
115127 IndexRoute: typeof IndexRoute;
116128 BillingRoute: typeof BillingRoute;
117129 SecretsRoute: typeof SecretsRoute;
130+ TeamRoute: typeof TeamRoute;
118131 ToolsRoute: typeof ToolsRoute;
119132 BillingPlansRoute: typeof BillingPlansRoute;
120133 SourcesNamespaceRoute: typeof SourcesNamespaceRoute;
@@ -130,6 +143,13 @@ declare module "@tanstack/react-router" { · 9 unmodified lines
130143 preLoaderRoute: typeof ToolsRouteImport;
131144 parentRoute: typeof rootRouteImport;
132145 };
146+ "/team": {
147+ id: "/team";
148+ path: "/team";
149+ fullPath: "/team";
150+ preLoaderRoute: typeof TeamRouteImport;
151+ parentRoute: typeof rootRouteImport;
152+ };
133153 "/secrets": {
134154 id: "/secrets";
135155 path: "/secrets";
@@ -179,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = { · 43 unmodified lines
179199 IndexRoute: IndexRoute,
180200 BillingRoute: BillingRoute,
181201 SecretsRoute: SecretsRoute,
202+ TeamRoute: TeamRoute,
182203 ToolsRoute: ToolsRoute,
183204 BillingPlansRoute: BillingPlansRoute,
184205 SourcesNamespaceRoute: SourcesNamespaceRoute,
apps/cloud/src/routes/__root.tsx+2−0
@@ -2,6 +2,7 @@ import React from "react"; · 1 unmodified line
22 import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
33 import { AutumnProvider } from "autumn-js/react";
44 import { ExecutorProvider } from "@executor/react/api/provider";
5+import { Toaster } from "@executor/react/components/sonner";
56 import { AuthProvider, useAuth } from "../web/auth";
67 import { LoginPage } from "../web/pages/login";
78 import { Shell } from "../web/shell";
@@ -70,6 +71,7 @@ function AuthGate() { · 62 unmodified lines
7071 <AutumnProvider pathPrefix="/api/autumn">
7172 <ExecutorProvider>
7273 <Shell />
74+ <Toaster />
7375 </ExecutorProvider>
7476 </AutumnProvider>
7577 );
apps/cloud/src/routes/billing.tsx+5−3
@@ -2,6 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 1 unmodified line
22 import { useCustomer, useListPlans } from "autumn-js/react";
33 import { Button } from "@executor/react/components/button";
44
5+type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
6+
57 export const Route = createFileRoute("/billing")({
68 component: BillingPage,
79 });
@@ -29,11 +31,11 @@ function BillingPage() { · 21 unmodified lines
2931 );
3032 }
3133
32− // Find current plan via customerEligibility from useListPlans
33− const activePlan = (plans ?? []).find(
34+ const allPlans: Plan[] = plans ?? [];
35+ const activePlan = allPlans.find(
3436 (p) => p.customerEligibility?.status === "active" && p.id !== "free",
3537 );
36− const scheduledPlan = (plans ?? []).find(
38+ const scheduledPlan = allPlans.find(
3739 (p) => p.customerEligibility?.status === "scheduled" && p.id !== "free",
3840 );
3941 const isCanceling = activePlan?.customerEligibility?.canceling ?? false;
apps/cloud/src/routes/billing_.plans.tsx+5−1
@@ -3,6 +3,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 2 unmodified lines
33 import { useCustomer, useListPlans } from "autumn-js/react";
44 import { Button } from "@executor/react/components/button";
55
6+type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
7+
68 export const Route = createFileRoute("/billing_/plans")({
79 component: PlansPage,
810 });
@@ -45,7 +47,9 @@ function PlansPage() { · 36 unmodified lines
4547
4648 const isLoading = customerLoading || plansLoading;
4749
48− const paidPlans = (plans ?? []).filter((p) => p.id === "hobby" || p.id === "professional");
50+ const paidPlans = (plans ?? ([] as Plan[])).filter(
51+ (p) => p.id === "hobby" || p.id === "professional",
52+ );
4953
5054 return (
5155 <div className="min-h-0 flex-1 overflow-y-auto">
apps/cloud/src/routes/team.tsx+446−0
@@ -0,0 +1,446 @@
1+import { useReducer, useState } from "react";
2+import { Exit } from "effect";
3+import { createFileRoute } from "@tanstack/react-router";
4+import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
5+import { toast } from "sonner";
6+import {
7+ Dialog,
8+ DialogContent,
9+ DialogHeader,
10+ DialogTitle,
11+ DialogDescription,
12+ DialogFooter,
13+ DialogClose,
14+} from "@executor/react/components/dialog";
15+import { Button } from "@executor/react/components/button";
16+import { Input } from "@executor/react/components/input";
17+import { Label } from "@executor/react/components/label";
18+import {
19+ Select,
20+ SelectContent,
21+ SelectItem,
22+ SelectTrigger,
23+ SelectValue,
24+} from "@executor/react/components/select";
25+import {
26+ DropdownMenu,
27+ DropdownMenuContent,
28+ DropdownMenuItem,
29+ DropdownMenuSub,
30+ DropdownMenuSubContent,
31+ DropdownMenuSubTrigger,
32+ DropdownMenuTrigger,
33+ DropdownMenuSeparator,
34+} from "@executor/react/components/dropdown-menu";
35+import {
36+ teamMembersAtom,
37+ teamRolesAtom,
38+ inviteMember,
39+ removeMember,
40+ updateMemberRole,
41+} from "../web/team-atoms";
42+
43+export const Route = createFileRoute("/team")({
44+ component: TeamPage,
45+});
46+
47+type InviteState = {
48+ email: string;
49+ roleSlug: string;
50+ status: "idle" | "sending" | "error";
51+ error: string | null;
52+};
53+
54+const initialInviteState: InviteState = {
55+ email: "",
56+ roleSlug: "",
57+ status: "idle",
58+ error: null,
59+};
60+
61+type InviteAction =
62+ | { type: "setEmail"; email: string }
63+ | { type: "setRole"; roleSlug: string }
64+ | { type: "send" }
65+ | { type: "error"; message: string }
66+ | { type: "reset" };
67+
68+function inviteReducer(state: InviteState, action: InviteAction): InviteState {
69+ switch (action.type) {
70+ case "setEmail":
71+ return { ...state, email: action.email };
72+ case "setRole":
73+ return { ...state, roleSlug: action.roleSlug };
74+ case "send":
75+ return { ...state, status: "sending", error: null };
76+ case "error":
77+ return { ...state, status: "error", error: action.message };
78+ case "reset":
79+ return initialInviteState;
80+ }
81+}
82+
83+function formatLastActive(lastActiveAt: string | null): string {
84+ if (!lastActiveAt) return "\u2014";
85+ const date = new Date(lastActiveAt);
86+ const diffMs = Date.now() - date.getTime();
87+ const diffMins = Math.floor(diffMs / 60000);
88+ if (diffMins < 1) return "Just now";
89+ if (diffMins < 60) return `${diffMins}m ago`;
90+ const diffHours = Math.floor(diffMins / 60);
91+ if (diffHours < 24) return `${diffHours}h ago`;
92+ const diffDays = Math.floor(diffHours / 24);
93+ if (diffDays < 30) return `${diffDays}d ago`;
94+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
95+}
96+
97+function TeamPage() {
98+ const membersResult = useAtomValue(teamMembersAtom);
99+ const rolesResult = useAtomValue(teamRolesAtom);
100+ const refreshMembers = useAtomRefresh(teamMembersAtom);
101+ const doRemove = useAtomSet(removeMember, { mode: "promiseExit" });
102+ const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" });
103+ const [inviteOpen, setInviteOpen] = useState(false);
104+ const [search, setSearch] = useState("");
105+
106+ const roles = Result.match(rolesResult, {
107+ onInitial: () => [] as readonly { slug: string; name: string }[],
108+ onFailure: () => [] as readonly { slug: string; name: string }[],
109+ onSuccess: ({ value }) => value.roles,
110+ });
111+
112+ const handleRemove = async (membershipId: string, name: string) => {
113+ const exit = await doRemove({ path: { membershipId } });
114+ if (Exit.isSuccess(exit)) {
115+ toast.success(`Removed ${name}`);
116+ refreshMembers();
117+ } else {
118+ toast.error("Failed to remove member");
119+ }
120+ };
121+
122+ const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => {
123+ const exit = await doUpdateRole({ path: { membershipId }, payload: { roleSlug } });
124+ if (Exit.isSuccess(exit)) {
125+ toast.success(`Role changed to ${roleName}`);
126+ refreshMembers();
127+ } else {
128+ toast.error("Failed to change role");
129+ }
130+ };
131+
132+ return (
133+ <div className="min-h-0 flex-1 overflow-y-auto">
134+ <div className="mx-auto max-w-3xl px-6 py-10 lg:px-8 lg:py-14">
135+ <div className="flex items-end justify-between mb-10">
136+ <h1 className="font-display text-[2rem] tracking-tight text-foreground leading-none">
137+ Team
138+ </h1>
139+ <Button size="sm" onClick={() => setInviteOpen(true)}>
140+ Invite member
141+ </Button>
142+ </div>
143+
144+ {/* Search */}
145+ <div className="mb-4">
146+ <Input
147+ type="text"
148+ placeholder="Search by name or email..."
149+ value={search}
150+ onChange={(e) => setSearch((e.target as HTMLInputElement).value)}
151+ className="text-[0.8125rem] h-9"
152+ />
153+ </div>
154+
155+ {/* Members */}
156+ {Result.match(membersResult, {
157+ onInitial: () => (
158+ <div className="space-y-2">
159+ {[1, 2, 3].map((i) => (
160+ <div key={i} className="h-14 animate-pulse rounded-lg bg-muted" />
161+ ))}
162+ </div>
163+ ),
164+ onFailure: () => (
165+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
166+ <p className="text-[0.8125rem] text-destructive">Failed to load team members</p>
167+ </div>
168+ ),
169+ onSuccess: ({ value }) => {
170+ const members = value.members;
171+ const filtered = search
172+ ? members.filter(
173+ (m) =>
174+ m.email.toLowerCase().includes(search.toLowerCase()) ||
175+ (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false),
176+ )
177+ : members;
178+
179+ if (filtered.length === 0) {
180+ return (
181+ <p className="py-8 text-center text-[0.8125rem] text-muted-foreground/60">
182+ {search ? "No matching members" : "No team members yet"}
183+ </p>
184+ );
185+ }
186+
187+ return (
188+ <div className="space-y-px">
189+ {filtered.map((member) => (
190+ <div
191+ key={member.id}
192+ className="group relative grid grid-cols-[2rem_1fr_6rem_5rem_2rem] items-center gap-3 rounded-lg border border-transparent px-4 py-3 transition-all hover:bg-muted/30"
193+ >
194+ {/* Avatar */}
195+ {member.avatarUrl ? (
196+ <img src={member.avatarUrl} alt="" className="size-8 rounded-full" />
197+ ) : (
198+ <div className="flex size-8 items-center justify-center rounded-full bg-muted text-[0.625rem] font-semibold text-muted-foreground">
199+ {member.name
200+ ? member.name
201+ .split(" ")
202+ .map((n) => n[0])
203+ .join("")
204+ .slice(0, 2)
205+ .toUpperCase()
206+ : member.email[0]!.toUpperCase()}
207+ </div>
208+ )}
209+
210+ {/* Name + email */}
211+ <div className="min-w-0">
212+ <div className="flex items-center gap-2">
213+ <p className="truncate text-[0.8125rem] font-medium text-foreground leading-none">
214+ {member.name ?? member.email}
215+ </p>
216+ {member.isCurrentUser && (
217+ <span className="rounded bg-muted px-1.5 py-0.5 text-[0.625rem] font-medium text-muted-foreground leading-none">
218+ You
219+ </span>
220+ )}
221+ {member.status === "pending" && (
222+ <span className="rounded bg-amber-500/10 px-1.5 py-0.5 text-[0.625rem] font-medium text-amber-600 dark:text-amber-400 leading-none">
223+ Invited
224+ </span>
225+ )}
226+ </div>
227+ {member.name && (
228+ <p className="mt-0.5 truncate text-[0.75rem] text-muted-foreground/70 leading-none">
229+ {member.email}
230+ </p>
231+ )}
232+ </div>
233+
234+ {/* Role */}
235+ <p className="text-[0.8125rem] text-muted-foreground capitalize leading-none">
236+ {member.role}
237+ </p>
238+
239+ {/* Last active */}
240+ <p className="text-[0.75rem] text-muted-foreground/60 leading-none">
241+ {formatLastActive(member.lastActiveAt)}
242+ </p>
243+
244+ {/* Actions */}
245+ {!member.isCurrentUser ? (
246+ <DropdownMenu>
247+ <DropdownMenuTrigger asChild>
248+ <Button
249+ variant="ghost"
250+ size="icon"
251+ className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
252+ >
253+ <svg viewBox="0 0 16 16" className="size-3">
254+ <circle cx="8" cy="3" r="1.2" fill="currentColor" />
255+ <circle cx="8" cy="8" r="1.2" fill="currentColor" />
256+ <circle cx="8" cy="13" r="1.2" fill="currentColor" />
257+ </svg>
258+ </Button>
259+ </DropdownMenuTrigger>
260+ <DropdownMenuContent align="end" className="w-44">
261+ {roles.length > 0 && (
262+ <>
263+ <DropdownMenuSub>
264+ <DropdownMenuSubTrigger className="text-[0.75rem]">
265+ Change role
266+ </DropdownMenuSubTrigger>
267+ <DropdownMenuSubContent>
268+ {roles.map((role) => (
269+ <DropdownMenuItem
270+ key={role.slug}
271+ className="text-[0.75rem]"
272+ disabled={role.slug === member.role}
273+ onClick={() =>
274+ handleChangeRole(member.id, role.slug, role.name)
275+ }
276+ >
277+ {role.name}
278+ {role.slug === member.role && (
279+ <span className="ml-auto text-muted-foreground/50">
280+ <svg viewBox="0 0 16 16" fill="none" className="size-3">
281+ <path
282+ d="M3.5 8.5L6.5 11.5L12.5 5"
283+ stroke="currentColor"
284+ strokeWidth="1.5"
285+ strokeLinecap="round"
286+ strokeLinejoin="round"
287+ />
288+ </svg>
289+ </span>
290+ )}
291+ </DropdownMenuItem>
292+ ))}
293+ </DropdownMenuSubContent>
294+ </DropdownMenuSub>
295+ <DropdownMenuSeparator />
296+ </>
297+ )}
298+ <DropdownMenuItem
299+ className="text-destructive focus:text-destructive text-[0.75rem]"
300+ onClick={() => handleRemove(member.id, member.name ?? member.email)}
301+ >
302+ Remove member
303+ </DropdownMenuItem>
304+ </DropdownMenuContent>
305+ </DropdownMenu>
306+ ) : (
307+ <div />
308+ )}
309+ </div>
310+ ))}
311+ </div>
312+ );
313+ },
314+ })}
315+
316+ <InviteDialog
317+ open={inviteOpen}
318+ onOpenChange={setInviteOpen}
319+ onInvited={refreshMembers}
320+ roles={roles}
321+ />
322+ </div>
323+ </div>
324+ );
325+}
326+
327+function InviteDialog(props: {
328+ open: boolean;
329+ onOpenChange: (v: boolean) => void;
330+ onInvited: () => void;
331+ roles: readonly { slug: string; name: string }[];
332+}) {
333+ const [state, dispatch] = useReducer(inviteReducer, initialInviteState);
334+ const doInvite = useAtomSet(inviteMember, { mode: "promiseExit" });
335+
336+ const handleInvite = async () => {
337+ if (!state.email.trim()) return;
338+ dispatch({ type: "send" });
339+
340+ const exit = await doInvite({
341+ payload: {
342+ email: state.email.trim(),
343+ ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}),
344+ },
345+ });
346+
347+ if (Exit.isSuccess(exit)) {
348+ toast.success(`Invitation sent to ${state.email.trim()}`);
349+ dispatch({ type: "reset" });
350+ props.onOpenChange(false);
351+ props.onInvited();
352+ } else {
353+ dispatch({ type: "error", message: "Failed to send invitation" });
354+ }
355+ };
356+
357+ return (
358+ <Dialog
359+ open={props.open}
360+ onOpenChange={(v) => {
361+ if (!v) dispatch({ type: "reset" });
362+ props.onOpenChange(v);
363+ }}
364+ >
365+ <DialogContent className="sm:max-w-[400px]">
366+ <DialogHeader>
367+ <DialogTitle className="font-display text-xl">Invite member</DialogTitle>
368+ <DialogDescription className="text-[0.8125rem] leading-relaxed">
369+ Send an email invitation to join your team.
370+ </DialogDescription>
371+ </DialogHeader>
372+
373+ <div className="grid gap-4 py-3">
374+ <div className="grid gap-1.5">
375+ <Label
376+ htmlFor="invite-email"
377+ className="text-[0.6875rem] font-medium uppercase tracking-wider text-muted-foreground"
378+ >
379+ Email
380+ </Label>
381+ <Input
382+ id="invite-email"
383+ type="email"
384+ placeholder="colleague@company.com"
385+ value={state.email}
386+ onChange={(e) =>
387+ dispatch({ type: "setEmail", email: (e.target as HTMLInputElement).value })
388+ }
389+ onKeyDown={(e) => {
390+ if (e.key === "Enter") handleInvite();
391+ }}
392+ className="text-[0.8125rem] h-9"
393+ />
394+ </div>
395+
396+ {props.roles.length > 0 && (
397+ <div className="grid gap-1.5">
398+ <Label
399+ htmlFor="invite-role"
400+ className="text-[0.6875rem] font-medium uppercase tracking-wider text-muted-foreground"
401+ >
402+ Role
403+ </Label>
404+ <Select
405+ value={state.roleSlug}
406+ onValueChange={(v) => dispatch({ type: "setRole", roleSlug: v })}
407+ >
408+ <SelectTrigger id="invite-role" className="h-9 text-[0.8125rem]">
409+ <SelectValue placeholder="Select a role" />
410+ </SelectTrigger>
411+ <SelectContent>
412+ {props.roles.map((role) => (
413+ <SelectItem key={role.slug} value={role.slug}>
414+ {role.name}
415+ </SelectItem>
416+ ))}
417+ </SelectContent>
418+ </Select>
419+ </div>
420+ )}
421+
422+ {state.status === "error" && state.error && (
423+ <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2">
424+ <p className="text-[0.75rem] text-destructive">{state.error}</p>
425+ </div>
426+ )}
427+ </div>
428+
429+ <DialogFooter>
430+ <DialogClose asChild>
431+ <Button variant="ghost" size="sm">
432+ Cancel
433+ </Button>
434+ </DialogClose>
435+ <Button
436+ size="sm"
437+ onClick={handleInvite}
438+ disabled={!state.email.trim() || state.status === "sending"}
439+ >
440+ {state.status === "sending" ? "Sending…" : "Send invite"}
441+ </Button>
442+ </DialogFooter>
443+ </DialogContent>
444+ </Dialog>
445+ );
446+}
apps/cloud/src/team/api.ts+92−0
@@ -0,0 +1,92 @@
1+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import { Schema } from "effect";
3+import { WorkOSError } from "../auth/errors";
4+
5+export class Forbidden extends Schema.TaggedError<Forbidden>()(
6+ "Forbidden",
7+ {},
8+ HttpApiSchema.annotations({ status: 403 }),
9+) {}
10+
11+const TeamMember = Schema.Struct({
12+ id: Schema.String,
13+ userId: Schema.String,
14+ email: Schema.String,
15+ name: Schema.NullOr(Schema.String),
16+ avatarUrl: Schema.NullOr(Schema.String),
17+ role: Schema.String,
18+ status: Schema.String,
19+ lastActiveAt: Schema.NullOr(Schema.String),
20+ isCurrentUser: Schema.Boolean,
21+});
22+
23+const TeamMembersResponse = Schema.Struct({
24+ members: Schema.Array(TeamMember),
25+});
26+
27+const TeamRole = Schema.Struct({
28+ slug: Schema.String,
29+ name: Schema.String,
30+});
31+
32+const TeamRolesResponse = Schema.Struct({
33+ roles: Schema.Array(TeamRole),
34+});
35+
36+const InviteBody = Schema.Struct({
37+ email: Schema.String,
38+ roleSlug: Schema.optional(Schema.String),
39+});
40+
41+const InviteResponse = Schema.Struct({
42+ id: Schema.String,
43+ email: Schema.String,
44+});
45+
46+const membershipIdParam = HttpApiSchema.param("membershipId", Schema.String);
47+
48+const RemoveResponse = Schema.Struct({
49+ success: Schema.Boolean,
50+});
51+
52+const UpdateRoleBody = Schema.Struct({
53+ roleSlug: Schema.String,
54+});
55+
56+const UpdateRoleResponse = Schema.Struct({
57+ success: Schema.Boolean,
58+});
59+
60+export { TeamMember, TeamMembersResponse };
61+
62+export class TeamApi extends HttpApiGroup.make("team")
63+ .add(
64+ HttpApiEndpoint.get("listMembers")`/team/members`
65+ .addSuccess(TeamMembersResponse)
66+ .addError(WorkOSError),
67+ )
68+ .add(
69+ HttpApiEndpoint.get("listRoles")`/team/roles`
70+ .addSuccess(TeamRolesResponse)
71+ .addError(WorkOSError),
72+ )
73+ .add(
74+ HttpApiEndpoint.post("invite")`/team/invite`
75+ .setPayload(InviteBody)
76+ .addSuccess(InviteResponse)
77+ .addError(WorkOSError)
78+ .addError(Forbidden),
79+ )
80+ .add(
81+ HttpApiEndpoint.del("removeMember")`/team/members/${membershipIdParam}`
82+ .addSuccess(RemoveResponse)
83+ .addError(WorkOSError)
84+ .addError(Forbidden),
85+ )
86+ .add(
87+ HttpApiEndpoint.patch("updateMemberRole")`/team/members/${membershipIdParam}/role`
88+ .setPayload(UpdateRoleBody)
89+ .addSuccess(UpdateRoleResponse)
90+ .addError(WorkOSError)
91+ .addError(Forbidden),
92+ ) {}
apps/cloud/src/team/compose.ts+6−0
@@ -0,0 +1,6 @@
1+import { HttpApi } from "@effect/platform";
2+import { OrgAuth } from "../auth/middleware";
3+import { TeamApi } from "./api";
4+
5+/** Team API with org-level auth — requires authenticated session with an org. */
6+export const TeamOrgApi = HttpApi.make("teamOrg").add(TeamApi).middleware(OrgAuth);
apps/cloud/src/team/handlers.test.ts+309−0
@@ -0,0 +1,309 @@
1+import { describe, it, expect } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+
4+import { AuthContext } from "../auth/middleware";
5+import { WorkOSAuth } from "../auth/workos";
6+import { Forbidden } from "./api";
7+
8+// ---------------------------------------------------------------------------
9+// Stub factory — only implement what each test calls
10+// ---------------------------------------------------------------------------
11+
12+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types
13+type StubFn = (...args: never[]) => Effect.Effect<any>;
14+
15+type StubOverrides = {
16+ listOrgMembers?: StubFn;
17+ getUser?: StubFn;
18+ sendInvitation?: StubFn;
19+ deleteOrgMembership?: StubFn;
20+ updateOrgMembershipRole?: StubFn;
21+ listOrgRoles?: StubFn;
22+};
23+
24+const stubWorkOS = (overrides: StubOverrides = {}) =>
25+ Layer.succeed(
26+ WorkOSAuth,
27+ new Proxy({} as WorkOSAuth["Type"], {
28+ get: (_target, prop) => {
29+ if (prop in overrides) return (overrides as Record<string, unknown>)[prop as string];
30+ return () => {
31+ throw new Error(`WorkOSAuth.${String(prop)} not stubbed`);
32+ };
33+ },
34+ }),
35+ );
36+
37+// ---------------------------------------------------------------------------
38+// Fixtures
39+// ---------------------------------------------------------------------------
40+
41+const adminAuth = {
42+ accountId: "user_admin",
43+ organizationId: "org_1",
44+ email: "admin@test.com",
45+ name: "Admin",
46+ avatarUrl: null,
47+};
48+
49+const memberAuth = {
50+ accountId: "user_member",
51+ organizationId: "org_1",
52+ email: "member@test.com",
53+ name: "Member",
54+ avatarUrl: null,
55+};
56+
57+type FakeMembership = {
58+ id: string;
59+ userId: string;
60+ status: string;
61+ role: { slug: string };
62+};
63+type FakeUser = {
64+ email: string;
65+ firstName: string | null;
66+ lastName: string | null;
67+ profilePictureUrl: string | null;
68+ lastSignInAt: string | null;
69+};
70+type FakeRole = { slug: string; name: string };
71+
72+const fakeMemberships: FakeMembership[] = [
73+ {
74+ id: "mem_admin",
75+ userId: "user_admin",
76+ status: "active",
77+ role: { slug: "admin" },
78+ },
79+ {
80+ id: "mem_member",
81+ userId: "user_member",
82+ status: "active",
83+ role: { slug: "member" },
84+ },
85+];
86+
87+const fakeUsers: Record<string, FakeUser> = {
88+ user_admin: {
89+ email: "admin@test.com",
90+ firstName: "Admin",
91+ lastName: null,
92+ profilePictureUrl: null,
93+ lastSignInAt: "2026-04-09T00:00:00Z",
94+ },
95+ user_member: {
96+ email: "member@test.com",
97+ firstName: "Member",
98+ lastName: null,
99+ profilePictureUrl: null,
100+ lastSignInAt: null,
101+ },
102+};
103+
104+const fakeRoles: FakeRole[] = [
105+ { slug: "admin", name: "Admin" },
106+ { slug: "member", name: "Member" },
107+];
108+
109+// ---------------------------------------------------------------------------
110+// The admin guard — mirrors handlers.ts
111+// ---------------------------------------------------------------------------
112+
113+const requireAdmin = Effect.gen(function* () {
114+ const auth = yield* AuthContext;
115+ const workos = yield* WorkOSAuth;
116+ const memberships = yield* workos.listOrgMembers(auth.organizationId);
117+ const current = memberships.data.find((m: FakeMembership) => m.userId === auth.accountId);
118+ if (!current || current.role?.slug !== "admin") {
119+ return yield* new Forbidden();
120+ }
121+});
122+
123+const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) =>
124+ Layer.mergeAll(Layer.succeed(AuthContext, auth), stubWorkOS(workosOverrides));
125+
126+const withMembers: StubOverrides = {
127+ listOrgMembers: () => Effect.succeed({ data: fakeMemberships }),
128+};
129+
130+// ---------------------------------------------------------------------------
131+// Tests
132+// ---------------------------------------------------------------------------
133+
134+describe("Team handlers", () => {
135+ describe("listMembers", () => {
136+ it.effect("returns members with isCurrentUser set correctly", () =>
137+ Effect.gen(function* () {
138+ const auth = yield* AuthContext;
139+ const workos = yield* WorkOSAuth;
140+ const result = yield* workos.listOrgMembers(auth.organizationId);
141+ const members = yield* Effect.all(
142+ result.data.map((m: FakeMembership) =>
143+ Effect.gen(function* () {
144+ const user = yield* workos.getUser(m.userId);
145+ return {
146+ id: m.id,
147+ email: user.email,
148+ role: m.role?.slug ?? "member",
149+ isCurrentUser: m.userId === auth.accountId,
150+ };
151+ }),
152+ ),
153+ );
154+
155+ expect(members).toHaveLength(2);
156+ expect(members[0]).toMatchObject({
157+ email: "admin@test.com",
158+ isCurrentUser: true,
159+ });
160+ expect(members[1]).toMatchObject({
161+ email: "member@test.com",
162+ isCurrentUser: false,
163+ });
164+ }).pipe(
165+ Effect.provide(
166+ provide(adminAuth, {
167+ ...withMembers,
168+ getUser: (id: string) => Effect.succeed(fakeUsers[id]),
169+ }),
170+ ),
171+ ),
172+ );
173+ });
174+
175+ describe("listRoles", () => {
176+ it.effect("returns available roles", () =>
177+ Effect.gen(function* () {
178+ const auth = yield* AuthContext;
179+ const workos = yield* WorkOSAuth;
180+ const result = yield* workos.listOrgRoles(auth.organizationId);
181+ const roles = result.data.map((r: FakeRole) => ({
182+ slug: r.slug,
183+ name: r.name,
184+ }));
185+
186+ expect(roles).toEqual(fakeRoles);
187+ }).pipe(
188+ Effect.provide(
189+ provide(adminAuth, {
190+ listOrgRoles: () => Effect.succeed({ data: fakeRoles }),
191+ }),
192+ ),
193+ ),
194+ );
195+ });
196+
197+ describe("requireAdmin", () => {
198+ it.effect("passes for admin user", () =>
199+ requireAdmin.pipe(Effect.provide(provide(adminAuth, withMembers))),
200+ );
201+
202+ it.effect("rejects non-admin with Forbidden", () =>
203+ Effect.gen(function* () {
204+ const error = yield* Effect.flip(requireAdmin);
205+ expect(error).toBeInstanceOf(Forbidden);
206+ }).pipe(Effect.provide(provide(memberAuth, withMembers))),
207+ );
208+ });
209+
210+ describe("invite (admin-gated)", () => {
211+ it.effect("admin can invite", () =>
212+ Effect.gen(function* () {
213+ yield* requireAdmin;
214+ const auth = yield* AuthContext;
215+ const workos = yield* WorkOSAuth;
216+ const result = yield* workos.sendInvitation({
217+ email: "new@test.com",
218+ organizationId: auth.organizationId,
219+ });
220+
221+ expect(result.email).toBe("new@test.com");
222+ }).pipe(
223+ Effect.provide(
224+ provide(adminAuth, {
225+ ...withMembers,
226+ sendInvitation: (p: { email: string }) =>
227+ Effect.succeed({ id: "inv_1", email: p.email }),
228+ }),
229+ ),
230+ ),
231+ );
232+
233+ it.effect("member cannot invite", () =>
234+ Effect.gen(function* () {
235+ const error = yield* Effect.flip(
236+ Effect.gen(function* () {
237+ yield* requireAdmin;
238+ const workos = yield* WorkOSAuth;
239+ yield* workos.sendInvitation({
240+ email: "x",
241+ organizationId: "org_1",
242+ });
243+ }),
244+ );
245+ expect(error).toBeInstanceOf(Forbidden);
246+ }).pipe(Effect.provide(provide(memberAuth, withMembers))),
247+ );
248+ });
249+
250+ describe("removeMember (admin-gated)", () => {
251+ it.effect("admin can remove", () =>
252+ Effect.gen(function* () {
253+ yield* requireAdmin;
254+ const workos = yield* WorkOSAuth;
255+ yield* workos.deleteOrgMembership("mem_member");
256+ }).pipe(
257+ Effect.provide(
258+ provide(adminAuth, {
259+ ...withMembers,
260+ deleteOrgMembership: () => Effect.void,
261+ }),
262+ ),
263+ ),
264+ );
265+
266+ it.effect("member cannot remove", () =>
267+ Effect.gen(function* () {
268+ const error = yield* Effect.flip(
269+ Effect.gen(function* () {
270+ yield* requireAdmin;
271+ const workos = yield* WorkOSAuth;
272+ yield* workos.deleteOrgMembership("mem_admin");
273+ }),
274+ );
275+ expect(error).toBeInstanceOf(Forbidden);
276+ }).pipe(Effect.provide(provide(memberAuth, withMembers))),
277+ );
278+ });
279+
280+ describe("updateMemberRole (admin-gated)", () => {
281+ it.effect("admin can change role", () =>
282+ Effect.gen(function* () {
283+ yield* requireAdmin;
284+ const workos = yield* WorkOSAuth;
285+ yield* workos.updateOrgMembershipRole("mem_member", "admin");
286+ }).pipe(
287+ Effect.provide(
288+ provide(adminAuth, {
289+ ...withMembers,
290+ updateOrgMembershipRole: () => Effect.void,
291+ }),
292+ ),
293+ ),
294+ );
295+
296+ it.effect("member cannot change role", () =>
297+ Effect.gen(function* () {
298+ const error = yield* Effect.flip(
299+ Effect.gen(function* () {
300+ yield* requireAdmin;
301+ const workos = yield* WorkOSAuth;
302+ yield* workos.updateOrgMembershipRole("mem_admin", "member");
303+ }),
304+ );
305+ expect(error).toBeInstanceOf(Forbidden);
306+ }).pipe(Effect.provide(provide(memberAuth, withMembers))),
307+ );
308+ });
309+});
apps/cloud/src/team/handlers.ts+97−0
@@ -0,0 +1,97 @@
1+import { HttpApiBuilder } from "@effect/platform";
2+import { Effect } from "effect";
3+
4+import { AuthContext } from "../auth/middleware";
5+import { WorkOSAuth } from "../auth/workos";
6+import { TeamOrgApi } from "./compose";
7+import { Forbidden } from "./api";
8+
9+const requireAdmin = Effect.gen(function* () {
10+ const auth = yield* AuthContext;
11+ const workos = yield* WorkOSAuth;
12+ const memberships = yield* workos.listOrgMembers(auth.organizationId);
13+ const currentMembership = memberships.data.find((m) => m.userId === auth.accountId);
14+ if (!currentMembership || currentMembership.role?.slug !== "admin") {
15+ return yield* new Forbidden();
16+ }
17+});
18+
19+export const TeamHandlers = HttpApiBuilder.group(TeamOrgApi, "team", (handlers) =>
20+ handlers
21+ .handle("listMembers", () =>
22+ Effect.gen(function* () {
23+ const auth = yield* AuthContext;
24+ const workos = yield* WorkOSAuth;
25+
26+ const memberships = yield* workos.listOrgMembers(auth.organizationId);
27+
28+ const members = yield* Effect.all(
29+ memberships.data.map((m) =>
30+ Effect.gen(function* () {
31+ const user = yield* workos.getUser(m.userId);
32+ return {
33+ id: m.id,
34+ userId: m.userId,
35+ email: user.email,
36+ name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null,
37+ avatarUrl: user.profilePictureUrl ?? null,
38+ role: m.role?.slug ?? "member",
39+ status: m.status,
40+ lastActiveAt: user.lastSignInAt ?? null,
41+ isCurrentUser: m.userId === auth.accountId,
42+ };
43+ }),
44+ ),
45+ { concurrency: 5 },
46+ );
47+
48+ return { members };
49+ }),
50+ )
51+ .handle("listRoles", () =>
52+ Effect.gen(function* () {
53+ const auth = yield* AuthContext;
54+ const workos = yield* WorkOSAuth;
55+
56+ const result = yield* workos.listOrgRoles(auth.organizationId);
57+
58+ return {
59+ roles: result.data.map((r) => ({
60+ slug: r.slug,
61+ name: r.name,
62+ })),
63+ };
64+ }),
65+ )
66+ .handle("invite", ({ payload }) =>
67+ Effect.gen(function* () {
68+ yield* requireAdmin;
69+ const auth = yield* AuthContext;
70+ const workos = yield* WorkOSAuth;
71+
72+ const invitation = yield* workos.sendInvitation({
73+ email: payload.email,
74+ organizationId: auth.organizationId,
75+ roleSlug: payload.roleSlug,
76+ });
77+
78+ return { id: invitation.id, email: invitation.email };
79+ }),
80+ )
81+ .handle("removeMember", ({ path }) =>
82+ Effect.gen(function* () {
83+ yield* requireAdmin;
84+ const workos = yield* WorkOSAuth;
85+ yield* workos.deleteOrgMembership(path.membershipId);
86+ return { success: true };
87+ }),
88+ )
89+ .handle("updateMemberRole", ({ path, payload }) =>
90+ Effect.gen(function* () {
91+ yield* requireAdmin;
92+ const workos = yield* WorkOSAuth;
93+ yield* workos.updateOrgMembershipRole(path.membershipId, payload.roleSlug);
94+ return { success: true };
95+ }),
96+ ),
97+);
apps/cloud/src/web/client.tsx+3−2
@@ -3,12 +3,13 @@ import { FetchHttpClient } from "@effect/platform"; · 2 unmodified lines
33 import { addGroup } from "@executor/api";
44 import { getBaseUrl } from "@executor/react/api/base-url";
55 import { CloudAuthApi } from "../auth/api";
6+import { TeamApi } from "../team/api";
67
78 // ---------------------------------------------------------------------------
8−// Cloud API client — core API + cloud auth
9+// Cloud API client — core API + cloud auth + team
910 // ---------------------------------------------------------------------------
1011
11−const CloudApi = addGroup(CloudAuthApi);
12+const CloudApi = addGroup(CloudAuthApi).add(TeamApi);
1213
1314 class CloudApiClient extends AtomHttpApi.Tag<CloudApiClient>()("CloudApiClient", {
1415 api: CloudApi,
apps/cloud/src/web/shell.tsx+2−0
@@ -138,6 +138,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 137 unmodified lines
138138 const isHome = props.pathname === "/";
139139 const isSecrets = props.pathname === "/secrets";
140140 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
141+ const isTeam = props.pathname === "/team";
141142
142143 return (
143144 <>
@@ -152,6 +153,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 8 unmodified lines
152153 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
153154 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />
154155 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />
156+ <NavItem to="/team" label="Team" active={isTeam} onNavigate={props.onNavigate} />
155157 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />
156158
157159 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">
apps/cloud/src/web/team-atoms.ts+15−0
@@ -0,0 +1,15 @@
1+import { CloudApiClient } from "./client";
2+
3+export const teamMembersAtom = CloudApiClient.query("team", "listMembers", {
4+ timeToLive: "30 seconds",
5+});
6+
7+export const teamRolesAtom = CloudApiClient.query("team", "listRoles", {
8+ timeToLive: "5 minutes",
9+});
10+
11+export const inviteMember = CloudApiClient.mutation("team", "invite");
12+
13+export const removeMember = CloudApiClient.mutation("team", "removeMember");
14+
15+export const updateMemberRole = CloudApiClient.mutation("team", "updateMemberRole");