@allbluecn/admin-app 0.2.3 → 0.2.5
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/package.json +13 -8
- package/src/__tests__/components/provider-icon.test.tsx +59 -0
- package/src/__tests__/setup.ts +35 -0
- package/src/components/invitations/invitations-page.tsx +527 -0
- package/src/components/layout/sidebar-02/app-sidebar.tsx +4 -0
- package/src/components/oauth-providers/provider-page.tsx +324 -0
- package/src/routeTree.gen.ts +46 -0
- package/src/routes/_nav/invitations/route.lazy.tsx +28 -0
- package/src/routes/_nav/invitations/route.tsx +20 -0
- package/src/routes/_nav/login-providers/route.lazy.tsx +28 -0
- package/src/routes/_nav/login-providers/route.tsx +20 -0
- package/src/server/__tests__/admin-billing.test.ts +182 -0
- package/src/server/__tests__/compliance.test.ts +136 -0
- package/src/server/__tests__/invitations.test.ts +311 -0
- package/src/server/__tests__/permissions.test.ts +301 -0
- package/src/server/middlewares/security-headers.ts +113 -0
- package/src/server/serverFns/components/import.ts +3 -3
- package/src/server/serverFns/invitations.ts +165 -0
- package/src/server/serverFns/oauth-providers.ts +161 -0
- package/src/start.ts +2 -1
- package/src/types/jest-dom-vitest.d.ts +31 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
19
|
+
|
|
20
|
+
vi.mock('@tanstack/react-start', () => ({
|
|
21
|
+
createServerFn: (_config: unknown) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema
|
|
26
|
+
return this
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data
|
|
33
|
+
}
|
|
34
|
+
return fn({ data })
|
|
35
|
+
}
|
|
36
|
+
return wrapped
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
return api
|
|
40
|
+
},
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
// prisma mock 必须覆盖被测模块触达的全部 model 键——缺一个会在用例内
|
|
48
|
+
// 报 "Cannot read properties of undefined"(见 memrec vitest-mock 陷阱)
|
|
49
|
+
vi.mock('@allbluecn/database', () => ({
|
|
50
|
+
prisma: {
|
|
51
|
+
customRole: {
|
|
52
|
+
count: vi.fn(),
|
|
53
|
+
create: vi.fn(),
|
|
54
|
+
findMany: vi.fn(),
|
|
55
|
+
findFirst: vi.fn(),
|
|
56
|
+
delete: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
user: {
|
|
59
|
+
findMany: vi.fn(),
|
|
60
|
+
},
|
|
61
|
+
roleAssignment: {
|
|
62
|
+
count: vi.fn(),
|
|
63
|
+
create: vi.fn(),
|
|
64
|
+
createMany: vi.fn(),
|
|
65
|
+
delete: vi.fn(),
|
|
66
|
+
findMany: vi.fn(),
|
|
67
|
+
},
|
|
68
|
+
workspace: {
|
|
69
|
+
findMany: vi.fn(),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
75
|
+
requireAdmin: vi.fn().mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
vi.mock('@/server/entitlements', () => ({
|
|
79
|
+
requireFeature: vi.fn().mockResolvedValue(undefined),
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
const services = vi.hoisted(() => new Map<string, unknown>())
|
|
83
|
+
|
|
84
|
+
vi.mock('@/server/pro-services', () => ({
|
|
85
|
+
getProService: (key: string) => services.get(key) ?? null,
|
|
86
|
+
}))
|
|
87
|
+
|
|
88
|
+
import { prisma } from '@allbluecn/database'
|
|
89
|
+
import { requireAdmin } from '@/server/auth-helpers'
|
|
90
|
+
import { requireFeature } from '@/server/entitlements'
|
|
91
|
+
import {
|
|
92
|
+
listRolesFn,
|
|
93
|
+
createRoleFn,
|
|
94
|
+
deleteRoleFn,
|
|
95
|
+
assignRoleFn,
|
|
96
|
+
unassignRoleFn,
|
|
97
|
+
listRoleAssignmentsFn,
|
|
98
|
+
listWorkspacesFn,
|
|
99
|
+
} from '../serverFns/permissions'
|
|
100
|
+
|
|
101
|
+
const customRole = prisma.customRole as unknown as {
|
|
102
|
+
count: ReturnType<typeof vi.fn>
|
|
103
|
+
create: ReturnType<typeof vi.fn>
|
|
104
|
+
findMany: ReturnType<typeof vi.fn>
|
|
105
|
+
findFirst: ReturnType<typeof vi.fn>
|
|
106
|
+
delete: ReturnType<typeof vi.fn>
|
|
107
|
+
}
|
|
108
|
+
const userMock = prisma.user as unknown as { findMany: ReturnType<typeof vi.fn> }
|
|
109
|
+
const roleAssignment = prisma.roleAssignment as unknown as {
|
|
110
|
+
count: ReturnType<typeof vi.fn>
|
|
111
|
+
create:ReturnType<typeof vi.fn>
|
|
112
|
+
createMany: ReturnType<typeof vi.fn>
|
|
113
|
+
delete: ReturnType<typeof vi.fn>
|
|
114
|
+
findMany: ReturnType<typeof vi.fn>
|
|
115
|
+
}
|
|
116
|
+
const workspaceMock = prisma.workspace as unknown as { findMany: ReturnType<typeof vi.fn> }
|
|
117
|
+
const requireAdminMock = requireAdmin as unknown as ReturnType<typeof vi.fn>
|
|
118
|
+
const requireFeatureMock = requireFeature as unknown as ReturnType<typeof vi.fn>
|
|
119
|
+
|
|
120
|
+
const WS = 'ws-1'
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
vi.clearAllMocks()
|
|
124
|
+
services.clear()
|
|
125
|
+
requireAdminMock.mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } })
|
|
126
|
+
requireFeatureMock.mockResolvedValue(undefined)
|
|
127
|
+
customRole.count.mockResolvedValue(0)
|
|
128
|
+
customRole.findMany.mockResolvedValue([])
|
|
129
|
+
customRole.findFirst.mockResolvedValue(null)
|
|
130
|
+
userMock.findMany.mockResolvedValue([])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('listRolesFn', () => {
|
|
134
|
+
it('非管理员拒绝:requireAdmin 抛错时零 prisma 调用', async () => {
|
|
135
|
+
requireAdminMock.mockRejectedValue(new Error('not admin'))
|
|
136
|
+
|
|
137
|
+
await expect(listRolesFn({ data: { workspaceId: WS } })).rejects.toThrow('not admin')
|
|
138
|
+
expect(customRole.count).not.toHaveBeenCalled()
|
|
139
|
+
expect(customRole.findMany).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('feature 未开通拒绝:requireFeature 抛错时零 prisma 调用', async () => {
|
|
143
|
+
requireFeatureMock.mockRejectedValue(new Error('locked'))
|
|
144
|
+
|
|
145
|
+
await expect(listRolesFn({ data: { workspaceId: WS } })).rejects.toThrow('locked')
|
|
146
|
+
expect(customRole.count).not.toHaveBeenCalled()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('只查指定 workspaceId(跨租户隔离,不泄露其他 workspace 角色)', async () => {
|
|
150
|
+
customRole.count.mockResolvedValue(3)
|
|
151
|
+
customRole.findMany.mockResolvedValue([{ id: 'role-1', name: 'owner' }])
|
|
152
|
+
|
|
153
|
+
const res = await listRolesFn({ data: { workspaceId: WS } })
|
|
154
|
+
|
|
155
|
+
expect(customRole.findMany).toHaveBeenCalledWith({
|
|
156
|
+
where: { workspaceId: WS },
|
|
157
|
+
include: { _count: { select: { assignments: true } } },
|
|
158
|
+
orderBy: { createdAt: 'desc' },
|
|
159
|
+
})
|
|
160
|
+
expect(customRole.count).toHaveBeenCalledWith({ where: { workspaceId: WS } })
|
|
161
|
+
expect(res).toEqual({ items: [{ id: 'role-1', name: 'owner' }], total: 3, seeded: 'done' })
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('已有系统角色时跳过种子化(不重复创建预设)', async () => {
|
|
165
|
+
customRole.count.mockResolvedValue(5)
|
|
166
|
+
|
|
167
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
168
|
+
|
|
169
|
+
expect(customRole.create).not.toHaveBeenCalled()
|
|
170
|
+
expect(roleAssignment.createMany).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('首次种子化创建 5 个系统角色预设', async () => {
|
|
174
|
+
customRole.count.mockResolvedValue(0)
|
|
175
|
+
services.set('permissions', { kind: 'pro' })
|
|
176
|
+
|
|
177
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
178
|
+
|
|
179
|
+
const names = customRole.create.mock.calls.map((c: any[]) => c[0].data.name)
|
|
180
|
+
expect(names).toEqual(['owner', 'admin', 'member', 'editor', 'viewer'])
|
|
181
|
+
// 系统角色必须标记 isSystem,用户不可伪造/删除
|
|
182
|
+
expect(customRole.create.mock.calls.every((c: any[]) => c[0].data.isSystem === true)).toBe(true)
|
|
183
|
+
expect(customRole.create.mock.calls.every((c: any[]) => c[0].data.workspaceId === WS)).toBe(true)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('种子化时给 ADMIN 用户赋 owner 角色', async () => {
|
|
187
|
+
customRole.count.mockResolvedValue(0)
|
|
188
|
+
services.set('permissions', { kind: 'pro' })
|
|
189
|
+
userMock.findMany.mockResolvedValue([{ id: 'u-1' }, { id: 'u-2' }])
|
|
190
|
+
customRole.findFirst.mockResolvedValue({ id: 'role-owner', name: 'owner' })
|
|
191
|
+
|
|
192
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
193
|
+
|
|
194
|
+
expect(userMock.findMany).toHaveBeenCalledWith({
|
|
195
|
+
where: { role: 'ADMIN' },
|
|
196
|
+
select: { id: true },
|
|
197
|
+
})
|
|
198
|
+
expect(roleAssignment.createMany).toHaveBeenCalledWith({
|
|
199
|
+
data: [{ userId: 'u-1', roleId: 'role-owner' }, { userId: 'u-2', roleId: 'role-owner' }],
|
|
200
|
+
skipDuplicates: true,
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('pro service 缺失时不种子化(OSS 部署无高级权限)', async () => {
|
|
205
|
+
customRole.count.mockResolvedValue(0)
|
|
206
|
+
|
|
207
|
+
await listRolesFn({ data: { workspaceId: WS } })
|
|
208
|
+
|
|
209
|
+
expect(customRole.create).not.toHaveBeenCalled()
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
describe('createRoleFn', () => {
|
|
214
|
+
it('强制 isSystem=false(不可伪造系统角色)', async () => {
|
|
215
|
+
customRole.create.mockResolvedValue({ id: 'role-new' })
|
|
216
|
+
|
|
217
|
+
await createRoleFn({ data: { workspaceId: WS, name: 'custom-1', permissions: { 'story:read': 'read' } } })
|
|
218
|
+
|
|
219
|
+
expect(customRole.create).toHaveBeenCalledWith({
|
|
220
|
+
data: {
|
|
221
|
+
workspaceId: WS,
|
|
222
|
+
name: 'custom-1',
|
|
223
|
+
permissions: { 'story:read': 'read' },
|
|
224
|
+
isSystem: false,
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('permissions 缺省为空对象', async () => {
|
|
230
|
+
customRole.create.mockResolvedValue({ id: 'role-new' })
|
|
231
|
+
|
|
232
|
+
await createRoleFn({ data: { workspaceId: WS, name: 'custom-2' } })
|
|
233
|
+
|
|
234
|
+
expect(customRole.create.mock.calls[0][0].data.permissions).toEqual({})
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('assignRoleFn', () => {
|
|
239
|
+
it('写入 userId 与 roleId 并返回 ok', async () => {
|
|
240
|
+
roleAssignment.create.mockResolvedValue({ id: 'assign-1' })
|
|
241
|
+
|
|
242
|
+
const res = await assignRoleFn({ data: { userId: 'u-1', roleId: 'role-1' } })
|
|
243
|
+
|
|
244
|
+
expect(roleAssignment.create).toHaveBeenCalledWith({ data: { userId: 'u-1', roleId: 'role-1' } })
|
|
245
|
+
expect(res).toEqual({ ok: true })
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('unassignRoleFn', () => {
|
|
250
|
+
it('按 assignmentId 删除', async () => {
|
|
251
|
+
roleAssignment.delete.mockResolvedValue({ id: 'assign-1' })
|
|
252
|
+
|
|
253
|
+
const res = await unassignRoleFn({ data: { assignmentId: 'assign-1' } })
|
|
254
|
+
|
|
255
|
+
expect(roleAssignment.delete).toHaveBeenCalledWith({ where: { id: 'assign-1' } })
|
|
256
|
+
expect(res).toEqual({ ok: true })
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('deleteRoleFn', () => {
|
|
261
|
+
it('按 roleId 删除并返回 success', async () => {
|
|
262
|
+
customRole.delete.mockResolvedValue({ id: 'role-1' })
|
|
263
|
+
|
|
264
|
+
const res = await deleteRoleFn({ data: { roleId: 'role-1' } })
|
|
265
|
+
|
|
266
|
+
expect(customRole.delete).toHaveBeenCalledWith({ where: { id: 'role-1' } })
|
|
267
|
+
expect(res).toEqual({ success: true })
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('listRoleAssignmentsFn', () => {
|
|
272
|
+
it('按 roleId 过滤并附带用户信息选择集', async () => {
|
|
273
|
+
roleAssignment.findMany.mockResolvedValue([{ id: 'assign-1', user: { id: 'u-1', username: 'alice' } }])
|
|
274
|
+
|
|
275
|
+
const res = await listRoleAssignmentsFn({ data: { roleId: 'role-1' } })
|
|
276
|
+
|
|
277
|
+
expect(roleAssignment.findMany).toHaveBeenCalledWith({
|
|
278
|
+
where: { roleId: 'role-1' },
|
|
279
|
+
include: { user: { select: { id: true, username: true, email: true } } },
|
|
280
|
+
orderBy: { createdAt: 'desc' },
|
|
281
|
+
})
|
|
282
|
+
expect(res).toHaveLength(1)
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe('listWorkspacesFn', () => {
|
|
287
|
+
it('按 name 升序返回工作空间', async () => {
|
|
288
|
+
workspaceMock.findMany.mockResolvedValue([
|
|
289
|
+
{ id: 'ws-a', name: 'alpha' },
|
|
290
|
+
{ id: 'ws-b', name: 'beta' },
|
|
291
|
+
])
|
|
292
|
+
|
|
293
|
+
const res = await listWorkspacesFn({ data: {} })
|
|
294
|
+
|
|
295
|
+
expect(workspaceMock.findMany).toHaveBeenCalledWith({
|
|
296
|
+
select: { id: true, name: true },
|
|
297
|
+
orderBy: { name: 'asc' },
|
|
298
|
+
})
|
|
299
|
+
expect(res).toHaveLength(2)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createMiddleware } from '@tanstack/react-start'
|
|
19
|
+
|
|
20
|
+
const PROD_CSP = [
|
|
21
|
+
"default-src 'self'",
|
|
22
|
+
"script-src 'self'",
|
|
23
|
+
"style-src 'self' 'unsafe-inline'",
|
|
24
|
+
"img-src 'self' data: blob: https:",
|
|
25
|
+
"font-src 'self' data:",
|
|
26
|
+
"connect-src 'self' https: wss:",
|
|
27
|
+
"media-src 'self' blob: https:",
|
|
28
|
+
"object-src 'none'",
|
|
29
|
+
"base-uri 'self'",
|
|
30
|
+
"form-action 'self'",
|
|
31
|
+
"child-src 'none'",
|
|
32
|
+
"frame-src 'none'",
|
|
33
|
+
"worker-src 'self' blob:",
|
|
34
|
+
"frame-ancestors 'none'",
|
|
35
|
+
"upgrade-insecure-requests",
|
|
36
|
+
].join('; ')
|
|
37
|
+
|
|
38
|
+
const DEV_CSP = [
|
|
39
|
+
"default-src 'self' 'unsafe-inline' data: blob:",
|
|
40
|
+
"script-src 'self' 'unsafe-inline' data: blob:",
|
|
41
|
+
"style-src 'self' 'unsafe-inline'",
|
|
42
|
+
"img-src 'self' data: blob: https: http:",
|
|
43
|
+
"font-src 'self' data:",
|
|
44
|
+
"connect-src 'self' ws: wss: http: https:",
|
|
45
|
+
"media-src 'self' blob: https: http:",
|
|
46
|
+
"object-src 'none'",
|
|
47
|
+
"base-uri 'self'",
|
|
48
|
+
"form-action 'self'",
|
|
49
|
+
"child-src 'none'",
|
|
50
|
+
"frame-src 'none'",
|
|
51
|
+
"worker-src 'self' blob:",
|
|
52
|
+
"frame-ancestors 'none'",
|
|
53
|
+
].join('; ')
|
|
54
|
+
|
|
55
|
+
export function buildSecurityHeaders(prod: boolean): Record<string, string> {
|
|
56
|
+
const headers: Record<string, string> = {
|
|
57
|
+
'X-Content-Type-Options': 'nosniff',
|
|
58
|
+
'X-Frame-Options': 'DENY',
|
|
59
|
+
'Cross-Origin-Resource-Policy': 'same-origin',
|
|
60
|
+
'Cross-Origin-Opener-Policy': 'same-origin',
|
|
61
|
+
'Permissions-Policy':
|
|
62
|
+
'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)',
|
|
63
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
64
|
+
'Content-Security-Policy': prod ? PROD_CSP : DEV_CSP,
|
|
65
|
+
}
|
|
66
|
+
if (prod) {
|
|
67
|
+
headers['Strict-Transport-Security'] = 'max-age=63072000; includeSubDomains; preload'
|
|
68
|
+
}
|
|
69
|
+
return headers
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mergeSecurityHeaders(source: Headers, headers: Record<string, string>): Headers {
|
|
73
|
+
const entries = Object.entries(headers)
|
|
74
|
+
try {
|
|
75
|
+
for (const [name, value] of entries) source.set(name, value)
|
|
76
|
+
return source
|
|
77
|
+
} catch {
|
|
78
|
+
// @auth/core 响应(登录回调成功/失败路径)的 headers 为 immutable guard,
|
|
79
|
+
// 原地 set 抛 TypeError: immutable → 未捕获异常 → 登录 500。
|
|
80
|
+
const merged = new Headers(source)
|
|
81
|
+
for (const [name, value] of entries) merged.set(name, value)
|
|
82
|
+
return merged
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function withSecurityHeaders(result: Response, headers: Record<string, string>): Response {
|
|
87
|
+
const merged = mergeSecurityHeaders(result.headers, headers)
|
|
88
|
+
if (merged === result.headers) return result
|
|
89
|
+
return new Response(result.body, {
|
|
90
|
+
status: result.status,
|
|
91
|
+
statusText: result.statusText,
|
|
92
|
+
headers: merged,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const securityHeadersMiddleware = createMiddleware().server(async ({ next }: any) => {
|
|
97
|
+
const headers = buildSecurityHeaders(process.env.NODE_ENV === 'production')
|
|
98
|
+
const result = await next() as any
|
|
99
|
+
|
|
100
|
+
if (result instanceof Response) return withSecurityHeaders(result, headers)
|
|
101
|
+
|
|
102
|
+
const carrier: any = result?.response || result
|
|
103
|
+
const target: Headers | undefined = carrier?.headers
|
|
104
|
+
if (!target) return result
|
|
105
|
+
|
|
106
|
+
const merged = mergeSecurityHeaders(target, headers)
|
|
107
|
+
if (merged === target) return result
|
|
108
|
+
return new Response(result?.body ?? null, {
|
|
109
|
+
status: result?.status ?? 200,
|
|
110
|
+
statusText: result?.statusText,
|
|
111
|
+
headers: merged,
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { createServerFn } from '@tanstack/react-start'
|
|
19
19
|
import { z } from 'zod'
|
|
20
20
|
import { prisma } from '@allbluecn/database'
|
|
21
|
-
import {
|
|
21
|
+
import { execFileSync } from 'node:child_process'
|
|
22
22
|
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs'
|
|
23
23
|
import { resolve, join } from 'node:path'
|
|
24
24
|
|
|
@@ -89,7 +89,7 @@ export const importComponentFn = createServerFn({ method: 'POST' })
|
|
|
89
89
|
if (!packageName) throw new Error('npm 包名不能为空')
|
|
90
90
|
const prototypeDir = join(monorepoRoot, 'packages/prototype')
|
|
91
91
|
try {
|
|
92
|
-
|
|
92
|
+
execFileSync('pnpm', ['add', packageName], {
|
|
93
93
|
cwd: prototypeDir,
|
|
94
94
|
stdio: 'pipe',
|
|
95
95
|
timeout: 60000,
|
|
@@ -114,7 +114,7 @@ export const importComponentFn = createServerFn({ method: 'POST' })
|
|
|
114
114
|
} else {
|
|
115
115
|
const compName = importPath.split('/').pop() ?? importPath
|
|
116
116
|
try {
|
|
117
|
-
|
|
117
|
+
execFileSync('pnpm', ['dlx', 'shadcn@latest', 'add', importPath, '--yes'], {
|
|
118
118
|
cwd: webDir,
|
|
119
119
|
stdio: 'pipe',
|
|
120
120
|
timeout: 120000,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
19
|
+
import { z } from 'zod'
|
|
20
|
+
import { prisma } from '@allbluecn/database'
|
|
21
|
+
import { requireAdmin } from '@/server/auth-helpers'
|
|
22
|
+
|
|
23
|
+
function randomInvitationCode(): string {
|
|
24
|
+
const hex = crypto
|
|
25
|
+
.getRandomValues(new Uint8Array(4))
|
|
26
|
+
.reduce((s, b) => s + b.toString(16).padStart(2, '0'), '')
|
|
27
|
+
return hex.toUpperCase()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const pageSchema = z.object({
|
|
31
|
+
page: z.number().int().min(1).default(1),
|
|
32
|
+
pageSize: z.number().int().min(1).max(100).default(50),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export const listInvitationCodesFn = createServerFn({ method: 'GET' })
|
|
36
|
+
.validator(pageSchema)
|
|
37
|
+
.handler(async ({ data }) => {
|
|
38
|
+
await requireAdmin()
|
|
39
|
+
const skip = (data.page - 1) * data.pageSize
|
|
40
|
+
const [codes, total] = await Promise.all([
|
|
41
|
+
prisma.invitationCode.findMany({
|
|
42
|
+
where: { disabledAt: null },
|
|
43
|
+
select: {
|
|
44
|
+
id: true,
|
|
45
|
+
code: true,
|
|
46
|
+
maxUses: true,
|
|
47
|
+
usedCount: true,
|
|
48
|
+
expiresAt: true,
|
|
49
|
+
createdById: true,
|
|
50
|
+
disabledAt: true,
|
|
51
|
+
createdAt: true,
|
|
52
|
+
uses: {
|
|
53
|
+
select: { userId: true, usedAt: true, user: { select: { username: true } } },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
orderBy: { createdAt: 'desc' },
|
|
57
|
+
skip,
|
|
58
|
+
take: data.pageSize,
|
|
59
|
+
}),
|
|
60
|
+
prisma.invitationCode.count({ where: { disabledAt: null } }),
|
|
61
|
+
])
|
|
62
|
+
const creatorIds = [
|
|
63
|
+
...new Set(codes.map((c) => c.createdById).filter((v): v is string => !!v)),
|
|
64
|
+
]
|
|
65
|
+
const creators = creatorIds.length
|
|
66
|
+
? await prisma.user.findMany({
|
|
67
|
+
where: { id: { in: creatorIds } },
|
|
68
|
+
select: { id: true, username: true },
|
|
69
|
+
})
|
|
70
|
+
: []
|
|
71
|
+
const creatorMap = new Map(creators.map((u) => [u.id, u.username]))
|
|
72
|
+
return {
|
|
73
|
+
codes: codes.map((c) => ({
|
|
74
|
+
...c,
|
|
75
|
+
creator: c.createdById ? (creatorMap.get(c.createdById) ?? null) : null,
|
|
76
|
+
})),
|
|
77
|
+
total,
|
|
78
|
+
page: data.page,
|
|
79
|
+
pageSize: data.pageSize,
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const createCodeSchema = z.object({
|
|
84
|
+
code: z
|
|
85
|
+
.string()
|
|
86
|
+
.trim()
|
|
87
|
+
.regex(/^[A-Za-z0-9]{8,12}$/, '邀请码需为 8-12 位字母或数字')
|
|
88
|
+
.refine((v) => /[a-zA-Z]/.test(v) && /[0-9]/.test(v), '邀请码需同时包含字母和数字')
|
|
89
|
+
.optional(),
|
|
90
|
+
maxUses: z.number().int().min(1).max(9999).default(1),
|
|
91
|
+
validDays: z.number().int().min(1).max(365).default(30),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export const createInvitationCodeFn = createServerFn({ method: 'POST' })
|
|
95
|
+
.validator(createCodeSchema)
|
|
96
|
+
.handler(async ({ data }) => {
|
|
97
|
+
const session = await requireAdmin()
|
|
98
|
+
const code = data.code ? data.code.trim().toUpperCase() : randomInvitationCode()
|
|
99
|
+
const row = await prisma.invitationCode.create({
|
|
100
|
+
data: {
|
|
101
|
+
code,
|
|
102
|
+
maxUses: data.maxUses,
|
|
103
|
+
expiresAt: new Date(Date.now() + data.validDays * 24 * 60 * 60 * 1000),
|
|
104
|
+
createdById: session.user?.id ?? null,
|
|
105
|
+
},
|
|
106
|
+
})
|
|
107
|
+
return row
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const idSchema = z.object({ id: z.string().min(1) })
|
|
111
|
+
|
|
112
|
+
export const toggleInvitationCodeFn = createServerFn({ method: 'POST' })
|
|
113
|
+
.validator(idSchema)
|
|
114
|
+
.handler(async ({ data }) => {
|
|
115
|
+
await requireAdmin()
|
|
116
|
+
const row = await prisma.invitationCode.findUnique({ where: { id: data.id } })
|
|
117
|
+
if (!row) throw new Error('邀请码不存在')
|
|
118
|
+
const disabledAt = row.disabledAt ? null : new Date()
|
|
119
|
+
const updated = await prisma.invitationCode.update({
|
|
120
|
+
where: { id: data.id },
|
|
121
|
+
data: { disabledAt },
|
|
122
|
+
})
|
|
123
|
+
return updated
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
export const deleteInvitationCodeFn = createServerFn({ method: 'POST' })
|
|
127
|
+
.validator(idSchema)
|
|
128
|
+
.handler(async ({ data }) => {
|
|
129
|
+
await requireAdmin()
|
|
130
|
+
const row = await prisma.invitationCode.findUnique({
|
|
131
|
+
where: { id: data.id },
|
|
132
|
+
select: { id: true, usedCount: true },
|
|
133
|
+
})
|
|
134
|
+
if (!row) throw new Error('邀请码不存在')
|
|
135
|
+
if (row.usedCount > 0) throw new Error('已有使用记录,请改用禁用')
|
|
136
|
+
await prisma.invitationCode.delete({ where: { id: data.id } })
|
|
137
|
+
return { deleted: true }
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
export const getRegistrationModeFn = createServerFn({ method: 'GET' })
|
|
141
|
+
.validator(z.object({}))
|
|
142
|
+
.handler(async () => {
|
|
143
|
+
await requireAdmin()
|
|
144
|
+
const setting = await prisma.systemSetting.findUnique({
|
|
145
|
+
where: { key: 'auth.registrationMode' },
|
|
146
|
+
})
|
|
147
|
+
return { mode: setting?.value === 'open' ? 'open' : 'invite' }
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
export const setRegistrationModeFn = createServerFn({ method: 'POST' })
|
|
151
|
+
.validator(z.object({ mode: z.enum(['invite', 'open']) }))
|
|
152
|
+
.handler(async ({ data }) => {
|
|
153
|
+
await requireAdmin()
|
|
154
|
+
await prisma.systemSetting.upsert({
|
|
155
|
+
where: { key: 'auth.registrationMode' },
|
|
156
|
+
update: { value: data.mode },
|
|
157
|
+
create: {
|
|
158
|
+
key: 'auth.registrationMode',
|
|
159
|
+
value: data.mode,
|
|
160
|
+
category: 'auth',
|
|
161
|
+
desc: '注册模式:invite 需邀请码,open 开放注册',
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
return { mode: data.mode }
|
|
165
|
+
})
|