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

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.
- 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

View File

@@ -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)}')

View File

@@ -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)

View File

@@ -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>
</>
)}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>

View File

@@ -231,7 +231,7 @@ export default function Register() {
variant="outline"
className="w-full cursor-pointer"
>
{t('register.registerLocal')}
{t('register.registerWithPassword')}
</Button>
</form>
</Form>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;