mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-10 15:56:03 +00:00
feat: implement account settings dialog for managing user passwords and binding Space accounts
This commit is contained in:
@@ -5,7 +5,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -21,26 +26,24 @@ export default function SpaceOAuthCallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [status, setStatus] = useState<
|
||||
'loading' | 'confirm' | 'success' | 'error'
|
||||
>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [isBindMode, setIsBindMode] = useState(false);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string) => {
|
||||
async (authCode: string) => {
|
||||
try {
|
||||
const response = await httpClient.exchangeSpaceOAuthCode(code);
|
||||
|
||||
// Store token and user info
|
||||
const response = await httpClient.exchangeSpaceOAuthCode(authCode);
|
||||
localStorage.setItem('token', response.token);
|
||||
if (response.user) {
|
||||
localStorage.setItem('userEmail', response.user);
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
toast.success(t('common.spaceLoginSuccess'));
|
||||
|
||||
// Redirect to home after a brief delay to show success state
|
||||
setTimeout(() => {
|
||||
router.push('/home');
|
||||
}, 1000);
|
||||
@@ -52,10 +55,40 @@ export default function SpaceOAuthCallback() {
|
||||
[router, t],
|
||||
);
|
||||
|
||||
const [bindState, setBindState] = useState<string | null>(null);
|
||||
|
||||
const handleBindAccount = useCallback(
|
||||
async (authCode: string, state: string) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const response = await httpClient.bindSpaceAccount(authCode, state);
|
||||
localStorage.setItem('token', response.token);
|
||||
if (response.user) {
|
||||
localStorage.setItem('userEmail', response.user);
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('account.bindSpaceSuccess'));
|
||||
setTimeout(() => {
|
||||
router.push('/home');
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : t('account.bindSpaceFailed'),
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[router, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get('code');
|
||||
const authCode = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
const mode = searchParams.get('mode');
|
||||
const state = searchParams.get('state');
|
||||
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
@@ -65,21 +98,44 @@ export default function SpaceOAuthCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
if (!authCode) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginNoCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
handleOAuthCallback(code);
|
||||
setCode(authCode);
|
||||
|
||||
if (mode === 'bind') {
|
||||
// Bind mode - verify state (token) exists
|
||||
if (!state) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('account.bindSpaceInvalidState'));
|
||||
return;
|
||||
}
|
||||
setBindState(state);
|
||||
setIsBindMode(true);
|
||||
setStatus('confirm');
|
||||
} else {
|
||||
// Normal login/register mode
|
||||
handleOAuthCallback(authCode);
|
||||
}
|
||||
}, [searchParams, handleOAuthCallback, t]);
|
||||
|
||||
const handleConfirmBind = () => {
|
||||
if (code && bindState) {
|
||||
handleBindAccount(code, bindState);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelBind = () => {
|
||||
router.push('/home');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
|
||||
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
|
||||
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
||||
<CardHeader className="text-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={langbotIcon.src}
|
||||
alt="LangBot"
|
||||
@@ -87,12 +143,20 @@ export default function SpaceOAuthCallback() {
|
||||
/>
|
||||
<CardTitle className="text-xl">
|
||||
{status === 'loading' && t('common.spaceLoginProcessing')}
|
||||
{status === 'success' && t('common.spaceLoginSuccess')}
|
||||
{status === 'error' && t('common.spaceLoginError')}
|
||||
{status === 'confirm' && t('account.bindSpaceConfirmTitle')}
|
||||
{status === 'success' &&
|
||||
(isBindMode
|
||||
? t('account.bindSpaceSuccess')
|
||||
: t('common.spaceLoginSuccess'))}
|
||||
{status === 'error' &&
|
||||
(isBindMode
|
||||
? t('account.bindSpaceFailed')
|
||||
: t('common.spaceLoginError'))}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'loading' &&
|
||||
t('common.spaceLoginProcessingDescription')}
|
||||
{status === 'confirm' && t('account.bindSpaceConfirmDescription')}
|
||||
{status === 'success' && t('common.spaceLoginSuccessDescription')}
|
||||
{status === 'error' && errorMessage}
|
||||
</CardDescription>
|
||||
@@ -101,6 +165,34 @@ export default function SpaceOAuthCallback() {
|
||||
{status === 'loading' && (
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
)}
|
||||
{status === 'confirm' && (
|
||||
<>
|
||||
<AlertTriangle className="h-12 w-12 text-yellow-500" />
|
||||
<p className="text-sm text-center text-muted-foreground px-4">
|
||||
{t('account.bindSpaceWarning')}
|
||||
</p>
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleCancelBind}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleConfirmBind}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<CheckCircle2 className="h-12 w-12 text-green-500" />
|
||||
)}
|
||||
@@ -108,10 +200,10 @@ export default function SpaceOAuthCallback() {
|
||||
<>
|
||||
<AlertCircle className="h-12 w-12 text-red-500" />
|
||||
<Button
|
||||
onClick={() => router.push('/login')}
|
||||
onClick={() => router.push(isBindMode ? '/home' : '/login')}
|
||||
className="w-full mt-4"
|
||||
>
|
||||
{t('common.backToLogin')}
|
||||
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} 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';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Loader2, ExternalLink } from 'lucide-react';
|
||||
|
||||
interface AccountSettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AccountSettingsDialog({
|
||||
open,
|
||||
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: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadUserInfo();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmNewPassword: '',
|
||||
});
|
||||
}, [hasPassword, form]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const info = await httpClient.getUserInfo();
|
||||
setAccountType(info.account_type);
|
||||
setHasPassword(info.has_password);
|
||||
setUserEmail(info.user);
|
||||
} catch {
|
||||
toast.error(t('common.error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
setSpaceBindLoading(false);
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
// Pass token as state for security verification
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
setSpaceBindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
Monitor,
|
||||
CircleHelp,
|
||||
Lightbulb,
|
||||
Lock,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
@@ -33,7 +33,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog';
|
||||
import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';
|
||||
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
@@ -85,7 +85,7 @@ export default function HomeSidebar({
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
|
||||
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
@@ -95,6 +95,7 @@ export default function HomeSidebar({
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
|
||||
// 处理模型对话框的打开和关闭,同时更新 URL
|
||||
function handleModelsDialogChange(open: boolean) {
|
||||
@@ -120,6 +121,21 @@ export default function HomeSidebar({
|
||||
localStorage.setItem('userEmail', 'test@example.com');
|
||||
}
|
||||
|
||||
// Load user email
|
||||
const storedEmail = localStorage.getItem('userEmail');
|
||||
if (storedEmail) {
|
||||
setUserEmail(storedEmail);
|
||||
} else {
|
||||
// Fetch from API if not in localStorage
|
||||
httpClient
|
||||
.getUserInfo()
|
||||
.then((info) => {
|
||||
setUserEmail(info.user);
|
||||
localStorage.setItem('userEmail', info.user);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
getCloudServiceClientSync()
|
||||
.get('/api/v1/dist/info/repo')
|
||||
.then((response) => {
|
||||
@@ -374,6 +390,20 @@ export default function HomeSidebar({
|
||||
|
||||
<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"
|
||||
@@ -416,19 +446,6 @@ export default function HomeSidebar({
|
||||
<Lightbulb className="w-4 h-4 mr-2" />
|
||||
{t('common.featureRequest')}
|
||||
</Button>
|
||||
{systemInfo?.allow_change_password && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => {
|
||||
setPasswordChangeOpen(true);
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
{t('common.changePassword')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start font-normal"
|
||||
@@ -443,9 +460,9 @@ export default function HomeSidebar({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<PasswordChangeDialog
|
||||
open={passwordChangeOpen}
|
||||
onOpenChange={setPasswordChangeOpen}
|
||||
<AccountSettingsDialog
|
||||
open={accountSettingsOpen}
|
||||
onOpenChange={setAccountSettingsOpen}
|
||||
/>
|
||||
<ApiIntegrationDialog
|
||||
open={apiKeyDialogOpen}
|
||||
|
||||
@@ -726,10 +726,40 @@ export class BackendClient extends BaseHttpClient {
|
||||
public getUserInfo(): Promise<{
|
||||
user: string;
|
||||
account_type: 'local' | 'space';
|
||||
has_password: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/info');
|
||||
}
|
||||
|
||||
public getAccountInfo(): Promise<{
|
||||
initialized: boolean;
|
||||
account_type?: 'local' | 'space';
|
||||
has_password?: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/account-info');
|
||||
}
|
||||
|
||||
public setPassword(
|
||||
newPassword: string,
|
||||
currentPassword?: string,
|
||||
): Promise<{ user: string }> {
|
||||
return this.post('/api/v1/user/set-password', {
|
||||
new_password: newPassword,
|
||||
current_password: currentPassword,
|
||||
});
|
||||
}
|
||||
|
||||
public bindSpaceAccount(
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
user: string;
|
||||
account_type: 'local' | 'space';
|
||||
}> {
|
||||
return this.post('/api/v1/user/bind-space', { code, state });
|
||||
}
|
||||
|
||||
// ============ Space OAuth API (Redirect Flow) ============
|
||||
public getSpaceAuthorizeUrl(
|
||||
redirectUri: string,
|
||||
|
||||
@@ -36,10 +36,15 @@ const formSchema = (t: (key: string) => string) =>
|
||||
password: z.string().min(1, t('common.emptyPassword')),
|
||||
});
|
||||
|
||||
type AccountType = 'local' | 'space';
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
const [accountType, setAccountType] = useState<AccountType | null>(null);
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -50,19 +55,25 @@ export default function Login() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getIsInitialized();
|
||||
checkIfAlreadyLoggedIn();
|
||||
checkAccountInfo();
|
||||
}, []);
|
||||
|
||||
function getIsInitialized() {
|
||||
httpClient
|
||||
.checkIfInited()
|
||||
.then((res) => {
|
||||
if (!res.initialized) {
|
||||
router.push('/register');
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
async function checkAccountInfo() {
|
||||
try {
|
||||
const res = await httpClient.getAccountInfo();
|
||||
if (!res.initialized) {
|
||||
router.push('/register');
|
||||
return;
|
||||
}
|
||||
setAccountType(res.account_type || 'local');
|
||||
setHasPassword(res.has_password || false);
|
||||
setLoading(false);
|
||||
|
||||
// Also check if already logged in
|
||||
checkIfAlreadyLoggedIn();
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfAlreadyLoggedIn() {
|
||||
@@ -95,19 +106,12 @@ export default function Login() {
|
||||
});
|
||||
}
|
||||
|
||||
// Space OAuth redirect handler
|
||||
const handleSpaceLoginClick = async () => {
|
||||
setSpaceLoading(true);
|
||||
|
||||
try {
|
||||
// Build the redirect URI to the OAuth callback page
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback`;
|
||||
|
||||
// Get the authorization URL from backend
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
|
||||
|
||||
// Redirect to Space authorization page
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
@@ -115,6 +119,19 @@ export default function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine what to show based on account type
|
||||
const showLocalLogin =
|
||||
accountType === 'local' || (accountType === 'space' && hasPassword);
|
||||
const showSpaceLogin = accountType === 'space';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900">
|
||||
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
|
||||
@@ -136,128 +153,135 @@ export default function Login() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Space Login - Recommended */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={handleSpaceLoginClick}
|
||||
disabled={spaceLoading}
|
||||
>
|
||||
{spaceLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
className="mr-2 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>
|
||||
)}
|
||||
{t('common.loginWithSpace')}
|
||||
</Button>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('common.spaceLoginRecommended')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local Account Login */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('common.enterEmail')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-between">
|
||||
<FormLabel>{t('common.password')}</FormLabel>
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-blue-500"
|
||||
>
|
||||
{t('common.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('common.enterPassword')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Space Login - only show for space accounts */}
|
||||
{showSpaceLogin && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="w-full cursor-pointer"
|
||||
onClick={handleSpaceLoginClick}
|
||||
disabled={spaceLoading}
|
||||
>
|
||||
{t('common.loginLocal')}
|
||||
{spaceLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
className="mr-2 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>
|
||||
)}
|
||||
{t('common.loginWithSpace')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider - only show if both login methods are available */}
|
||||
{showSpaceLogin && showLocalLogin && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Account Login - show for local accounts or space accounts with password */}
|
||||
{showLocalLogin && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('common.enterEmail')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-between">
|
||||
<FormLabel>{t('common.password')}</FormLabel>
|
||||
<Link
|
||||
href="/reset-password"
|
||||
className="text-sm text-blue-500"
|
||||
>
|
||||
{t('common.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('common.enterPassword')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant={showSpaceLogin ? 'outline' : 'default'}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('common.loginWithPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function Register() {
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('register.registerLocal')}
|
||||
{t('register.registerWithPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -51,6 +51,7 @@ const enUS = {
|
||||
loginWithSpace: 'Login with Space',
|
||||
spaceLoginRecommended: 'Recommended: Sync models and credits from Space',
|
||||
loginLocal: 'Login with local account',
|
||||
loginWithPassword: 'Login with password',
|
||||
spaceLoginTitle: 'Login with Space',
|
||||
spaceLoginDescription:
|
||||
'Scan the QR code or visit the link below to authorize',
|
||||
@@ -69,6 +70,7 @@ const enUS = {
|
||||
spaceLoginError: 'Login Failed',
|
||||
spaceLoginNoCode: 'Missing authorization code',
|
||||
backToLogin: 'Back to Login',
|
||||
backToHome: 'Back to Home',
|
||||
spaceAccountCannotChangePassword:
|
||||
'Space accounts cannot change password here',
|
||||
theme: 'Theme',
|
||||
@@ -707,6 +709,7 @@ const enUS = {
|
||||
initWithSpace: 'Initialize with Space',
|
||||
spaceRecommended: 'Recommended: Sync models and credits from Space',
|
||||
registerLocal: 'Register local account',
|
||||
registerWithPassword: 'Register with email and password',
|
||||
initSuccess: 'Initialization successful, please login',
|
||||
initFailed: 'Initialization failed: ',
|
||||
},
|
||||
@@ -749,6 +752,26 @@ const enUS = {
|
||||
viewUpdateGuide: 'View Update Guide',
|
||||
noReleaseNotes: 'No release notes available',
|
||||
},
|
||||
account: {
|
||||
settings: 'Account Settings',
|
||||
setPassword: 'Set Password',
|
||||
passwordSetSuccess: 'Password set successfully',
|
||||
bindSpace: 'Bind Space Account',
|
||||
bindSpaceDescription:
|
||||
'Link your local account to a Space account to sync models and credits',
|
||||
bindSpaceButton: 'Bind Space Account',
|
||||
bindSpaceConfirmTitle: 'Confirm Binding',
|
||||
bindSpaceConfirmDescription:
|
||||
'You are about to bind your local account to a Space account',
|
||||
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.',
|
||||
bindSpaceSuccess: 'Space account bound successfully',
|
||||
bindSpaceFailed: 'Failed to bind Space account',
|
||||
bindSpaceInvalidState:
|
||||
'Invalid bind request. Please try again from account settings.',
|
||||
setPasswordHint:
|
||||
'After setting a password, you can login with email and password',
|
||||
},
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
|
||||
@@ -52,6 +52,7 @@ const jaJP = {
|
||||
loginWithSpace: 'Space でログイン',
|
||||
spaceLoginRecommended: 'おすすめ:Space からモデルとクレジットを同期',
|
||||
loginLocal: 'ローカルアカウントでログイン',
|
||||
loginWithPassword: 'パスワードでログイン',
|
||||
spaceLoginTitle: 'Space でログイン',
|
||||
spaceLoginDescription:
|
||||
'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
|
||||
@@ -71,6 +72,7 @@ const jaJP = {
|
||||
spaceLoginError: 'ログインに失敗しました',
|
||||
spaceLoginNoCode: '認証コードがありません',
|
||||
backToLogin: 'ログインに戻る',
|
||||
backToHome: 'ホームに戻る',
|
||||
spaceAccountCannotChangePassword:
|
||||
'Space アカウントはここでパスワードを変更できません',
|
||||
theme: 'テーマ',
|
||||
@@ -715,6 +717,7 @@ const jaJP = {
|
||||
initWithSpace: 'Space で初期化',
|
||||
spaceRecommended: 'おすすめ:Space からモデルとクレジットを同期',
|
||||
registerLocal: 'ローカルアカウントを登録',
|
||||
registerWithPassword: 'メールアドレスとパスワードで登録',
|
||||
initSuccess: '初期化に成功しました。ログインしてください',
|
||||
initFailed: '初期化に失敗しました:',
|
||||
},
|
||||
@@ -757,6 +760,26 @@ const jaJP = {
|
||||
viewUpdateGuide: 'アップデート方法を見る',
|
||||
noReleaseNotes: 'リリースノートはありません',
|
||||
},
|
||||
account: {
|
||||
settings: 'アカウント設定',
|
||||
setPassword: 'パスワードを設定',
|
||||
passwordSetSuccess: 'パスワードの設定に成功しました',
|
||||
bindSpace: 'Space アカウントを連携',
|
||||
bindSpaceDescription:
|
||||
'ローカルアカウントを Space アカウントに連携して、モデルとクレジットを同期します',
|
||||
bindSpaceButton: 'Space アカウントを連携',
|
||||
bindSpaceConfirmTitle: '連携を確認',
|
||||
bindSpaceConfirmDescription:
|
||||
'ローカルアカウントを Space アカウントに連携しようとしています',
|
||||
bindSpaceWarning:
|
||||
'連携後、ログインメールアドレスは Space アカウントのメールアドレスに変更されます。パスワードを設定している場合は、引き続きメールアドレスとパスワードでログインできます。',
|
||||
bindSpaceSuccess: 'Space アカウントの連携に成功しました',
|
||||
bindSpaceFailed: 'Space アカウントの連携に失敗しました',
|
||||
bindSpaceInvalidState:
|
||||
'無効な連携リクエストです。アカウント設定から再度お試しください。',
|
||||
setPasswordHint:
|
||||
'パスワードを設定すると、メールアドレスとパスワードでログインできます',
|
||||
},
|
||||
};
|
||||
|
||||
export default jaJP;
|
||||
|
||||
@@ -51,6 +51,7 @@ const zhHans = {
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
spaceLoginRecommended: '推荐:从 Space 同步模型和点数',
|
||||
loginLocal: '使用本地账号登录',
|
||||
loginWithPassword: '通过密码登录',
|
||||
spaceLoginTitle: '通过 Space 登录',
|
||||
spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
|
||||
spaceLoginUserCode: '您的验证码',
|
||||
@@ -67,6 +68,7 @@ const zhHans = {
|
||||
spaceLoginError: '登录失败',
|
||||
spaceLoginNoCode: '缺少授权码',
|
||||
backToLogin: '返回登录',
|
||||
backToHome: '返回首页',
|
||||
spaceAccountCannotChangePassword: 'Space 账户无法在此修改密码',
|
||||
theme: '主题',
|
||||
changePassword: '修改密码',
|
||||
@@ -680,6 +682,7 @@ const zhHans = {
|
||||
initWithSpace: '通过 Space 初始化',
|
||||
spaceRecommended: '推荐:从 Space 同步模型和点数',
|
||||
registerLocal: '注册本地账号',
|
||||
registerWithPassword: '通过邮箱密码组合注册',
|
||||
initSuccess: '初始化成功 请登录',
|
||||
initFailed: '初始化失败:',
|
||||
},
|
||||
@@ -720,6 +723,22 @@ const zhHans = {
|
||||
viewUpdateGuide: '查看更新方式',
|
||||
noReleaseNotes: '暂无更新日志',
|
||||
},
|
||||
account: {
|
||||
settings: '账户设置',
|
||||
setPassword: '设置密码',
|
||||
passwordSetSuccess: '密码设置成功',
|
||||
bindSpace: '绑定 Space 账户',
|
||||
bindSpaceDescription: '将本地账户关联到 Space 账户,以同步模型和点数',
|
||||
bindSpaceButton: '绑定 Space 账户',
|
||||
bindSpaceConfirmTitle: '确认绑定',
|
||||
bindSpaceConfirmDescription: '您即将把本地账户绑定到 Space 账户',
|
||||
bindSpaceWarning:
|
||||
'绑定后,您的登录邮箱将更改为 Space 账户的邮箱。如果您已设置密码,仍可使用邮箱密码登录。',
|
||||
bindSpaceSuccess: 'Space 账户绑定成功',
|
||||
bindSpaceFailed: '绑定 Space 账户失败',
|
||||
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
||||
setPasswordHint: '设置密码后,您可使用邮箱、密码组合登录',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHans;
|
||||
|
||||
@@ -51,6 +51,7 @@ const zhHant = {
|
||||
loginWithSpace: '透過 Space 登入',
|
||||
spaceLoginRecommended: '推薦:從 Space 同步模型和點數',
|
||||
loginLocal: '使用本地帳號登入',
|
||||
loginWithPassword: '透過密碼登入',
|
||||
spaceLoginTitle: '透過 Space 登入',
|
||||
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
|
||||
spaceLoginUserCode: '您的驗證碼',
|
||||
@@ -67,6 +68,7 @@ const zhHant = {
|
||||
spaceLoginError: '登入失敗',
|
||||
spaceLoginNoCode: '缺少授權碼',
|
||||
backToLogin: '返回登入',
|
||||
backToHome: '返回首頁',
|
||||
spaceAccountCannotChangePassword: 'Space 帳戶無法在此修改密碼',
|
||||
theme: '主題',
|
||||
changePassword: '修改密碼',
|
||||
@@ -677,6 +679,7 @@ const zhHant = {
|
||||
initWithSpace: '透過 Space 初始化',
|
||||
spaceRecommended: '推薦:從 Space 同步模型和點數',
|
||||
registerLocal: '註冊本地帳號',
|
||||
registerWithPassword: '透過電子郵件密碼組合註冊',
|
||||
initSuccess: '初始化成功 請登入',
|
||||
initFailed: '初始化失敗:',
|
||||
},
|
||||
@@ -717,6 +720,22 @@ const zhHant = {
|
||||
viewUpdateGuide: '查看更新方式',
|
||||
noReleaseNotes: '暫無更新日誌',
|
||||
},
|
||||
account: {
|
||||
settings: '帳戶設定',
|
||||
setPassword: '設定密碼',
|
||||
passwordSetSuccess: '密碼設定成功',
|
||||
bindSpace: '綁定 Space 帳戶',
|
||||
bindSpaceDescription: '將本地帳戶關聯到 Space 帳戶,以同步模型和點數',
|
||||
bindSpaceButton: '綁定 Space 帳戶',
|
||||
bindSpaceConfirmTitle: '確認綁定',
|
||||
bindSpaceConfirmDescription: '您即將把本地帳戶綁定到 Space 帳戶',
|
||||
bindSpaceWarning:
|
||||
'綁定後,您的登入電子郵件將更改為 Space 帳戶的電子郵件。如果您已設定密碼,仍可使用電子郵件密碼登入。',
|
||||
bindSpaceSuccess: 'Space 帳戶綁定成功',
|
||||
bindSpaceFailed: '綁定 Space 帳戶失敗',
|
||||
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
|
||||
setPasswordHint: '設定密碼後,您可使用電子郵件、密碼組合登入',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHant;
|
||||
|
||||
Reference in New Issue
Block a user