@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,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
+ })
package/src/start.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  sentryGlobalRequestMiddleware,
21
21
  } from '@sentry/tanstackstart-react'
22
22
  import { createStart, createMiddleware, createCsrfMiddleware } from '@tanstack/react-start'
23
+ import { securityHeadersMiddleware } from '@/server/middlewares/security-headers'
23
24
  import { requestContextMiddleware } from '@/server/middlewares/request'
24
25
  import {
25
26
  observabilityMiddleware,
@@ -80,6 +81,6 @@ if (typeof process !== 'undefined' && process.versions?.node && process.env.NODE
80
81
  }
81
82
 
82
83
  export const startInstance = createStart(() => ({
83
- requestMiddleware: [sentryGlobalRequestMiddleware, requestContextMiddleware, observabilityMiddleware, debugMiddleware, apiCorsMiddleware, csrfMiddleware],
84
+ requestMiddleware: [securityHeadersMiddleware, sentryGlobalRequestMiddleware, requestContextMiddleware, observabilityMiddleware, debugMiddleware, apiCorsMiddleware, csrfMiddleware],
84
85
  functionMiddleware: [sentryGlobalFunctionMiddleware],
85
86
  }))
@@ -0,0 +1,31 @@
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
+ // Vitest 5 类型兼容层(与 apps/web 同名文件保持一致):Vitest 5 的 `Assertion`
19
+ // 接口使用双类型参数 `Assertion<R, T>`,而 @testing-library/jest-dom 官方
20
+ // types/vitest.d.ts 声明单参数 `Assertion<T = any>`,泛型参数个数不一致导致
21
+ // 声明合并报 TS2428。这里以正确的双参数签名重写增强,使 `toBeInTheDocument`
22
+ // 等 jest-dom matcher 类型可用。运行时 matcher 注册在 src/__tests__/setup.ts
23
+ // 的 `import '@testing-library/jest-dom/vitest'`。
24
+ import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'
25
+
26
+ declare module 'vitest' {
27
+ interface Assertion<R extends void | Promise<void> = void, T = unknown>
28
+ extends TestingLibraryMatchers<R, T> {}
29
+ interface AsymmetricMatchersContaining
30
+ extends TestingLibraryMatchers<any, any> {}
31
+ }