@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.
@@ -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 { execSync } from 'node:child_process'
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
- execSync(`pnpm add ${packageName}`, {
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
- execSync(`pnpm dlx shadcn@latest add ${importPath} --yes`, {
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
+ })