@allbluecn/web-app 0.11.0 → 0.11.2

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.
Files changed (101) hide show
  1. package/package.json +6 -3
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-dark.png +0 -0
  6. package/public/logo/allblue-logo-light.png +0 -0
  7. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  8. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  9. package/src/components/auth/icon-input.tsx +9 -1
  10. package/src/components/auth/password-input.tsx +9 -1
  11. package/src/components/changelog/changelog-page.tsx +44 -1
  12. package/src/components/forms/app-field.tsx +3 -1
  13. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  14. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  15. package/src/components/notifications/settings-notifications.tsx +41 -1
  16. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  17. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  18. package/src/lib/app-version.ts +1 -1
  19. package/src/lib/auth/auth.config.ts +123 -99
  20. package/src/lib/changelog/sdk-versions.ts +4 -4
  21. package/src/lib/edition-desktop.ts +22 -0
  22. package/src/lib/session.ts +2 -2
  23. package/src/routeTree.gen.ts +114 -0
  24. package/src/routes/__root.tsx +20 -3
  25. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  26. package/src/routes/__tests__/-edition-routing.test.tsx +14 -1
  27. package/src/routes/__tests__/route-tree-structure.test.ts +1 -0
  28. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  29. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  30. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  31. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  32. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  33. package/src/routes/_login/login/route.lazy.tsx +4 -2
  34. package/src/routes/_login/register/route.lazy.tsx +28 -17
  35. package/src/routes/_login/register/route.tsx +8 -1
  36. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  37. package/src/routes/_login/schemas/-auth.ts +30 -7
  38. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  39. package/src/routes/_login/verify-email/route.tsx +28 -0
  40. package/src/routes/_nav/route.tsx +5 -0
  41. package/src/routes/_public/index/route.tsx +2 -1
  42. package/src/routes/api/auth/desktop-callback.ts +2 -2
  43. package/src/routes/api/billing-webhook.ts +4 -0
  44. package/src/routes/api/cron/changelog-release.ts +100 -0
  45. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  46. package/src/routes/api.auth.$.ts +3 -5
  47. package/src/server/__tests__/auth-registration.test.ts +60 -0
  48. package/src/server/auth-registration.ts +40 -0
  49. package/src/server/billing-email.ts +39 -0
  50. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  51. package/src/server/email/__tests__/index.test.ts +32 -0
  52. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  53. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  54. package/src/server/email/__tests__/registry.test.tsx +50 -0
  55. package/src/server/email/deliverability.ts +57 -0
  56. package/src/server/email/drivers/console.ts +11 -0
  57. package/src/server/email/drivers/resend.ts +31 -0
  58. package/src/server/email/drivers/smtp.ts +27 -0
  59. package/src/server/email/index.ts +56 -0
  60. package/src/server/email/outbox.ts +141 -0
  61. package/src/server/email/preferences.ts +20 -0
  62. package/src/server/email/registry.ts +38 -0
  63. package/src/server/email/sender.ts +34 -17
  64. package/src/server/email/templates/_base/button.tsx +12 -0
  65. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  66. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  67. package/src/server/email/templates/_base/theme.ts +22 -0
  68. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  69. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  70. package/src/server/email/templates/auth/welcome.tsx +49 -0
  71. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  72. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  73. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  74. package/src/server/email/templates/notification/mirror.tsx +50 -0
  75. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  76. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  77. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  78. package/src/server/email/templates/team/approved.tsx +53 -0
  79. package/src/server/email/templates/team/invite.tsx +57 -0
  80. package/src/server/middlewares/security-headers.ts +11 -2
  81. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  82. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  83. package/src/server/notifications/notification-service.ts +71 -1
  84. package/src/server/notifications/verify-reminder.ts +55 -0
  85. package/src/server/oauth-config.ts +59 -0
  86. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  87. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  88. package/src/server/serverFns/__tests__/team-invitation.test.ts +2 -2
  89. package/src/server/serverFns/auth/email-verification.ts +132 -0
  90. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  91. package/src/server/serverFns/auth/providers.ts +4 -3
  92. package/src/server/serverFns/auth/register.ts +63 -19
  93. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  94. package/src/server/serverFns/auth/reset-password.ts +1 -1
  95. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  96. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  97. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  98. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  99. package/src/server/serverFns/notifications.ts +10 -0
  100. package/src/server/serverFns/team.ts +49 -11
  101. package/vite.shared.ts +87 -3
@@ -339,8 +339,8 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
339
339
  >
340
340
  {kb.name}
341
341
  </BreadcrumbLink>
342
- <BreadcrumbSeparator />
343
342
  </BreadcrumbItem>
343
+ <BreadcrumbSeparator />
344
344
  <BreadcrumbItem>
345
345
  <DropdownMenu>
346
346
  <DropdownMenuTrigger className="flex items-center text-sm text-muted-foreground hover:text-foreground">
@@ -357,8 +357,8 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
357
357
  ))}
358
358
  </DropdownMenuContent>
359
359
  </DropdownMenu>
360
- <BreadcrumbSeparator />
361
360
  </BreadcrumbItem>
361
+ <BreadcrumbSeparator />
362
362
  {breadcrumb.slice(-2).map((item, index, arr) => {
363
363
  const isLast = index === arr.length - 1
364
364
  return (
@@ -368,16 +368,14 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
368
368
  {item.title}
369
369
  </BreadcrumbPage>
370
370
  ) : (
371
- <>
372
- <BreadcrumbLink
373
- className="text-sm font-semibold cursor-pointer hover:text-foreground"
374
- onClick={() => onNavigate?.(item.id)}
375
- >
376
- {item.title}
377
- </BreadcrumbLink>
378
- <BreadcrumbSeparator />
379
- </>
371
+ <BreadcrumbLink
372
+ className="text-sm font-semibold cursor-pointer hover:text-foreground"
373
+ onClick={() => onNavigate?.(item.id)}
374
+ >
375
+ {item.title}
376
+ </BreadcrumbLink>
380
377
  )}
378
+ {!isLast && <BreadcrumbSeparator />}
381
379
  </BreadcrumbItem>
382
380
  )
383
381
  })}
@@ -391,8 +389,8 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
391
389
  >
392
390
  {kb.name}
393
391
  </BreadcrumbLink>
394
- <BreadcrumbSeparator />
395
392
  </BreadcrumbItem>
393
+ <BreadcrumbSeparator />
396
394
  {breadcrumb.map((item, index) => {
397
395
  const isLast = index === breadcrumb.length - 1
398
396
  return (
@@ -402,16 +400,14 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
402
400
  {item.title}
403
401
  </BreadcrumbPage>
404
402
  ) : (
405
- <>
406
- <BreadcrumbLink
407
- className="text-sm font-semibold cursor-pointer hover:text-foreground"
408
- onClick={() => onNavigate?.(item.id)}
409
- >
410
- {item.title}
411
- </BreadcrumbLink>
412
- <BreadcrumbSeparator />
413
- </>
403
+ <BreadcrumbLink
404
+ className="text-sm font-semibold cursor-pointer hover:text-foreground"
405
+ onClick={() => onNavigate?.(item.id)}
406
+ >
407
+ {item.title}
408
+ </BreadcrumbLink>
414
409
  )}
410
+ {!isLast && <BreadcrumbSeparator />}
415
411
  </BreadcrumbItem>
416
412
  )
417
413
  })}
@@ -24,7 +24,6 @@ import {
24
24
  useSidebar,
25
25
  } from "@/components/ui/sidebar";
26
26
  import { cn } from "@/lib/utils";
27
- import { m } from "motion/react";
28
27
  import DashboardNavigation from "@/components/layout/sidebar-03/nav-main";
29
28
  import { NotificationsBell } from "@/components/layout/sidebar-03/nav-notifications";
30
29
  import { NavAnyHub } from "@/components/layout/sidebar-03/nav-anyhub";
@@ -57,15 +56,10 @@ export function DashboardSidebar() {
57
56
  {isCollapsed ? (
58
57
  <div className="flex flex-col items-center gap-2">
59
58
  <LogoIcon className="h-6 w-6" />
60
- <m.div
61
- className="flex flex-col-reverse items-center gap-1"
62
- initial={{ opacity: 0 }}
63
- animate={{ opacity: 1 }}
64
- transition={{ duration: 0.8 }}
65
- >
59
+ <div className="flex flex-col-reverse items-center gap-1">
66
60
  <NotificationsBell />
67
61
  <SidebarTrigger />
68
- </m.div>
62
+ </div>
69
63
  </div>
70
64
  ) : (
71
65
  <>
@@ -78,17 +72,12 @@ export function DashboardSidebar() {
78
72
  {/* <Badge className="h-4 text-[10px] relative -top-4 -left-9 ">v0</Badge> */}
79
73
  </div>
80
74
  </a>
81
- <m.div
82
- className="flex items-center gap-2"
83
- initial={{ opacity: 0 }}
84
- animate={{ opacity: 1 }}
85
- transition={{ duration: 0.8 }}
86
- >
75
+ <div className="flex items-center gap-2">
87
76
  <NotificationsBell />
88
77
  <AnimatedThemeToggler />
89
78
 
90
79
  <SidebarTrigger />
91
- </m.div>
80
+ </div>
92
81
  </>
93
82
  )}
94
83
  </SidebarHeader>
@@ -3,7 +3,7 @@
3
3
  import { useEffect, useState } from 'react'
4
4
  import { useQuery, useQueryClient } from '@tanstack/react-query'
5
5
  import { toast } from 'sonner'
6
- import { Bell } from 'lucide-react'
6
+ import { Bell, Mail } from 'lucide-react'
7
7
  import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field'
8
8
  import { InputGroup, InputGroupInput } from '@/components/ui/input-group'
9
9
  import {
@@ -17,6 +17,8 @@ import { Separator } from '@/components/ui/separator'
17
17
  import { Switch } from '@/components/ui/switch'
18
18
  import { useDebouncedCallback } from '@/lib/pacer'
19
19
  import { getNotificationPreferencesFn, updateNotificationPreferencesFn } from '@/server/serverFns/notifications'
20
+ import { getEmailVerifiedStatusFn } from '@/server/serverFns/auth/email-verification'
21
+ import { useSession } from '@/components/shared-kit/auth/auth-client'
20
22
  import type {
21
23
  CategoryFrequencies,
22
24
  NotificationCategory,
@@ -47,12 +49,24 @@ const PREFERENCES_SAVE_DEBOUNCE_MS = 500
47
49
 
48
50
  export function SettingsNotifications() {
49
51
  const queryClient = useQueryClient()
52
+ const { data: session } = useSession()
53
+ const userId = session?.user?.id ?? null
50
54
 
51
55
  const { data: prefsData, isLoading } = useQuery({
52
56
  queryKey: ['notifications', 'preferences'],
53
57
  queryFn: () => getNotificationPreferencesFn({ data: {} }),
54
58
  })
55
59
 
60
+ const { data: emailVerified = null } = useQuery({
61
+ queryKey: ['email-verified-status', userId ?? 'anonymous'],
62
+ queryFn: async () => {
63
+ const r = await getEmailVerifiedStatusFn({ data: {} })
64
+ return r.verified
65
+ },
66
+ enabled: userId !== null,
67
+ staleTime: 60_000,
68
+ })
69
+
56
70
  const [prefs, setPrefs] = useState<NotificationPreferences | null>(null)
57
71
 
58
72
  useEffect(() => {
@@ -137,6 +151,32 @@ export function SettingsNotifications() {
137
151
  />
138
152
  </div>
139
153
  </Field>
154
+
155
+ <Field>
156
+ <div className="flex items-center justify-between">
157
+ <div className="flex flex-col gap-1">
158
+ <div className="flex items-center gap-2">
159
+ <Mail className="size-4 text-muted-foreground" />
160
+ <FieldLabel htmlFor={`${meta.key}-email`}>
161
+ {m.notifications_preferences_emailChannel()}
162
+ </FieldLabel>
163
+ </div>
164
+ <FieldDescription>
165
+ {emailVerified === false
166
+ ? m.notifications_preferences_emailNeedVerify()
167
+ : m.notifications_preferences_emailChannelDesc()}
168
+ </FieldDescription>
169
+ </div>
170
+ <Switch
171
+ checked={prefs.emailCategories[meta.key]}
172
+ disabled={emailVerified !== true}
173
+ id={`${meta.key}-email`}
174
+ onCheckedChange={(checked) =>
175
+ patch({ emailCategories: { ...prefs.emailCategories, [meta.key]: checked } })
176
+ }
177
+ />
178
+ </div>
179
+ </Field>
140
180
  </div>
141
181
  )
142
182
  })}
@@ -17,10 +17,13 @@
17
17
 
18
18
  "use client";
19
19
 
20
- import { Loader2, Save, Upload } from "lucide-react";
20
+ import { Loader2, MailWarning, Save, Upload } from "lucide-react";
21
21
  import { useState } from "react"
22
+ import { useQuery } from "@tanstack/react-query"
22
23
  import { cn } from "@/lib/utils";
23
24
  import { Button } from "@/components/ui/button";
25
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
26
+ import { toast } from "@/components/ui/sonner";
24
27
  import {
25
28
  Card,
26
29
  CardContent,
@@ -46,6 +49,11 @@ import type {
46
49
  AvatarConfig,
47
50
  ShapeType,
48
51
  } from "@/components/settings/personal-team/account/avatar/avatar-config";
52
+ import { useSession } from "@/components/shared-kit/auth/auth-client";
53
+ import {
54
+ getEmailVerifiedStatusFn,
55
+ resendVerificationEmailFn,
56
+ } from "@/server/serverFns/auth/email-verification";
49
57
  import { m } from "@/paraglide/messages.js";
50
58
 
51
59
  export interface SocialLink {
@@ -127,6 +135,37 @@ export default function SettingsProfile({
127
135
  newEmail: "",
128
136
  currentPassword: "",
129
137
  });
138
+ const [isResendingVerify, setIsResendingVerify] = useState(false);
139
+
140
+ const { data: session } = useSession();
141
+ const userId = session?.user?.id ?? null;
142
+
143
+ const { data: verifyStatus } = useQuery({
144
+ queryKey: ["email-verified-status", userId ?? "anonymous"],
145
+ queryFn: async () => {
146
+ const r = await getEmailVerifiedStatusFn({ data: {} });
147
+ return r.verified;
148
+ },
149
+ enabled: userId !== null,
150
+ staleTime: 60_000,
151
+ });
152
+
153
+ const handleResendVerify = async () => {
154
+ if (isResendingVerify) return;
155
+ setIsResendingVerify(true);
156
+ try {
157
+ const r = await resendVerificationEmailFn({ data: {} });
158
+ if (r.alreadyVerified) {
159
+ toast.success(m.auth_verify_alreadyVerified());
160
+ } else {
161
+ toast.success(m.auth_verify_resendSuccess());
162
+ }
163
+ } catch (err) {
164
+ toast.error(err instanceof Error ? err.message : m.settings_profile_emailChangeFailed());
165
+ } finally {
166
+ setIsResendingVerify(false);
167
+ }
168
+ };
130
169
 
131
170
  const handleSave = async () => {
132
171
  setErrors({});
@@ -363,6 +402,34 @@ export default function SettingsProfile({
363
402
  </FieldContent>
364
403
  </Field>
365
404
 
405
+ {showEmailVerification && verifyStatus === false && (
406
+ <Alert className="mt-2">
407
+ <MailWarning className="size-4" />
408
+ <AlertTitle>{m.auth_account_emailUnverified()}</AlertTitle>
409
+ <AlertDescription className="flex items-center gap-2">
410
+ <span>{m.auth_account_emailUnverifiedDesc()}</span>
411
+ <Button
412
+ disabled={isResendingVerify}
413
+ onClick={handleResendVerify}
414
+ size="sm"
415
+ type="button"
416
+ variant="outline"
417
+ >
418
+ {isResendingVerify ? (
419
+ <Loader2 className="size-4 animate-spin" />
420
+ ) : (
421
+ m.auth_account_resendVerify()
422
+ )}
423
+ </Button>
424
+ </AlertDescription>
425
+ </Alert>
426
+ )}
427
+ {showEmailVerification && verifyStatus === true && (
428
+ <p className="text-muted-foreground mt-1 text-xs">
429
+ {m.auth_account_emailVerified()}
430
+ </p>
431
+ )}
432
+
366
433
  {showEmailChangeForm && (
367
434
  <div className="flex flex-col gap-4 rounded-lg border bg-muted/30 p-4">
368
435
  <Field>
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, afterEach, vi } from 'vitest'
2
+ import { isDesktopEdition } from '../edition-desktop'
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllEnvs()
6
+ })
7
+
8
+ describe('isDesktopEdition', () => {
9
+ it('VITE_DEPLOY_TARGET=desktop → true', () => {
10
+ vi.stubEnv('VITE_DEPLOY_TARGET', 'desktop')
11
+ expect(isDesktopEdition()).toBe(true)
12
+ })
13
+
14
+ it('VITE_DEPLOY_TARGET=web → false', () => {
15
+ vi.stubEnv('VITE_DEPLOY_TARGET', 'web')
16
+ expect(isDesktopEdition()).toBe(false)
17
+ })
18
+
19
+ it('未注入构建常量 → false(Web 部署默认)', () => {
20
+ vi.stubEnv('VITE_DEPLOY_TARGET', '')
21
+ expect(isDesktopEdition()).toBe(false)
22
+ })
23
+ })
@@ -17,7 +17,7 @@
17
17
 
18
18
  // 版本号由 vite.shared.ts 构建时注入(__APP_VERSION__),无需运行时读取 package.json
19
19
  declare const __APP_VERSION__: string
20
- const APP_VERSION: string = __APP_VERSION__
20
+ export const APP_VERSION: string = __APP_VERSION__
21
21
 
22
22
  declare const __APP_UPDATE_DATE__: string
23
23
  const APP_UPDATE_DATE: string = __APP_UPDATE_DATE__
@@ -23,6 +23,7 @@ import { prisma } from '@allbluecn/database'
23
23
  import bcrypt from 'bcryptjs'
24
24
  import { getRandomConfig, presetBgColors } from '@/components/settings/personal-team/account/avatar/avatar-config'
25
25
  import { resolveIpLocation, getClientIp } from '@allbluecn/kernel'
26
+ import { getResolvedOAuthProviders } from '@/server/oauth-config'
26
27
 
27
28
  type CachedProfile = {
28
29
  username: string
@@ -92,6 +93,109 @@ function pickBgColor(seed: string) {
92
93
  return presetBgColors[Math.abs(hash) % presetBgColors.length]
93
94
  }
94
95
 
96
+ const credentialsProvider = CredentialsProvider({
97
+ name: 'credentials',
98
+ credentials: {
99
+ email: { label: 'Email', type: 'email' },
100
+ password: { label: 'Password', type: 'password' },
101
+ ssoToken: { label: 'SSO Token', type: 'text' },
102
+ },
103
+ async authorize(credentials, request) {
104
+ const creds = credentials as { email: string; password: string; remember?: string; stayLoggedIn?: string; ssoToken?: string }
105
+ if (creds.ssoToken) {
106
+ const { resolveSsoTokenUser } = await import('./sso-authorize')
107
+ const user = await resolveSsoTokenUser(creds.ssoToken)
108
+ if (!user) return null
109
+ return { id: user.id, email: user.email, name: user.name, role: user.role }
110
+ }
111
+ if (!creds.email || !creds.password) return null
112
+
113
+ const user = await prisma.user.findUnique({
114
+ where: { email: creds.email.toLowerCase().trim() },
115
+ select: { id: true, email: true, username: true, password: true, role: true, status: true },
116
+ })
117
+
118
+ // 用户不存在:userId 为外键,无法记录日志(保持现状)
119
+ if (!user) return null
120
+
121
+ // 安全提取 IP/UA(start-authjs 传递的 request 类型可能不标准,失败不阻塞登录)
122
+ let ip: string | null = null
123
+ let userAgent: string | null = null
124
+ try {
125
+ const headers = request?.headers as { get?: (name: string) => string | null } | undefined
126
+ if (headers && typeof headers.get === 'function') {
127
+ ip = getClientIp(request as Request)
128
+ userAgent = headers.get('user-agent') || null
129
+ }
130
+ } catch {
131
+ // 忽略:IP/UA 仅用于日志,获取失败不影响登录
132
+ }
133
+
134
+ // 安全记录登录日志(DB 异常不阻塞登录流程)
135
+ const recordLog = async (success: boolean, message?: string, location?: string) => {
136
+ try {
137
+ await prisma.loginLog.create({
138
+ data: {
139
+ userId: user.id,
140
+ ip,
141
+ userAgent,
142
+ location: location ?? null,
143
+ success,
144
+ ...(message ? { message } : {}),
145
+ },
146
+ })
147
+ } catch (err) {
148
+ console.error('[auth] 记录登录日志失败:', err instanceof Error ? err.message : String(err))
149
+ }
150
+ }
151
+
152
+ // 账号状态异常:记录失败原因
153
+ if (user.status === 'BANNED') {
154
+ await recordLog(false, '账号已封禁')
155
+ return null
156
+ }
157
+ if (user.status === 'INACTIVE') {
158
+ await recordLog(false, '账号未激活')
159
+ return null
160
+ }
161
+ if (user.status !== 'ACTIVE') {
162
+ await recordLog(false, '账号状态异常')
163
+ return null
164
+ }
165
+
166
+ const isValid = await bcrypt.compare(creds.password, user.password)
167
+ if (!isValid) {
168
+ await recordLog(false, '密码错误')
169
+ return null
170
+ }
171
+
172
+ // 登录成功:记录 IP/UA/地理位置
173
+ const location = await resolveIpLocation(ip)
174
+ await recordLog(true, undefined, location)
175
+ await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
176
+
177
+ const remember = creds.remember === 'true'
178
+ const stayLoggedIn = creds.stayLoggedIn === 'true'
179
+ return {
180
+ id: user.id,
181
+ email: user.email,
182
+ name: user.username,
183
+ role: user.role,
184
+ remember,
185
+ stayLoggedIn,
186
+ }
187
+ },
188
+ })
189
+
190
+ const GOOGLE_PROVIDER_BASE = {
191
+ authorization: {
192
+ url: 'https://accounts.google.com/o/oauth2/v2/auth',
193
+ params: { prompt: 'consent', access_type: 'offline', response_type: 'code' },
194
+ },
195
+ token: 'https://oauth2.googleapis.com/token',
196
+ userinfo: 'https://www.googleapis.com/oauth2/v3/userinfo',
197
+ } as const
198
+
95
199
  export const authConfig: StartAuthJSConfig = {
96
200
  basePath: '/api/auth',
97
201
  session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60 },
@@ -111,109 +215,12 @@ export const authConfig: StartAuthJSConfig = {
111
215
  },
112
216
  },
113
217
  providers: [
114
- CredentialsProvider({
115
- name: 'credentials',
116
- credentials: {
117
- email: { label: 'Email', type: 'email' },
118
- password: { label: 'Password', type: 'password' },
119
- ssoToken: { label: 'SSO Token', type: 'text' },
120
- },
121
- async authorize(credentials, request) {
122
- const creds = credentials as { email: string; password: string; remember?: string; stayLoggedIn?: string; ssoToken?: string }
123
- if (creds.ssoToken) {
124
- const { resolveSsoTokenUser } = await import('./sso-authorize')
125
- const user = await resolveSsoTokenUser(creds.ssoToken)
126
- if (!user) return null
127
- return { id: user.id, email: user.email, name: user.name, role: user.role }
128
- }
129
- if (!creds.email || !creds.password) return null
130
-
131
- const user = await prisma.user.findUnique({
132
- where: { email: creds.email },
133
- select: { id: true, email: true, username: true, password: true, role: true, status: true },
134
- })
135
-
136
- // 用户不存在:userId 为外键,无法记录日志(保持现状)
137
- if (!user) return null
138
-
139
- // 安全提取 IP/UA(start-authjs 传递的 request 类型可能不标准,失败不阻塞登录)
140
- let ip: string | null = null
141
- let userAgent: string | null = null
142
- try {
143
- const headers = request?.headers as { get?: (name: string) => string | null } | undefined
144
- if (headers && typeof headers.get === 'function') {
145
- ip = getClientIp(request as Request)
146
- userAgent = headers.get('user-agent') || null
147
- }
148
- } catch {
149
- // 忽略:IP/UA 仅用于日志,获取失败不影响登录
150
- }
151
-
152
- // 安全记录登录日志(DB 异常不阻塞登录流程)
153
- const recordLog = async (success: boolean, message?: string, location?: string) => {
154
- try {
155
- await prisma.loginLog.create({
156
- data: {
157
- userId: user.id,
158
- ip,
159
- userAgent,
160
- location: location ?? null,
161
- success,
162
- ...(message ? { message } : {}),
163
- },
164
- })
165
- } catch (err) {
166
- console.error('[auth] 记录登录日志失败:', err instanceof Error ? err.message : String(err))
167
- }
168
- }
169
-
170
- // 账号状态异常:记录失败原因
171
- if (user.status === 'BANNED') {
172
- await recordLog(false, '账号已封禁')
173
- return null
174
- }
175
- if (user.status === 'INACTIVE') {
176
- await recordLog(false, '账号未激活')
177
- return null
178
- }
179
- if (user.status !== 'ACTIVE') {
180
- await recordLog(false, '账号状态异常')
181
- return null
182
- }
183
-
184
- const isValid = await bcrypt.compare(creds.password, user.password)
185
- if (!isValid) {
186
- await recordLog(false, '密码错误')
187
- return null
188
- }
189
-
190
- // 登录成功:记录 IP/UA/地理位置
191
- const location = await resolveIpLocation(ip)
192
- await recordLog(true, undefined, location)
193
- await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } })
194
-
195
- const remember = creds.remember === 'true'
196
- const stayLoggedIn = creds.stayLoggedIn === 'true'
197
- return {
198
- id: user.id,
199
- email: user.email,
200
- name: user.username,
201
- role: user.role,
202
- remember,
203
- stayLoggedIn,
204
- }
205
- },
206
- }),
218
+ credentialsProvider,
207
219
  ...(process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET
208
220
  ? [GoogleProvider({
209
221
  clientId: process.env.AUTH_GOOGLE_ID,
210
222
  clientSecret: process.env.AUTH_GOOGLE_SECRET,
211
- authorization: {
212
- url: 'https://accounts.google.com/o/oauth2/v2/auth',
213
- params: { prompt: 'consent', access_type: 'offline', response_type: 'code' },
214
- },
215
- token: 'https://oauth2.googleapis.com/token',
216
- userinfo: 'https://www.googleapis.com/oauth2/v3/userinfo',
223
+ ...GOOGLE_PROVIDER_BASE,
217
224
  })]
218
225
  : []),
219
226
  ],
@@ -381,3 +388,20 @@ export const authConfig: StartAuthJSConfig = {
381
388
  },
382
389
  },
383
390
  }
391
+
392
+ /** 异步工厂:DB OAuth 配置优先(60s 缓存),无则回落 env 静态 authConfig.providers */
393
+ export async function getAuthConfig(): Promise<StartAuthJSConfig> {
394
+ const resolved = await getResolvedOAuthProviders()
395
+ const google = resolved.find((p) => p.provider === 'google')
396
+ const providers = google
397
+ ? [
398
+ credentialsProvider,
399
+ GoogleProvider({
400
+ clientId: google.clientId,
401
+ clientSecret: google.clientSecret,
402
+ ...GOOGLE_PROVIDER_BASE,
403
+ }),
404
+ ]
405
+ : authConfig.providers
406
+ return { ...authConfig, providers }
407
+ }
@@ -95,15 +95,15 @@ export const SDK_GROUPS: SdkGroup[] = [
95
95
  "items": [
96
96
  {
97
97
  "name": "@allbluecn/web-app",
98
- "version": "v0.11.0",
99
- "lastUpdate": "Sep 11, 2026",
98
+ "version": "v0.11.2",
99
+ "lastUpdate": "Sep 12, 2026",
100
100
  "source": "apps/web/package.json",
101
101
  "docsUrl": "https://github.com/allbluecn/allblue"
102
102
  },
103
103
  {
104
104
  "name": "@allbluecn/kernel",
105
- "version": "v0.6.1",
106
- "lastUpdate": "Sep 11, 2026",
105
+ "version": "v0.8.0",
106
+ "lastUpdate": "Sep 12, 2026",
107
107
  "source": "packages/kernel/package.json",
108
108
  "docsUrl": "https://github.com/allbluecn/allblue"
109
109
  }
@@ -0,0 +1,22 @@
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
+ // beforeLoad 同构执行(SSR + 客户端导航),不能读 process.env;
19
+ // Vite define 已注入 import.meta.env.VITE_DEPLOY_TARGET(见 vite.shared.ts)
20
+ export function isDesktopEdition(): boolean {
21
+ return import.meta.env.VITE_DEPLOY_TARGET === 'desktop'
22
+ }
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { getSession } from 'start-authjs'
19
19
  import type { AuthSession } from 'start-authjs'
20
- import { authConfig } from './auth/auth.config'
20
+ import { getAuthConfig } from './auth/auth.config'
21
21
 
22
22
  export type SessionUser = NonNullable<AuthSession['user']> & {
23
23
  id?: string
@@ -41,7 +41,7 @@ export type Session = Omit<AuthSession, 'user'> & { user?: SessionUser | null }
41
41
  export async function getCurrentSession(request?: Request): Promise<Session | null> {
42
42
  if (!request) return null
43
43
  try {
44
- return (await getSession(request, authConfig)) as Session | null
44
+ return (await getSession(request, await getAuthConfig())) as Session | null
45
45
  } catch (error) {
46
46
  // JWT 解密失败(AUTH_SECRET 轮换 / 跨应用同名 cookie 污染)等效于无 session:
47
47
  // 返回 null 走未登录分支;@auth/core 会自行清理坏 cookie。