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
@@ -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: unknown[]
18
+ users: TenantMember[]
19
+ roles: TenantRole[]
16
20
  todos: Todo[]
17
21
  topics: Topic[]
18
22
  stats: TenantStats
19
- subscription: unknown
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
- createUser: (data: unknown) => Promise<boolean>
25
- updateUser: (userId: number, data: unknown) => Promise<boolean>
26
- deleteUser: (userId: number) => Promise<boolean>
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
- export const useTenantStore = create<TenantState>(set => ({
51
- isAuthenticated: !!localStorage.getItem('tenant-token'),
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
- setCurrentTenant: tenant => set({ currentTenant: tenant }),
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
- setLoading: loading => set({ loading }),
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
- fetchUsers: async () => {
70
- console.warn('User management not implemented for tenant module')
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
- createUser: async () => {
74
- console.warn('User creation not implemented for tenant module')
75
- return false
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
- updateUser: async () => {
79
- console.warn('User update not implemented for tenant module')
80
- return false
124
+ logout: () => {
125
+ setToken(null)
126
+ setSlug(null)
127
+ set({ isAuthenticated: false, currentTenant: null, users: [], todos: [] })
81
128
  },
82
129
 
83
- deleteUser: async () => {
84
- console.warn('User deletion not implemented for tenant module')
85
- return false
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
- createTopic: async () => {
89
- console.warn('Topic creation not implemented for tenant module')
90
- return false
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
- updateTopic: async () => {
94
- console.warn('Topic update not implemented for tenant module')
95
- return false
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
- deleteTopic: async () => {
99
- console.warn('Topic deletion not implemented for tenant module')
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
- fetchSubscription: async () => {
104
- console.warn('Subscription management not implemented for tenant module')
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
- fetchCurrentTenant: async (_slug: string) => {
108
- console.warn('Tenant routes not registered - tenant module not active')
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
- console.warn('Tenant routes not registered - tenant module not active')
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
- console.warn('Tenant routes not registered - tenant module not active')
117
- return false
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
- console.warn('Tenant routes not registered - tenant module not active')
122
- return false
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
- console.warn('Tenant routes not registered - tenant module not active')
127
- return false
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
- console.warn('Tenant routes not registered - tenant module not active')
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
- console.warn('Tenant routes not registered - tenant module not active')
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
- console.warn('Stats endpoint not implemented for tenant module')
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:',