diff --git a/AGENTS.md b/AGENTS.md index aa6acf23..c9c2e2b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/langbot/pkg/api/http/controller/groups/user.py b/src/langbot/pkg/api/http/controller/groups/user.py index 16aa9a51..89fd6507 100644 --- a/src/langbot/pkg/api/http/controller/groups/user.py +++ b/src/langbot/pkg/api/http/controller/groups/user.py @@ -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)}') diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 5f98dc6f..4db1e013 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -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) diff --git a/web/src/app/auth/space/callback/page.tsx b/web/src/app/auth/space/callback/page.tsx index 87ac2b9f..d0e7ddc4 100644 --- a/web/src/app/auth/space/callback/page.tsx +++ b/web/src/app/auth/space/callback/page.tsx @@ -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(''); + const [isBindMode, setIsBindMode] = useState(false); + const [code, setCode] = useState(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(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 (
- + - {/* eslint-disable-next-line @next/next/no-img-element */} LangBot {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'))} {status === 'loading' && t('common.spaceLoginProcessingDescription')} + {status === 'confirm' && t('account.bindSpaceConfirmDescription')} {status === 'success' && t('common.spaceLoginSuccessDescription')} {status === 'error' && errorMessage} @@ -101,6 +165,34 @@ export default function SpaceOAuthCallback() { {status === 'loading' && ( )} + {status === 'confirm' && ( + <> + +

+ {t('account.bindSpaceWarning')} +

+
+ + +
+ + )} {status === 'success' && ( )} @@ -108,10 +200,10 @@ export default function SpaceOAuthCallback() { <> )} diff --git a/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx b/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx new file mode 100644 index 00000000..29b49d0a --- /dev/null +++ b/web/src/app/home/components/account-settings-dialog/AccountSettingsDialog.tsx @@ -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>({ + 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) => { + 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 ( + + + + {t('account.settings')} + {userEmail} + + + {loading ? ( +
+ +
+ ) : ( +
+ {/* Password Section */} +
+

+ {hasPassword + ? t('common.changePassword') + : t('account.setPassword')} +

+ {!hasPassword && ( +

+ {t('account.setPasswordHint')} +

+ )} +
+ + {hasPassword && ( + ( + + {t('common.currentPassword')} + + + + + + )} + /> + )} + ( + + {t('common.newPassword')} + + + + + + )} + /> + ( + + {t('common.confirmNewPassword')} + + + + + + )} + /> + + + +
+ + {/* Bind Space Account - only for local accounts */} + {accountType === 'local' && ( + <> + +
+

+ {t('account.bindSpace')} +

+

+ {t('account.bindSpaceDescription')} +

+ +
+ + )} +
+ )} +
+
+ ); +} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 764acfe2..6ab5b3a9 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -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(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(''); // 处理模型对话框的打开和关闭,同时更新 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({
{t('common.account')} + {/* User email display */} + - {systemInfo?.allow_change_password && ( - - )}
- { 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, diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 60d0e118..4ce2f722 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -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(null); + const [hasPassword, setHasPassword] = useState(false); + const [loading, setLoading] = useState(true); const form = useForm>>({ 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 ( +
+ +
+ ); + } + + // Determine what to show based on account type + const showLocalLogin = + accountType === 'local' || (accountType === 'space' && hasPassword); + const showSpaceLogin = accountType === 'space'; + return (
@@ -136,128 +153,135 @@ export default function Login() { - {/* Space Login - Recommended */} -
- -

- {t('common.spaceLoginRecommended')} -

-
- -
-
- -
-
- - {t('common.or')} - -
-
- - {/* Local Account Login */} -
- - ( - - {t('common.email')} - -
- - -
-
- -
- )} - /> - - ( - -
- {t('common.password')} - - {t('common.forgotPassword')} - -
- - -
- - -
-
- -
- )} - /> - + {/* Space Login - only show for space accounts */} + {showSpaceLogin && ( +
- - +
+ )} + + {/* Divider - only show if both login methods are available */} + {showSpaceLogin && showLocalLogin && ( +
+
+ +
+
+ + {t('common.or')} + +
+
+ )} + + {/* Local Account Login - show for local accounts or space accounts with password */} + {showLocalLogin && ( +
+ + ( + + {t('common.email')} + +
+ + +
+
+ +
+ )} + /> + + ( + +
+ {t('common.password')} + + {t('common.forgotPassword')} + +
+ + +
+ + +
+
+ +
+ )} + /> + + + + + )}
diff --git a/web/src/app/register/page.tsx b/web/src/app/register/page.tsx index 64c89356..5ec3b249 100644 --- a/web/src/app/register/page.tsx +++ b/web/src/app/register/page.tsx @@ -231,7 +231,7 @@ export default function Register() { variant="outline" className="w-full cursor-pointer" > - {t('register.registerLocal')} + {t('register.registerWithPassword')} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 64cafc47..4a85db8a 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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; diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 38dbea4a..f78687d2 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 8c98c0f6..c60b9506 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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; diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index ad4e5ced..5b8fbb97 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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;