@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.
@@ -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
+ })