feat: update dependencies and enhance account settings dialog with password management and improved UI elements

This commit is contained in:
Junyan Qin
2025-12-28 22:38:11 +08:00
parent 24c15b4479
commit 07ad846e96
12 changed files with 549 additions and 340 deletions
+5 -1
View File
@@ -33,6 +33,7 @@ export default function SpaceOAuthCallback() {
const [isBindMode, setIsBindMode] = useState(false);
const [code, setCode] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [localEmail, setLocalEmail] = useState<string>('');
const handleOAuthCallback = useCallback(
async (authCode: string) => {
@@ -115,6 +116,7 @@ export default function SpaceOAuthCallback() {
}
setBindState(state);
setIsBindMode(true);
setLocalEmail(localStorage.getItem('userEmail') || '');
setStatus('confirm');
} else {
// Normal login/register mode
@@ -169,7 +171,9 @@ export default function SpaceOAuthCallback() {
<>
<AlertTriangle className="h-12 w-12 text-yellow-500" />
<p className="text-sm text-center text-muted-foreground px-4">
{t('account.bindSpaceWarning')}
{t('account.bindSpaceWarning', {
localEmail: localEmail || '-',
})}
</p>
<div className="flex gap-3 w-full">
<Button
@@ -2,9 +2,6 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
@@ -16,17 +13,16 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Loader2, ExternalLink } from 'lucide-react';
import { Loader2, ExternalLink, KeyRound } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
interface AccountSettingsDialogProps {
open: boolean;
@@ -38,46 +34,12 @@ export default function AccountSettingsDialog({
onOpenChange,
}: AccountSettingsDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
// Schema with optional currentPassword
const formSchema = z
.object({
currentPassword: z.string().optional(),
newPassword: z
.string()
.min(1, { message: t('common.newPasswordRequired') }),
confirmNewPassword: z
.string()
.min(1, { message: t('common.confirmPasswordRequired') }),
})
.refine((data) => data.newPassword === data.confirmNewPassword, {
message: t('common.passwordsDoNotMatch'),
path: ['confirmNewPassword'],
})
.refine(
(data) =>
!hasPassword ||
(data.currentPassword && data.currentPassword.length > 0),
{
message: t('common.currentPasswordRequired'),
path: ['currentPassword'],
},
);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
},
});
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (open) {
@@ -85,14 +47,6 @@ export default function AccountSettingsDialog({
}
}, [open]);
useEffect(() => {
form.reset({
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
});
}, [hasPassword, form]);
async function loadUserInfo() {
setLoading(true);
try {
@@ -107,20 +61,6 @@ export default function AccountSettingsDialog({
}
}
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsSubmitting(true);
try {
await httpClient.setPassword(values.newPassword, values.currentPassword);
toast.success(t('account.passwordSetSuccess'));
form.reset();
setHasPassword(true);
} catch {
toast.error(t('common.changePasswordFailed'));
} finally {
setIsSubmitting(false);
}
};
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
@@ -144,131 +84,123 @@ export default function AccountSettingsDialog({
}
};
const handlePasswordDialogClose = (dialogOpen: boolean) => {
setPasswordDialogOpen(dialogOpen);
if (!dialogOpen) {
// Reload user info to update password status
loadUserInfo();
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('account.settings')}</DialogTitle>
<DialogDescription>{userEmail}</DialogDescription>
</DialogHeader>
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('account.settings')}</DialogTitle>
<DialogDescription>{userEmail}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-6">
{/* Password Section */}
<div className="space-y-4">
<h4 className="text-sm font-medium">
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</h4>
{!hasPassword && (
<p className="text-sm text-muted-foreground">
{t('account.setPasswordHint')}
</p>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
{hasPassword && (
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.currentPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterCurrentPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.newPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterNewPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.confirmNewPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterConfirmPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isSubmitting}
className="w-full"
>
{isSubmitting ? t('common.saving') : t('common.save')}
</Button>
</form>
</Form>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
{/* Bind Space Account - only for local accounts */}
{accountType === 'local' && (
<>
<Separator />
<div className="space-y-4">
<h4 className="text-sm font-medium">
{t('account.bindSpace')}
</h4>
<p className="text-sm text-muted-foreground">
{t('account.bindSpaceDescription')}
</p>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
className="w-full"
onClick={handleBindSpace}
disabled={spaceBindLoading}
size="sm"
onClick={() => setPasswordDialogOpen(true)}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</div>
</>
)}
</div>
)}
</DialogContent>
</Dialog>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={spaceBindLoading}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
</DialogContent>
</Dialog>
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</>
);
}
@@ -5,6 +5,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Trash2, Plus } from 'lucide-react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import {
Dialog,
DialogContent,
@@ -66,6 +67,9 @@ export default function ApiIntegrationDialog({
onOpenChange,
}: ApiIntegrationDialogProps) {
const { t } = useTranslation();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -84,6 +88,30 @@ export default function ApiIntegrationDialog({
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
// Sync URL with dialog state
useEffect(() => {
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showApiIntegrationSettings');
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
}
}, [open]);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
if (!newOpen) {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
router.replace(newUrl, { scroll: false });
}
onOpenChange(newOpen);
};
// 清理 body 样式,防止对话框关闭后页面无法交互
useEffect(() => {
if (!deleteKeyId && !deleteWebhookId) {
@@ -231,16 +259,7 @@ export default function ApiIntegrationDialog({
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
// 如果删除确认框是打开的,不允许关闭主对话框
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
onOpenChange(newOpen);
}}
>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
@@ -20,7 +20,6 @@ import {
Lightbulb,
LogOut,
KeyRound,
User,
} from 'lucide-react';
import { useTheme } from 'next-themes';
@@ -79,6 +78,12 @@ export default function HomeSidebar({
if (searchParams.get('action') === 'showModelSettings') {
setModelsDialogOpen(true);
}
if (searchParams.get('action') === 'showAccountSettings') {
setAccountSettingsOpen(true);
}
if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true);
}
}, [searchParams]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
@@ -114,6 +119,23 @@ export default function HomeSidebar({
}
}
// 处理账户设置对话框的打开和关闭,同时更新 URL
function handleAccountSettingsChange(open: boolean) {
setAccountSettingsOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showAccountSettings');
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
router.replace(newUrl, { scroll: false });
}
}
useEffect(() => {
initSelect();
if (!localStorage.getItem('token')) {
@@ -337,44 +359,47 @@ export default function HomeSidebar({
<PopoverContent
side="right"
align="end"
className="w-auto p-4 flex flex-col gap-4"
className="w-auto p-2 flex flex-col gap-2"
>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.theme')}</span>
<ToggleGroup
type="single"
value={theme}
onValueChange={(value) => {
if (value) setTheme(value);
}}
className="justify-start"
>
<ToggleGroupItem value="light" size="sm">
<Sun className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" size="sm">
<Moon className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="system" size="sm">
<Monitor className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<div
className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent cursor-pointer"
onClick={() => {
handleAccountSettingsChange(true);
setPopoverOpen(false);
}}
>
<div className="w-10 h-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
{userEmail ? userEmail.charAt(0).toUpperCase() : 'U'}
</div>
<span className="text-sm truncate max-w-[180px]">
{userEmail || t('account.settings')}
</span>
</div>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">
{t('common.language')}
</span>
<LanguageSelector
triggerClassName="w-full"
onOpenChange={setLanguageSelectorOpen}
/>
</div>
<LanguageSelector
triggerClassName="w-full"
onOpenChange={setLanguageSelectorOpen}
/>
<ToggleGroup
type="single"
value={theme}
onValueChange={(value) => {
if (value) setTheme(value);
}}
className="w-full justify-start"
>
<ToggleGroupItem value="light" size="sm">
<Sun className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" size="sm">
<Moon className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="system" size="sm">
<Monitor className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">
{t('common.integration')}
</span>
<div className="flex flex-col gap-1">
<Button
variant="ghost"
className="w-full justify-start font-normal"
@@ -386,36 +411,12 @@ export default function HomeSidebar({
<KeyRound className="w-4 h-4 mr-2" />
{t('common.apiIntegration')}
</Button>
</div>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.account')}</span>
{/* User email display */}
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
setAccountSettingsOpen(true);
setPopoverOpen(false);
}}
>
<User className="w-4 h-4 mr-2" />
<span className="truncate max-w-[180px]">
{userEmail || t('account.settings')}
</span>
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
// open docs.langbot.app
const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
if (language === 'zh-Hans' || language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
@@ -449,9 +450,7 @@ export default function HomeSidebar({
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
handleLogout();
}}
onClick={() => handleLogout()}
>
<LogOut className="w-4 h-4 mr-2" />
{t('common.logout')}
@@ -462,7 +461,7 @@ export default function HomeSidebar({
</div>
<AccountSettingsDialog
open={accountSettingsOpen}
onOpenChange={setAccountSettingsOpen}
onOpenChange={handleAccountSettingsChange}
/>
<ApiIntegrationDialog
open={apiKeyDialogOpen}
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -26,36 +26,39 @@ import {
import { Input } from '@/components/ui/input';
import { httpClient } from '@/app/infra/http/HttpClient';
const getFormSchema = (t: (key: string) => string) =>
z
.object({
currentPassword: z
.string()
.min(1, { message: t('common.currentPasswordRequired') }),
newPassword: z
.string()
.min(1, { message: t('common.newPasswordRequired') }),
confirmNewPassword: z
.string()
.min(1, { message: t('common.confirmPasswordRequired') }),
})
.refine((data) => data.newPassword === data.confirmNewPassword, {
message: t('common.passwordsDoNotMatch'),
path: ['confirmNewPassword'],
});
interface PasswordChangeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hasPassword?: boolean;
}
export default function PasswordChangeDialog({
open,
onOpenChange,
hasPassword = true,
}: PasswordChangeDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = getFormSchema(t);
const getFormSchema = () =>
z
.object({
currentPassword: hasPassword
? z.string().min(1, { message: t('common.currentPasswordRequired') })
: z.string().optional(),
newPassword: z
.string()
.min(1, { message: t('common.newPasswordRequired') }),
confirmNewPassword: z
.string()
.min(1, { message: t('common.confirmPasswordRequired') }),
})
.refine((data) => data.newPassword === data.confirmNewPassword, {
message: t('common.passwordsDoNotMatch'),
path: ['confirmNewPassword'],
});
const formSchema = getFormSchema();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -66,14 +69,30 @@ export default function PasswordChangeDialog({
},
});
// Reset form when dialog opens/closes or hasPassword changes
useEffect(() => {
if (open) {
form.reset({
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
});
}
}, [open, hasPassword, form]);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsSubmitting(true);
try {
await httpClient.changePassword(
values.currentPassword,
values.newPassword,
);
toast.success(t('common.changePasswordSuccess'));
if (hasPassword) {
await httpClient.changePassword(
values.currentPassword!,
values.newPassword,
);
toast.success(t('common.changePasswordSuccess'));
} else {
await httpClient.setPassword(values.newPassword, undefined);
toast.success(t('account.passwordSetSuccess'));
}
form.reset();
onOpenChange(false);
} catch {
@@ -87,27 +106,33 @@ export default function PasswordChangeDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.changePassword')}</DialogTitle>
<DialogTitle>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.currentPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterCurrentPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{hasPassword && (
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.currentPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterCurrentPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="newPassword"