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.
- package/dist/cli/index.js +65 -2
- package/dist/cli/index.js.map +1 -1
- package/package.json +12 -12
- package/template/package.json +10 -10
- package/template/src/cli/modules/tenant/index.ts +89 -0
- 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 +13 -0
- 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/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/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(
|
|
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,
|
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
22
|
-
<
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
element={
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 } =
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
19
|
+
logout()
|
|
20
|
+
// basename=/tenant 下的路由跳转
|
|
21
|
+
window.location.href = '/tenant/login'
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
const userMenuItems = [
|