Skip to content

Commit ec8d886

Browse files
authored
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
1 parent 261c7b2 commit ec8d886

19 files changed

Lines changed: 1079 additions & 16 deletions

File tree

apps/cloud/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import {
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(
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 = () =>
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 = (
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> => {
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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,48 @@ const make = Effect.gen(function* () {
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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
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({
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 {
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 {
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 {
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 {
8796
| "/"
8897
| "/billing"
8998
| "/secrets"
99+
| "/team"
90100
| "/tools"
91101
| "/billing/plans"
92102
| "/sources/$namespace"
@@ -96,6 +106,7 @@ export interface FileRouteTypes {
96106
| "/"
97107
| "/billing"
98108
| "/secrets"
109+
| "/team"
99110
| "/tools"
100111
| "/billing/plans"
101112
| "/sources/$namespace"
@@ -105,6 +116,7 @@ export interface FileRouteTypes {
105116
| "/"
106117
| "/billing"
107118
| "/secrets"
119+
| "/team"
108120
| "/tools"
109121
| "/billing_/plans"
110122
| "/sources/$namespace"
@@ -115,6 +127,7 @@ export interface RootRouteChildren {
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" {
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 = {
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from "react";
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() {
7071
<AutumnProvider pathPrefix="/api/autumn">
7172
<ExecutorProvider>
7273
<Shell />
74+
<Toaster />
7375
</ExecutorProvider>
7476
</AutumnProvider>
7577
);

apps/cloud/src/routes/billing.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";
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() {
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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { createFileRoute, Link } from "@tanstack/react-router";
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() {
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">

0 commit comments

Comments
 (0)