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
@@ -61,6 +61,8 @@ export async function seedTodosIfEmpty(): Promise<void> {
61
61
  export async function listTodos(options?: {
62
62
  page?: number
63
63
  limit?: number
64
+ /** 租户上下文存在时按租户隔离(saas 等含 tenant 模块的 preset) */
65
+ tenantId?: number
64
66
  }): Promise<{ todos: Todo[]; total: number; page: number; limit: number }> {
65
67
  const db = await getDb()
66
68
  const page = options?.page ?? 1
@@ -71,15 +73,20 @@ export async function listTodos(options?: {
71
73
  const rows = await db
72
74
  .select()
73
75
  .from(todos)
76
+ .where(options?.tenantId != null ? eq(todos.tenantId, options.tenantId) : undefined)
74
77
  .orderBy(desc(todos.createdAt))
75
78
  .limit(limit)
76
79
  .offset(offset)
77
80
  // db 为 LibSQL|D1 联合类型:select(config) 会重载坍缩,$count 两驱动同签名
78
- const total = await db.$count(todos)
81
+ const total = await db.$count(
82
+ todos,
83
+ options?.tenantId != null ? eq(todos.tenantId, options.tenantId) : undefined
84
+ )
79
85
 
80
86
  return {
81
87
  todos: rows.map((row: TodoTable) => ({
82
88
  id: row.id,
89
+ tenantId: row.tenantId ?? null,
83
90
  title: row.title,
84
91
  description: row.description ?? undefined,
85
92
  status: row.status,
@@ -109,12 +116,13 @@ export async function getTodo(id: number): Promise<Todo | null> {
109
116
  }
110
117
  }
111
118
 
112
- export async function createTodo(input: CreateTodoInput): Promise<Todo> {
119
+ export async function createTodo(input: CreateTodoInput, tenantId?: number): Promise<Todo> {
113
120
  const db = await getDb()
114
121
  const now = new Date()
115
122
  const result = await db
116
123
  .insert(todos)
117
124
  .values({
125
+ tenantId: tenantId ?? null,
118
126
  title: input.title,
119
127
  description: input.description ?? null,
120
128
  status: 'pending',
@@ -126,6 +134,7 @@ export async function createTodo(input: CreateTodoInput): Promise<Todo> {
126
134
  const row = result[0]
127
135
  return {
128
136
  id: row.id,
137
+ tenantId: row.tenantId ?? null,
129
138
  title: row.title,
130
139
  description: row.description ?? undefined,
131
140
  status: row.status,
@@ -158,6 +167,7 @@ export async function updateTodo(id: number, input: UpdateTodoInput): Promise<To
158
167
  const row = result[0]
159
168
  return {
160
169
  id: row.id,
170
+ tenantId: row.tenantId ?? null,
161
171
  title: row.title,
162
172
  description: row.description ?? undefined,
163
173
  status: row.status,
@@ -0,0 +1,10 @@
1
+ /**
2
+ * SSR 渲染桥(本文件在模板中为全量版;CLI 脚手架时按 preset 重新生成:
3
+ * cli-only 等无 client 的 preset 导出 null,避免悬空导入)。
4
+ */
5
+ import { renderSSR as renderSSRImpl } from '@client/entry-server'
6
+
7
+ // data 由 ISR registry fetch 提供(形状随模块而异),renderSSR 内部自取所需字段
8
+ export function renderSSR(pathname: string, data: unknown): { html: string } {
9
+ return renderSSRImpl(pathname, data as Parameters<typeof renderSSRImpl>[1])
10
+ }
@@ -6,10 +6,12 @@ function extractCodeFromSvg(image: string): string {
6
6
  const decoded = Buffer.from(image.split(',')[1], 'base64').toString()
7
7
  const matches = decoded.match(/>\s*([A-Z0-9])\s*<\/text>/g)
8
8
  if (!matches) throw new Error('Could not extract code from SVG')
9
- return matches.map(m => {
10
- const char = m.match(/>\s*([A-Z0-9])\s*</)
11
- return char ? char[1] : ''
12
- }).join('')
9
+ return matches
10
+ .map((m: string) => {
11
+ const char = m.match(/>\s*([A-Z0-9])\s*</)
12
+ return char ? char[1] : ''
13
+ })
14
+ .join('')
13
15
  }
14
16
 
15
17
  describe('captcha', () => {
@@ -3,3 +3,16 @@ export function parseModuleId(prefix: string, id: string): number {
3
3
  if (isNaN(num)) return -1
4
4
  return num
5
5
  }
6
+
7
+ /** 租户域等文本主键:前缀 + 随机串(tenant_roles/tenant_members/invitations) */
8
+ export function generateId(prefix: string): string {
9
+ return `${prefix}_${globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 20)}`
10
+ }
11
+
12
+ /** 一次性令牌(邀请链接等),48 hex 字符 */
13
+ export function generateToken(): string {
14
+ return (
15
+ globalThis.crypto.randomUUID().replace(/-/g, '') +
16
+ globalThis.crypto.randomUUID().replace(/-/g, '').slice(0, 16)
17
+ )
18
+ }
@@ -121,6 +121,24 @@ export {
121
121
  TenantListResponseSchema,
122
122
  TenantQuerySchema,
123
123
  TenantIdResponseSchema,
124
+ TenantRoleSchema,
125
+ CreateTenantRoleSchema,
126
+ UpdateTenantRoleSchema,
127
+ TenantMemberSchema,
128
+ UpdateMemberRoleSchema,
129
+ TenantInvitationSchema,
130
+ InviteMemberSchema,
131
+ PublicInvitationSchema,
132
+ StringIdResponseSchema,
133
+ TenantArrayResponseSchema,
134
+ TenantRoleArrayResponseSchema,
135
+ TenantMemberArrayResponseSchema,
136
+ TenantPermission,
137
+ TENANT_PERMISSION_VALUES,
138
+ TENANT_PERMISSION_LABELS,
139
+ TenantRoleCode,
140
+ TENANT_ROLE_TEMPLATES,
141
+ PLAN_ROLE_LIMITS,
124
142
  type Tenant,
125
143
  type TenantStatus,
126
144
  type TenantPlan,
@@ -132,6 +150,16 @@ export {
132
150
  type TenantListResponse,
133
151
  type TenantQuery,
134
152
  type TenantIdResponse,
153
+ type TenantRole,
154
+ type CreateTenantRoleInput,
155
+ type UpdateTenantRoleInput,
156
+ type TenantMember,
157
+ type UpdateMemberRoleInput,
158
+ type TenantInvitation,
159
+ type InviteMemberInput,
160
+ type PublicInvitation,
161
+ type StringIdResponse,
162
+ type TenantRoleTemplate,
135
163
  } from './tenant'
136
164
  export {
137
165
  OrderStatusSchema,
@@ -264,12 +292,7 @@ export {
264
292
  type ProfileActivity,
265
293
  type ProfileResponse,
266
294
  } from './community'
267
- export {
268
- ResourceTypeSchema,
269
- ActionTypeSchema,
270
- AuditLogSchema,
271
- type AuditLogType,
272
- } from './audit'
295
+ export { ResourceTypeSchema, ActionTypeSchema, AuditLogSchema, type AuditLogType } from './audit'
273
296
  export {
274
297
  RoleSchema,
275
298
  CreateRoleSchema,
@@ -22,3 +22,33 @@ export {
22
22
  type TenantQuery,
23
23
  type TenantIdResponse,
24
24
  } from './schemas'
25
+ export {
26
+ TenantRoleSchema,
27
+ CreateTenantRoleSchema,
28
+ UpdateTenantRoleSchema,
29
+ TenantMemberSchema,
30
+ UpdateMemberRoleSchema,
31
+ TenantInvitationSchema,
32
+ InviteMemberSchema,
33
+ PublicInvitationSchema,
34
+ StringIdResponseSchema,
35
+ TenantArrayResponseSchema,
36
+ TenantRoleArrayResponseSchema,
37
+ TenantMemberArrayResponseSchema,
38
+ type TenantRole,
39
+ type CreateTenantRoleInput,
40
+ type UpdateTenantRoleInput,
41
+ type TenantMember,
42
+ type UpdateMemberRoleInput,
43
+ type TenantInvitation,
44
+ type InviteMemberInput,
45
+ type PublicInvitation,
46
+ type StringIdResponse,
47
+ } from './schemas'
48
+ export { TenantPermission, TENANT_PERMISSION_VALUES, TENANT_PERMISSION_LABELS } from './permissions'
49
+ export {
50
+ TenantRoleCode,
51
+ TENANT_ROLE_TEMPLATES,
52
+ PLAN_ROLE_LIMITS,
53
+ type TenantRoleTemplate,
54
+ } from './role-templates'
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 租户内权限点(与平台级 permission 模块区分:平台管全局,这里管租户内)。
3
+ * 角色的 permissions 字段存本枚举值数组的 JSON。
4
+ */
5
+ export enum TenantPermission {
6
+ MEMBER_VIEW = 'tenant:member:view',
7
+ MEMBER_INVITE = 'tenant:member:invite',
8
+ MEMBER_REMOVE = 'tenant:member:remove',
9
+ MEMBER_ROLE_ASSIGN = 'tenant:member:role:assign',
10
+
11
+ ROLE_VIEW = 'tenant:role:view',
12
+ ROLE_CREATE = 'tenant:role:create',
13
+ ROLE_EDIT = 'tenant:role:edit',
14
+ ROLE_DELETE = 'tenant:role:delete',
15
+
16
+ SETTINGS_VIEW = 'tenant:settings:view',
17
+ SETTINGS_EDIT = 'tenant:settings:edit',
18
+
19
+ DATA_VIEW = 'tenant:data:view',
20
+ DATA_CREATE = 'tenant:data:create',
21
+ DATA_EDIT = 'tenant:data:edit',
22
+ DATA_DELETE = 'tenant:data:delete',
23
+ DATA_EXPORT = 'tenant:data:export',
24
+ DATA_IMPORT = 'tenant:data:import',
25
+
26
+ BILLING_VIEW = 'tenant:billing:view',
27
+ BILLING_MANAGE = 'tenant:billing:manage',
28
+
29
+ AUDIT_VIEW = 'tenant:audit:view',
30
+ }
31
+
32
+ export const TENANT_PERMISSION_VALUES = Object.values(TenantPermission)
33
+
34
+ export const TENANT_PERMISSION_LABELS: Record<TenantPermission, string> = {
35
+ [TenantPermission.MEMBER_VIEW]: '查看成员',
36
+ [TenantPermission.MEMBER_INVITE]: '邀请成员',
37
+ [TenantPermission.MEMBER_REMOVE]: '移除成员',
38
+ [TenantPermission.MEMBER_ROLE_ASSIGN]: '分配角色',
39
+ [TenantPermission.ROLE_VIEW]: '查看角色',
40
+ [TenantPermission.ROLE_CREATE]: '创建角色',
41
+ [TenantPermission.ROLE_EDIT]: '编辑角色',
42
+ [TenantPermission.ROLE_DELETE]: '删除角色',
43
+ [TenantPermission.SETTINGS_VIEW]: '查看设置',
44
+ [TenantPermission.SETTINGS_EDIT]: '编辑设置',
45
+ [TenantPermission.DATA_VIEW]: '查看数据',
46
+ [TenantPermission.DATA_CREATE]: '创建数据',
47
+ [TenantPermission.DATA_EDIT]: '编辑数据',
48
+ [TenantPermission.DATA_DELETE]: '删除数据',
49
+ [TenantPermission.DATA_EXPORT]: '导出数据',
50
+ [TenantPermission.DATA_IMPORT]: '导入数据',
51
+ [TenantPermission.BILLING_VIEW]: '查看账单',
52
+ [TenantPermission.BILLING_MANAGE]: '管理账单',
53
+ [TenantPermission.AUDIT_VIEW]: '查看审计',
54
+ }
@@ -0,0 +1,66 @@
1
+ import { TenantPermission } from './permissions'
2
+
3
+ export enum TenantRoleCode {
4
+ ADMIN = 'tenant_admin',
5
+ MEMBER = 'tenant_member',
6
+ GUEST = 'tenant_guest',
7
+ }
8
+
9
+ export interface TenantRoleTemplate {
10
+ code: TenantRoleCode
11
+ name: string
12
+ label: string
13
+ description: string
14
+ isSystem: boolean
15
+ permissions: TenantPermission[]
16
+ }
17
+
18
+ /** 开租户时事务内播种的三个系统角色 */
19
+ export const TENANT_ROLE_TEMPLATES: TenantRoleTemplate[] = [
20
+ {
21
+ code: TenantRoleCode.ADMIN,
22
+ name: 'tenant_admin',
23
+ label: '租户管理员',
24
+ description: '拥有租户内所有权限,可管理成员、角色和设置',
25
+ isSystem: true,
26
+ permissions: Object.values(TenantPermission),
27
+ },
28
+ {
29
+ code: TenantRoleCode.MEMBER,
30
+ name: 'tenant_member',
31
+ label: '普通成员',
32
+ description: '可访问租户数据,无法管理成员和设置',
33
+ isSystem: true,
34
+ permissions: [
35
+ TenantPermission.MEMBER_VIEW,
36
+ TenantPermission.ROLE_VIEW,
37
+ TenantPermission.SETTINGS_VIEW,
38
+ TenantPermission.DATA_VIEW,
39
+ TenantPermission.DATA_CREATE,
40
+ TenantPermission.DATA_EDIT,
41
+ TenantPermission.DATA_DELETE,
42
+ TenantPermission.DATA_EXPORT,
43
+ ],
44
+ },
45
+ {
46
+ code: TenantRoleCode.GUEST,
47
+ name: 'tenant_guest',
48
+ label: '访客',
49
+ description: '只读权限,仅可查看数据',
50
+ isSystem: true,
51
+ permissions: [
52
+ TenantPermission.MEMBER_VIEW,
53
+ TenantPermission.ROLE_VIEW,
54
+ TenantPermission.SETTINGS_VIEW,
55
+ TenantPermission.DATA_VIEW,
56
+ ],
57
+ },
58
+ ]
59
+
60
+ /** 套餐→自定义角色数上限(-1 不限),超限创建角色时真实拦截 */
61
+ export const PLAN_ROLE_LIMITS: Record<string, number> = {
62
+ free: 3,
63
+ starter: 5,
64
+ pro: 10,
65
+ enterprise: -1,
66
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from '@hono/zod-openapi'
2
+ import { TENANT_PERMISSION_VALUES } from './permissions'
2
3
 
3
4
  export const TenantStatusSchema = z.enum(['active', 'suspended', 'trial'])
4
5
  export type TenantStatus = z.infer<typeof TenantStatusSchema>
@@ -36,7 +37,8 @@ export const CreateTenantSchema = z.object({
36
37
  .regex(/^[a-z0-9-]+$/),
37
38
  plan: TenantPlanSchema.default('free'),
38
39
  maxUsers: z.number().int().min(1).max(1000).default(5),
39
- settings: TenantSettingsSchema,
40
+ // 可选:建租户时通常没有初始配置,service 层兜底空对象
41
+ settings: TenantSettingsSchema.nullish().default({}),
40
42
  })
41
43
 
42
44
  export type CreateTenantInput = z.infer<typeof CreateTenantSchema>
@@ -86,3 +88,113 @@ export const TenantQuerySchema = z.object({
86
88
  })
87
89
 
88
90
  export type TenantQuery = z.infer<typeof TenantQuerySchema>
91
+
92
+ // ============ 租户角色 ============
93
+ export const TenantRoleSchema = z.object({
94
+ id: z.string(),
95
+ tenantId: z.number().int().positive(),
96
+ code: z.string().min(1).max(100),
97
+ name: z.string().min(1).max(100),
98
+ label: z.string().min(1).max(100),
99
+ description: z.string().nullish(),
100
+ permissions: z.array(z.enum(TENANT_PERMISSION_VALUES as [string, ...string[]])),
101
+ isSystem: z.boolean(),
102
+ isActive: z.boolean(),
103
+ sortOrder: z.number().int(),
104
+ createdAt: z.string().datetime(),
105
+ updatedAt: z.string().datetime(),
106
+ })
107
+
108
+ export type TenantRole = z.infer<typeof TenantRoleSchema>
109
+
110
+ export const CreateTenantRoleSchema = z.object({
111
+ code: z
112
+ .string()
113
+ .min(1)
114
+ .max(100)
115
+ .regex(/^[a-z0-9_-]+$/),
116
+ name: z.string().min(1).max(100),
117
+ label: z.string().min(1).max(100),
118
+ description: z.string().nullish(),
119
+ permissions: z.array(z.enum(TENANT_PERMISSION_VALUES as [string, ...string[]])).min(1),
120
+ })
121
+
122
+ export type CreateTenantRoleInput = z.infer<typeof CreateTenantRoleSchema>
123
+
124
+ export const UpdateTenantRoleSchema = z.object({
125
+ label: z.string().min(1).max(100).nullish(),
126
+ description: z.string().nullish(),
127
+ permissions: z
128
+ .array(z.enum(TENANT_PERMISSION_VALUES as [string, ...string[]]))
129
+ .min(1)
130
+ .nullish(),
131
+ })
132
+
133
+ export type UpdateTenantRoleInput = z.infer<typeof UpdateTenantRoleSchema>
134
+
135
+ // ============ 租户成员 ============
136
+ export const TenantMemberSchema = z.object({
137
+ id: z.string(),
138
+ tenantId: z.number().int().positive(),
139
+ userId: z.string(),
140
+ roleId: z.string(),
141
+ /** 展示名:developers 表用户名,dev token 用户回退 userId */
142
+ username: z.string().nullish(),
143
+ role: TenantRoleSchema.nullish(),
144
+ status: z.enum(['active', 'pending', 'suspended', 'left']),
145
+ invitedBy: z.string().nullish(),
146
+ invitedAt: z.string().datetime().nullish(),
147
+ joinedAt: z.string().datetime(),
148
+ lastActiveAt: z.string().datetime().nullish(),
149
+ })
150
+
151
+ export type TenantMember = z.infer<typeof TenantMemberSchema>
152
+
153
+ export const UpdateMemberRoleSchema = z.object({
154
+ roleId: z.string().min(1),
155
+ })
156
+
157
+ export type UpdateMemberRoleInput = z.infer<typeof UpdateMemberRoleSchema>
158
+
159
+ // ============ 租户邀请 ============
160
+ export const TenantInvitationSchema = z.object({
161
+ id: z.string(),
162
+ tenantId: z.number().int().positive(),
163
+ email: z.string().email(),
164
+ roleId: z.string(),
165
+ inviterId: z.string(),
166
+ token: z.string(),
167
+ status: z.enum(['pending', 'accepted', 'declined', 'expired', 'cancelled']),
168
+ expiresAt: z.string().datetime(),
169
+ acceptedAt: z.string().datetime().nullish(),
170
+ createdAt: z.string().datetime(),
171
+ })
172
+
173
+ export type TenantInvitation = z.infer<typeof TenantInvitationSchema>
174
+
175
+ export const InviteMemberSchema = z.object({
176
+ email: z.string().email(),
177
+ roleId: z.string().min(1),
178
+ })
179
+
180
+ export type InviteMemberInput = z.infer<typeof InviteMemberSchema>
181
+
182
+ /** 邀请详情(公开接口):脱敏后返回,不含 inviterId */
183
+ export const PublicInvitationSchema = z.object({
184
+ tenantName: z.string(),
185
+ tenantSlug: z.string(),
186
+ email: z.string().email(),
187
+ roleLabel: z.string(),
188
+ status: z.enum(['pending', 'accepted', 'declined', 'expired', 'cancelled']),
189
+ expiresAt: z.string().datetime(),
190
+ })
191
+
192
+ export type PublicInvitation = z.infer<typeof PublicInvitationSchema>
193
+
194
+ // ============ 列表/ID 响应包装(路由 responses 不得内联 schema) ============
195
+ export const StringIdResponseSchema = z.object({ id: z.string() })
196
+ export type StringIdResponse = z.infer<typeof StringIdResponseSchema>
197
+
198
+ export const TenantArrayResponseSchema = z.array(TenantSchema)
199
+ export const TenantRoleArrayResponseSchema = z.array(TenantRoleSchema)
200
+ export const TenantMemberArrayResponseSchema = z.array(TenantMemberSchema)
@@ -4,6 +4,7 @@ export const TodoStatusSchema = z.enum(['pending', 'in_progress', 'completed'])
4
4
 
5
5
  export const TodoSchema = z.object({
6
6
  id: z.number().int().positive(),
7
+ tenantId: z.number().int().positive().nullish(),
7
8
  title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
8
9
  description: z.string().max(1000, 'Description too long').nullish(),
9
10
  status: TodoStatusSchema,
@@ -225,6 +225,24 @@ export {
225
225
  TenantListResponseSchema,
226
226
  TenantQuerySchema,
227
227
  TenantIdResponseSchema,
228
+ TenantRoleSchema,
229
+ CreateTenantRoleSchema,
230
+ UpdateTenantRoleSchema,
231
+ TenantMemberSchema,
232
+ UpdateMemberRoleSchema,
233
+ TenantInvitationSchema,
234
+ InviteMemberSchema,
235
+ PublicInvitationSchema,
236
+ StringIdResponseSchema,
237
+ TenantArrayResponseSchema,
238
+ TenantRoleArrayResponseSchema,
239
+ TenantMemberArrayResponseSchema,
240
+ TenantPermission,
241
+ TENANT_PERMISSION_VALUES,
242
+ TENANT_PERMISSION_LABELS,
243
+ TenantRoleCode,
244
+ TENANT_ROLE_TEMPLATES,
245
+ PLAN_ROLE_LIMITS,
228
246
  type Tenant,
229
247
  type TenantStatus,
230
248
  type TenantPlan,
@@ -236,6 +254,16 @@ export {
236
254
  type TenantListResponse,
237
255
  type TenantQuery,
238
256
  type TenantIdResponse,
257
+ type TenantRole,
258
+ type CreateTenantRoleInput,
259
+ type UpdateTenantRoleInput,
260
+ type TenantMember,
261
+ type UpdateMemberRoleInput,
262
+ type TenantInvitation,
263
+ type InviteMemberInput,
264
+ type PublicInvitation,
265
+ type StringIdResponse,
266
+ type TenantRoleTemplate,
239
267
  } from '../modules/tenant'
240
268
  export {
241
269
  DisputeTypeSchema,
@@ -1,5 +1,5 @@
1
1
  import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
2
- import { ConfigProvider } from 'antd'
2
+ import { ConfigProvider, App as AntApp } from 'antd'
3
3
  import { Layout } from './layouts/Layout'
4
4
  import { DashboardPage } from './pages/DashboardPage'
5
5
  import { UsersPage } from './pages/UsersPage'
@@ -7,6 +7,8 @@ import { SubscriptionPage } from './pages/SubscriptionPage'
7
7
  import { SettingsPage } from './pages/SettingsPage'
8
8
  import { TodosPage } from './pages/TodosPage'
9
9
  import { ContentPage } from './pages/ContentPage'
10
+ import { LoginPage } from './pages/LoginPage'
11
+ import { InviteAcceptPage } from './pages/InviteAcceptPage'
10
12
  import { TenantGuard } from './components/TenantGuard'
11
13
 
12
14
  export const App: React.FC = () => {
@@ -18,28 +20,33 @@ export const App: React.FC = () => {
18
20
  },
19
21
  }}
20
22
  >
21
- <BrowserRouter basename="/tenant">
22
- <Routes>
23
- <Route
24
- path="/*"
25
- element={
26
- <TenantGuard>
27
- <Layout>
28
- <Routes>
29
- <Route path="/" element={<Navigate to="/dashboard" replace />} />
30
- <Route path="/dashboard" element={<DashboardPage />} />
31
- <Route path="/users" element={<UsersPage />} />
32
- <Route path="/subscription" element={<SubscriptionPage />} />
33
- <Route path="/settings" element={<SettingsPage />} />
34
- <Route path="/todos" element={<TodosPage />} />
35
- <Route path="/content" element={<ContentPage />} />
36
- </Routes>
37
- </Layout>
38
- </TenantGuard>
39
- }
40
- />
41
- </Routes>
42
- </BrowserRouter>
23
+ <AntApp>
24
+ <BrowserRouter basename="/tenant">
25
+ <Routes>
26
+ {/* 公开路由:登录 + 邀请落地(TenantGuard 的 /login 回跳自此可达) */}
27
+ <Route path="/login" element={<LoginPage />} />
28
+ <Route path="/invite/:token" element={<InviteAcceptPage />} />
29
+ <Route
30
+ path="/*"
31
+ element={
32
+ <TenantGuard>
33
+ <Layout>
34
+ <Routes>
35
+ <Route path="/" element={<Navigate to="/dashboard" replace />} />
36
+ <Route path="/dashboard" element={<DashboardPage />} />
37
+ <Route path="/users" element={<UsersPage />} />
38
+ <Route path="/subscription" element={<SubscriptionPage />} />
39
+ <Route path="/settings" element={<SettingsPage />} />
40
+ <Route path="/todos" element={<TodosPage />} />
41
+ <Route path="/content" element={<ContentPage />} />
42
+ </Routes>
43
+ </Layout>
44
+ </TenantGuard>
45
+ }
46
+ />
47
+ </Routes>
48
+ </BrowserRouter>
49
+ </AntApp>
43
50
  </ConfigProvider>
44
51
  )
45
52
  }
@@ -9,14 +9,24 @@ interface TenantGuardProps {
9
9
 
10
10
  export const TenantGuard: React.FC<TenantGuardProps> = ({ children }) => {
11
11
  const location = useLocation()
12
- const { isAuthenticated, currentTenant, loading, fetchCurrentTenant } = useTenantStore()
12
+ const { isAuthenticated, currentTenant, loading, fetchCurrentTenant, restoreFromToken } =
13
+ useTenantStore()
13
14
 
14
15
  useEffect(() => {
16
+ // 已有租户上下文即短路——fetch 会 set 新对象,若把它放进依赖/重复
17
+ // 触发会形成无限请求循环(250ms 内百次请求打满限流被弹回登录)
18
+ if (currentTenant) return
19
+ if (!isAuthenticated) return
20
+
15
21
  const tenantSlug = extractTenantSlug()
16
22
  if (tenantSlug) {
17
23
  fetchCurrentTenant(tenantSlug)
24
+ return
18
25
  }
19
- }, [fetchCurrentTenant])
26
+ // 无 slug(如邀请接受后直跳):token 在则从 mine 恢复租户上下文
27
+ restoreFromToken()
28
+ // eslint-disable-next-line react-hooks/exhaustive-deps
29
+ }, [isAuthenticated, currentTenant])
20
30
 
21
31
  if (loading) {
22
32
  return (
@@ -5,6 +5,7 @@ import {
5
5
  UserOutlined,
6
6
  LogoutOutlined,
7
7
  } from '@ant-design/icons'
8
+ import { useTenantStore } from '../stores/tenantStore'
8
9
 
9
10
  interface HeaderProps {
10
11
  collapsed: boolean
@@ -12,9 +13,12 @@ interface HeaderProps {
12
13
  }
13
14
 
14
15
  export const Header: React.FC<HeaderProps> = ({ collapsed, onToggle }) => {
16
+ const logout = useTenantStore(state => state.logout)
17
+
15
18
  const handleLogout = () => {
16
- localStorage.removeItem('tenant-token')
17
- window.location.href = '/login'
19
+ logout()
20
+ // basename=/tenant 下的路由跳转
21
+ window.location.href = '/tenant/login'
18
22
  }
19
23
 
20
24
  const userMenuItems = [