create-fullstack-scaffold 0.5.7 → 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 (46) hide show
  1. package/dist/cli/index.js +65 -2
  2. package/dist/cli/index.js.map +1 -1
  3. package/package.json +12 -12
  4. package/template/package.json +10 -10
  5. package/template/src/cli/modules/tenant/index.ts +89 -0
  6. package/template/src/server/db/init.ts +0 -52
  7. package/template/src/server/db/schema/index.ts +3 -0
  8. package/template/src/server/db/schema/tenant-invitations.ts +33 -0
  9. package/template/src/server/db/schema/tenant-members.ts +38 -0
  10. package/template/src/server/db/schema/tenant-roles.ts +32 -0
  11. package/template/src/server/db/schema/todos.ts +4 -0
  12. package/template/src/server/db/test-setup.ts +128 -0
  13. package/template/src/server/entries/node.ts +13 -0
  14. package/template/src/server/index.ts +12 -0
  15. package/template/src/server/middleware/__tests__/tenant-isolation.test.ts +55 -9
  16. package/template/src/server/middleware/auth.ts +17 -19
  17. package/template/src/server/middleware/tenant-isolation.ts +4 -2
  18. package/template/src/server/module-auth/module.ts +2 -1
  19. package/template/src/server/module-auth/services/auth-service.ts +39 -0
  20. package/template/src/server/module-tenant/__tests__/tenant-routes.test.ts +97 -0
  21. package/template/src/server/module-tenant/__tests__/tenant-service.test.ts +201 -22
  22. package/template/src/server/module-tenant/module.ts +1 -1
  23. package/template/src/server/module-tenant/routes/tenant-routes.ts +339 -2
  24. package/template/src/server/module-tenant/services/tenant-service.ts +581 -17
  25. package/template/src/server/module-todos/routes/todos-routes.ts +10 -2
  26. package/template/src/server/module-todos/services/todo-service.ts +12 -2
  27. package/template/src/server/utils/__tests__/captcha.test.ts +6 -4
  28. package/template/src/server/utils/id-helpers.ts +13 -0
  29. package/template/src/shared/modules/index.ts +29 -6
  30. package/template/src/shared/modules/tenant/index.ts +30 -0
  31. package/template/src/shared/modules/tenant/permissions.ts +54 -0
  32. package/template/src/shared/modules/tenant/role-templates.ts +66 -0
  33. package/template/src/shared/modules/tenant/schemas.ts +113 -1
  34. package/template/src/shared/modules/todos/schemas.ts +1 -0
  35. package/template/src/shared/schemas/index.ts +28 -0
  36. package/template/src/tenant/App.tsx +30 -23
  37. package/template/src/tenant/components/TenantGuard.tsx +12 -2
  38. package/template/src/tenant/layouts/Header.tsx +6 -2
  39. package/template/src/tenant/pages/InviteAcceptPage.tsx +97 -0
  40. package/template/src/tenant/pages/LoginPage.tsx +82 -0
  41. package/template/src/tenant/pages/SubscriptionPage.tsx +32 -55
  42. package/template/src/tenant/pages/UsersPage.tsx +134 -88
  43. package/template/src/tenant/services/tenantApi.ts +61 -0
  44. package/template/src/tenant/stores/tenantStore.ts +172 -49
  45. package/template/src/test/setup-db-path.ts +20 -0
  46. package/template/vitest.config.ts +3 -1
@@ -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: {