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
|
@@ -6,32 +6,44 @@ import type {
|
|
|
6
6
|
CreateTodoInput,
|
|
7
7
|
UpdateTodoInput,
|
|
8
8
|
Topic,
|
|
9
|
+
TenantMember,
|
|
10
|
+
TenantRole,
|
|
9
11
|
} from '@shared/schemas'
|
|
12
|
+
import { api, setToken, setSlug, getToken } from '../services/tenantApi'
|
|
10
13
|
|
|
11
14
|
interface TenantState {
|
|
12
15
|
isAuthenticated: boolean
|
|
13
16
|
currentTenant: Tenant | null
|
|
14
17
|
loading: boolean
|
|
15
|
-
users:
|
|
18
|
+
users: TenantMember[]
|
|
19
|
+
roles: TenantRole[]
|
|
16
20
|
todos: Todo[]
|
|
17
21
|
topics: Topic[]
|
|
18
22
|
stats: TenantStats
|
|
19
|
-
subscription:
|
|
23
|
+
subscription: TenantSubscription | null
|
|
20
24
|
|
|
25
|
+
login: (
|
|
26
|
+
account: string,
|
|
27
|
+
password: string
|
|
28
|
+
) => Promise<{ ok: boolean; hasTenant: boolean; error?: string }>
|
|
29
|
+
/** 有 token 无租户上下文时(如邀请接受后)从 /tenants/mine 恢复 */
|
|
30
|
+
restoreFromToken: () => Promise<void>
|
|
31
|
+
logout: () => void
|
|
21
32
|
setCurrentTenant: (tenant: Tenant | null) => void
|
|
22
33
|
fetchCurrentTenant: (slug: string) => Promise<void>
|
|
23
34
|
fetchUsers: () => Promise<void>
|
|
24
|
-
|
|
25
|
-
updateUser: (userId:
|
|
26
|
-
deleteUser: (userId:
|
|
35
|
+
inviteUser: (email: string, roleId: string) => Promise<boolean>
|
|
36
|
+
updateUser: (userId: string, data: { roleId: string }) => Promise<boolean>
|
|
37
|
+
deleteUser: (userId: string) => Promise<boolean>
|
|
38
|
+
fetchRoles: () => Promise<void>
|
|
27
39
|
fetchTodos: () => Promise<void>
|
|
28
40
|
createTodo: (data: CreateTodoInput) => Promise<boolean>
|
|
29
41
|
updateTodo: (todoId: number, data: UpdateTodoInput) => Promise<boolean>
|
|
30
42
|
deleteTodo: (todoId: number) => Promise<boolean>
|
|
31
43
|
fetchTopics: () => Promise<void>
|
|
32
44
|
createTopic: (data: unknown) => Promise<boolean>
|
|
33
|
-
updateTopic: (topicId: number, data: unknown) => Promise<boolean>
|
|
34
|
-
deleteTopic: (topicId: number) => Promise<boolean>
|
|
45
|
+
updateTopic: (topicId: string | number, data: unknown) => Promise<boolean>
|
|
46
|
+
deleteTopic: (topicId: string | number) => Promise<boolean>
|
|
35
47
|
fetchSubscription: () => Promise<void>
|
|
36
48
|
updateTenant: (tenantId: number, data: UpdateTenantInput) => Promise<boolean>
|
|
37
49
|
fetchStats: () => Promise<void>
|
|
@@ -47,11 +59,19 @@ interface TenantStats {
|
|
|
47
59
|
monthlyRevenue: number
|
|
48
60
|
}
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
/** 订阅信息由租户 plan/maxUsers 派生(P4 在服务端执行配额) */
|
|
63
|
+
interface TenantSubscription {
|
|
64
|
+
plan: string
|
|
65
|
+
maxUsers: number
|
|
66
|
+
currentUsers: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const useTenantStore = create<TenantState>((set, getState) => ({
|
|
70
|
+
isAuthenticated: !!getToken(),
|
|
52
71
|
currentTenant: null,
|
|
53
72
|
loading: false,
|
|
54
73
|
users: [],
|
|
74
|
+
roles: [],
|
|
55
75
|
todos: [],
|
|
56
76
|
topics: [],
|
|
57
77
|
stats: {
|
|
@@ -62,82 +82,185 @@ export const useTenantStore = create<TenantState>(set => ({
|
|
|
62
82
|
},
|
|
63
83
|
subscription: null,
|
|
64
84
|
|
|
65
|
-
|
|
85
|
+
// 登录 = 平台认证 + mine 选定租户(取第一个成员租户)
|
|
86
|
+
login: async (account, password) => {
|
|
87
|
+
set({ loading: true })
|
|
88
|
+
try {
|
|
89
|
+
const loginRes = await api<{ token: string }>('/auth/login', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
body: { account, password },
|
|
92
|
+
})
|
|
93
|
+
if (!loginRes.success || !loginRes.data?.token) {
|
|
94
|
+
return { ok: false, hasTenant: false, error: 'Invalid credentials' }
|
|
95
|
+
}
|
|
96
|
+
setToken(loginRes.data.token)
|
|
66
97
|
|
|
67
|
-
|
|
98
|
+
const mineRes = await api<Tenant[]>('/tenants/mine')
|
|
99
|
+
// 认证已成功——无租户账号保留 token(受邀新用户需登录态接受邀请),
|
|
100
|
+
// 由调用方引导回邀请落地页而非硬拒
|
|
101
|
+
if (!mineRes.success || !mineRes.data || mineRes.data.length === 0) {
|
|
102
|
+
return { ok: true, hasTenant: false }
|
|
103
|
+
}
|
|
68
104
|
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
const tenant = mineRes.data[0]
|
|
106
|
+
setSlug(tenant.slug)
|
|
107
|
+
set({ isAuthenticated: true, currentTenant: tenant })
|
|
108
|
+
return { ok: true, hasTenant: true }
|
|
109
|
+
} finally {
|
|
110
|
+
set({ loading: false })
|
|
111
|
+
}
|
|
71
112
|
},
|
|
72
113
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
114
|
+
restoreFromToken: async () => {
|
|
115
|
+
if (!getToken()) return
|
|
116
|
+
const mineRes = await api<Tenant[]>('/tenants/mine')
|
|
117
|
+
if (mineRes.success && mineRes.data && mineRes.data.length > 0) {
|
|
118
|
+
const tenant = mineRes.data[0]
|
|
119
|
+
setSlug(tenant.slug)
|
|
120
|
+
set({ isAuthenticated: true, currentTenant: tenant })
|
|
121
|
+
}
|
|
76
122
|
},
|
|
77
123
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
124
|
+
logout: () => {
|
|
125
|
+
setToken(null)
|
|
126
|
+
setSlug(null)
|
|
127
|
+
set({ isAuthenticated: false, currentTenant: null, users: [], todos: [] })
|
|
81
128
|
},
|
|
82
129
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
130
|
+
setCurrentTenant: tenant => set({ currentTenant: tenant }),
|
|
131
|
+
|
|
132
|
+
setLoading: loading => set({ loading }),
|
|
133
|
+
|
|
134
|
+
fetchCurrentTenant: async slug => {
|
|
135
|
+
if (!getToken()) {
|
|
136
|
+
set({ isAuthenticated: false })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const res = await api<Tenant>(`/tenants/slug/${slug}`)
|
|
140
|
+
if (res.success && res.data) {
|
|
141
|
+
set({ isAuthenticated: true, currentTenant: res.data })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
// 仅认证确实失效(401)才回登录;429/网络错误按瞬态跳过,
|
|
145
|
+
// 否则限流抖动会把已登录用户弹出去
|
|
146
|
+
if (res.status === 401) {
|
|
147
|
+
set({ isAuthenticated: false })
|
|
148
|
+
}
|
|
86
149
|
},
|
|
87
150
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return
|
|
151
|
+
fetchRoles: async () => {
|
|
152
|
+
const tenant = getState().currentTenant
|
|
153
|
+
if (!tenant) return
|
|
154
|
+
const res = await api<TenantRole[]>(`/tenants/${tenant.id}/roles`)
|
|
155
|
+
if (res.success && res.data) set({ roles: res.data })
|
|
91
156
|
},
|
|
92
157
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
158
|
+
fetchUsers: async () => {
|
|
159
|
+
const tenant = getState().currentTenant
|
|
160
|
+
if (!tenant) return
|
|
161
|
+
const res = await api<TenantMember[]>(`/tenants/${tenant.id}/members`)
|
|
162
|
+
if (res.success && res.data) set({ users: res.data })
|
|
96
163
|
},
|
|
97
164
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return false
|
|
165
|
+
inviteUser: async (email, roleId) => {
|
|
166
|
+
const tenant = getState().currentTenant
|
|
167
|
+
if (!tenant) return false
|
|
168
|
+
const res = await api(`/tenants/${tenant.id}/members/invite`, {
|
|
169
|
+
method: 'POST',
|
|
170
|
+
body: { email, roleId },
|
|
171
|
+
})
|
|
172
|
+
return res.success
|
|
101
173
|
},
|
|
102
174
|
|
|
103
|
-
|
|
104
|
-
|
|
175
|
+
updateUser: async (memberId, data) => {
|
|
176
|
+
const tenant = getState().currentTenant
|
|
177
|
+
if (!tenant) return false
|
|
178
|
+
const res = await api(`/tenants/${tenant.id}/members/${memberId}`, {
|
|
179
|
+
method: 'PUT',
|
|
180
|
+
body: data,
|
|
181
|
+
})
|
|
182
|
+
return res.success
|
|
105
183
|
},
|
|
106
184
|
|
|
107
|
-
|
|
108
|
-
|
|
185
|
+
deleteUser: async memberId => {
|
|
186
|
+
const tenant = getState().currentTenant
|
|
187
|
+
if (!tenant) return false
|
|
188
|
+
const res = await api(`/tenants/${tenant.id}/members/${memberId}`, { method: 'DELETE' })
|
|
189
|
+
return res.success
|
|
109
190
|
},
|
|
110
191
|
|
|
111
192
|
fetchTodos: async () => {
|
|
112
|
-
|
|
193
|
+
const res = await api<{ todos: Todo[]; total: number }>('/todos?limit=100')
|
|
194
|
+
if (res.success && res.data) set({ todos: res.data.todos })
|
|
113
195
|
},
|
|
114
196
|
|
|
115
|
-
createTodo: async
|
|
116
|
-
|
|
117
|
-
|
|
197
|
+
createTodo: async data => {
|
|
198
|
+
const res = await api<Todo>('/todos', { method: 'POST', body: data })
|
|
199
|
+
if (res.success) await getState().fetchTodos()
|
|
200
|
+
return res.success
|
|
118
201
|
},
|
|
119
202
|
|
|
120
|
-
updateTodo: async () => {
|
|
121
|
-
|
|
122
|
-
|
|
203
|
+
updateTodo: async (todoId, data) => {
|
|
204
|
+
const res = await api<Todo>(`/todos/${todoId}`, { method: 'PUT', body: data })
|
|
205
|
+
if (res.success) await getState().fetchTodos()
|
|
206
|
+
return res.success
|
|
123
207
|
},
|
|
124
208
|
|
|
125
|
-
deleteTodo: async
|
|
126
|
-
|
|
127
|
-
|
|
209
|
+
deleteTodo: async todoId => {
|
|
210
|
+
const res = await api(`/todos/${todoId}`, { method: 'DELETE' })
|
|
211
|
+
if (res.success) await getState().fetchTodos()
|
|
212
|
+
return res.success
|
|
128
213
|
},
|
|
129
214
|
|
|
130
215
|
fetchTopics: async () => {
|
|
131
|
-
|
|
216
|
+
const res = await api<Topic[]>('/public/topics?limit=50')
|
|
217
|
+
if (res.success && res.data) set({ topics: res.data })
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
createTopic: async () => false,
|
|
221
|
+
|
|
222
|
+
updateTopic: async () => false,
|
|
223
|
+
|
|
224
|
+
deleteTopic: async () => false,
|
|
225
|
+
|
|
226
|
+
fetchSubscription: async () => {
|
|
227
|
+
const tenant = getState().currentTenant
|
|
228
|
+
if (!tenant) return
|
|
229
|
+
const members = await api<TenantMember[]>(`/tenants/${tenant.id}/members`)
|
|
230
|
+
set({
|
|
231
|
+
subscription: {
|
|
232
|
+
plan: tenant.plan,
|
|
233
|
+
maxUsers: tenant.maxUsers,
|
|
234
|
+
currentUsers: members.data?.length ?? 0,
|
|
235
|
+
},
|
|
236
|
+
})
|
|
132
237
|
},
|
|
133
238
|
|
|
134
|
-
updateTenant: async () => {
|
|
135
|
-
|
|
239
|
+
updateTenant: async (tenantId, data) => {
|
|
240
|
+
const res = await api<Tenant>(`/tenants/${tenantId}`, { method: 'PUT', body: data })
|
|
241
|
+
if (res.success && res.data) {
|
|
242
|
+
set({ currentTenant: res.data })
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
136
245
|
return false
|
|
137
246
|
},
|
|
138
247
|
|
|
139
248
|
fetchStats: async () => {
|
|
140
|
-
|
|
249
|
+
const tenant = getState().currentTenant
|
|
250
|
+
if (!tenant) return
|
|
251
|
+
const [members, todos] = await Promise.all([
|
|
252
|
+
api<TenantMember[]>(`/tenants/${tenant.id}/members`),
|
|
253
|
+
api<{ todos: Todo[]; total: number }>('/todos?limit=100'),
|
|
254
|
+
])
|
|
255
|
+
const activeTodos = (todos.data?.todos ?? []).filter(t => t.status !== 'completed').length
|
|
256
|
+
set({
|
|
257
|
+
stats: {
|
|
258
|
+
totalUsers: members.data?.length ?? 0,
|
|
259
|
+
activeTodos,
|
|
260
|
+
contentCount: 0,
|
|
261
|
+
monthlyRevenue: 0,
|
|
262
|
+
},
|
|
263
|
+
})
|
|
141
264
|
},
|
|
142
265
|
|
|
143
266
|
startLoading: (_text?: string) => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vitest setupFile:把测试库从 :memory: 换成每进程独立的临时文件。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不能用 :memory:——@libsql/client 的 transaction() 会置空内部连接
|
|
5
|
+
* (#db = null,事务跑在旧连接上),下一次普通查询惰性新建连接;文件库
|
|
6
|
+
* 会重开同一文件,而 :memory: 会得到一个全新的空库(后续所有查询
|
|
7
|
+
* "no such table")。租户开通等真实事务依赖此修正。
|
|
8
|
+
*/
|
|
9
|
+
import { tmpdir } from 'os'
|
|
10
|
+
import { join } from 'path'
|
|
11
|
+
import { existsSync, rmSync } from 'fs'
|
|
12
|
+
|
|
13
|
+
if (!process.env.SQLITE_PATH || process.env.SQLITE_PATH === ':memory:') {
|
|
14
|
+
const dbPath = join(tmpdir(), `cfs-test-${process.pid}.db`)
|
|
15
|
+
// 旧运行可能残留旧 DDL 的库(IF NOT EXISTS 不会升级表结构)——先删干净
|
|
16
|
+
for (const f of [dbPath, `${dbPath}-journal`, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
17
|
+
if (existsSync(f)) rmSync(f)
|
|
18
|
+
}
|
|
19
|
+
process.env.SQLITE_PATH = dbPath
|
|
20
|
+
}
|
|
@@ -23,7 +23,6 @@ export default defineConfig({
|
|
|
23
23
|
return 'node'
|
|
24
24
|
}
|
|
25
25
|
})(),
|
|
26
|
-
setupFiles: ['./vitest.setup.ts'],
|
|
27
26
|
include: [
|
|
28
27
|
'**/__tests__/**/*.test.ts',
|
|
29
28
|
'**/__tests__/**/*.test.tsx',
|
|
@@ -32,6 +31,9 @@ export default defineConfig({
|
|
|
32
31
|
exclude: ['**/node_modules/**', '**/dist/**'],
|
|
33
32
|
testTimeout: 60000,
|
|
34
33
|
hookTimeout: 60000,
|
|
34
|
+
// setup-db-path 必须在前:把 :memory: 换成临时文件(libsql 事务与
|
|
35
|
+
// :memory: 不兼容),vitest.setup 再注册 jest-dom 等
|
|
36
|
+
setupFiles: ['./src/test/setup-db-path.ts', './vitest.setup.ts'],
|
|
35
37
|
env: {
|
|
36
38
|
NODE_ENV: 'test',
|
|
37
39
|
SQLITE_PATH: ':memory:',
|