@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.
- package/package.json +6 -3
- package/public/favicon-dark.svg +1 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +1 -0
- package/public/logo/allblue-logo-dark.png +0 -0
- package/public/logo/allblue-logo-light.png +0 -0
- package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
- package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
- package/src/components/auth/icon-input.tsx +9 -1
- package/src/components/auth/password-input.tsx +9 -1
- package/src/components/changelog/changelog-page.tsx +44 -1
- package/src/components/forms/app-field.tsx +3 -1
- package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
- package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
- package/src/components/notifications/settings-notifications.tsx +41 -1
- package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
- package/src/lib/__tests__/edition-desktop.test.ts +23 -0
- package/src/lib/app-version.ts +1 -1
- package/src/lib/auth/auth.config.ts +123 -99
- package/src/lib/changelog/sdk-versions.ts +4 -4
- package/src/lib/edition-desktop.ts +22 -0
- package/src/lib/session.ts +2 -2
- package/src/routeTree.gen.ts +114 -0
- package/src/routes/__root.tsx +20 -3
- package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +14 -1
- package/src/routes/__tests__/route-tree-structure.test.ts +1 -0
- package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
- package/src/routes/_login/changelog/confirm/route.tsx +28 -0
- package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
- package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
- package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
- package/src/routes/_login/login/route.lazy.tsx +4 -2
- package/src/routes/_login/register/route.lazy.tsx +28 -17
- package/src/routes/_login/register/route.tsx +8 -1
- package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
- package/src/routes/_login/schemas/-auth.ts +30 -7
- package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
- package/src/routes/_login/verify-email/route.tsx +28 -0
- package/src/routes/_nav/route.tsx +5 -0
- package/src/routes/_public/index/route.tsx +2 -1
- package/src/routes/api/auth/desktop-callback.ts +2 -2
- package/src/routes/api/billing-webhook.ts +4 -0
- package/src/routes/api/cron/changelog-release.ts +100 -0
- package/src/routes/api/cron/subscription-reminders.ts +110 -0
- package/src/routes/api.auth.$.ts +3 -5
- package/src/server/__tests__/auth-registration.test.ts +60 -0
- package/src/server/auth-registration.ts +40 -0
- package/src/server/billing-email.ts +39 -0
- package/src/server/email/__tests__/deliverability.test.ts +68 -0
- package/src/server/email/__tests__/index.test.ts +32 -0
- package/src/server/email/__tests__/outbox.test.tsx +129 -0
- package/src/server/email/__tests__/product-templates.test.tsx +106 -0
- package/src/server/email/__tests__/registry.test.tsx +50 -0
- package/src/server/email/deliverability.ts +57 -0
- package/src/server/email/drivers/console.ts +11 -0
- package/src/server/email/drivers/resend.ts +31 -0
- package/src/server/email/drivers/smtp.ts +27 -0
- package/src/server/email/index.ts +56 -0
- package/src/server/email/outbox.ts +141 -0
- package/src/server/email/preferences.ts +20 -0
- package/src/server/email/registry.ts +38 -0
- package/src/server/email/sender.ts +34 -17
- package/src/server/email/templates/_base/button.tsx +12 -0
- package/src/server/email/templates/_base/email-layout.tsx +117 -0
- package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
- package/src/server/email/templates/_base/theme.ts +22 -0
- package/src/server/email/templates/auth/reset-password.tsx +58 -0
- package/src/server/email/templates/auth/verify-email.tsx +59 -0
- package/src/server/email/templates/auth/welcome.tsx +49 -0
- package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
- package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
- package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
- package/src/server/email/templates/notification/mirror.tsx +50 -0
- package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
- package/src/server/email/templates/product/changelog-release.tsx +65 -0
- package/src/server/email/templates/team/apply-notify.tsx +53 -0
- package/src/server/email/templates/team/approved.tsx +53 -0
- package/src/server/email/templates/team/invite.tsx +57 -0
- package/src/server/middlewares/security-headers.ts +11 -2
- package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
- package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
- package/src/server/notifications/notification-service.ts +71 -1
- package/src/server/notifications/verify-reminder.ts +55 -0
- package/src/server/oauth-config.ts +59 -0
- package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
- package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +2 -2
- package/src/server/serverFns/auth/email-verification.ts +132 -0
- package/src/server/serverFns/auth/forgot-password.ts +22 -2
- package/src/server/serverFns/auth/providers.ts +4 -3
- package/src/server/serverFns/auth/register.ts +63 -19
- package/src/server/serverFns/auth/registration-mode.ts +23 -0
- package/src/server/serverFns/auth/reset-password.ts +1 -1
- package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
- package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
- package/src/server/serverFns/changelog/subscribe.ts +89 -0
- package/src/server/serverFns/email-unsubscribe.ts +66 -0
- package/src/server/serverFns/notifications.ts +10 -0
- package/src/server/serverFns/team.ts +49 -11
- 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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
+
})
|
package/src/lib/app-version.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
99
|
-
"lastUpdate": "Sep
|
|
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.
|
|
106
|
-
"lastUpdate": "Sep
|
|
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
|
+
}
|
package/src/lib/session.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { getSession } from 'start-authjs'
|
|
19
19
|
import type { AuthSession } from 'start-authjs'
|
|
20
|
-
import {
|
|
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,
|
|
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。
|