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
46 "jose": "^5.6.3",46 "jose": "^5.6.3",
47 "postgres": "^3.4.9",47 "postgres": "^3.4.9",
48 "react": "catalog:",48 "react": "catalog:",
49 "react-dom": "catalog:"49 "react-dom": "catalog:",
50 "sonner": "^2.0.7"
50 },51 },
51 "devDependencies": {52 "devDependencies": {
52 "@cloudflare/vitest-pool-workers": "^0.14.3",53 "@cloudflare/vitest-pool-workers": "^0.14.3",
apps/cloud/src/api.ts+27−0
@@ -46,6 +46,8 @@ import { · 45 unmodified lines
46import { WorkOSAuth } from "./auth/workos";46import { WorkOSAuth } from "./auth/workos";
47import { DbService } from "./services/db";47import { DbService } from "./services/db";
48import { createOrgExecutor } from "./services/executor";48import { createOrgExecutor } from "./services/executor";
49import { TeamOrgApi } from "./team/compose";
50import { TeamHandlers } from "./team/handlers";
49import { server } from "./env";51import { server } from "./env";
50 52
51// ---------------------------------------------------------------------------53// ---------------------------------------------------------------------------
@@ -90,6 +92,11 @@ const NonProtectedApiLive = HttpApiBuilder.api(NonProtectedApi).pipe( · 38 unmodified lines
90 Layer.provideMerge(SessionAuthLive),92 Layer.provideMerge(SessionAuthLive),
91);93);
92 94
95const TeamApiLive = HttpApiBuilder.api(TeamOrgApi).pipe(
96 Layer.provide(TeamHandlers),
97 Layer.provideMerge(OrgAuthLive),
98);
99
93// ---------------------------------------------------------------------------100// ---------------------------------------------------------------------------
94// Public auth web handler101// Public auth web handler
95// ---------------------------------------------------------------------------102// ---------------------------------------------------------------------------
@@ -113,6 +120,12 @@ const createNonProtectedHandler = () => · 17 unmodified lines
113 { middleware: HttpMiddleware.logger },120 { middleware: HttpMiddleware.logger },
114 );121 );
115 122
123const createTeamHandler = () =>
124 HttpApiBuilder.toWebHandler(
125 TeamApiLive.pipe(Layer.provideMerge(SharedServices), Layer.provideMerge(RouterConfig)),
126 { middleware: HttpMiddleware.logger },
127 );
128
116// ---------------------------------------------------------------------------129// ---------------------------------------------------------------------------
117// Protected handler — must be built per-request because the executor varies130// Protected handler — must be built per-request because the executor varies
118// ---------------------------------------------------------------------------131// ---------------------------------------------------------------------------
@@ -158,6 +171,7 @@ const buildProtectedHandler = ( · 39 unmodified lines
158 171
159const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/");172const isAuthPath = (pathname: string): boolean => pathname.startsWith("/auth/");
160const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/");173const isAutumnPath = (pathname: string): boolean => pathname.startsWith("/autumn/");
174const isTeamPath = (pathname: string): boolean => pathname.startsWith("/team/");
161const isExecutionPath = (pathname: string): boolean =>175const isExecutionPath = (pathname: string): boolean =>
162 pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname);176 pathname === "/executions" || /^\/executions\/[^/]+\/resume$/.test(pathname);
163 177
@@ -241,9 +255,22 @@ const handleAutumnRequest = async (request: Request): Promise<Response> => { · 77 unmodified lines
241 );255 );
242};256};
243 257
258// ---------------------------------------------------------------------------
259// Widget token endpoint — returns a WorkOS widget token for the session user
260// ---------------------------------------------------------------------------
261
244export const handleApiRequest = async (request: Request): Promise<Response> => {262export const handleApiRequest = async (request: Request): Promise<Response> => {
245 const pathname = new URL(request.url).pathname;263 const pathname = new URL(request.url).pathname;
246 264
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
247 if (isAutumnPath(pathname)) {274 if (isAutumnPath(pathname)) {
248 return handleAutumnRequest(request);275 return handleAutumnRequest(request);
249 }276 }
apps/cloud/src/auth/workos.ts+39−1
@@ -138,10 +138,48 @@ const make = Effect.gen(function* () { · 137 unmodified lines
138 if (!sessionData) return null;138 if (!sessionData) return null;
139 return yield* authenticateSealedSession(sessionData);139 return yield* authenticateSealedSession(sessionData);
140 }),140 }),
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 })),
141 };179 };
142});180});
143 181
144type WorkOSAuthService = Effect.Effect.Success<typeof make>;182export type WorkOSAuthService = Effect.Effect.Success<typeof make>;
145 183
146export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")<184export class WorkOSAuth extends Context.Tag("@executor/cloud/WorkOSAuth")<
147 WorkOSAuth,185 WorkOSAuth,
apps/cloud/src/routeTree.gen.ts+21−0
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 10
11import { Route as rootRouteImport } from "./routes/__root";11import { Route as rootRouteImport } from "./routes/__root";
12import { Route as ToolsRouteImport } from "./routes/tools";12import { Route as ToolsRouteImport } from "./routes/tools";
13import { Route as TeamRouteImport } from "./routes/team";
13import { Route as SecretsRouteImport } from "./routes/secrets";14import { Route as SecretsRouteImport } from "./routes/secrets";
14import { Route as BillingRouteImport } from "./routes/billing";15import { Route as BillingRouteImport } from "./routes/billing";
15import { Route as IndexRouteImport } from "./routes/index";16import { Route as IndexRouteImport } from "./routes/index";
@@ -22,6 +23,11 @@ const ToolsRoute = ToolsRouteImport.update({ · 6 unmodified lines
22 path: "/tools",23 path: "/tools",
23 getParentRoute: () => rootRouteImport,24 getParentRoute: () => rootRouteImport,
24} as any);25} as any);
26const TeamRoute = TeamRouteImport.update({
27 id: "/team",
28 path: "/team",
29 getParentRoute: () => rootRouteImport,
30} as any);
25const SecretsRoute = SecretsRouteImport.update({31const SecretsRoute = SecretsRouteImport.update({
26 id: "/secrets",32 id: "/secrets",
27 path: "/secrets",33 path: "/secrets",
@@ -57,6 +63,7 @@ export interface FileRoutesByFullPath { · 29 unmodified lines
57 "/": typeof IndexRoute;63 "/": typeof IndexRoute;
58 "/billing": typeof BillingRoute;64 "/billing": typeof BillingRoute;
59 "/secrets": typeof SecretsRoute;65 "/secrets": typeof SecretsRoute;
66 "/team": typeof TeamRoute;
60 "/tools": typeof ToolsRoute;67 "/tools": typeof ToolsRoute;
61 "/billing/plans": typeof BillingPlansRoute;68 "/billing/plans": typeof BillingPlansRoute;
62 "/sources/$namespace": typeof SourcesNamespaceRoute;69 "/sources/$namespace": typeof SourcesNamespaceRoute;
@@ -66,6 +73,7 @@ export interface FileRoutesByTo { · 3 unmodified lines
66 "/": typeof IndexRoute;73 "/": typeof IndexRoute;
67 "/billing": typeof BillingRoute;74 "/billing": typeof BillingRoute;
68 "/secrets": typeof SecretsRoute;75 "/secrets": typeof SecretsRoute;
76 "/team": typeof TeamRoute;
69 "/tools": typeof ToolsRoute;77 "/tools": typeof ToolsRoute;
70 "/billing/plans": typeof BillingPlansRoute;78 "/billing/plans": typeof BillingPlansRoute;
71 "/sources/$namespace": typeof SourcesNamespaceRoute;79 "/sources/$namespace": typeof SourcesNamespaceRoute;
@@ -76,6 +84,7 @@ export interface FileRoutesById { · 4 unmodified lines
76 "/": typeof IndexRoute;84 "/": typeof IndexRoute;
77 "/billing": typeof BillingRoute;85 "/billing": typeof BillingRoute;
78 "/secrets": typeof SecretsRoute;86 "/secrets": typeof SecretsRoute;
87 "/team": typeof TeamRoute;
79 "/tools": typeof ToolsRoute;88 "/tools": typeof ToolsRoute;
80 "/billing_/plans": typeof BillingPlansRoute;89 "/billing_/plans": typeof BillingPlansRoute;
81 "/sources/$namespace": typeof SourcesNamespaceRoute;90 "/sources/$namespace": typeof SourcesNamespaceRoute;
@@ -87,6 +96,7 @@ export interface FileRouteTypes { · 5 unmodified lines
87 | "/"96 | "/"
88 | "/billing"97 | "/billing"
89 | "/secrets"98 | "/secrets"
99 | "/team"
90 | "/tools"100 | "/tools"
91 | "/billing/plans"101 | "/billing/plans"
92 | "/sources/$namespace"102 | "/sources/$namespace"
@@ -96,6 +106,7 @@ export interface FileRouteTypes { · 3 unmodified lines
96 | "/"106 | "/"
97 | "/billing"107 | "/billing"
98 | "/secrets"108 | "/secrets"
109 | "/team"
99 | "/tools"110 | "/tools"
100 | "/billing/plans"111 | "/billing/plans"
101 | "/sources/$namespace"112 | "/sources/$namespace"
@@ -105,6 +116,7 @@ export interface FileRouteTypes { · 3 unmodified lines
105 | "/"116 | "/"
106 | "/billing"117 | "/billing"
107 | "/secrets"118 | "/secrets"
119 | "/team"
108 | "/tools"120 | "/tools"
109 | "/billing_/plans"121 | "/billing_/plans"
110 | "/sources/$namespace"122 | "/sources/$namespace"
@@ -115,6 +127,7 @@ export interface RootRouteChildren { · 4 unmodified lines
115 IndexRoute: typeof IndexRoute;127 IndexRoute: typeof IndexRoute;
116 BillingRoute: typeof BillingRoute;128 BillingRoute: typeof BillingRoute;
117 SecretsRoute: typeof SecretsRoute;129 SecretsRoute: typeof SecretsRoute;
130 TeamRoute: typeof TeamRoute;
118 ToolsRoute: typeof ToolsRoute;131 ToolsRoute: typeof ToolsRoute;
119 BillingPlansRoute: typeof BillingPlansRoute;132 BillingPlansRoute: typeof BillingPlansRoute;
120 SourcesNamespaceRoute: typeof SourcesNamespaceRoute;133 SourcesNamespaceRoute: typeof SourcesNamespaceRoute;
@@ -130,6 +143,13 @@ declare module "@tanstack/react-router" { · 9 unmodified lines
130 preLoaderRoute: typeof ToolsRouteImport;143 preLoaderRoute: typeof ToolsRouteImport;
131 parentRoute: typeof rootRouteImport;144 parentRoute: typeof rootRouteImport;
132 };145 };
146 "/team": {
147 id: "/team";
148 path: "/team";
149 fullPath: "/team";
150 preLoaderRoute: typeof TeamRouteImport;
151 parentRoute: typeof rootRouteImport;
152 };
133 "/secrets": {153 "/secrets": {
134 id: "/secrets";154 id: "/secrets";
135 path: "/secrets";155 path: "/secrets";
@@ -179,6 +199,7 @@ const rootRouteChildren: RootRouteChildren = { · 43 unmodified lines
179 IndexRoute: IndexRoute,199 IndexRoute: IndexRoute,
180 BillingRoute: BillingRoute,200 BillingRoute: BillingRoute,
181 SecretsRoute: SecretsRoute,201 SecretsRoute: SecretsRoute,
202 TeamRoute: TeamRoute,
182 ToolsRoute: ToolsRoute,203 ToolsRoute: ToolsRoute,
183 BillingPlansRoute: BillingPlansRoute,204 BillingPlansRoute: BillingPlansRoute,
184 SourcesNamespaceRoute: SourcesNamespaceRoute,205 SourcesNamespaceRoute: SourcesNamespaceRoute,
apps/cloud/src/routes/__root.tsx+2−0
@@ -2,6 +2,7 @@ import React from "react"; · 1 unmodified line
2import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";2import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
3import { AutumnProvider } from "autumn-js/react";3import { AutumnProvider } from "autumn-js/react";
4import { ExecutorProvider } from "@executor/react/api/provider";4import { ExecutorProvider } from "@executor/react/api/provider";
5import { Toaster } from "@executor/react/components/sonner";
5import { AuthProvider, useAuth } from "../web/auth";6import { AuthProvider, useAuth } from "../web/auth";
6import { LoginPage } from "../web/pages/login";7import { LoginPage } from "../web/pages/login";
7import { Shell } from "../web/shell";8import { Shell } from "../web/shell";
@@ -70,6 +71,7 @@ function AuthGate() { · 62 unmodified lines
70 <AutumnProvider pathPrefix="/api/autumn">71 <AutumnProvider pathPrefix="/api/autumn">
71 <ExecutorProvider>72 <ExecutorProvider>
72 <Shell />73 <Shell />
74 <Toaster />
73 </ExecutorProvider>75 </ExecutorProvider>
74 </AutumnProvider>76 </AutumnProvider>
75 );77 );
apps/cloud/src/routes/billing.tsx+5−3
@@ -2,6 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; · 1 unmodified line
2import { useCustomer, useListPlans } from "autumn-js/react";2import { useCustomer, useListPlans } from "autumn-js/react";
3import { Button } from "@executor/react/components/button";3import { Button } from "@executor/react/components/button";
4 4
5type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
6
5export const Route = createFileRoute("/billing")({7export const Route = createFileRoute("/billing")({
6 component: BillingPage,8 component: BillingPage,
7});9});
@@ -29,11 +31,11 @@ function BillingPage() { · 21 unmodified lines
29 );31 );
30 }32 }
31 33
32 // Find current plan via customerEligibility from useListPlans34 const allPlans: Plan[] = plans ?? [];
33 const activePlan = (plans ?? []).find(35 const activePlan = allPlans.find(
34 (p) => p.customerEligibility?.status === "active" && p.id !== "free",36 (p) => p.customerEligibility?.status === "active" && p.id !== "free",
35 );37 );
36 const scheduledPlan = (plans ?? []).find(38 const scheduledPlan = allPlans.find(
37 (p) => p.customerEligibility?.status === "scheduled" && p.id !== "free",39 (p) => p.customerEligibility?.status === "scheduled" && p.id !== "free",
38 );40 );
39 const isCanceling = activePlan?.customerEligibility?.canceling ?? false;41 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
3import { useCustomer, useListPlans } from "autumn-js/react";3import { useCustomer, useListPlans } from "autumn-js/react";
4import { Button } from "@executor/react/components/button";4import { Button } from "@executor/react/components/button";
5 5
6type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number];
7
6export const Route = createFileRoute("/billing_/plans")({8export const Route = createFileRoute("/billing_/plans")({
7 component: PlansPage,9 component: PlansPage,
8});10});
@@ -45,7 +47,9 @@ function PlansPage() { · 36 unmodified lines
45 47
46 const isLoading = customerLoading || plansLoading;48 const isLoading = customerLoading || plansLoading;
47 49
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 );
49 53
50 return (54 return (
51 <div className="min-h-0 flex-1 overflow-y-auto">55 <div className="min-h-0 flex-1 overflow-y-auto">
apps/cloud/src/routes/team.tsx+446−0
@@ -0,0 +1,446 @@
1import { useReducer, useState } from "react";
2import { Exit } from "effect";
3import { createFileRoute } from "@tanstack/react-router";
4import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";
5import { toast } from "sonner";
6import {
7 Dialog,
8 DialogContent,
9 DialogHeader,
10 DialogTitle,
11 DialogDescription,
12 DialogFooter,
13 DialogClose,
14} from "@executor/react/components/dialog";
15import { Button } from "@executor/react/components/button";
16import { Input } from "@executor/react/components/input";
17import { Label } from "@executor/react/components/label";
18import {
19 Select,
20 SelectContent,
21 SelectItem,
22 SelectTrigger,
23 SelectValue,
24} from "@executor/react/components/select";
25import {
26 DropdownMenu,
27 DropdownMenuContent,
28 DropdownMenuItem,
29 DropdownMenuSub,
30 DropdownMenuSubContent,
31 DropdownMenuSubTrigger,
32 DropdownMenuTrigger,
33 DropdownMenuSeparator,
34} from "@executor/react/components/dropdown-menu";
35import {
36 teamMembersAtom,
37 teamRolesAtom,
38 inviteMember,
39 removeMember,
40 updateMemberRole,
41} from "../web/team-atoms";
42
43export const Route = createFileRoute("/team")({
44 component: TeamPage,
45});
46
47type InviteState = {
48 email: string;
49 roleSlug: string;
50 status: "idle" | "sending" | "error";
51 error: string | null;
52};
53
54const initialInviteState: InviteState = {
55 email: "",
56 roleSlug: "",
57 status: "idle",
58 error: null,
59};
60
61type InviteAction =
62 | { type: "setEmail"; email: string }
63 | { type: "setRole"; roleSlug: string }
64 | { type: "send" }
65 | { type: "error"; message: string }
66 | { type: "reset" };
67
68function 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
83function 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
97function 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
327function 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 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";
3import { WorkOSError } from "../auth/errors";
4
5export class Forbidden extends Schema.TaggedError<Forbidden>()(
6 "Forbidden",
7 {},
8 HttpApiSchema.annotations({ status: 403 }),
9) {}
10
11const 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
23const TeamMembersResponse = Schema.Struct({
24 members: Schema.Array(TeamMember),
25});
26
27const TeamRole = Schema.Struct({
28 slug: Schema.String,
29 name: Schema.String,
30});
31
32const TeamRolesResponse = Schema.Struct({
33 roles: Schema.Array(TeamRole),
34});
35
36const InviteBody = Schema.Struct({
37 email: Schema.String,
38 roleSlug: Schema.optional(Schema.String),
39});
40
41const InviteResponse = Schema.Struct({
42 id: Schema.String,
43 email: Schema.String,
44});
45
46const membershipIdParam = HttpApiSchema.param("membershipId", Schema.String);
47
48const RemoveResponse = Schema.Struct({
49 success: Schema.Boolean,
50});
51
52const UpdateRoleBody = Schema.Struct({
53 roleSlug: Schema.String,
54});
55
56const UpdateRoleResponse = Schema.Struct({
57 success: Schema.Boolean,
58});
59
60export { TeamMember, TeamMembersResponse };
61
62export 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 @@
1import { HttpApi } from "@effect/platform";
2import { OrgAuth } from "../auth/middleware";
3import { TeamApi } from "./api";
4
5/** Team API with org-level auth — requires authenticated session with an org. */
6export const TeamOrgApi = HttpApi.make("teamOrg").add(TeamApi).middleware(OrgAuth);
apps/cloud/src/team/handlers.test.ts+309−0
@@ -0,0 +1,309 @@
1import { describe, it, expect } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3
4import { AuthContext } from "../auth/middleware";
5import { WorkOSAuth } from "../auth/workos";
6import { 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
13type StubFn = (...args: never[]) => Effect.Effect<any>;
14
15type StubOverrides = {
16 listOrgMembers?: StubFn;
17 getUser?: StubFn;
18 sendInvitation?: StubFn;
19 deleteOrgMembership?: StubFn;
20 updateOrgMembershipRole?: StubFn;
21 listOrgRoles?: StubFn;
22};
23
24const 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
41const adminAuth = {
42 accountId: "user_admin",
43 organizationId: "org_1",
44 email: "admin@test.com",
45 name: "Admin",
46 avatarUrl: null,
47};
48
49const memberAuth = {
50 accountId: "user_member",
51 organizationId: "org_1",
52 email: "member@test.com",
53 name: "Member",
54 avatarUrl: null,
55};
56
57type FakeMembership = {
58 id: string;
59 userId: string;
60 status: string;
61 role: { slug: string };
62};
63type FakeUser = {
64 email: string;
65 firstName: string | null;
66 lastName: string | null;
67 profilePictureUrl: string | null;
68 lastSignInAt: string | null;
69};
70type FakeRole = { slug: string; name: string };
71
72const 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
87const 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
104const fakeRoles: FakeRole[] = [
105 { slug: "admin", name: "Admin" },
106 { slug: "member", name: "Member" },
107];
108
109// ---------------------------------------------------------------------------
110// The admin guard — mirrors handlers.ts
111// ---------------------------------------------------------------------------
112
113const 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
123const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) =>
124 Layer.mergeAll(Layer.succeed(AuthContext, auth), stubWorkOS(workosOverrides));
125
126const withMembers: StubOverrides = {
127 listOrgMembers: () => Effect.succeed({ data: fakeMemberships }),
128};
129
130// ---------------------------------------------------------------------------
131// Tests
132// ---------------------------------------------------------------------------
133
134describe("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 @@
1import { HttpApiBuilder } from "@effect/platform";
2import { Effect } from "effect";
3
4import { AuthContext } from "../auth/middleware";
5import { WorkOSAuth } from "../auth/workos";
6import { TeamOrgApi } from "./compose";
7import { Forbidden } from "./api";
8
9const 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
19export 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
3import { addGroup } from "@executor/api";3import { addGroup } from "@executor/api";
4import { getBaseUrl } from "@executor/react/api/base-url";4import { getBaseUrl } from "@executor/react/api/base-url";
5import { CloudAuthApi } from "../auth/api";5import { CloudAuthApi } from "../auth/api";
6import { TeamApi } from "../team/api";
6 7
7// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
8// Cloud API client — core API + cloud auth9// Cloud API client — core API + cloud auth + team
9// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
10 11
11const CloudApi = addGroup(CloudAuthApi);12const CloudApi = addGroup(CloudAuthApi).add(TeamApi);
12 13
13class CloudApiClient extends AtomHttpApi.Tag<CloudApiClient>()("CloudApiClient", {14class CloudApiClient extends AtomHttpApi.Tag<CloudApiClient>()("CloudApiClient", {
14 api: CloudApi,15 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
138 const isHome = props.pathname === "/";138 const isHome = props.pathname === "/";
139 const isSecrets = props.pathname === "/secrets";139 const isSecrets = props.pathname === "/secrets";
140 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");140 const isBilling = props.pathname === "/billing" || props.pathname.startsWith("/billing/");
141 const isTeam = props.pathname === "/team";
141 142
142 return (143 return (
143 <>144 <>
@@ -152,6 +153,7 @@ function SidebarContent(props: { pathname: string; onNavigate?: () => void; show · 8 unmodified lines
152 <nav className="flex flex-1 flex-col overflow-y-auto p-2">153 <nav className="flex flex-1 flex-col overflow-y-auto p-2">
153 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />154 <NavItem to="/" label="Sources" active={isHome} onNavigate={props.onNavigate} />
154 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />155 <NavItem to="/secrets" label="Secrets" active={isSecrets} onNavigate={props.onNavigate} />
156 <NavItem to="/team" label="Team" active={isTeam} onNavigate={props.onNavigate} />
155 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />157 <NavItem to="/billing" label="Billing" active={isBilling} onNavigate={props.onNavigate} />
156 158
157 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">159 <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 @@
1import { CloudApiClient } from "./client";
2
3export const teamMembersAtom = CloudApiClient.query("team", "listMembers", {
4 timeToLive: "30 seconds",
5});
6
7export const teamRolesAtom = CloudApiClient.query("team", "listRoles", {
8 timeToLive: "5 minutes",
9});
10
11export const inviteMember = CloudApiClient.mutation("team", "invite");
12
13export const removeMember = CloudApiClient.mutation("team", "removeMember");
14
15export const updateMemberRole = CloudApiClient.mutation("team", "updateMemberRole");