@allbluecn/admin-app 0.2.4 → 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 +4 -4
- 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__/invitations.test.ts +311 -0
- package/src/server/serverFns/invitations.ts +165 -0
- package/src/server/serverFns/oauth-providers.ts +161 -0
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
vi.mock('@allbluecn/database', () => ({
|
|
48
|
+
prisma: {
|
|
49
|
+
invitationCode: {
|
|
50
|
+
findMany: vi.fn(),
|
|
51
|
+
count: vi.fn(),
|
|
52
|
+
findUnique: vi.fn(),
|
|
53
|
+
create: vi.fn(),
|
|
54
|
+
update: vi.fn(),
|
|
55
|
+
delete: vi.fn(),
|
|
56
|
+
},
|
|
57
|
+
user: {
|
|
58
|
+
findMany: vi.fn(),
|
|
59
|
+
},
|
|
60
|
+
systemSetting: {
|
|
61
|
+
findUnique: vi.fn(),
|
|
62
|
+
upsert: vi.fn(),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
68
|
+
requireAdmin: vi
|
|
69
|
+
.fn()
|
|
70
|
+
.mockResolvedValue({ user: { id: 'admin-1', role: 'SUPER_ADMIN' } }),
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
import { prisma } from '@allbluecn/database'
|
|
74
|
+
import {
|
|
75
|
+
listInvitationCodesFn,
|
|
76
|
+
createInvitationCodeFn,
|
|
77
|
+
toggleInvitationCodeFn,
|
|
78
|
+
deleteInvitationCodeFn,
|
|
79
|
+
getRegistrationModeFn,
|
|
80
|
+
setRegistrationModeFn,
|
|
81
|
+
} from '../serverFns/invitations'
|
|
82
|
+
|
|
83
|
+
const ic = prisma.invitationCode as unknown as {
|
|
84
|
+
findMany: ReturnType<typeof vi.fn>
|
|
85
|
+
count: ReturnType<typeof vi.fn>
|
|
86
|
+
findUnique: ReturnType<typeof vi.fn>
|
|
87
|
+
create: ReturnType<typeof vi.fn>
|
|
88
|
+
update: ReturnType<typeof vi.fn>
|
|
89
|
+
delete: ReturnType<typeof vi.fn>
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const user = prisma.user as unknown as { findMany: ReturnType<typeof vi.fn> }
|
|
93
|
+
const systemSetting = prisma.systemSetting as unknown as {
|
|
94
|
+
findUnique: ReturnType<typeof vi.fn>
|
|
95
|
+
upsert: ReturnType<typeof vi.fn>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('invitations serverFn(admin 邀请码 CRUD 与注册模式)', () => {
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
vi.clearAllMocks()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('listInvitationCodesFn:分页查询 disabledAt=null 并批量解析创建人', async () => {
|
|
104
|
+
const codes = [
|
|
105
|
+
{ id: 'c1', code: 'ABC1', maxUses: 1, usedCount: 0, expiresAt: new Date(), createdById: 'u1', disabledAt: null, createdAt: new Date(), uses: [] },
|
|
106
|
+
{ id: 'c2', code: 'DEF2', maxUses: 5, usedCount: 2, expiresAt: new Date(), createdById: null, disabledAt: null, createdAt: new Date(), uses: [] },
|
|
107
|
+
]
|
|
108
|
+
ic.findMany.mockResolvedValue(codes)
|
|
109
|
+
ic.count.mockResolvedValue(42)
|
|
110
|
+
user.findMany.mockResolvedValue([{ id: 'u1', username: 'alice' }])
|
|
111
|
+
|
|
112
|
+
const result = await listInvitationCodesFn({ data: { page: 2, pageSize: 20 } })
|
|
113
|
+
|
|
114
|
+
expect(ic.findMany).toHaveBeenCalledWith(
|
|
115
|
+
expect.objectContaining({ skip: 20, take: 20, where: { disabledAt: null } }),
|
|
116
|
+
)
|
|
117
|
+
expect(ic.count).toHaveBeenCalledWith({ where: { disabledAt: null } })
|
|
118
|
+
expect(user.findMany).toHaveBeenCalledWith({
|
|
119
|
+
where: { id: { in: ['u1'] } },
|
|
120
|
+
select: { id: true, username: true },
|
|
121
|
+
})
|
|
122
|
+
expect(result).toMatchObject({
|
|
123
|
+
total: 42,
|
|
124
|
+
page: 2,
|
|
125
|
+
pageSize: 20,
|
|
126
|
+
codes: [
|
|
127
|
+
{ code: 'ABC1', creator: 'alice' },
|
|
128
|
+
{ code: 'DEF2', creator: null },
|
|
129
|
+
],
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('listInvitationCodesFn:无创建人时不发起用户查询', async () => {
|
|
134
|
+
ic.findMany.mockResolvedValue([{ id: 'c1', createdById: null, uses: [] }])
|
|
135
|
+
ic.count.mockResolvedValue(1)
|
|
136
|
+
|
|
137
|
+
await listInvitationCodesFn({ data: { page: 1, pageSize: 10 } })
|
|
138
|
+
|
|
139
|
+
expect(user.findMany).not.toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('createInvitationCodeFn:自定义 code 归一化为大写并写入 createdById', async () => {
|
|
143
|
+
ic.create.mockImplementation(({ data }: { data: { code: string } }) =>
|
|
144
|
+
Promise.resolve({ id: 'c-new', code: data.code }),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const result = await createInvitationCodeFn({ data: { code: ' ab12cd34 ' } })
|
|
148
|
+
|
|
149
|
+
expect(ic.create).toHaveBeenCalledWith({
|
|
150
|
+
data: expect.objectContaining({
|
|
151
|
+
code: 'AB12CD34',
|
|
152
|
+
maxUses: 1,
|
|
153
|
+
createdById: 'admin-1',
|
|
154
|
+
}),
|
|
155
|
+
})
|
|
156
|
+
expect(result.code).toBe('AB12CD34')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('createInvitationCodeFn:自定义 code 过短 → 拒绝', async () => {
|
|
160
|
+
await expect(createInvitationCodeFn({ data: { code: 'abc000' } })).rejects.toThrow(
|
|
161
|
+
'邀请码需为 8-12 位字母或数字',
|
|
162
|
+
)
|
|
163
|
+
expect(ic.create).not.toHaveBeenCalled()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('createInvitationCodeFn:自定义 code 含下划线短横 → 拒绝', async () => {
|
|
167
|
+
await expect(createInvitationCodeFn({ data: { code: 'ab12_cd34' } })).rejects.toThrow(
|
|
168
|
+
'邀请码需为 8-12 位字母或数字',
|
|
169
|
+
)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('createInvitationCodeFn:纯字母 code(缺数字)→ 拒绝', async () => {
|
|
173
|
+
await expect(createInvitationCodeFn({ data: { code: 'abcdefgh' } })).rejects.toThrow(
|
|
174
|
+
'邀请码需同时包含字母和数字',
|
|
175
|
+
)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('createInvitationCodeFn:纯数字 code(缺字母)→ 拒绝', async () => {
|
|
179
|
+
await expect(createInvitationCodeFn({ data: { code: '12345678' } })).rejects.toThrow(
|
|
180
|
+
'邀请码需同时包含字母和数字',
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('createInvitationCodeFn:未传 code 时自动生成满足 /^[A-F0-9]{8}$/', async () => {
|
|
185
|
+
ic.create.mockImplementation(({ data }: { data: { code: string } }) =>
|
|
186
|
+
Promise.resolve({ id: 'c-new', code: data.code }),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
const result = await createInvitationCodeFn({ data: {} })
|
|
190
|
+
|
|
191
|
+
expect(result.code).toMatch(/^[A-F0-9]{8}$/)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('toggleInvitationCodeFn:已启用(disabledAt=null)→ 写入当前时间', async () => {
|
|
195
|
+
ic.findUnique.mockResolvedValue({ id: 'c1', disabledAt: null })
|
|
196
|
+
const captures: Array<{ where: { id: string }; data: { disabledAt: Date | null } }> = []
|
|
197
|
+
ic.update.mockImplementation((arg: { where: { id: string }; data: { disabledAt: Date | null } }) => {
|
|
198
|
+
captures.push(arg)
|
|
199
|
+
return Promise.resolve({ id: arg.where.id, disabledAt: arg.data.disabledAt })
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const before = Date.now()
|
|
203
|
+
await toggleInvitationCodeFn({ data: { id: 'c1' } })
|
|
204
|
+
const after = Date.now()
|
|
205
|
+
|
|
206
|
+
expect(captures).toHaveLength(1)
|
|
207
|
+
expect(captures[0].where).toEqual({ id: 'c1' })
|
|
208
|
+
const written = captures[0].data.disabledAt
|
|
209
|
+
expect(written).toBeInstanceOf(Date)
|
|
210
|
+
if (written instanceof Date) {
|
|
211
|
+
expect(written.getTime()).toBeGreaterThanOrEqual(before)
|
|
212
|
+
expect(written.getTime()).toBeLessThanOrEqual(after)
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('toggleInvitationCodeFn:已禁用 → 置回 null(重新启用)', async () => {
|
|
217
|
+
ic.findUnique.mockResolvedValue({ id: 'c1', disabledAt: new Date('2026-01-01') })
|
|
218
|
+
ic.update.mockImplementation(({ data }: { data: { disabledAt: Date | null } }) =>
|
|
219
|
+
Promise.resolve({ id: 'c1', disabledAt: data.disabledAt }),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const result = await toggleInvitationCodeFn({ data: { id: 'c1' } })
|
|
223
|
+
|
|
224
|
+
expect(result.disabledAt).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('toggleInvitationCodeFn:不存在的 id 抛「邀请码不存在」', async () => {
|
|
228
|
+
ic.findUnique.mockResolvedValue(null)
|
|
229
|
+
|
|
230
|
+
await expect(toggleInvitationCodeFn({ data: { id: 'missing' } })).rejects.toThrow(
|
|
231
|
+
'邀请码不存在',
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('deleteInvitationCodeFn:usedCount > 0 拒绝删除', async () => {
|
|
236
|
+
ic.findUnique.mockResolvedValue({ id: 'c1', usedCount: 3 })
|
|
237
|
+
|
|
238
|
+
await expect(deleteInvitationCodeFn({ data: { id: 'c1' } })).rejects.toThrow(
|
|
239
|
+
'已有使用记录,请改用禁用',
|
|
240
|
+
)
|
|
241
|
+
expect(ic.delete).not.toHaveBeenCalled()
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('deleteInvitationCodeFn:usedCount === 0 删除成功', async () => {
|
|
245
|
+
ic.findUnique.mockResolvedValue({ id: 'c1', usedCount: 0 })
|
|
246
|
+
ic.delete.mockResolvedValue({ id: 'c1' })
|
|
247
|
+
|
|
248
|
+
const result = await deleteInvitationCodeFn({ data: { id: 'c1' } })
|
|
249
|
+
|
|
250
|
+
expect(ic.delete).toHaveBeenCalledWith({ where: { id: 'c1' } })
|
|
251
|
+
expect(result).toEqual({ deleted: true })
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('deleteInvitationCodeFn:不存在的 id 抛「邀请码不存在」', async () => {
|
|
255
|
+
ic.findUnique.mockResolvedValue(null)
|
|
256
|
+
|
|
257
|
+
await expect(deleteInvitationCodeFn({ data: { id: 'missing' } })).rejects.toThrow(
|
|
258
|
+
'邀请码不存在',
|
|
259
|
+
)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('getRegistrationModeFn:无行时默认 invite', async () => {
|
|
263
|
+
systemSetting.findUnique.mockResolvedValue(null)
|
|
264
|
+
|
|
265
|
+
await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'invite' })
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('getRegistrationModeFn:value=open 返回 open', async () => {
|
|
269
|
+
systemSetting.findUnique.mockResolvedValue({ key: 'auth.registrationMode', value: 'open' })
|
|
270
|
+
|
|
271
|
+
await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'open' })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('setRegistrationModeFn:写入后 upsert create 分支带 category=auth', async () => {
|
|
275
|
+
systemSetting.upsert.mockResolvedValue({ id: 's1' })
|
|
276
|
+
|
|
277
|
+
const result = await setRegistrationModeFn({ data: { mode: 'open' } })
|
|
278
|
+
|
|
279
|
+
expect(systemSetting.upsert).toHaveBeenCalledWith({
|
|
280
|
+
where: { key: 'auth.registrationMode' },
|
|
281
|
+
update: { value: 'open' },
|
|
282
|
+
create: expect.objectContaining({
|
|
283
|
+
key: 'auth.registrationMode',
|
|
284
|
+
value: 'open',
|
|
285
|
+
category: 'auth',
|
|
286
|
+
}),
|
|
287
|
+
})
|
|
288
|
+
expect(result).toEqual({ mode: 'open' })
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('setRegistrationModeFn + getRegistrationModeFn:写读一致', async () => {
|
|
292
|
+
let stored: string | null = null
|
|
293
|
+
systemSetting.upsert.mockImplementation(
|
|
294
|
+
({ update }: { update: { value: string } }) => {
|
|
295
|
+
stored = update.value
|
|
296
|
+
return Promise.resolve({ id: 's1', value: stored })
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
systemSetting.findUnique.mockImplementation(() =>
|
|
300
|
+
stored === null
|
|
301
|
+
? Promise.resolve(null)
|
|
302
|
+
: Promise.resolve({ key: 'auth.registrationMode', value: stored }),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
await setRegistrationModeFn({ data: { mode: 'open' } })
|
|
306
|
+
await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'open' })
|
|
307
|
+
|
|
308
|
+
await setRegistrationModeFn({ data: { mode: 'invite' } })
|
|
309
|
+
await expect(getRegistrationModeFn({ data: {} })).resolves.toEqual({ mode: 'invite' })
|
|
310
|
+
})
|
|
311
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// ⚠ serverFn 纯净原则:本文件禁止 export 普通函数/常量;只允许 createServerFn 返回值和 interface。
|
|
2
|
+
// 工具函数(如 maskSecret)如需复用请放同目录其他 .ts 文件。
|
|
3
|
+
//
|
|
4
|
+
// 跨 app 缓存失效:admin 与 web 是独立 app(各自独立 prisma 实例),admin 写 DB 后 web 端
|
|
5
|
+
// oauth-config 60s 缓存最长延迟 60s 生效(TTL 自然过期,符合 plan2 spec),无跨进程失效通道。
|
|
6
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { prisma } from '@allbluecn/database'
|
|
9
|
+
import { requireAdmin } from '@/server/auth-helpers'
|
|
10
|
+
import { decryptSecret, encryptSecret } from '@allbluecn/kernel/crypto'
|
|
11
|
+
|
|
12
|
+
export interface OAuthProviderView {
|
|
13
|
+
provider: string
|
|
14
|
+
clientId: string
|
|
15
|
+
/** 数据库行是否含有效密文(v1. 前缀);'***' 占位视为 false */
|
|
16
|
+
hasSecret: boolean
|
|
17
|
+
enabled: boolean
|
|
18
|
+
hasEnvFallback: boolean
|
|
19
|
+
fromDb: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PROVIDER_KEY = 'google'
|
|
23
|
+
|
|
24
|
+
const GOOGLE_OPENID_URL = 'https://accounts.google.com/.well-known/openid-configuration'
|
|
25
|
+
|
|
26
|
+
function hasGoogleEnvFallback(): boolean {
|
|
27
|
+
return Boolean(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const listOAuthProvidersFn = createServerFn({ method: 'GET' })
|
|
31
|
+
.validator(z.object({}))
|
|
32
|
+
.handler(async (): Promise<{ providers: OAuthProviderView[] }> => {
|
|
33
|
+
await requireAdmin()
|
|
34
|
+
const row = await prisma.oAuthProviderConfig.findUnique({
|
|
35
|
+
where: { provider: PROVIDER_KEY },
|
|
36
|
+
})
|
|
37
|
+
return {
|
|
38
|
+
providers: [
|
|
39
|
+
row
|
|
40
|
+
? {
|
|
41
|
+
provider: PROVIDER_KEY,
|
|
42
|
+
clientId: row.clientId,
|
|
43
|
+
hasSecret:
|
|
44
|
+
row.clientSecret.startsWith('v1.') &&
|
|
45
|
+
decryptSecret(row.clientSecret) !== null,
|
|
46
|
+
enabled: row.enabled,
|
|
47
|
+
hasEnvFallback: false,
|
|
48
|
+
fromDb: true,
|
|
49
|
+
}
|
|
50
|
+
: {
|
|
51
|
+
provider: PROVIDER_KEY,
|
|
52
|
+
clientId: '',
|
|
53
|
+
hasSecret: false,
|
|
54
|
+
enabled: false,
|
|
55
|
+
hasEnvFallback: hasGoogleEnvFallback(),
|
|
56
|
+
fromDb: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
export const saveOAuthProviderFn = createServerFn({ method: 'POST' })
|
|
63
|
+
.validator(
|
|
64
|
+
z.object({
|
|
65
|
+
provider: z.literal('google'),
|
|
66
|
+
clientId: z.string().min(1, 'clientId 不能为空'),
|
|
67
|
+
clientSecret: z.string().optional(),
|
|
68
|
+
enabled: z.boolean(),
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
.handler(async ({ data }) => {
|
|
72
|
+
await requireAdmin()
|
|
73
|
+
const hasSecret = Boolean(data.clientSecret && data.clientSecret.length > 0)
|
|
74
|
+
if (!hasSecret) {
|
|
75
|
+
// 无 secret:仅当 DB 已有记录时更新 clientId/enabled;无记录时创建 secret='***' 占位
|
|
76
|
+
// ('***' 会被 web 端 decryptSecret 返回 null,filter 掉,实际回落 env)
|
|
77
|
+
const existing = await prisma.oAuthProviderConfig.findUnique({
|
|
78
|
+
where: { provider: data.provider },
|
|
79
|
+
})
|
|
80
|
+
if (existing) {
|
|
81
|
+
await prisma.oAuthProviderConfig.update({
|
|
82
|
+
where: { provider: data.provider },
|
|
83
|
+
data: { clientId: data.clientId, enabled: data.enabled },
|
|
84
|
+
})
|
|
85
|
+
} else {
|
|
86
|
+
await prisma.oAuthProviderConfig.create({
|
|
87
|
+
data: {
|
|
88
|
+
provider: data.provider,
|
|
89
|
+
clientId: data.clientId,
|
|
90
|
+
clientSecret: '***',
|
|
91
|
+
enabled: data.enabled,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
return { ok: true }
|
|
96
|
+
}
|
|
97
|
+
const encrypted = encryptSecret(data.clientSecret as string)
|
|
98
|
+
await prisma.oAuthProviderConfig.upsert({
|
|
99
|
+
where: { provider: data.provider },
|
|
100
|
+
create: {
|
|
101
|
+
provider: data.provider,
|
|
102
|
+
clientId: data.clientId,
|
|
103
|
+
clientSecret: encrypted,
|
|
104
|
+
enabled: data.enabled,
|
|
105
|
+
},
|
|
106
|
+
update: {
|
|
107
|
+
clientId: data.clientId,
|
|
108
|
+
clientSecret: encrypted,
|
|
109
|
+
enabled: data.enabled,
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
return { ok: true }
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
export const testOAuthProviderFn = createServerFn({ method: 'POST' })
|
|
116
|
+
.validator(
|
|
117
|
+
z.object({
|
|
118
|
+
provider: z.literal('google'),
|
|
119
|
+
clientId: z.string(),
|
|
120
|
+
}),
|
|
121
|
+
)
|
|
122
|
+
.handler(async ({ data }) => {
|
|
123
|
+
await requireAdmin()
|
|
124
|
+
if (!data.clientId || data.clientId.trim().length === 0) {
|
|
125
|
+
return { ok: false, detail: 'clientId 为空,无法测试' }
|
|
126
|
+
}
|
|
127
|
+
let res: Response
|
|
128
|
+
try {
|
|
129
|
+
res = await fetch(GOOGLE_OPENID_URL, {
|
|
130
|
+
method: 'GET',
|
|
131
|
+
headers: { 'accept': 'application/json' },
|
|
132
|
+
signal: AbortSignal.timeout(10_000),
|
|
133
|
+
})
|
|
134
|
+
} catch (e) {
|
|
135
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
136
|
+
return { ok: false, detail: `无法访问 Google OpenID 端点:${msg}` }
|
|
137
|
+
}
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
return { ok: false, detail: `Google OpenID 端点返回 HTTP ${res.status}` }
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
detail:
|
|
144
|
+
'仅验证 Google OAuth 端点可达,key 真伪需在实际登录流程中验证',
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
export const deleteOAuthProviderFn = createServerFn({ method: 'POST' })
|
|
149
|
+
.validator(z.object({ provider: z.literal('google') }))
|
|
150
|
+
.handler(async ({ data }) => {
|
|
151
|
+
await requireAdmin()
|
|
152
|
+
try {
|
|
153
|
+
await prisma.oAuthProviderConfig.delete({ where: { provider: data.provider } })
|
|
154
|
+
} catch (e) {
|
|
155
|
+
const code = (e as { code?: string }).code
|
|
156
|
+
if (code !== 'P2025') {
|
|
157
|
+
throw e
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { ok: true, fallbackToEnv: hasGoogleEnvFallback() }
|
|
161
|
+
})
|