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/cloud/src/team (4 of 19 files). Show all

Unified · Split

4 files
  1. apps/cloud/src/team/api.ts +92 −0
  2. apps/cloud/src/team/compose.ts +6 −0
  3. apps/cloud/src/team/handlers.test.ts +309 −0
  4. apps/cloud/src/team/handlers.ts +97 −0
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+);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t