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.
- package/dist/cli/index.js +80 -22
- package/dist/cli/index.js.map +1 -1
- package/package.json +12 -12
- package/template/package.json +10 -14
- package/template/src/cli/modules/tenant/index.ts +89 -0
- package/template/src/client/entry-server.tsx +7 -1
- package/template/src/client/main.tsx +0 -8
- package/template/src/client/pages/ContentDetailPage.tsx +1 -1
- package/template/src/client/pages/ContentListPage.tsx +1 -1
- package/template/src/client/pages/TodoPage.tsx +20 -4
- package/template/src/client/ssr-pages.ts +10 -21
- package/template/src/server/db/init.ts +0 -52
- package/template/src/server/db/schema/index.ts +3 -0
- package/template/src/server/db/schema/tenant-invitations.ts +33 -0
- package/template/src/server/db/schema/tenant-members.ts +38 -0
- package/template/src/server/db/schema/tenant-roles.ts +32 -0
- package/template/src/server/db/schema/todos.ts +4 -0
- package/template/src/server/db/test-setup.ts +128 -0
- package/template/src/server/entries/node.ts +38 -19
- package/template/src/server/index.ts +12 -0
- package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
- package/template/src/server/middleware/auth.ts +17 -19
- package/template/src/server/middleware/tenant-isolation.ts +4 -2
- package/template/src/server/module-auth/module.ts +2 -1
- package/template/src/server/module-auth/services/auth-service.ts +39 -0
- package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
- package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
- package/template/src/server/module-tenant/module.ts +1 -1
- package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
- package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
- package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
- package/template/src/server/module-todos/services/todo-service.ts +12 -2
- package/template/src/server/ssr-bridge.ts +10 -0
- package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
- package/template/src/server/utils/id-helpers.ts +13 -0
- package/template/src/shared/modules/index.ts +29 -6
- package/template/src/shared/modules/tenant/index.ts +30 -0
- package/template/src/shared/modules/tenant/permissions.ts +54 -0
- package/template/src/shared/modules/tenant/role-templates.ts +66 -0
- package/template/src/shared/modules/tenant/schemas.ts +113 -1
- package/template/src/shared/modules/todos/schemas.ts +1 -0
- package/template/src/shared/schemas/index.ts +28 -0
- package/template/src/tenant/App.tsx +30 -23
- package/template/src/tenant/components/TenantGuard.tsx +12 -2
- package/template/src/tenant/layouts/Header.tsx +6 -2
- package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
- package/template/src/tenant/pages/LoginPage.tsx +82 -0
- package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
- package/template/src/tenant/pages/UsersPage.tsx +134 -88
- package/template/src/tenant/services/tenantApi.ts +61 -0
- package/template/src/tenant/stores/tenantStore.ts +172 -49
- package/template/src/test/setup-db-path.ts +20 -0
- package/template/vite.config.ts +4 -73
- 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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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: {
|