mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat: implement account settings dialog for managing user passwords and binding Space accounts
This commit is contained in:
@@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -152,5 +152,87 @@ class UserRouterGroup(group.RouterGroup):
|
||||
data={
|
||||
'user': user_obj.user,
|
||||
'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)}')
|
||||
|
||||
@@ -6,6 +6,7 @@ import jwt
|
||||
import datetime
|
||||
import aiohttp
|
||||
import typing
|
||||
import asyncio
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import user
|
||||
@@ -14,9 +15,11 @@ from ....utils import constants
|
||||
|
||||
class UserService:
|
||||
ap: app.Application
|
||||
_create_user_lock: asyncio.Lock
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self._create_user_lock = asyncio.Lock()
|
||||
|
||||
def _get_space_config(self) -> typing.Dict[str, str]:
|
||||
"""Get Space configuration from config file"""
|
||||
@@ -197,43 +200,63 @@ class UserService:
|
||||
refresh_token: str,
|
||||
api_key: str,
|
||||
) -> user.User:
|
||||
"""Create or update a Space user account"""
|
||||
# Check if user with this Space UUID already exists
|
||||
existing_user = await self.get_user_by_space_account_uuid(space_account_uuid)
|
||||
"""Create or update a Space user account (only if system not initialized or user exists)"""
|
||||
async with self._create_user_lock:
|
||||
# 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:
|
||||
# Update existing user's tokens
|
||||
if existing_user:
|
||||
# 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(
|
||||
sqlalchemy.update(user.User)
|
||||
.where(user.User.space_account_uuid == space_account_uuid)
|
||||
.values(
|
||||
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)
|
||||
|
||||
# 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]:
|
||||
"""Authenticate with Space and return JWT token"""
|
||||
# Get user info from Space
|
||||
@@ -261,3 +284,71 @@ class UserService:
|
||||
jwt_token = await self.generate_jwt_token(email)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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