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
+1 -1
View File
@@ -34,7 +34,7 @@
"@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.4", "@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11", "@radix-ui/react-tabs": "^1.1.11",
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies:
specifier: ^2.2.4 specifier: ^2.2.4
version: 2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1) version: 2.2.6(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-separator': '@radix-ui/react-separator':
specifier: ^1.1.7 specifier: ^1.1.8
version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1) version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.7)(react-dom@19.2.1)(react@19.2.1)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.3 specifier: ^1.2.3
+5 -1
View File
@@ -33,6 +33,7 @@ export default function SpaceOAuthCallback() {
const [isBindMode, setIsBindMode] = useState(false); const [isBindMode, setIsBindMode] = useState(false);
const [code, setCode] = useState<string | null>(null); const [code, setCode] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [localEmail, setLocalEmail] = useState<string>('');
const handleOAuthCallback = useCallback( const handleOAuthCallback = useCallback(
async (authCode: string) => { async (authCode: string) => {
@@ -115,6 +116,7 @@ export default function SpaceOAuthCallback() {
} }
setBindState(state); setBindState(state);
setIsBindMode(true); setIsBindMode(true);
setLocalEmail(localStorage.getItem('userEmail') || '');
setStatus('confirm'); setStatus('confirm');
} else { } else {
// Normal login/register mode // Normal login/register mode
@@ -169,7 +171,9 @@ export default function SpaceOAuthCallback() {
<> <>
<AlertTriangle className="h-12 w-12 text-yellow-500" /> <AlertTriangle className="h-12 w-12 text-yellow-500" />
<p className="text-sm text-center text-muted-foreground px-4"> <p className="text-sm text-center text-muted-foreground px-4">
{t('account.bindSpaceWarning')} {t('account.bindSpaceWarning', {
localEmail: localEmail || '-',
})}
</p> </p>
<div className="flex gap-3 w-full"> <div className="flex gap-3 w-full">
<Button <Button
@@ -2,9 +2,6 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect } 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 { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -16,17 +13,16 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Form, Item,
FormControl, ItemMedia,
FormField, ItemContent,
FormItem, ItemTitle,
FormLabel, ItemDescription,
FormMessage, ItemActions,
} from '@/components/ui/form'; } from '@/components/ui/item';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { httpClient } from '@/app/infra/http/HttpClient'; 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 { interface AccountSettingsDialogProps {
open: boolean; open: boolean;
@@ -38,46 +34,12 @@ export default function AccountSettingsDialog({
onOpenChange, onOpenChange,
}: AccountSettingsDialogProps) { }: AccountSettingsDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [accountType, setAccountType] = useState<'local' | 'space'>('local'); const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false); const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState(''); const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false); const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = 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: '',
},
});
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -85,14 +47,6 @@ export default function AccountSettingsDialog({
} }
}, [open]); }, [open]);
useEffect(() => {
form.reset({
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
});
}, [hasPassword, form]);
async function loadUserInfo() { async function loadUserInfo() {
setLoading(true); setLoading(true);
try { 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 () => { const handleBindSpace = async () => {
setSpaceBindLoading(true); setSpaceBindLoading(true);
try { 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-md"> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogHeader> <DialogContent className="sm:max-w-md">
<DialogTitle>{t('account.settings')}</DialogTitle> <DialogHeader>
<DialogDescription>{userEmail}</DialogDescription> <DialogTitle>{t('account.settings')}</DialogTitle>
</DialogHeader> <DialogDescription>{userEmail}</DialogDescription>
</DialogHeader>
{loading ? ( {loading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" /> <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>
</div> </div>
) : (
{/* Bind Space Account - only for local accounts */} <div className="space-y-2">
{accountType === 'local' && ( {/* Password Item */}
<> <Item size="sm" variant="muted" className="rounded-lg">
<Separator /> <ItemMedia variant="icon">
<div className="space-y-4"> <KeyRound className="h-4 w-4" />
<h4 className="text-sm font-medium"> </ItemMedia>
{t('account.bindSpace')} <ItemContent>
</h4> <ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<p className="text-sm text-muted-foreground"> <ItemDescription>
{t('account.bindSpaceDescription')} {hasPassword
</p> ? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button <Button
variant="outline" variant="outline"
className="w-full" size="sm"
onClick={handleBindSpace} onClick={() => setPasswordDialogOpen(true)}
disabled={spaceBindLoading}
> >
{spaceBindLoading ? ( {hasPassword
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> ? t('common.changePassword')
) : ( : t('account.setPassword')}
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button> </Button>
</div> </ItemActions>
</> </Item>
)}
</div> {/* Space Account Item */}
)} <Item size="sm" variant="muted" className="rounded-lg">
</DialogContent> <ItemMedia variant="icon">
</Dialog> <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 { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Copy, Trash2, Plus } from 'lucide-react'; import { Copy, Trash2, Plus } from 'lucide-react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -66,6 +67,9 @@ export default function ApiIntegrationDialog({
onOpenChange, onOpenChange,
}: ApiIntegrationDialogProps) { }: ApiIntegrationDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys'); const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]); const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]); const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -84,6 +88,30 @@ export default function ApiIntegrationDialog({
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true); const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null); 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 样式,防止对话框关闭后页面无法交互 // 清理 body 样式,防止对话框关闭后页面无法交互
useEffect(() => { useEffect(() => {
if (!deleteKeyId && !deleteWebhookId) { if (!deleteKeyId && !deleteWebhookId) {
@@ -231,16 +259,7 @@ export default function ApiIntegrationDialog({
return ( return (
<> <>
<Dialog <Dialog open={open} onOpenChange={handleOpenChange}>
open={open}
onOpenChange={(newOpen) => {
// 如果删除确认框是打开的,不允许关闭主对话框
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
onOpenChange(newOpen);
}}
>
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col"> <DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle> <DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
@@ -20,7 +20,6 @@ import {
Lightbulb, Lightbulb,
LogOut, LogOut,
KeyRound, KeyRound,
User,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
@@ -79,6 +78,12 @@ export default function HomeSidebar({
if (searchParams.get('action') === 'showModelSettings') { if (searchParams.get('action') === 'showModelSettings') {
setModelsDialogOpen(true); setModelsDialogOpen(true);
} }
if (searchParams.get('action') === 'showAccountSettings') {
setAccountSettingsOpen(true);
}
if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true);
}
}, [searchParams]); }, [searchParams]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>(); 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(() => { useEffect(() => {
initSelect(); initSelect();
if (!localStorage.getItem('token')) { if (!localStorage.getItem('token')) {
@@ -337,44 +359,47 @@ export default function HomeSidebar({
<PopoverContent <PopoverContent
side="right" side="right"
align="end" 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"> <div
<span className="text-sm font-medium">{t('common.theme')}</span> className="flex items-center gap-3 p-2 rounded-lg hover:bg-accent cursor-pointer"
<ToggleGroup onClick={() => {
type="single" handleAccountSettingsChange(true);
value={theme} setPopoverOpen(false);
onValueChange={(value) => { }}
if (value) setTheme(value); >
}} <div className="w-10 h-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-medium">
className="justify-start" {userEmail ? userEmail.charAt(0).toUpperCase() : 'U'}
> </div>
<ToggleGroupItem value="light" size="sm"> <span className="text-sm truncate max-w-[180px]">
<Sun className="h-4 w-4" /> {userEmail || t('account.settings')}
</ToggleGroupItem> </span>
<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> </div>
<div className="flex flex-col gap-2 w-full"> <LanguageSelector
<span className="text-sm font-medium"> triggerClassName="w-full"
{t('common.language')} onOpenChange={setLanguageSelectorOpen}
</span> />
<LanguageSelector <ToggleGroup
triggerClassName="w-full" type="single"
onOpenChange={setLanguageSelectorOpen} value={theme}
/> onValueChange={(value) => {
</div> 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"> <div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{t('common.integration')}
</span>
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
@@ -386,36 +411,12 @@ export default function HomeSidebar({
<KeyRound className="w-4 h-4 mr-2" /> <KeyRound className="w-4 h-4 mr-2" />
{t('common.apiIntegration')} {t('common.apiIntegration')}
</Button> </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 <Button
variant="ghost" variant="ghost"
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
onClick={() => { 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'); const language = localStorage.getItem('langbot_language');
if (language === 'zh-Hans') { if (language === 'zh-Hans' || language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open( window.open(
'https://docs.langbot.app/zh/insight/guide.html', 'https://docs.langbot.app/zh/insight/guide.html',
'_blank', '_blank',
@@ -449,9 +450,7 @@ export default function HomeSidebar({
<Button <Button
variant="ghost" variant="ghost"
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
onClick={() => { onClick={() => handleLogout()}
handleLogout();
}}
> >
<LogOut className="w-4 h-4 mr-2" /> <LogOut className="w-4 h-4 mr-2" />
{t('common.logout')} {t('common.logout')}
@@ -462,7 +461,7 @@ export default function HomeSidebar({
</div> </div>
<AccountSettingsDialog <AccountSettingsDialog
open={accountSettingsOpen} open={accountSettingsOpen}
onOpenChange={setAccountSettingsOpen} onOpenChange={handleAccountSettingsChange}
/> />
<ApiIntegrationDialog <ApiIntegrationDialog
open={apiKeyDialogOpen} open={apiKeyDialogOpen}
@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
@@ -26,36 +26,39 @@ import {
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { httpClient } from '@/app/infra/http/HttpClient'; 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 { interface PasswordChangeDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
hasPassword?: boolean;
} }
export default function PasswordChangeDialog({ export default function PasswordChangeDialog({
open, open,
onOpenChange, onOpenChange,
hasPassword = true,
}: PasswordChangeDialogProps) { }: PasswordChangeDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false); 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>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(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>) => { const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await httpClient.changePassword( if (hasPassword) {
values.currentPassword, await httpClient.changePassword(
values.newPassword, values.currentPassword!,
); values.newPassword,
toast.success(t('common.changePasswordSuccess')); );
toast.success(t('common.changePasswordSuccess'));
} else {
await httpClient.setPassword(values.newPassword, undefined);
toast.success(t('account.passwordSetSuccess'));
}
form.reset(); form.reset();
onOpenChange(false); onOpenChange(false);
} catch { } catch {
@@ -87,27 +106,33 @@ export default function PasswordChangeDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('common.changePassword')}</DialogTitle> <DialogTitle>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</DialogTitle>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField {hasPassword && (
control={form.control} <FormField
name="currentPassword" control={form.control}
render={({ field }) => ( name="currentPassword"
<FormItem> render={({ field }) => (
<FormLabel>{t('common.currentPassword')}</FormLabel> <FormItem>
<FormControl> <FormLabel>{t('common.currentPassword')}</FormLabel>
<Input <FormControl>
type="password" <Input
placeholder={t('common.enterCurrentPassword')} type="password"
{...field} placeholder={t('common.enterCurrentPassword')}
/> {...field}
</FormControl> />
<FormMessage /> </FormControl>
</FormItem> <FormMessage />
)} </FormItem>
/> )}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
name="newPassword" name="newPassword"
+193
View File
@@ -0,0 +1,193 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { Separator } from '@/components/ui/separator';
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
role="list"
data-slot="item-group"
className={cn('group/item-group flex flex-col', className)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
);
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
);
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
className,
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
className,
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-actions"
className={cn('flex items-center gap-2', className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};
+19 -9
View File
@@ -49,7 +49,8 @@ const enUS = {
loading: 'Loading...', loading: 'Loading...',
or: 'or', or: 'or',
loginWithSpace: 'Login with Space', loginWithSpace: 'Login with Space',
spaceLoginRecommended: 'Recommended: Sync models and credits from Space', spaceLoginRecommended:
'Recommended: Use official stable model APIs and cloud services',
loginLocal: 'Login with local account', loginLocal: 'Login with local account',
loginWithPassword: 'Login with password', loginWithPassword: 'Login with password',
spaceLoginTitle: 'Login with Space', spaceLoginTitle: 'Login with Space',
@@ -707,7 +708,8 @@ const enUS = {
'The email and password you fill in will be used as the initial administrator account', 'The email and password you fill in will be used as the initial administrator account',
register: 'Register', register: 'Register',
initWithSpace: 'Initialize with Space', initWithSpace: 'Initialize with Space',
spaceRecommended: 'Recommended: Sync models and credits from Space', spaceRecommended:
'Recommended: Use official stable model APIs and cloud services',
registerLocal: 'Register local account', registerLocal: 'Register local account',
registerWithPassword: 'Register with email and password', registerWithPassword: 'Register with email and password',
initSuccess: 'Initialization successful, please login', initSuccess: 'Initialization successful, please login',
@@ -756,21 +758,29 @@ const enUS = {
settings: 'Account Settings', settings: 'Account Settings',
setPassword: 'Set Password', setPassword: 'Set Password',
passwordSetSuccess: 'Password set successfully', passwordSetSuccess: 'Password set successfully',
passwordStatus: 'Local Password',
passwordSet: 'Set',
passwordNotSet: 'Not Set',
passwordSetDescription:
'Password is set, you can login with email and password',
spaceStatus: 'Space Account',
spaceBound: 'Bound',
spaceNotBound: 'Not Bound',
spaceBoundDescription:
'Space account bound, official model APIs and cloud services available',
bindSpace: 'Bind Space Account', bindSpace: 'Bind Space Account',
bindSpaceDescription: bindSpaceDescription: 'Bind to use official model APIs and cloud services',
'Link your local account to a Space account to sync models and credits', bindSpaceButton: 'Bind',
bindSpaceButton: 'Bind Space Account',
bindSpaceConfirmTitle: 'Confirm Binding', bindSpaceConfirmTitle: 'Confirm Binding',
bindSpaceConfirmDescription: bindSpaceConfirmDescription:
'You are about to bind your local account to a Space account', 'You are about to bind your local instance to a Space account',
bindSpaceWarning: bindSpaceWarning:
'After binding, your login email will be changed to the Space account email. You can still use email/password login if you have set a password.', 'After binding, your login email will be changed from {{localEmail}} to the Space account email.',
bindSpaceSuccess: 'Space account bound successfully', bindSpaceSuccess: 'Space account bound successfully',
bindSpaceFailed: 'Failed to bind Space account', bindSpaceFailed: 'Failed to bind Space account',
bindSpaceInvalidState: bindSpaceInvalidState:
'Invalid bind request. Please try again from account settings.', 'Invalid bind request. Please try again from account settings.',
setPasswordHint: setPasswordHint: 'Set a password to login with email and password',
'After setting a password, you can login with email and password',
}, },
}; };
+19 -8
View File
@@ -50,7 +50,8 @@ const jaJP = {
loading: '読み込み中...', loading: '読み込み中...',
or: 'または', or: 'または',
loginWithSpace: 'Space でログイン', loginWithSpace: 'Space でログイン',
spaceLoginRecommended: 'おすすめ:Space からモデルとクレジットを同期', spaceLoginRecommended:
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
loginLocal: 'ローカルアカウントでログイン', loginLocal: 'ローカルアカウントでログイン',
loginWithPassword: 'パスワードでログイン', loginWithPassword: 'パスワードでログイン',
spaceLoginTitle: 'Space でログイン', spaceLoginTitle: 'Space でログイン',
@@ -715,7 +716,8 @@ const jaJP = {
'入力したメールアドレスとパスワードが初期管理者アカウントになります', '入力したメールアドレスとパスワードが初期管理者アカウントになります',
register: '登録', register: '登録',
initWithSpace: 'Space で初期化', initWithSpace: 'Space で初期化',
spaceRecommended: 'おすすめ:Space からモデルとクレジットを同期', spaceRecommended:
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
registerLocal: 'ローカルアカウントを登録', registerLocal: 'ローカルアカウントを登録',
registerWithPassword: 'メールアドレスとパスワードで登録', registerWithPassword: 'メールアドレスとパスワードで登録',
initSuccess: '初期化に成功しました。ログインしてください', initSuccess: '初期化に成功しました。ログインしてください',
@@ -764,21 +766,30 @@ const jaJP = {
settings: 'アカウント設定', settings: 'アカウント設定',
setPassword: 'パスワードを設定', setPassword: 'パスワードを設定',
passwordSetSuccess: 'パスワードの設定に成功しました', passwordSetSuccess: 'パスワードの設定に成功しました',
passwordStatus: 'ローカルパスワード',
passwordSet: '設定済み',
passwordNotSet: '未設定',
passwordSetDescription:
'パスワードが設定されています。メールとパスワードでログインできます',
spaceStatus: 'Space アカウント',
spaceBound: '連携済み',
spaceNotBound: '未連携',
spaceBoundDescription:
'Space アカウントと連携済み、公式モデル API とクラウドサービスが利用可能',
bindSpace: 'Space アカウントを連携', bindSpace: 'Space アカウントを連携',
bindSpaceDescription: bindSpaceDescription: '連携して公式モデル API とクラウドサービスを利用',
'ローカルアカウントを Space アカウントに連携して、モデルとクレジットを同期します', bindSpaceButton: '連携',
bindSpaceButton: 'Space アカウントを連携',
bindSpaceConfirmTitle: '連携を確認', bindSpaceConfirmTitle: '連携を確認',
bindSpaceConfirmDescription: bindSpaceConfirmDescription:
'ローカルアカウントを Space アカウントに連携しようとしています', 'ローカルインスタンスを Space アカウントに連携しようとしています',
bindSpaceWarning: bindSpaceWarning:
'連携後、ログインメールアドレスは Space アカウントのメールアドレスに変更されます。パスワードを設定している場合は、引き続きメールアドレスとパスワードでログインできます。', '連携後、ログインメールアドレスは {{localEmail}} から Space アカウントのメールアドレスに変更されます。',
bindSpaceSuccess: 'Space アカウントの連携に成功しました', bindSpaceSuccess: 'Space アカウントの連携に成功しました',
bindSpaceFailed: 'Space アカウントの連携に失敗しました', bindSpaceFailed: 'Space アカウントの連携に失敗しました',
bindSpaceInvalidState: bindSpaceInvalidState:
'無効な連携リクエストです。アカウント設定から再度お試しください。', '無効な連携リクエストです。アカウント設定から再度お試しください。',
setPasswordHint: setPasswordHint:
'パスワードを設定するとメールアドレスとパスワードでログインできます', 'パスワードを設定するとメールとパスワードでログインできます',
}, },
}; };
+15 -7
View File
@@ -49,7 +49,7 @@ const zhHans = {
loading: '加载中...', loading: '加载中...',
or: '或', or: '或',
loginWithSpace: '通过 Space 登录', loginWithSpace: '通过 Space 登录',
spaceLoginRecommended: '推荐:从 Space 同步模型和点数', spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
loginLocal: '使用本地账号登录', loginLocal: '使用本地账号登录',
loginWithPassword: '通过密码登录', loginWithPassword: '通过密码登录',
spaceLoginTitle: '通过 Space 登录', spaceLoginTitle: '通过 Space 登录',
@@ -680,7 +680,7 @@ const zhHans = {
adminAccountNote: '您填写的邮箱和密码将作为初始管理员账号', adminAccountNote: '您填写的邮箱和密码将作为初始管理员账号',
register: '注册', register: '注册',
initWithSpace: '通过 Space 初始化', initWithSpace: '通过 Space 初始化',
spaceRecommended: '推荐:从 Space 同步模型和点数', spaceRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
registerLocal: '注册本地账号', registerLocal: '注册本地账号',
registerWithPassword: '通过邮箱密码组合注册', registerWithPassword: '通过邮箱密码组合注册',
initSuccess: '初始化成功 请登录', initSuccess: '初始化成功 请登录',
@@ -727,17 +727,25 @@ const zhHans = {
settings: '账户设置', settings: '账户设置',
setPassword: '设置密码', setPassword: '设置密码',
passwordSetSuccess: '密码设置成功', passwordSetSuccess: '密码设置成功',
passwordStatus: '本地密码',
passwordSet: '已设置',
passwordNotSet: '未设置',
passwordSetDescription: '您已设置本地密码,可使用邮箱密码登录',
spaceStatus: 'Space 账户',
spaceBound: '已绑定',
spaceNotBound: '未绑定',
spaceBoundDescription: '已绑定 Space 账户,可使用官方模型 API 和云服务',
bindSpace: '绑定 Space 账户', bindSpace: '绑定 Space 账户',
bindSpaceDescription: '将本地账户关联到 Space 账户,以同步模型和点数', bindSpaceDescription: '绑定后可使用官方模型 API 和云服务',
bindSpaceButton: '绑定 Space 账户', bindSpaceButton: '绑定',
bindSpaceConfirmTitle: '确认绑定', bindSpaceConfirmTitle: '确认绑定',
bindSpaceConfirmDescription: '您即将把本地账户绑定到 Space 账户', bindSpaceConfirmDescription: '您即将把本地实例绑定到 Space 账户',
bindSpaceWarning: bindSpaceWarning:
'绑定后,您的登录邮箱将更改为 Space 账户的邮箱。如果您已设置密码,仍可使用邮箱密码登录。', '绑定后,您的登录邮箱将从 {{localEmail}} 更改为 Space 账户的邮箱。',
bindSpaceSuccess: 'Space 账户绑定成功', bindSpaceSuccess: 'Space 账户绑定成功',
bindSpaceFailed: '绑定 Space 账户失败', bindSpaceFailed: '绑定 Space 账户失败',
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起', bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
setPasswordHint: '设置密码后,您可使用邮箱密码组合登录', setPasswordHint: '设置密码后可使用邮箱密码登录',
}, },
}; };
+15 -7
View File
@@ -49,7 +49,7 @@ const zhHant = {
loading: '載入中...', loading: '載入中...',
or: '或', or: '或',
loginWithSpace: '透過 Space 登入', loginWithSpace: '透過 Space 登入',
spaceLoginRecommended: '推薦:從 Space 同步模型和點數', spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
loginLocal: '使用本地帳號登入', loginLocal: '使用本地帳號登入',
loginWithPassword: '透過密碼登入', loginWithPassword: '透過密碼登入',
spaceLoginTitle: '透過 Space 登入', spaceLoginTitle: '透過 Space 登入',
@@ -677,7 +677,7 @@ const zhHant = {
adminAccountNote: '您填寫的電子郵件和密碼將作為初始管理員帳號', adminAccountNote: '您填寫的電子郵件和密碼將作為初始管理員帳號',
register: '註冊', register: '註冊',
initWithSpace: '透過 Space 初始化', initWithSpace: '透過 Space 初始化',
spaceRecommended: '推薦:從 Space 同步模型和點數', spaceRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
registerLocal: '註冊本地帳號', registerLocal: '註冊本地帳號',
registerWithPassword: '透過電子郵件密碼組合註冊', registerWithPassword: '透過電子郵件密碼組合註冊',
initSuccess: '初始化成功 請登入', initSuccess: '初始化成功 請登入',
@@ -724,17 +724,25 @@ const zhHant = {
settings: '帳戶設定', settings: '帳戶設定',
setPassword: '設定密碼', setPassword: '設定密碼',
passwordSetSuccess: '密碼設定成功', passwordSetSuccess: '密碼設定成功',
passwordStatus: '本地密碼',
passwordSet: '已設定',
passwordNotSet: '未設定',
passwordSetDescription: '您已設定本地密碼,可使用電子郵件密碼登入',
spaceStatus: 'Space 帳戶',
spaceBound: '已綁定',
spaceNotBound: '未綁定',
spaceBoundDescription: '已綁定 Space 帳戶,可使用官方模型 API 和雲服務',
bindSpace: '綁定 Space 帳戶', bindSpace: '綁定 Space 帳戶',
bindSpaceDescription: '將本地帳戶關聯到 Space 帳戶,以同步模型和點數', bindSpaceDescription: '綁定後可使用官方模型 API 和雲服務',
bindSpaceButton: '綁定 Space 帳戶', bindSpaceButton: '綁定',
bindSpaceConfirmTitle: '確認綁定', bindSpaceConfirmTitle: '確認綁定',
bindSpaceConfirmDescription: '您即將把本地帳戶綁定到 Space 帳戶', bindSpaceConfirmDescription: '您即將把本地實例綁定到 Space 帳戶',
bindSpaceWarning: bindSpaceWarning:
'綁定後,您的登入電子郵件將更改為 Space 帳戶的電子郵件。如果您已設定密碼,仍可使用電子郵件密碼登入。', '綁定後,您的登入電子郵件將從 {{localEmail}} 更改為 Space 帳戶的電子郵件。',
bindSpaceSuccess: 'Space 帳戶綁定成功', bindSpaceSuccess: 'Space 帳戶綁定成功',
bindSpaceFailed: '綁定 Space 帳戶失敗', bindSpaceFailed: '綁定 Space 帳戶失敗',
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起', bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
setPasswordHint: '設定密碼後,您可使用電子郵件密碼組合登入', setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
}, },
}; };