feat: implement account settings dialog for managing user passwords and binding Space accounts

This commit is contained in:
Junyan Qin
2025-12-26 23:20:51 +08:00
parent 1d4c5bbdf1
commit 24c15b4479
13 changed files with 901 additions and 206 deletions
+1
View File
@@ -70,6 +70,7 @@ Plugin Runtime automatically starts each installed plugin and interacts through
- type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc. - type: must be a specific type, such as feat (new feature), fix (bug fix), docs (documentation), style (code style), refactor (refactoring), perf (performance optimization), etc.
- scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc. - scope: the scope of the commit, such as the package name, the file name, the function name, the class name, the module name, etc.
- subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc. - subject: the subject of the commit, such as the description of the commit, the reason for the commit, the impact of the commit, etc.
- If you changed the definition of database entities, please update the migration file in `src/langbot/pkg/persistence/migrations/` and update the constants.py file in `src/langbot/pkg/utils/constants.py` with the new migration number.
## Some Principles ## Some Principles
@@ -152,5 +152,87 @@ class UserRouterGroup(group.RouterGroup):
data={ data={
'user': user_obj.user, 'user': user_obj.user,
'account_type': user_obj.account_type, 'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
} }
) )
@self.route('/account-info', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Get account info for login page (account type and has_password)"""
if not await self.ap.user_service.is_initialized():
return self.success(data={'initialized': False})
user_obj = await self.ap.user_service.get_first_user()
if user_obj is None:
return self.success(data={'initialized': False})
return self.success(
data={
'initialized': True,
'account_type': user_obj.account_type,
'has_password': bool(user_obj.password and user_obj.password.strip()),
}
)
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str:
"""Set password for Space account (first time) or change password"""
json_data = await quart.request.json
new_password = json_data.get('new_password')
current_password = json_data.get('current_password')
if not new_password:
return self.http_status(400, -1, 'New password is required')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
try:
await self.ap.user_service.set_password(user_email, new_password, current_password)
return self.success(data={'user': user_email})
except ValueError as e:
return self.http_status(400, -1, str(e))
except argon2.exceptions.VerifyMismatchError:
return self.http_status(400, -1, 'Current password is incorrect')
@self.route('/bind-space', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
"""Bind Space account to existing local account"""
json_data = await quart.request.json
code = json_data.get('code')
state = json_data.get('state') # JWT token passed as state
if not code:
return self.http_status(400, -1, 'Missing authorization code')
if not state:
return self.http_status(400, -1, 'Missing state parameter')
# Verify state is a valid JWT token
try:
user_email = await self.ap.user_service.verify_jwt_token(state)
except Exception:
return self.http_status(401, -1, 'Invalid or expired state')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(404, -1, 'User not found')
if user_obj.account_type != 'local':
return self.http_status(400, -1, 'Only local accounts can bind to Space')
try:
updated_user = await self.ap.user_service.bind_space_account(user_email, code)
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user.user)
return self.success(
data={
'token': jwt_token,
'user': updated_user.user,
'account_type': updated_user.account_type,
}
)
except ValueError as e:
return self.http_status(400, -1, str(e))
except Exception as e:
return self.http_status(500, -1, f'Failed to bind Space account: {str(e)}')
+119 -28
View File
@@ -6,6 +6,7 @@ import jwt
import datetime import datetime
import aiohttp import aiohttp
import typing import typing
import asyncio
from ....core import app from ....core import app
from ....entity.persistence import user from ....entity.persistence import user
@@ -14,9 +15,11 @@ from ....utils import constants
class UserService: class UserService:
ap: app.Application ap: app.Application
_create_user_lock: asyncio.Lock
def __init__(self, ap: app.Application) -> None: def __init__(self, ap: app.Application) -> None:
self.ap = ap self.ap = ap
self._create_user_lock = asyncio.Lock()
def _get_space_config(self) -> typing.Dict[str, str]: def _get_space_config(self) -> typing.Dict[str, str]:
"""Get Space configuration from config file""" """Get Space configuration from config file"""
@@ -197,43 +200,63 @@ class UserService:
refresh_token: str, refresh_token: str,
api_key: str, api_key: str,
) -> user.User: ) -> user.User:
"""Create or update a Space user account""" """Create or update a Space user account (only if system not initialized or user exists)"""
# Check if user with this Space UUID already exists async with self._create_user_lock:
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid) # Check if user with this Space UUID already exists
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_user: if existing_user:
# Update existing user's tokens # Update existing user's tokens
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.space_account_uuid == space_account_uuid)
.values(
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
)
)
return await self.get_user_by_space_account_uuid(space_account_uuid)
# Check if user with same email exists
existing_email_user = await self.get_user_by_email(email)
if existing_email_user:
# Update existing user to link with Space account
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == email)
.values(
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
)
)
return await self.get_user_by_email(email)
# Check if system is already initialized
is_initialized = await self.is_initialized()
if is_initialized:
raise ValueError(
'This LangBot instance already has an account. Please use the existing account to login.'
)
# Create new Space user (first time initialization)
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User) sqlalchemy.insert(user.User).values(
.where(user.User.space_account_uuid == space_account_uuid) user=email,
.values( password='', # Space users don't have local password
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token, space_access_token=access_token,
space_refresh_token=refresh_token, space_refresh_token=refresh_token,
space_api_key=api_key, space_api_key=api_key,
) )
) )
return await self.get_user_by_space_account_uuid(space_account_uuid) return await self.get_user_by_space_account_uuid(space_account_uuid)
# Check if user with same email exists as local account
existing_email_user = await self.get_user_by_email(email)
if existing_email_user and existing_email_user.account_type == 'local':
raise ValueError('A local account with this email already exists. Please use a different email.')
# Create new Space user
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(user.User).values(
user=email,
password='', # Space users don't have local password
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
)
)
return await self.get_user_by_space_account_uuid(space_account_uuid)
async def authenticate_space_user(self, access_token: str, refresh_token: str) -> typing.Tuple[str, user.User]: async def authenticate_space_user(self, access_token: str, refresh_token: str) -> typing.Tuple[str, user.User]:
"""Authenticate with Space and return JWT token""" """Authenticate with Space and return JWT token"""
# Get user info from Space # Get user info from Space
@@ -261,3 +284,71 @@ class UserService:
jwt_token = await self.generate_jwt_token(email) jwt_token = await self.generate_jwt_token(email)
return jwt_token, user_obj return jwt_token, user_obj
async def get_first_user(self) -> user.User | None:
"""Get the first user (for single-user mode)"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(user.User).limit(1))
result_list = result.all()
return result_list[0] if result_list else None
async def set_password(self, user_email: str, new_password: str, current_password: str | None = None) -> None:
"""Set or change password for a user"""
ph = argon2.PasswordHasher()
user_obj = await self.get_user_by_email(user_email)
if user_obj is None:
raise ValueError('User not found')
# If user already has a password, verify current password
has_password = bool(user_obj.password and user_obj.password.strip())
if has_password:
if not current_password:
raise ValueError('Current password is required')
ph.verify(user_obj.password, current_password)
hashed_password = ph.hash(new_password)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User).where(user.User.user == user_email).values(password=hashed_password)
)
async def bind_space_account(self, user_email: str, code: str) -> user.User:
"""Bind Space account to existing local account"""
# Exchange code for tokens
token_data = await self.exchange_space_oauth_code(code)
access_token = token_data.get('access_token')
refresh_token = token_data.get('refresh_token')
if not access_token:
raise ValueError('Failed to get access token from Space')
# Get Space user info
user_info = await self.get_space_user_info(access_token)
account = user_info.get('account', {})
api_key = user_info.get('api_key', '')
space_account_uuid = account.get('uuid')
space_email = account.get('email')
if not space_account_uuid or not space_email:
raise ValueError('Invalid Space user info')
# Check if this Space account is already bound to another user
existing_space_user = await self.get_user_by_space_account_uuid(space_account_uuid)
if existing_space_user and existing_space_user.user != user_email:
raise ValueError('This Space account is already bound to another user')
# Update local account to Space account
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(user.User)
.where(user.User.user == user_email)
.values(
user=space_email, # Update email to Space email
account_type='space',
space_account_uuid=space_account_uuid,
space_access_token=access_token,
space_refresh_token=refresh_token,
space_api_key=api_key,
)
)
return await self.get_user_by_email(space_email)
+113 -21
View File
@@ -5,7 +5,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react'; import {
Loader2,
AlertCircle,
CheckCircle2,
AlertTriangle,
} from 'lucide-react';
import { import {
Card, Card,
CardContent, CardContent,
@@ -21,26 +26,24 @@ export default function SpaceOAuthCallback() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>( const [status, setStatus] = useState<
'loading', 'loading' | 'confirm' | 'success' | 'error'
); >('loading');
const [errorMessage, setErrorMessage] = useState<string>(''); 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( const handleOAuthCallback = useCallback(
async (code: string) => { async (authCode: string) => {
try { try {
const response = await httpClient.exchangeSpaceOAuthCode(code); const response = await httpClient.exchangeSpaceOAuthCode(authCode);
// Store token and user info
localStorage.setItem('token', response.token); localStorage.setItem('token', response.token);
if (response.user) { if (response.user) {
localStorage.setItem('userEmail', response.user); localStorage.setItem('userEmail', response.user);
} }
setStatus('success'); setStatus('success');
toast.success(t('common.spaceLoginSuccess')); toast.success(t('common.spaceLoginSuccess'));
// Redirect to home after a brief delay to show success state
setTimeout(() => { setTimeout(() => {
router.push('/home'); router.push('/home');
}, 1000); }, 1000);
@@ -52,10 +55,40 @@ export default function SpaceOAuthCallback() {
[router, t], [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(() => { useEffect(() => {
const code = searchParams.get('code'); const authCode = searchParams.get('code');
const error = searchParams.get('error'); const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description'); const errorDescription = searchParams.get('error_description');
const mode = searchParams.get('mode');
const state = searchParams.get('state');
if (error) { if (error) {
setStatus('error'); setStatus('error');
@@ -65,21 +98,44 @@ export default function SpaceOAuthCallback() {
return; return;
} }
if (!code) { if (!authCode) {
setStatus('error'); setStatus('error');
setErrorMessage(t('common.spaceLoginNoCode')); setErrorMessage(t('common.spaceLoginNoCode'));
return; return;
} }
// Exchange code for token setCode(authCode);
handleOAuthCallback(code);
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]); }, [searchParams, handleOAuthCallback, t]);
const handleConfirmBind = () => {
if (code && bindState) {
handleBindAccount(code, bindState);
}
};
const handleCancelBind = () => {
router.push('/home');
};
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900"> <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"> <CardHeader className="text-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={langbotIcon.src} src={langbotIcon.src}
alt="LangBot" alt="LangBot"
@@ -87,12 +143,20 @@ export default function SpaceOAuthCallback() {
/> />
<CardTitle className="text-xl"> <CardTitle className="text-xl">
{status === 'loading' && t('common.spaceLoginProcessing')} {status === 'loading' && t('common.spaceLoginProcessing')}
{status === 'success' && t('common.spaceLoginSuccess')} {status === 'confirm' && t('account.bindSpaceConfirmTitle')}
{status === 'error' && t('common.spaceLoginError')} {status === 'success' &&
(isBindMode
? t('account.bindSpaceSuccess')
: t('common.spaceLoginSuccess'))}
{status === 'error' &&
(isBindMode
? t('account.bindSpaceFailed')
: t('common.spaceLoginError'))}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{status === 'loading' && {status === 'loading' &&
t('common.spaceLoginProcessingDescription')} t('common.spaceLoginProcessingDescription')}
{status === 'confirm' && t('account.bindSpaceConfirmDescription')}
{status === 'success' && t('common.spaceLoginSuccessDescription')} {status === 'success' && t('common.spaceLoginSuccessDescription')}
{status === 'error' && errorMessage} {status === 'error' && errorMessage}
</CardDescription> </CardDescription>
@@ -101,6 +165,34 @@ export default function SpaceOAuthCallback() {
{status === 'loading' && ( {status === 'loading' && (
<Loader2 className="h-12 w-12 animate-spin text-primary" /> <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' && ( {status === 'success' && (
<CheckCircle2 className="h-12 w-12 text-green-500" /> <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" /> <AlertCircle className="h-12 w-12 text-red-500" />
<Button <Button
onClick={() => router.push('/login')} onClick={() => router.push(isBindMode ? '/home' : '/login')}
className="w-full mt-4" className="w-full mt-4"
> >
{t('common.backToLogin')} {isBindMode ? t('common.backToHome') : t('common.backToLogin')}
</Button> </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 { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList'; import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp'; 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 { getCloudServiceClientSync } from '@/app/infra/http';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -18,9 +18,9 @@ import {
Monitor, Monitor,
CircleHelp, CircleHelp,
Lightbulb, Lightbulb,
Lock,
LogOut, LogOut,
KeyRound, KeyRound,
User,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
@@ -33,7 +33,7 @@ import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { LanguageSelector } from '@/components/ui/language-selector'; import { LanguageSelector } from '@/components/ui/language-selector';
import { Badge } from '@/components/ui/badge'; 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 ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog'; import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog'; import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
@@ -85,7 +85,7 @@ export default function HomeSidebar({
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false); const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false); const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
const [starCount, setStarCount] = useState<number | null>(null); const [starCount, setStarCount] = useState<number | null>(null);
@@ -95,6 +95,7 @@ export default function HomeSidebar({
const [hasNewVersion, setHasNewVersion] = useState(false); const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false); const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
// 处理模型对话框的打开和关闭,同时更新 URL // 处理模型对话框的打开和关闭,同时更新 URL
function handleModelsDialogChange(open: boolean) { function handleModelsDialogChange(open: boolean) {
@@ -120,6 +121,21 @@ export default function HomeSidebar({
localStorage.setItem('userEmail', 'test@example.com'); 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() getCloudServiceClientSync()
.get('/api/v1/dist/info/repo') .get('/api/v1/dist/info/repo')
.then((response) => { .then((response) => {
@@ -374,6 +390,20 @@ export default function HomeSidebar({
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.account')}</span> <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 <Button
variant="ghost" variant="ghost"
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
@@ -416,19 +446,6 @@ export default function HomeSidebar({
<Lightbulb className="w-4 h-4 mr-2" /> <Lightbulb className="w-4 h-4 mr-2" />
{t('common.featureRequest')} {t('common.featureRequest')}
</Button> </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 <Button
variant="ghost" variant="ghost"
className="w-full justify-start font-normal" className="w-full justify-start font-normal"
@@ -443,9 +460,9 @@ export default function HomeSidebar({
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
<PasswordChangeDialog <AccountSettingsDialog
open={passwordChangeOpen} open={accountSettingsOpen}
onOpenChange={setPasswordChangeOpen} onOpenChange={setAccountSettingsOpen}
/> />
<ApiIntegrationDialog <ApiIntegrationDialog
open={apiKeyDialogOpen} open={apiKeyDialogOpen}
+30
View File
@@ -726,10 +726,40 @@ export class BackendClient extends BaseHttpClient {
public getUserInfo(): Promise<{ public getUserInfo(): Promise<{
user: string; user: string;
account_type: 'local' | 'space'; account_type: 'local' | 'space';
has_password: boolean;
}> { }> {
return this.get('/api/v1/user/info'); 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) ============ // ============ Space OAuth API (Redirect Flow) ============
public getSpaceAuthorizeUrl( public getSpaceAuthorizeUrl(
redirectUri: string, redirectUri: string,
+160 -136
View File
@@ -36,10 +36,15 @@ const formSchema = (t: (key: string) => string) =>
password: z.string().min(1, t('common.emptyPassword')), password: z.string().min(1, t('common.emptyPassword')),
}); });
type AccountType = 'local' | 'space';
export default function Login() { export default function Login() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const [spaceLoading, setSpaceLoading] = useState(false); 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>>>({ const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)), resolver: zodResolver(formSchema(t)),
@@ -50,19 +55,25 @@ export default function Login() {
}); });
useEffect(() => { useEffect(() => {
getIsInitialized(); checkAccountInfo();
checkIfAlreadyLoggedIn();
}, []); }, []);
function getIsInitialized() { async function checkAccountInfo() {
httpClient try {
.checkIfInited() const res = await httpClient.getAccountInfo();
.then((res) => { if (!res.initialized) {
if (!res.initialized) { router.push('/register');
router.push('/register'); return;
} }
}) setAccountType(res.account_type || 'local');
.catch(() => {}); setHasPassword(res.has_password || false);
setLoading(false);
// Also check if already logged in
checkIfAlreadyLoggedIn();
} catch {
setLoading(false);
}
} }
function checkIfAlreadyLoggedIn() { function checkIfAlreadyLoggedIn() {
@@ -95,19 +106,12 @@ export default function Login() {
}); });
} }
// Space OAuth redirect handler
const handleSpaceLoginClick = async () => { const handleSpaceLoginClick = async () => {
setSpaceLoading(true); setSpaceLoading(true);
try { try {
// Build the redirect URI to the OAuth callback page
const currentOrigin = window.location.origin; const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback`; const redirectUri = `${currentOrigin}/auth/space/callback`;
// Get the authorization URL from backend
const response = await httpClient.getSpaceAuthorizeUrl(redirectUri); const response = await httpClient.getSpaceAuthorizeUrl(redirectUri);
// Redirect to Space authorization page
window.location.href = response.authorize_url; window.location.href = response.authorize_url;
} catch { } catch {
toast.error(t('common.spaceLoginFailed')); 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 ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900"> <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"> <Card className="w-[375px] shadow-lg dark:shadow-white/10">
@@ -136,128 +153,135 @@ export default function Login() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Space Login - Recommended */} {/* Space Login - only show for space accounts */}
<div className="space-y-3"> {showSpaceLogin && (
<Button <div className="space-y-3">
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>
)}
/>
<Button <Button
type="submit" type="button"
variant="outline"
className="w-full cursor-pointer" 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> </Button>
</form> </div>
</Form> )}
{/* 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> </CardContent>
</Card> </Card>
</div> </div>
+1 -1
View File
@@ -231,7 +231,7 @@ export default function Register() {
variant="outline" variant="outline"
className="w-full cursor-pointer" className="w-full cursor-pointer"
> >
{t('register.registerLocal')} {t('register.registerWithPassword')}
</Button> </Button>
</form> </form>
</Form> </Form>
+23
View File
@@ -51,6 +51,7 @@ const enUS = {
loginWithSpace: 'Login with Space', loginWithSpace: 'Login with Space',
spaceLoginRecommended: 'Recommended: Sync models and credits from Space', spaceLoginRecommended: 'Recommended: Sync models and credits from Space',
loginLocal: 'Login with local account', loginLocal: 'Login with local account',
loginWithPassword: 'Login with password',
spaceLoginTitle: 'Login with Space', spaceLoginTitle: 'Login with Space',
spaceLoginDescription: spaceLoginDescription:
'Scan the QR code or visit the link below to authorize', 'Scan the QR code or visit the link below to authorize',
@@ -69,6 +70,7 @@ const enUS = {
spaceLoginError: 'Login Failed', spaceLoginError: 'Login Failed',
spaceLoginNoCode: 'Missing authorization code', spaceLoginNoCode: 'Missing authorization code',
backToLogin: 'Back to Login', backToLogin: 'Back to Login',
backToHome: 'Back to Home',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'Space accounts cannot change password here', 'Space accounts cannot change password here',
theme: 'Theme', theme: 'Theme',
@@ -707,6 +709,7 @@ const enUS = {
initWithSpace: 'Initialize with Space', initWithSpace: 'Initialize with Space',
spaceRecommended: 'Recommended: Sync models and credits from Space', spaceRecommended: 'Recommended: Sync models and credits from Space',
registerLocal: 'Register local account', registerLocal: 'Register local account',
registerWithPassword: 'Register with email and password',
initSuccess: 'Initialization successful, please login', initSuccess: 'Initialization successful, please login',
initFailed: 'Initialization failed: ', initFailed: 'Initialization failed: ',
}, },
@@ -749,6 +752,26 @@ const enUS = {
viewUpdateGuide: 'View Update Guide', viewUpdateGuide: 'View Update Guide',
noReleaseNotes: 'No release notes available', 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; export default enUS;
+23
View File
@@ -52,6 +52,7 @@ const jaJP = {
loginWithSpace: 'Space でログイン', loginWithSpace: 'Space でログイン',
spaceLoginRecommended: 'おすすめ:Space からモデルとクレジットを同期', spaceLoginRecommended: 'おすすめ:Space からモデルとクレジットを同期',
loginLocal: 'ローカルアカウントでログイン', loginLocal: 'ローカルアカウントでログイン',
loginWithPassword: 'パスワードでログイン',
spaceLoginTitle: 'Space でログイン', spaceLoginTitle: 'Space でログイン',
spaceLoginDescription: spaceLoginDescription:
'QRコードをスキャンするか、下のリンクにアクセスして認証してください', 'QRコードをスキャンするか、下のリンクにアクセスして認証してください',
@@ -71,6 +72,7 @@ const jaJP = {
spaceLoginError: 'ログインに失敗しました', spaceLoginError: 'ログインに失敗しました',
spaceLoginNoCode: '認証コードがありません', spaceLoginNoCode: '認証コードがありません',
backToLogin: 'ログインに戻る', backToLogin: 'ログインに戻る',
backToHome: 'ホームに戻る',
spaceAccountCannotChangePassword: spaceAccountCannotChangePassword:
'Space アカウントはここでパスワードを変更できません', 'Space アカウントはここでパスワードを変更できません',
theme: 'テーマ', theme: 'テーマ',
@@ -715,6 +717,7 @@ const jaJP = {
initWithSpace: 'Space で初期化', initWithSpace: 'Space で初期化',
spaceRecommended: 'おすすめ:Space からモデルとクレジットを同期', spaceRecommended: 'おすすめ:Space からモデルとクレジットを同期',
registerLocal: 'ローカルアカウントを登録', registerLocal: 'ローカルアカウントを登録',
registerWithPassword: 'メールアドレスとパスワードで登録',
initSuccess: '初期化に成功しました。ログインしてください', initSuccess: '初期化に成功しました。ログインしてください',
initFailed: '初期化に失敗しました:', initFailed: '初期化に失敗しました:',
}, },
@@ -757,6 +760,26 @@ const jaJP = {
viewUpdateGuide: 'アップデート方法を見る', viewUpdateGuide: 'アップデート方法を見る',
noReleaseNotes: 'リリースノートはありません', 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; export default jaJP;
+19
View File
@@ -51,6 +51,7 @@ const zhHans = {
loginWithSpace: '通过 Space 登录', loginWithSpace: '通过 Space 登录',
spaceLoginRecommended: '推荐:从 Space 同步模型和点数', spaceLoginRecommended: '推荐:从 Space 同步模型和点数',
loginLocal: '使用本地账号登录', loginLocal: '使用本地账号登录',
loginWithPassword: '通过密码登录',
spaceLoginTitle: '通过 Space 登录', spaceLoginTitle: '通过 Space 登录',
spaceLoginDescription: '扫描二维码或访问下方链接进行授权', spaceLoginDescription: '扫描二维码或访问下方链接进行授权',
spaceLoginUserCode: '您的验证码', spaceLoginUserCode: '您的验证码',
@@ -67,6 +68,7 @@ const zhHans = {
spaceLoginError: '登录失败', spaceLoginError: '登录失败',
spaceLoginNoCode: '缺少授权码', spaceLoginNoCode: '缺少授权码',
backToLogin: '返回登录', backToLogin: '返回登录',
backToHome: '返回首页',
spaceAccountCannotChangePassword: 'Space 账户无法在此修改密码', spaceAccountCannotChangePassword: 'Space 账户无法在此修改密码',
theme: '主题', theme: '主题',
changePassword: '修改密码', changePassword: '修改密码',
@@ -680,6 +682,7 @@ const zhHans = {
initWithSpace: '通过 Space 初始化', initWithSpace: '通过 Space 初始化',
spaceRecommended: '推荐:从 Space 同步模型和点数', spaceRecommended: '推荐:从 Space 同步模型和点数',
registerLocal: '注册本地账号', registerLocal: '注册本地账号',
registerWithPassword: '通过邮箱密码组合注册',
initSuccess: '初始化成功 请登录', initSuccess: '初始化成功 请登录',
initFailed: '初始化失败:', initFailed: '初始化失败:',
}, },
@@ -720,6 +723,22 @@ const zhHans = {
viewUpdateGuide: '查看更新方式', viewUpdateGuide: '查看更新方式',
noReleaseNotes: '暂无更新日志', 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; export default zhHans;
+19
View File
@@ -51,6 +51,7 @@ const zhHant = {
loginWithSpace: '透過 Space 登入', loginWithSpace: '透過 Space 登入',
spaceLoginRecommended: '推薦:從 Space 同步模型和點數', spaceLoginRecommended: '推薦:從 Space 同步模型和點數',
loginLocal: '使用本地帳號登入', loginLocal: '使用本地帳號登入',
loginWithPassword: '透過密碼登入',
spaceLoginTitle: '透過 Space 登入', spaceLoginTitle: '透過 Space 登入',
spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權', spaceLoginDescription: '掃描二維碼或訪問下方連結進行授權',
spaceLoginUserCode: '您的驗證碼', spaceLoginUserCode: '您的驗證碼',
@@ -67,6 +68,7 @@ const zhHant = {
spaceLoginError: '登入失敗', spaceLoginError: '登入失敗',
spaceLoginNoCode: '缺少授權碼', spaceLoginNoCode: '缺少授權碼',
backToLogin: '返回登入', backToLogin: '返回登入',
backToHome: '返回首頁',
spaceAccountCannotChangePassword: 'Space 帳戶無法在此修改密碼', spaceAccountCannotChangePassword: 'Space 帳戶無法在此修改密碼',
theme: '主題', theme: '主題',
changePassword: '修改密碼', changePassword: '修改密碼',
@@ -677,6 +679,7 @@ const zhHant = {
initWithSpace: '透過 Space 初始化', initWithSpace: '透過 Space 初始化',
spaceRecommended: '推薦:從 Space 同步模型和點數', spaceRecommended: '推薦:從 Space 同步模型和點數',
registerLocal: '註冊本地帳號', registerLocal: '註冊本地帳號',
registerWithPassword: '透過電子郵件密碼組合註冊',
initSuccess: '初始化成功 請登入', initSuccess: '初始化成功 請登入',
initFailed: '初始化失敗:', initFailed: '初始化失敗:',
}, },
@@ -717,6 +720,22 @@ const zhHant = {
viewUpdateGuide: '查看更新方式', viewUpdateGuide: '查看更新方式',
noReleaseNotes: '暫無更新日誌', 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; export default zhHant;