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 @@
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);

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