Skip to content

Commit 93838ca

Browse files
authored
feat(auth): admin organization management routes (#876) (#880)
1 parent ed70afd commit 93838ca

11 files changed

Lines changed: 1674 additions & 0 deletions

File tree

‎plugins/auth/src/contract.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { InferContractRouterInputs, InferContractRouterOutputs } from "@orpc/contract";
22
import { oc } from "@orpc/contract";
3+
import { adminOrganizationRoutes } from "./contract/admin-organizations.contract";
34
import { adminUsersRoutes } from "./contract/admin-users.contract";
45
import { apiKeyRoutes } from "./contract/api-keys.contract";
56
import { invitationRoutes } from "./contract/invitations.contract";
@@ -17,6 +18,7 @@ export { apiKeySchema } from "./contract/schemas";
1718
export const contract = oc.router({
1819
...sessionRoutes,
1920
...organizationRoutes,
21+
...adminOrganizationRoutes,
2022
...memberRoutes,
2123
...invitationRoutes,
2224
...teamRoutes,
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import "@orpc/openapi/extensions/route";
2+
import { oc } from "@orpc/contract";
3+
import { z } from "zod";
4+
import { Errors } from "./errors";
5+
import {
6+
adminOrganizationSummarySchema,
7+
invitationSchema,
8+
memberSchema,
9+
memberWithUserSchema,
10+
organizationInfoSchema,
11+
teamMemberSchema,
12+
teamSchema,
13+
} from "./schemas";
14+
15+
export const adminOrganizationRoutes = {
16+
adminListOrganizations: oc
17+
.route({
18+
method: "GET",
19+
path: "/v1/auth/admin/organizations",
20+
summary: "List all organizations",
21+
description:
22+
"Requires the platform admin role. Search by name, slug, or id substring and filter " +
23+
"by status and the personal-organization metadata flag, paginated server-side.",
24+
})
25+
.input(
26+
z
27+
.object({
28+
search: z.string().optional(),
29+
status: z.enum(["active", "pending", "rejected"]).optional(),
30+
personal: z.boolean().optional(),
31+
limit: z.number().int().positive().max(100).optional(),
32+
offset: z.number().int().nonnegative().optional(),
33+
})
34+
.optional(),
35+
)
36+
.output(
37+
z.object({
38+
organizations: z.array(adminOrganizationSummarySchema),
39+
total: z.number(),
40+
}),
41+
)
42+
.errors(Errors),
43+
44+
adminGetOrganizationFull: oc
45+
.route({
46+
method: "GET",
47+
path: "/v1/auth/admin/organizations/{organizationId}/full",
48+
summary: "Get the full organization detail",
49+
description:
50+
"Requires the platform admin role. Returns the organization with its members " +
51+
"(including user info), invitations, and teams.",
52+
})
53+
.input(z.object({ organizationId: z.string() }))
54+
.output(
55+
organizationInfoSchema.extend({
56+
createdAt: z.date(),
57+
members: z.array(memberWithUserSchema),
58+
invitations: z.array(invitationSchema),
59+
teams: z.array(teamSchema),
60+
}),
61+
)
62+
.errors(Errors),
63+
64+
adminCreateOrganizationForUser: oc
65+
.route({
66+
method: "POST",
67+
path: "/v1/auth/admin/organizations/create-for-user",
68+
summary: "Create an organization on behalf of a user",
69+
description:
70+
"Requires the platform admin role. Creates the organization and an owner member row " +
71+
"for the given user. The slug is derived from the name when omitted.",
72+
})
73+
.input(
74+
z.object({
75+
userId: z.string(),
76+
name: z.string(),
77+
slug: z.string().optional(),
78+
logo: z.string().optional(),
79+
metadata: z.record(z.string(), z.unknown()).optional(),
80+
}),
81+
)
82+
.output(organizationInfoSchema.extend({ createdAt: z.date() }))
83+
.errors(Errors),
84+
85+
adminUpdateOrganization: oc
86+
.route({
87+
method: "POST",
88+
path: "/v1/auth/admin/organizations/update",
89+
summary: "Update any organization",
90+
description:
91+
"Requires the platform admin role. Updates name, slug, logo, status, or metadata " +
92+
"without requiring membership in the organization.",
93+
})
94+
.input(
95+
z.object({
96+
organizationId: z.string(),
97+
data: z.object({
98+
name: z.string().optional(),
99+
slug: z.string().optional(),
100+
logo: z.string().nullable().optional(),
101+
status: z.enum(["active", "pending", "rejected"]).optional(),
102+
metadata: z.record(z.string(), z.unknown()).optional(),
103+
}),
104+
}),
105+
)
106+
.output(organizationInfoSchema)
107+
.errors(Errors),
108+
109+
adminDeleteOrganization: oc
110+
.route({
111+
method: "POST",
112+
path: "/v1/auth/admin/organizations/delete",
113+
summary: "Delete any organization",
114+
description:
115+
"Requires the platform admin role. Deletes the organization, its members, teams, and " +
116+
"invitations. Downstream attribution cleanup is the consumer's responsibility.",
117+
})
118+
.input(z.object({ organizationId: z.string() }))
119+
.output(z.object({ success: z.boolean() }))
120+
.errors(Errors),
121+
122+
adminAddMember: oc
123+
.route({
124+
method: "POST",
125+
path: "/v1/auth/admin/organizations/members",
126+
summary: "Add a member to any organization",
127+
description:
128+
"Requires the platform admin role. Enforces the organization membership limit policy.",
129+
})
130+
.input(
131+
z.object({
132+
organizationId: z.string(),
133+
userId: z.string(),
134+
role: z.enum(["owner", "admin", "member"]),
135+
}),
136+
)
137+
.output(memberSchema)
138+
.errors(Errors),
139+
140+
adminRemoveMember: oc
141+
.route({
142+
method: "POST",
143+
path: "/v1/auth/admin/organizations/members/remove",
144+
summary: "Remove a member from any organization",
145+
description:
146+
"Requires the platform admin role. Refuses to remove an organization's only owner.",
147+
})
148+
.input(z.object({ organizationId: z.string(), userId: z.string() }))
149+
.output(z.object({ success: z.boolean() }))
150+
.errors(Errors),
151+
152+
adminUpdateMemberRole: oc
153+
.route({
154+
method: "POST",
155+
path: "/v1/auth/admin/organizations/members/update-role",
156+
summary: "Update a member's role in any organization",
157+
description: "Requires the platform admin role.",
158+
})
159+
.input(
160+
z.object({
161+
organizationId: z.string(),
162+
userId: z.string(),
163+
role: z.enum(["owner", "admin", "member"]),
164+
}),
165+
)
166+
.output(memberSchema)
167+
.errors(Errors),
168+
169+
adminCreateTeam: oc
170+
.route({
171+
method: "POST",
172+
path: "/v1/auth/admin/organizations/teams",
173+
summary: "Create a team in any organization",
174+
description: "Requires the platform admin role.",
175+
})
176+
.input(
177+
z.object({
178+
organizationId: z.string(),
179+
name: z.string(),
180+
metadata: z.record(z.string(), z.unknown()).optional(),
181+
eventId: z.string().optional(),
182+
}),
183+
)
184+
.output(teamSchema)
185+
.errors(Errors),
186+
187+
adminUpdateTeam: oc
188+
.route({
189+
method: "POST",
190+
path: "/v1/auth/admin/organizations/teams/update",
191+
summary: "Update a team",
192+
description: "Requires the platform admin role.",
193+
})
194+
.input(
195+
z.object({
196+
teamId: z.string(),
197+
data: z.object({
198+
name: z.string().optional(),
199+
metadata: z.record(z.string(), z.unknown()).optional(),
200+
}),
201+
}),
202+
)
203+
.output(teamSchema)
204+
.errors(Errors),
205+
206+
adminDeleteTeam: oc
207+
.route({
208+
method: "POST",
209+
path: "/v1/auth/admin/organizations/teams/delete",
210+
summary: "Delete a team",
211+
description: "Requires the platform admin role.",
212+
})
213+
.input(z.object({ teamId: z.string() }))
214+
.output(z.object({ success: z.boolean() }))
215+
.errors(Errors),
216+
217+
adminAddTeamMember: oc
218+
.route({
219+
method: "POST",
220+
path: "/v1/auth/admin/organizations/teams/members",
221+
summary: "Add a member to a team",
222+
description:
223+
"Requires the platform admin role. The user must already be a member of the " +
224+
"team's organization. Team roles must be declared in siwn.permissions.roles.",
225+
})
226+
.input(
227+
z.object({
228+
teamId: z.string(),
229+
userId: z.string(),
230+
role: z.string().optional(),
231+
}),
232+
)
233+
.output(teamMemberSchema)
234+
.errors(Errors),
235+
236+
adminRemoveTeamMember: oc
237+
.route({
238+
method: "POST",
239+
path: "/v1/auth/admin/organizations/teams/members/remove",
240+
summary: "Remove a member from a team",
241+
description: "Requires the platform admin role.",
242+
})
243+
.input(z.object({ teamId: z.string(), userId: z.string() }))
244+
.output(z.object({ success: z.boolean() }))
245+
.errors(Errors),
246+
};

‎plugins/auth/src/contract/schemas.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,12 @@ export const apiKeyRateLimitSchema = z.object({
161161
max: z.number().int().positive().optional(),
162162
});
163163

164+
export const adminOrganizationSummarySchema = organizationInfoSchema.extend({
165+
createdAt: z.date(),
166+
memberCount: z.number(),
167+
teamCount: z.number(),
168+
});
169+
164170
export const memberSchema = z.object({
165171
id: z.string(),
166172
userId: z.string(),
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { Implementer } from "@orpc/server";
2+
import { createAuthMiddleware } from "everything-dev/api";
3+
import type { ContractType } from "../contract";
4+
import type {
5+
AuthHandlerAuthedContext,
6+
AuthHandlerContext,
7+
RequireAuthMiddleware,
8+
} from "../middleware";
9+
import { AdminOrganizationsService } from "../services/admin-organizations";
10+
import { mapDomainError } from "../services/domain-errors";
11+
import { createHeaders } from "../utils";
12+
13+
export function createAdminOrganizationHandlers(
14+
builder: Implementer<ContractType, AuthHandlerContext>,
15+
requireAuth: RequireAuthMiddleware,
16+
) {
17+
const { requireAdmin } = createAuthMiddleware<AuthHandlerAuthedContext>(builder);
18+
return {
19+
adminListOrganizations: builder.adminListOrganizations
20+
.use(requireAuth)
21+
.use(requireAdmin)
22+
.effect(function* ({ input }) {
23+
const admin = yield* AdminOrganizationsService;
24+
return yield* mapDomainError(admin.listOrganizations(input));
25+
}),
26+
27+
adminGetOrganizationFull: builder.adminGetOrganizationFull
28+
.use(requireAuth)
29+
.use(requireAdmin)
30+
.effect(function* ({ input }) {
31+
const admin = yield* AdminOrganizationsService;
32+
return yield* mapDomainError(admin.getOrganizationFull(input.organizationId));
33+
}),
34+
35+
adminCreateOrganizationForUser: builder.adminCreateOrganizationForUser
36+
.use(requireAuth)
37+
.use(requireAdmin)
38+
.effect(function* ({ input }) {
39+
const admin = yield* AdminOrganizationsService;
40+
return yield* mapDomainError(admin.createOrganizationForUser(input));
41+
}),
42+
43+
adminUpdateOrganization: builder.adminUpdateOrganization
44+
.use(requireAuth)
45+
.use(requireAdmin)
46+
.effect(function* ({ input }) {
47+
const admin = yield* AdminOrganizationsService;
48+
return yield* mapDomainError(admin.updateOrganization(input));
49+
}),
50+
51+
adminDeleteOrganization: builder.adminDeleteOrganization
52+
.use(requireAuth)
53+
.use(requireAdmin)
54+
.effect(function* ({ input }) {
55+
const admin = yield* AdminOrganizationsService;
56+
return yield* mapDomainError(admin.deleteOrganization(input.organizationId));
57+
}),
58+
59+
adminAddMember: builder.adminAddMember
60+
.use(requireAuth)
61+
.use(requireAdmin)
62+
.effect(function* ({ input, context }) {
63+
const admin = yield* AdminOrganizationsService;
64+
return yield* mapDomainError(admin.addMember(createHeaders(context.reqHeaders), input));
65+
}),
66+
67+
adminRemoveMember: builder.adminRemoveMember
68+
.use(requireAuth)
69+
.use(requireAdmin)
70+
.effect(function* ({ input }) {
71+
const admin = yield* AdminOrganizationsService;
72+
return yield* mapDomainError(admin.removeMember(input));
73+
}),
74+
75+
adminUpdateMemberRole: builder.adminUpdateMemberRole
76+
.use(requireAuth)
77+
.use(requireAdmin)
78+
.effect(function* ({ input }) {
79+
const admin = yield* AdminOrganizationsService;
80+
return yield* mapDomainError(admin.updateMemberRole(input));
81+
}),
82+
83+
adminCreateTeam: builder.adminCreateTeam
84+
.use(requireAuth)
85+
.use(requireAdmin)
86+
.effect(function* ({ input }) {
87+
const admin = yield* AdminOrganizationsService;
88+
return yield* mapDomainError(admin.createTeam(input));
89+
}),
90+
91+
adminUpdateTeam: builder.adminUpdateTeam
92+
.use(requireAuth)
93+
.use(requireAdmin)
94+
.effect(function* ({ input }) {
95+
const admin = yield* AdminOrganizationsService;
96+
return yield* mapDomainError(admin.updateTeam(input));
97+
}),
98+
99+
adminDeleteTeam: builder.adminDeleteTeam
100+
.use(requireAuth)
101+
.use(requireAdmin)
102+
.effect(function* ({ input }) {
103+
const admin = yield* AdminOrganizationsService;
104+
return yield* mapDomainError(admin.deleteTeam(input.teamId));
105+
}),
106+
107+
adminAddTeamMember: builder.adminAddTeamMember
108+
.use(requireAuth)
109+
.use(requireAdmin)
110+
.effect(function* ({ input }) {
111+
const admin = yield* AdminOrganizationsService;
112+
return yield* mapDomainError(admin.addTeamMember(input));
113+
}),
114+
115+
adminRemoveTeamMember: builder.adminRemoveTeamMember
116+
.use(requireAuth)
117+
.use(requireAdmin)
118+
.effect(function* ({ input }) {
119+
const admin = yield* AdminOrganizationsService;
120+
return yield* mapDomainError(admin.removeTeamMember(input));
121+
}),
122+
};
123+
}

0 commit comments

Comments
 (0)