create-fullstack-scaffold 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/cli/index.js +80 -22
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +12 -12
  4. package/template/package.json +10 -14
  5. package/template/src/cli/modules/tenant/index.ts +89 -0
  6. package/template/src/client/entry-server.tsx +7 -1
  7. package/template/src/client/main.tsx +0 -8
  8. package/template/src/client/pages/ContentDetailPage.tsx +1 -1
  9. package/template/src/client/pages/ContentListPage.tsx +1 -1
  10. package/template/src/client/pages/TodoPage.tsx +20 -4
  11. package/template/src/client/ssr-pages.ts +10 -21
  12. package/template/src/server/db/init.ts +0 -52
  13. package/template/src/server/db/schema/index.ts +3 -0
  14. package/template/src/server/db/schema/tenant-invitations.ts +33 -0
  15. package/template/src/server/db/schema/tenant-members.ts +38 -0
  16. package/template/src/server/db/schema/tenant-roles.ts +32 -0
  17. package/template/src/server/db/schema/todos.ts +4 -0
  18. package/template/src/server/db/test-setup.ts +128 -0
  19. package/template/src/server/entries/node.ts +38 -19
  20. package/template/src/server/index.ts +12 -0
  21. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
  22. package/template/src/server/middleware/auth.ts +17 -19
  23. package/template/src/server/middleware/tenant-isolation.ts +4 -2
  24. package/template/src/server/module-auth/module.ts +2 -1
  25. package/template/src/server/module-auth/services/auth-service.ts +39 -0
  26. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
  27. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
  28. package/template/src/server/module-tenant/module.ts +1 -1
  29. package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
  30. package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
  31. package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
  32. package/template/src/server/module-todos/services/todo-service.ts +12 -2
  33. package/template/src/server/ssr-bridge.ts +10 -0
  34. package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
  35. package/template/src/server/utils/id-helpers.ts +13 -0
  36. package/template/src/shared/modules/index.ts +29 -6
  37. package/template/src/shared/modules/tenant/index.ts +30 -0
  38. package/template/src/shared/modules/tenant/permissions.ts +54 -0
  39. package/template/src/shared/modules/tenant/role-templates.ts +66 -0
  40. package/template/src/shared/modules/tenant/schemas.ts +113 -1
  41. package/template/src/shared/modules/todos/schemas.ts +1 -0
  42. package/template/src/shared/schemas/index.ts +28 -0
  43. package/template/src/tenant/App.tsx +30 -23
  44. package/template/src/tenant/components/TenantGuard.tsx +12 -2
  45. package/template/src/tenant/layouts/Header.tsx +6 -2
  46. package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
  47. package/template/src/tenant/pages/LoginPage.tsx +82 -0
  48. package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
  49. package/template/src/tenant/pages/UsersPage.tsx +134 -88
  50. package/template/src/tenant/services/tenantApi.ts +61 -0
  51. package/template/src/tenant/stores/tenantStore.ts +172 -49
  52. package/template/src/test/setup-db-path.ts +20 -0
  53. package/template/vite.config.ts +4 -73
  54. package/template/vitest.config.ts +3 -1
@@ -7,7 +7,19 @@ import {
7
7
  createTenant,
8
8
  updateTenant,
9
9
  deleteTenant,
10
+ getTenantRoles,
11
+ createTenantRole,
12
+ getTenantMembers,
13
+ getUserTenants,
14
+ getUserTenantPermissions,
15
+ hasTenantPermission,
16
+ requireTenantMembership,
17
+ inviteMember,
18
+ getInvitationByToken,
19
+ acceptInvitation,
20
+ removeMember,
10
21
  } from '../services/tenant-service'
22
+ import { TenantPermission } from '@shared/schemas'
11
23
  import type { CreateTenantInput } from '@shared/schemas'
12
24
 
13
25
  describe('Tenant Service', () => {
@@ -84,7 +96,7 @@ describe('Tenant Service', () => {
84
96
  maxUsers: 5,
85
97
  settings: null,
86
98
  }
87
- const result = await createTenant(input)
99
+ const result = await createTenant(input, 'test-super-admin-1')
88
100
  expect(result.name).toBe('New Corp')
89
101
  expect(result.slug).toBe('new-corp')
90
102
  expect(result.status).toBe('trial')
@@ -93,13 +105,16 @@ describe('Tenant Service', () => {
93
105
 
94
106
  it('should reject duplicate slug', async () => {
95
107
  await expect(
96
- createTenant({
97
- name: 'Duplicate',
98
- slug: 'demo',
99
- plan: 'free',
100
- maxUsers: 5,
101
- settings: null,
102
- })
108
+ createTenant(
109
+ {
110
+ name: 'Duplicate',
111
+ slug: 'demo',
112
+ plan: 'free',
113
+ maxUsers: 5,
114
+ settings: null,
115
+ },
116
+ 'test-super-admin-1'
117
+ )
103
118
  ).rejects.toThrow(/already exists/)
104
119
  })
105
120
  })
@@ -112,13 +127,16 @@ describe('Tenant Service', () => {
112
127
 
113
128
  it('should update tenant fields', async () => {
114
129
  // Create a tenant to update
115
- const created = await createTenant({
116
- name: 'Update Test',
117
- slug: 'update-test',
118
- plan: 'free',
119
- maxUsers: 5,
120
- settings: null,
121
- })
130
+ const created = await createTenant(
131
+ {
132
+ name: 'Update Test',
133
+ slug: 'update-test',
134
+ plan: 'free',
135
+ maxUsers: 5,
136
+ settings: null,
137
+ },
138
+ 'test-super-admin-1'
139
+ )
122
140
 
123
141
  const result = await updateTenant(created.id, { name: 'Updated Name' })
124
142
  expect(result).not.toBeNull()
@@ -133,13 +151,16 @@ describe('Tenant Service', () => {
133
151
  })
134
152
 
135
153
  it('should delete and return true', async () => {
136
- const created = await createTenant({
137
- name: 'Delete Test',
138
- slug: 'delete-test',
139
- plan: 'free',
140
- maxUsers: 5,
141
- settings: null,
142
- })
154
+ const created = await createTenant(
155
+ {
156
+ name: 'Delete Test',
157
+ slug: 'delete-test',
158
+ plan: 'free',
159
+ maxUsers: 5,
160
+ settings: null,
161
+ },
162
+ 'test-super-admin-1'
163
+ )
143
164
 
144
165
  const result = await deleteTenant(created.id)
145
166
  expect(result).toBe(true)
@@ -150,3 +171,161 @@ describe('Tenant Service', () => {
150
171
  })
151
172
  })
152
173
  })
174
+
175
+ describe('provisioning & membership (P1)', () => {
176
+ beforeAll(async () => {
177
+ await setupTestDatabase()
178
+ })
179
+
180
+ afterAll(async () => {
181
+ await cleanupTestDatabase()
182
+ })
183
+
184
+ it('createTenant 事务播种 3 个系统角色 + owner 为租户管理员', async () => {
185
+ const tenant = await createTenant(
186
+ { name: '开通验证', slug: 'prov-check', plan: 'pro', maxUsers: 20, settings: {} },
187
+ 'test-super-admin-1'
188
+ )
189
+ const roles = await getTenantRoles(tenant.id)
190
+ expect(roles.length).toBe(3)
191
+ expect(roles.map(r => r.code).sort()).toEqual(['tenant_admin', 'tenant_guest', 'tenant_member'])
192
+ expect(roles.every(r => r.isSystem)).toBe(true)
193
+
194
+ const members = await getTenantMembers(tenant.id)
195
+ expect(members.length).toBe(1)
196
+ expect(members[0].userId).toBe('test-super-admin-1')
197
+ expect(members[0].role?.code).toBe('tenant_admin')
198
+ expect(members[0].status).toBe('active')
199
+ })
200
+
201
+ it('owner 拥有全部租户权限,陌生人无任何权限', async () => {
202
+ const tenant = await createTenant(
203
+ { name: '权限验证', slug: 'perm-check', plan: 'free', maxUsers: 5, settings: {} },
204
+ 'test-super-admin-1'
205
+ )
206
+ const ownerPerms = await getUserTenantPermissions('test-super-admin-1', tenant.id)
207
+ expect(ownerPerms.length).toBeGreaterThan(10)
208
+ expect(
209
+ await hasTenantPermission('test-super-admin-1', tenant.id, TenantPermission.MEMBER_INVITE)
210
+ ).toBe(true)
211
+ expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
212
+ false
213
+ )
214
+ await expect(requireTenantMembership('test-user-2', tenant.id)).rejects.toThrow()
215
+ })
216
+
217
+ it('邀请→接受→成员可见→移除后失权 全链路', async () => {
218
+ const tenant = await createTenant(
219
+ { name: '邀请验证', slug: 'invite-check', plan: 'free', maxUsers: 5, settings: {} },
220
+ 'test-super-admin-1'
221
+ )
222
+ const guestRole = (await getTenantRoles(tenant.id)).find(r => r.code === 'tenant_guest')!
223
+
224
+ const invitation = await inviteMember(
225
+ tenant.id,
226
+ 'newbie@example.com',
227
+ guestRole.id,
228
+ 'test-super-admin-1'
229
+ )
230
+ expect(invitation.status).toBe('pending')
231
+ expect(invitation.token.length).toBeGreaterThanOrEqual(32)
232
+
233
+ // 公开详情可查(带租户名/角色名)
234
+ const detail = await getInvitationByToken(invitation.token)
235
+ expect(detail?.tenantName).toBe('邀请验证')
236
+ expect(detail?.roleLabel).toBe('访客')
237
+
238
+ // 陌生用户接受邀请后成为 guest
239
+ const member = await acceptInvitation(invitation.token, 'test-user-2')
240
+ expect(member?.status).toBe('active')
241
+ expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
242
+ true
243
+ )
244
+ expect(
245
+ await hasTenantPermission('test-user-2', tenant.id, TenantPermission.MEMBER_INVITE)
246
+ ).toBe(false)
247
+
248
+ // 二次接受同一 token 失效
249
+ const again = await acceptInvitation(invitation.token, 'test-user-3')
250
+ expect(again).toBeNull()
251
+
252
+ // 移除后立即失权
253
+ await removeMember(tenant.id, member!.id)
254
+ expect(await hasTenantPermission('test-user-2', tenant.id, TenantPermission.DATA_VIEW)).toBe(
255
+ false
256
+ )
257
+ await expect(requireTenantMembership('test-user-2', tenant.id)).rejects.toThrow()
258
+ })
259
+
260
+ it('套餐角色数配额:free 上限 3,创建第 4 个被拦截', async () => {
261
+ const tenant = await createTenant(
262
+ { name: '配额验证', slug: 'quota-check', plan: 'free', maxUsers: 5, settings: {} },
263
+ 'test-super-admin-1'
264
+ )
265
+ // 3 个系统角色已占满 free 上限
266
+ await expect(
267
+ createTenantRole(tenant.id, {
268
+ code: 'custom-a',
269
+ name: 'custom-a',
270
+ label: '自定义A',
271
+ description: null,
272
+ permissions: [TenantPermission.DATA_VIEW],
273
+ })
274
+ ).rejects.toThrow(/上限/)
275
+
276
+ // 升级 pro 后可继续创建
277
+ await updateTenant(tenant.id, { plan: 'pro' })
278
+ const role = await createTenantRole(tenant.id, {
279
+ code: 'custom-a',
280
+ name: 'custom-a',
281
+ label: '自定义A',
282
+ description: null,
283
+ permissions: [TenantPermission.DATA_VIEW],
284
+ })
285
+ expect(role.isSystem).toBe(false)
286
+ })
287
+
288
+ it('P4:套餐成员配额——满员后邀请被拦截,升级后放行', async () => {
289
+ // maxUsers=1 的租户:owner 已占 1 席
290
+ const tiny = await createTenant(
291
+ { name: '配额租户', slug: 'quota-member', plan: 'free', maxUsers: 1, settings: {} },
292
+ 'test-super-admin-1'
293
+ )
294
+ const guestRole = (await getTenantRoles(tiny.id)).find(r => r.code === 'tenant_guest')!
295
+
296
+ await expect(
297
+ inviteMember(tiny.id, 'full@quota.io', guestRole.id, 'test-super-admin-1')
298
+ ).rejects.toThrow(/上限/)
299
+
300
+ // 升级 maxUsers=2 后放行;接受时也过配额
301
+ await updateTenant(tiny.id, { maxUsers: 2 })
302
+ const inv = await inviteMember(tiny.id, 'full@quota.io', guestRole.id, 'test-super-admin-1')
303
+ const member = await acceptInvitation(inv.token, 'test-user-4')
304
+ expect(member?.status).toBe('active')
305
+
306
+ // 满员(2/2)后再邀新邮箱 → 拦截
307
+ await expect(
308
+ inviteMember(tiny.id, 'overflow@quota.io', guestRole.id, 'test-super-admin-1')
309
+ ).rejects.toThrow(/上限/)
310
+ })
311
+
312
+ it('P4:接受时二次校验——邀请有效期内满员则接受被拦截', async () => {
313
+ const t = await createTenant(
314
+ { name: '二次校验', slug: 'quota-accept', plan: 'free', maxUsers: 2, settings: {} },
315
+ 'test-super-admin-1'
316
+ )
317
+ const guestRole = (await getTenantRoles(t.id)).find(r => r.code === 'tenant_guest')!
318
+ const inv = await inviteMember(t.id, 'late@quota.io', guestRole.id, 'test-super-admin-1')
319
+
320
+ // 邀请发出后把 maxUsers 降到 1(owner 已满员)
321
+ await updateTenant(t.id, { maxUsers: 1 })
322
+ await expect(acceptInvitation(inv.token, 'test-user-5')).rejects.toThrow(/上限/)
323
+ })
324
+
325
+ it('getUserTenants 只返回本人 active 成员的租户', async () => {
326
+ const mine = await getUserTenants('test-super-admin-1')
327
+ expect(mine.length).toBeGreaterThanOrEqual(2) // demo 种子 + 本组新建
328
+ expect(mine.every(t => t.slug)).toBe(true)
329
+ expect((await getUserTenants('test-user-2')).length).toBe(0)
330
+ })
331
+ })
@@ -22,7 +22,7 @@ const tenantManifest: ModuleManifest = {
22
22
  clientPages: [],
23
23
 
24
24
  dbSchemas: {
25
- files: ['tenants'],
25
+ files: ['tenants', 'tenant-roles', 'tenant-members', 'tenant-invitations'],
26
26
  hasSeed: true,
27
27
  seed: { serviceFile: 'tenant-service', functionName: 'seedTenantsIfEmpty' },
28
28
  },
@@ -9,9 +9,39 @@ import {
9
9
  TenantQuerySchema,
10
10
  TenantIdResponseSchema,
11
11
  } from '@shared/schemas'
12
+ import {
13
+ TenantRoleSchema,
14
+ TenantMemberSchema,
15
+ TenantInvitationSchema,
16
+ PublicInvitationSchema,
17
+ StringIdResponseSchema,
18
+ TenantArrayResponseSchema,
19
+ TenantRoleArrayResponseSchema,
20
+ TenantMemberArrayResponseSchema,
21
+ CreateTenantRoleSchema,
22
+ UpdateTenantRoleSchema,
23
+ InviteMemberSchema,
24
+ UpdateMemberRoleSchema,
25
+ TenantPermission,
26
+ type PublicInvitation,
27
+ } from '@shared/schemas'
12
28
  import { successResponse, errorResponse, success } from '@server/utils/route-helpers'
13
- import { NotFoundError } from '@server/utils/app-error'
29
+ import { NotFoundError, AuthorizationError } from '@server/utils/app-error'
14
30
  import { authMiddleware, requireSuperAdminMiddleware } from '@server/middleware/auth'
31
+ import { Role } from '@shared/modules/permission'
32
+ import type { Context } from 'hono'
33
+
34
+ /**
35
+ * 租户内权限门禁:超管 bypass;成员须持有对应 TenantPermission
36
+ * (非成员直接 403——这就是"租户隔离"的路由层执行点)
37
+ */
38
+ async function requireTenantPerm(c: Context, tenantId: number, perm: TenantPermission) {
39
+ const user = c.get('authUser')
40
+ if (user.role === Role.SUPER_ADMIN) return
41
+ await tenantService.requireTenantMembership(user.id, tenantId)
42
+ const ok = await tenantService.hasTenantPermission(user.id, tenantId, perm)
43
+ if (!ok) throw AuthorizationError.permissionDenied(perm)
44
+ }
15
45
 
16
46
  const listRoute = createRoute({
17
47
  method: 'get',
@@ -107,6 +137,207 @@ const deleteRoute = createRoute({
107
137
  },
108
138
  })
109
139
 
140
+ const myTenantsRoute = createRoute({
141
+ method: 'get',
142
+ path: '/tenants/mine',
143
+ tags: ['tenants'],
144
+ security: [{ Bearer: [] }],
145
+ middleware: [authMiddleware()],
146
+ responses: {
147
+ 200: successResponse(TenantArrayResponseSchema, 'List tenants the current user belongs to'),
148
+ },
149
+ })
150
+
151
+ const tenantRolesListRoute = createRoute({
152
+ method: 'get',
153
+ path: '/tenants/{tenantId}/roles',
154
+ tags: ['tenants'],
155
+ security: [{ Bearer: [] }],
156
+ middleware: [authMiddleware()],
157
+ request: {
158
+ params: z.object({ tenantId: z.coerce.number().int().positive() }),
159
+ },
160
+ responses: {
161
+ 200: successResponse(TenantRoleArrayResponseSchema, 'List tenant roles'),
162
+ 403: errorResponse('Not a member of this tenant'),
163
+ },
164
+ })
165
+
166
+ const tenantRoleCreateRoute = createRoute({
167
+ method: 'post',
168
+ path: '/tenants/{tenantId}/roles',
169
+ tags: ['tenants'],
170
+ security: [{ Bearer: [] }],
171
+ middleware: [authMiddleware()],
172
+ request: {
173
+ params: z.object({ tenantId: z.coerce.number().int().positive() }),
174
+ body: { content: { 'application/json': { schema: CreateTenantRoleSchema } } },
175
+ },
176
+ responses: {
177
+ 201: successResponse(TenantRoleSchema, 'Create a custom tenant role'),
178
+ 400: errorResponse('Invalid input or plan role limit reached'),
179
+ 403: errorResponse('No permission'),
180
+ },
181
+ })
182
+
183
+ const tenantRoleUpdateRoute = createRoute({
184
+ method: 'put',
185
+ path: '/tenants/{tenantId}/roles/{roleId}',
186
+ tags: ['tenants'],
187
+ security: [{ Bearer: [] }],
188
+ middleware: [authMiddleware()],
189
+ request: {
190
+ params: z.object({
191
+ tenantId: z.coerce.number().int().positive(),
192
+ roleId: z.string(),
193
+ }),
194
+ body: { content: { 'application/json': { schema: UpdateTenantRoleSchema } } },
195
+ },
196
+ responses: {
197
+ 200: successResponse(TenantRoleSchema, 'Update a tenant role'),
198
+ 403: errorResponse('No permission'),
199
+ 404: errorResponse('Role not found'),
200
+ },
201
+ })
202
+
203
+ const tenantRoleDeleteRoute = createRoute({
204
+ method: 'delete',
205
+ path: '/tenants/{tenantId}/roles/{roleId}',
206
+ tags: ['tenants'],
207
+ security: [{ Bearer: [] }],
208
+ middleware: [authMiddleware()],
209
+ request: {
210
+ params: z.object({
211
+ tenantId: z.coerce.number().int().positive(),
212
+ roleId: z.string(),
213
+ }),
214
+ },
215
+ responses: {
216
+ 200: successResponse(StringIdResponseSchema, 'Soft-delete a tenant role'),
217
+ 400: errorResponse('System role cannot be deleted'),
218
+ 403: errorResponse('No permission'),
219
+ },
220
+ })
221
+
222
+ const tenantMembersListRoute = createRoute({
223
+ method: 'get',
224
+ path: '/tenants/{tenantId}/members',
225
+ tags: ['tenants'],
226
+ security: [{ Bearer: [] }],
227
+ middleware: [authMiddleware()],
228
+ request: {
229
+ params: z.object({ tenantId: z.coerce.number().int().positive() }),
230
+ },
231
+ responses: {
232
+ 200: successResponse(TenantMemberArrayResponseSchema, 'List tenant members with roles'),
233
+ 403: errorResponse('Not a member of this tenant'),
234
+ },
235
+ })
236
+
237
+ const memberInviteRoute = createRoute({
238
+ method: 'post',
239
+ path: '/tenants/{tenantId}/members/invite',
240
+ tags: ['tenants'],
241
+ security: [{ Bearer: [] }],
242
+ middleware: [authMiddleware()],
243
+ request: {
244
+ params: z.object({ tenantId: z.coerce.number().int().positive() }),
245
+ body: { content: { 'application/json': { schema: InviteMemberSchema } } },
246
+ },
247
+ responses: {
248
+ 201: successResponse(TenantInvitationSchema, 'Create an invitation (7-day token)'),
249
+ 400: errorResponse('Invalid input or role not in tenant'),
250
+ 403: errorResponse('No permission'),
251
+ },
252
+ })
253
+
254
+ const memberUpdateRoute = createRoute({
255
+ method: 'put',
256
+ path: '/tenants/{tenantId}/members/{memberId}',
257
+ tags: ['tenants'],
258
+ security: [{ Bearer: [] }],
259
+ middleware: [authMiddleware()],
260
+ request: {
261
+ params: z.object({
262
+ tenantId: z.coerce.number().int().positive(),
263
+ memberId: z.string(),
264
+ }),
265
+ body: { content: { 'application/json': { schema: UpdateMemberRoleSchema } } },
266
+ },
267
+ responses: {
268
+ 200: successResponse(TenantMemberSchema, 'Change member role'),
269
+ 403: errorResponse('No permission'),
270
+ 404: errorResponse('Member not found'),
271
+ },
272
+ })
273
+
274
+ const memberRemoveRoute = createRoute({
275
+ method: 'delete',
276
+ path: '/tenants/{tenantId}/members/{memberId}',
277
+ tags: ['tenants'],
278
+ security: [{ Bearer: [] }],
279
+ middleware: [authMiddleware()],
280
+ request: {
281
+ params: z.object({
282
+ tenantId: z.coerce.number().int().positive(),
283
+ memberId: z.string(),
284
+ }),
285
+ },
286
+ responses: {
287
+ 200: successResponse(StringIdResponseSchema, 'Remove member (soft, status=left)'),
288
+ 403: errorResponse('No permission'),
289
+ 404: errorResponse('Member not found'),
290
+ },
291
+ })
292
+
293
+ const invitationCancelRoute = createRoute({
294
+ method: 'delete',
295
+ path: '/tenants/{tenantId}/invitations/{invitationId}',
296
+ tags: ['tenants'],
297
+ security: [{ Bearer: [] }],
298
+ middleware: [authMiddleware()],
299
+ request: {
300
+ params: z.object({
301
+ tenantId: z.coerce.number().int().positive(),
302
+ invitationId: z.string(),
303
+ }),
304
+ },
305
+ responses: {
306
+ 200: successResponse(StringIdResponseSchema, 'Cancel invitation'),
307
+ 403: errorResponse('No permission'),
308
+ 404: errorResponse('Invitation not found'),
309
+ },
310
+ })
311
+
312
+ const invitationDetailRoute = createRoute({
313
+ method: 'get',
314
+ path: '/tenants/invitations/{token}',
315
+ tags: ['tenants'],
316
+ summary: 'Get invitation detail (public, for the invite landing page)',
317
+ request: {
318
+ params: z.object({ token: z.string() }),
319
+ },
320
+ responses: {
321
+ 200: successResponse(PublicInvitationSchema, 'Invitation detail (sanitized)'),
322
+ 404: errorResponse('Invitation not found'),
323
+ },
324
+ })
325
+
326
+ const invitationAcceptRoute = createRoute({
327
+ method: 'post',
328
+ path: '/tenants/invitations/{token}/accept',
329
+ tags: ['tenants'],
330
+ security: [{ Bearer: [] }],
331
+ middleware: [authMiddleware()],
332
+ request: {
333
+ params: z.object({ token: z.string() }),
334
+ },
335
+ responses: {
336
+ 200: successResponse(TenantMemberSchema, 'Accept invitation and join tenant'),
337
+ 404: errorResponse('Invitation not found or expired'),
338
+ },
339
+ })
340
+
110
341
  const getCurrentTenantRoute = createRoute({
111
342
  method: 'get',
112
343
  path: '/tenant/current',
@@ -130,6 +361,11 @@ export const apiRoutes = new OpenAPIHono()
130
361
 
131
362
  return c.json(success(result), 200)
132
363
  })
364
+ .openapi(myTenantsRoute, async c => {
365
+ const user = c.get('authUser')
366
+ const tenantsForUser = await tenantService.getUserTenants(user.id)
367
+ return c.json(success(tenantsForUser), 200)
368
+ })
133
369
  .openapi(getRoute, async c => {
134
370
  const { id } = c.req.valid('param')
135
371
  const tenant = await tenantService.getTenantById(id)
@@ -144,7 +380,8 @@ export const apiRoutes = new OpenAPIHono()
144
380
  })
145
381
  .openapi(createRouteDef, async c => {
146
382
  const data = c.req.valid('json')
147
- const tenant = await tenantService.createTenant(data)
383
+ const user = c.get('authUser')
384
+ const tenant = await tenantService.createTenant(data, user.id)
148
385
  return c.json(success(tenant), 201)
149
386
  })
150
387
  .openapi(updateRoute, async c => {
@@ -166,6 +403,106 @@ export const apiRoutes = new OpenAPIHono()
166
403
  return c.json(success(tenant), 200)
167
404
  })
168
405
  // todos 模块已占用 /docs;tenant 的 OpenAPI 文档挂独立路径避免 shadow
406
+ .openapi(tenantRolesListRoute, async c => {
407
+ const { tenantId } = c.req.valid('param')
408
+ await requireTenantPerm(c, tenantId, TenantPermission.ROLE_VIEW)
409
+ const roles = await tenantService.getTenantRoles(tenantId)
410
+ return c.json(success(roles), 200)
411
+ })
412
+ .openapi(tenantRoleCreateRoute, async c => {
413
+ const { tenantId } = c.req.valid('param')
414
+ await requireTenantPerm(c, tenantId, TenantPermission.ROLE_CREATE)
415
+ const data = c.req.valid('json')
416
+ const role = await tenantService.createTenantRole(tenantId, {
417
+ code: data.code,
418
+ name: data.code,
419
+ label: data.label,
420
+ description: data.description ?? null,
421
+ permissions: data.permissions,
422
+ })
423
+ return c.json(success(role), 201)
424
+ })
425
+ .openapi(tenantRoleUpdateRoute, async c => {
426
+ const { tenantId, roleId } = c.req.valid('param')
427
+ await requireTenantPerm(c, tenantId, TenantPermission.ROLE_EDIT)
428
+ // 目标角色必须属于本租户
429
+ const roles = await tenantService.getTenantRoles(tenantId)
430
+ if (!roles.some(r => r.id === roleId)) throw new NotFoundError('Role', roleId)
431
+ const data = c.req.valid('json')
432
+ const role = await tenantService.updateTenantRole(roleId, {
433
+ label: data.label ?? undefined,
434
+ description: data.description ?? undefined,
435
+ permissions: data.permissions ?? undefined,
436
+ })
437
+ if (!role) throw new NotFoundError('Role', roleId)
438
+ return c.json(success(role), 200)
439
+ })
440
+ .openapi(tenantRoleDeleteRoute, async c => {
441
+ const { tenantId, roleId } = c.req.valid('param')
442
+ await requireTenantPerm(c, tenantId, TenantPermission.ROLE_DELETE)
443
+ const roles = await tenantService.getTenantRoles(tenantId)
444
+ if (!roles.some(r => r.id === roleId)) throw new NotFoundError('Role', roleId)
445
+ const result = await tenantService.deleteTenantRole(roleId)
446
+ if (!result) throw new NotFoundError('Role', roleId)
447
+ return c.json(success({ id: roleId }), 200)
448
+ })
449
+ .openapi(tenantMembersListRoute, async c => {
450
+ const { tenantId } = c.req.valid('param')
451
+ await requireTenantPerm(c, tenantId, TenantPermission.MEMBER_VIEW)
452
+ const members = await tenantService.getTenantMembers(tenantId)
453
+ return c.json(success(members), 200)
454
+ })
455
+ .openapi(memberInviteRoute, async c => {
456
+ const { tenantId } = c.req.valid('param')
457
+ await requireTenantPerm(c, tenantId, TenantPermission.MEMBER_INVITE)
458
+ const user = c.get('authUser')
459
+ const data = c.req.valid('json')
460
+ const invitation = await tenantService.inviteMember(tenantId, data.email, data.roleId, user.id)
461
+ return c.json(success(invitation), 201)
462
+ })
463
+ .openapi(memberUpdateRoute, async c => {
464
+ const { tenantId, memberId } = c.req.valid('param')
465
+ await requireTenantPerm(c, tenantId, TenantPermission.MEMBER_ROLE_ASSIGN)
466
+ const data = c.req.valid('json')
467
+ const member = await tenantService.updateMemberRole(tenantId, memberId, data.roleId)
468
+ if (!member) throw new NotFoundError('Member', memberId)
469
+ return c.json(success(member), 200)
470
+ })
471
+ .openapi(memberRemoveRoute, async c => {
472
+ const { tenantId, memberId } = c.req.valid('param')
473
+ await requireTenantPerm(c, tenantId, TenantPermission.MEMBER_REMOVE)
474
+ const result = await tenantService.removeMember(tenantId, memberId)
475
+ if (!result) throw new NotFoundError('Member', memberId)
476
+ return c.json(success({ id: memberId }), 200)
477
+ })
478
+ .openapi(invitationCancelRoute, async c => {
479
+ const { tenantId, invitationId } = c.req.valid('param')
480
+ await requireTenantPerm(c, tenantId, TenantPermission.MEMBER_INVITE)
481
+ const result = await tenantService.cancelInvitation(tenantId, invitationId)
482
+ if (!result) throw new NotFoundError('Invitation', invitationId)
483
+ return c.json(success({ id: invitationId }), 200)
484
+ })
485
+ .openapi(invitationDetailRoute, async c => {
486
+ const { token } = c.req.valid('param')
487
+ const detail = await tenantService.getInvitationByToken(token)
488
+ if (!detail) throw new NotFoundError('Invitation', token)
489
+ const payload: PublicInvitation = {
490
+ tenantName: detail.tenantName,
491
+ tenantSlug: detail.tenantSlug,
492
+ email: detail.invitation.email,
493
+ roleLabel: detail.roleLabel,
494
+ status: detail.invitation.status,
495
+ expiresAt: detail.invitation.expiresAt,
496
+ }
497
+ return c.json(success(payload), 200)
498
+ })
499
+ .openapi(invitationAcceptRoute, async c => {
500
+ const { token } = c.req.valid('param')
501
+ const user = c.get('authUser')
502
+ const member = await tenantService.acceptInvitation(token, user.id)
503
+ if (!member) throw new NotFoundError('Invitation (invalid, used or expired)', token)
504
+ return c.json(success(member), 200)
505
+ })
169
506
  .doc('/tenant/docs', {
170
507
  openapi: '3.0.0',
171
508
  info: {