From 48e763156970cd97826bb2419b6cb74612a3d921 Mon Sep 17 00:00:00 2001 From: CoderLuii <203967356+CoderLuii@users.noreply.github.com> Date: Thu, 9 Jul 2026 03:52:12 -0400 Subject: [PATCH] feat: add local account management controls --- server/modules/auth/auth.middleware.ts | 42 ++++- server/modules/auth/auth.module.ts | 16 +- server/modules/auth/auth.routes.ts | 13 ++ server/modules/auth/auth.service.ts | 72 +++++++- .../modules/auth/tests/auth.service.test.ts | 138 +++++++++++++++- .../database/repositories/app-config.ts | 9 + server/modules/database/repositories/users.ts | 16 ++ src/components/auth/context/AuthContext.tsx | 25 +++ src/components/auth/types.ts | 1 + .../settings/constants/constants.ts | 2 + .../settings/hooks/normalizeMainTab.ts | 12 ++ .../hooks/useSettingsController.test.ts | 17 ++ .../settings/hooks/useSettingsController.ts | 12 +- src/components/settings/types/types.ts | 2 +- src/components/settings/view/Settings.tsx | 3 + .../settings/view/SettingsMainTabs.tsx | 3 +- .../settings/view/SettingsSidebar.tsx | 10 +- .../view/tabs/AccountSettingsTab.test.ts | 38 +++++ .../settings/view/tabs/AccountSettingsTab.tsx | 155 ++++++++++++++++++ .../settings/view/tabs/accountSettings.ts | 23 +++ src/utils/api.js | 4 + 21 files changed, 584 insertions(+), 29 deletions(-) create mode 100644 src/components/settings/hooks/normalizeMainTab.ts create mode 100644 src/components/settings/hooks/useSettingsController.test.ts create mode 100644 src/components/settings/view/tabs/AccountSettingsTab.test.ts create mode 100644 src/components/settings/view/tabs/AccountSettingsTab.tsx create mode 100644 src/components/settings/view/tabs/accountSettings.ts diff --git a/server/modules/auth/auth.middleware.ts b/server/modules/auth/auth.middleware.ts index 23dded8d2c..6106f28970 100644 --- a/server/modules/auth/auth.middleware.ts +++ b/server/modules/auth/auth.middleware.ts @@ -5,9 +5,21 @@ import { userDb, appConfigDb } from '../database/index.js'; const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true'; +export const AUTH_TOKEN_GENERATION_KEY = 'auth_token_generation'; + // Use env var if set, otherwise auto-generate a unique secret per installation const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret(); +const getCurrentAuthTokenGeneration = () => appConfigDb.getStrict(AUTH_TOKEN_GENERATION_KEY); + +const isTokenGenerationValid = (decoded) => { + const currentGeneration = getCurrentAuthTokenGeneration(); + if (!currentGeneration) { + return true; + } + return decoded.authTokenGeneration === currentGeneration; +}; + // Optional API key middleware const validateApiKey = (req, res, next) => { // Skip API key validation if not configured @@ -59,6 +71,14 @@ const authenticateToken = async (req, res, next) => { try { const decoded = jwt.verify(token, JWT_SECRET); + if (!isTokenGenerationValid(decoded)) { + res.setHeader('X-Auth-Error', 'invalid-token'); + return res.status(401).json({ + error: 'Invalid token. Please sign in again.', + code: 'AUTH_TOKEN_INVALID', + }); + } + // Verify user still exists and is active const user = userDb.getUserById(decoded.userId); if (!user) { @@ -104,14 +124,16 @@ const authenticateToken = async (req, res, next) => { // Generate JWT token const generateToken = (user) => { - return jwt.sign( - { - userId: user.id, - username: user.username - }, - JWT_SECRET, - { expiresIn: '7d' } - ); + const payload = { + userId: user.id, + username: user.username + }; + const currentGeneration = getCurrentAuthTokenGeneration(); + if (currentGeneration) { + payload.authTokenGeneration = currentGeneration; + } + + return jwt.sign(payload, JWT_SECRET, { expiresIn: '7d' }); }; // WebSocket authentication function @@ -137,6 +159,10 @@ const authenticateWebSocket = (token) => { try { const decoded = jwt.verify(token, JWT_SECRET); + if (!isTokenGenerationValid(decoded)) { + return null; + } + // Verify user actually exists in database (matches REST authenticateToken behavior) const user = userDb.getUserById(decoded.userId); if (!user) { diff --git a/server/modules/auth/auth.module.ts b/server/modules/auth/auth.module.ts index 33a4f431e4..e6c15a0d3d 100644 --- a/server/modules/auth/auth.module.ts +++ b/server/modules/auth/auth.module.ts @@ -1,8 +1,13 @@ +import { randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; -import { getConnection, userDb } from '@/modules/database/index.js'; +import { appConfigDb, getConnection, userDb } from '@/modules/database/index.js'; -import { authenticateToken, generateToken } from './auth.middleware.js'; +import { + AUTH_TOKEN_GENERATION_KEY, + authenticateToken, + generateToken, +} from './auth.middleware.js'; import { createAuthRouter } from './auth.routes.js'; import { createAuthService } from './auth.service.js'; @@ -22,13 +27,20 @@ const authService = createAuthService({ hasUsers: () => userDb.hasUsers(), createUser: (username, passwordHash) => userDb.createUser(username, passwordHash), getUserByUsername: (username) => userDb.getUserByUsername(username), + getUserAuthById: (userId) => userDb.getUserAuthById(userId), + updatePasswordHash: (userId, passwordHash) => userDb.updatePasswordHash(userId, passwordHash), updateLastLogin: (userId) => userDb.updateLastLogin(userId), }, + authConfig: { + setTokenGeneration: (value) => appConfigDb.set(AUTH_TOKEN_GENERATION_KEY, value), + }, transaction: { begin: () => databaseConnection.prepare('BEGIN').run(), commit: () => databaseConnection.prepare('COMMIT').run(), rollback: () => databaseConnection.prepare('ROLLBACK').run(), }, + isPlatform: process.env.VITE_IS_PLATFORM === 'true', + createTokenGeneration: randomUUID, hashPassword: (password) => bcrypt.hash(password, 12), comparePassword: (password, passwordHash) => bcrypt.compare(password, passwordHash), generateToken, diff --git a/server/modules/auth/auth.routes.ts b/server/modules/auth/auth.routes.ts index 99342fe88d..36aa614da2 100644 --- a/server/modules/auth/auth.routes.ts +++ b/server/modules/auth/auth.routes.ts @@ -45,6 +45,19 @@ export function createAuthRouter( res.json(service.getCurrentUser((req as AuthenticatedRequest).user)); }); + router.post('/change-password', authenticateToken, async (req, res, next) => { + try { + const body = req.body as { currentPassword?: unknown; newPassword?: unknown }; + res.json(await service.changePassword( + (req as AuthenticatedRequest).user, + body.currentPassword, + body.newPassword, + )); + } catch (error) { + next(error); + } + }); + router.post('/refresh', authenticateToken, (req, res) => { res.json(service.refreshSession((req as AuthenticatedRequest).user)); }); diff --git a/server/modules/auth/auth.service.ts b/server/modules/auth/auth.service.ts index 91d61f0e5b..973f474001 100644 --- a/server/modules/auth/auth.service.ts +++ b/server/modules/auth/auth.service.ts @@ -12,13 +12,20 @@ type AuthDependencies = { hasUsers(): boolean; createUser(username: string, passwordHash: string): AuthUser; getUserByUsername(username: string): AuthLoginUser | undefined; + getUserAuthById(userId: number): AuthLoginUser | undefined; + updatePasswordHash(userId: number, passwordHash: string): void; updateLastLogin(userId: number): void; }; + authConfig: { + setTokenGeneration(value: string): void; + }; transaction: { begin(): void; commit(): void; rollback(): void; }; + isPlatform: boolean; + createTokenGeneration(): string; hashPassword(password: string): Promise; comparePassword(password: string, passwordHash: string): Promise; generateToken(user: AuthUser): string; @@ -65,6 +72,7 @@ export function createAuthService(dependencies: AuthDependencies) { ); } + const passwordHash = await dependencies.hashPassword(password); dependencies.transaction.begin(); try { if (dependencies.users.hasUsers()) { @@ -74,7 +82,6 @@ export function createAuthService(dependencies: AuthDependencies) { }); } - const passwordHash = await dependencies.hashPassword(password); const user = dependencies.users.createUser(username, passwordHash); const token = dependencies.generateToken(user); dependencies.transaction.commit(); @@ -130,6 +137,69 @@ export function createAuthService(dependencies: AuthDependencies) { return { user }; }, + async changePassword(userInput: unknown, currentPasswordInput: unknown, newPasswordInput: unknown) { + if (dependencies.isPlatform) { + throw new AppError('Password changes are not available in platform mode', { + code: 'AUTH_PASSWORD_CHANGE_UNAVAILABLE', + statusCode: 403, + }); + } + + const currentPassword = typeof currentPasswordInput === 'string' ? currentPasswordInput : ''; + const newPassword = typeof newPasswordInput === 'string' ? newPasswordInput : ''; + if (!currentPassword || !newPassword) { + throw new AppError('Current password and new password are required', { + code: 'AUTH_PASSWORDS_REQUIRED', + statusCode: 400, + }); + } + if (newPassword.length < 6) { + throw new AppError('New password must be at least 6 characters', { + code: 'AUTH_PASSWORD_TOO_SHORT', + statusCode: 400, + }); + } + if ( + typeof userInput !== 'object' + || userInput === null + || !('id' in userInput) + || (typeof userInput.id !== 'number' && typeof userInput.id !== 'bigint') + ) { + throw new AppError('Authenticated user is required', { + code: 'AUTH_USER_REQUIRED', + statusCode: 401, + }); + } + + const user = dependencies.users.getUserAuthById(numericUserId(userInput.id)); + if (!user) { + throw new AppError('Invalid token. User not found.', { + code: 'AUTH_TOKEN_INVALID', + statusCode: 401, + }); + } + if (!(await dependencies.comparePassword(currentPassword, user.password_hash))) { + throw new AppError('Current password is incorrect', { + code: 'AUTH_CURRENT_PASSWORD_INCORRECT', + statusCode: 401, + }); + } + + const passwordHash = await dependencies.hashPassword(newPassword); + const nextTokenGeneration = dependencies.createTokenGeneration(); + dependencies.transaction.begin(); + try { + dependencies.users.updatePasswordHash(numericUserId(user.id), passwordHash); + dependencies.authConfig.setTokenGeneration(nextTokenGeneration); + dependencies.transaction.commit(); + } catch (error) { + dependencies.transaction.rollback(); + throw error; + } + + return { success: true, message: 'Password updated. Please sign in again.' }; + }, + refreshSession(user: unknown) { if ( typeof user !== 'object' diff --git a/server/modules/auth/tests/auth.service.test.ts b/server/modules/auth/tests/auth.service.test.ts index e5f89c1c2f..0c4d238348 100644 --- a/server/modules/auth/tests/auth.service.test.ts +++ b/server/modules/auth/tests/auth.service.test.ts @@ -13,13 +13,20 @@ function createDependencies(overrides: Partial = {}): AuthDepe hasUsers: () => false, createUser: (username, passwordHash) => ({ id: 1, username, password_hash: passwordHash }), getUserByUsername: () => undefined, + getUserAuthById: () => undefined, + updatePasswordHash: () => undefined, updateLastLogin: () => undefined, }, + authConfig: { + setTokenGeneration: () => undefined, + }, transaction: { begin: () => undefined, commit: () => undefined, rollback: () => undefined, }, + isPlatform: false, + createTokenGeneration: () => 'token-generation', hashPassword: async () => 'hashed-password', comparePassword: async () => false, generateToken: () => 'signed-token', @@ -46,6 +53,8 @@ test('register hashes credentials and commits through injected dependencies', as return { id: 1, username, password_hash: passwordHash }; }, getUserByUsername: () => undefined, + getUserAuthById: () => undefined, + updatePasswordHash: () => undefined, updateLastLogin: (userId) => operations.push(`login:${userId}`), }, })); @@ -53,7 +62,7 @@ test('register hashes credentials and commits through injected dependencies', as const result = await service.register('alice', 'secret12'); assert.equal(result.token, 'signed-token'); - assert.deepEqual(operations, ['begin', 'hash:secret12', 'create:alice:hash', 'commit', 'login:1']); + assert.deepEqual(operations, ['hash:secret12', 'begin', 'create:alice:hash', 'commit', 'login:1']); }); test('login rejects an invalid password without issuing a token', async () => { @@ -63,6 +72,8 @@ test('login rejects an invalid password without issuing a token', async () => { hasUsers: () => true, createUser: () => { throw new Error('unused'); }, getUserByUsername: () => ({ id: 1, username: 'alice', password_hash: 'hash' }), + getUserAuthById: () => undefined, + updatePasswordHash: () => undefined, updateLastLogin: () => undefined, }, comparePassword: async () => false, @@ -79,6 +90,131 @@ test('login rejects an invalid password without issuing a token', async () => { assert.equal(tokenIssued, false); }); +test('changePassword stores the new hash and rotates the token generation', async () => { + const operations: string[] = []; + const service = createAuthService(createDependencies({ + users: { + hasUsers: () => true, + createUser: () => { throw new Error('unused'); }, + getUserByUsername: () => undefined, + getUserAuthById: () => ({ id: 7, username: 'alice', password_hash: 'current-hash' }), + updatePasswordHash: (userId, passwordHash) => operations.push(`password:${userId}:${passwordHash}`), + updateLastLogin: () => undefined, + }, + authConfig: { + setTokenGeneration: (value) => operations.push(`generation:${value}`), + }, + transaction: { + begin: () => operations.push('begin'), + commit: () => operations.push('commit'), + rollback: () => operations.push('rollback'), + }, + comparePassword: async (password, passwordHash) => ( + password === 'current-password' && passwordHash === 'current-hash' + ), + hashPassword: async (password) => `hash:${password}`, + createTokenGeneration: () => 'next-generation', + })); + + const result = await service.changePassword( + { id: 7, username: 'alice' }, + 'current-password', + 'replacement-password', + ); + + assert.deepEqual(result, { + success: true, + message: 'Password updated. Please sign in again.', + }); + assert.deepEqual(operations, [ + 'begin', + 'password:7:hash:replacement-password', + 'generation:next-generation', + 'commit', + ]); +}); + +test('changePassword rejects the wrong current password before changing state', async () => { + let transactionStarted = false; + const service = createAuthService(createDependencies({ + users: { + hasUsers: () => true, + createUser: () => { throw new Error('unused'); }, + getUserByUsername: () => undefined, + getUserAuthById: () => ({ id: 7, username: 'alice', password_hash: 'current-hash' }), + updatePasswordHash: () => { throw new Error('must not update'); }, + updateLastLogin: () => undefined, + }, + transaction: { + begin: () => { transactionStarted = true; }, + commit: () => undefined, + rollback: () => undefined, + }, + })); + + await assert.rejects( + service.changePassword({ id: 7 }, 'wrong-password', 'replacement-password'), + (error: unknown) => error instanceof AppError + && error.code === 'AUTH_CURRENT_PASSWORD_INCORRECT', + ); + assert.equal(transactionStarted, false); +}); + +test('changePassword validates its input before reading account state', async () => { + let userRead = false; + const service = createAuthService(createDependencies({ + users: { + hasUsers: () => true, + createUser: () => { throw new Error('unused'); }, + getUserByUsername: () => undefined, + getUserAuthById: () => { + userRead = true; + return undefined; + }, + updatePasswordHash: () => undefined, + updateLastLogin: () => undefined, + }, + })); + + await assert.rejects( + service.changePassword({ id: 7 }, '', 'short'), + (error: unknown) => error instanceof AppError && error.code === 'AUTH_PASSWORDS_REQUIRED', + ); + await assert.rejects( + service.changePassword({ id: 7 }, 'current-password', 'short'), + (error: unknown) => error instanceof AppError && error.code === 'AUTH_PASSWORD_TOO_SHORT', + ); + assert.equal(userRead, false); +}); + +test('changePassword requires an authenticated user', async () => { + const service = createAuthService(createDependencies()); + + await assert.rejects( + service.changePassword(undefined, 'current-password', 'replacement-password'), + (error: unknown) => error instanceof AppError && error.code === 'AUTH_USER_REQUIRED', + ); +}); + +test('changePassword rejects a token whose user no longer exists', async () => { + const service = createAuthService(createDependencies()); + + await assert.rejects( + service.changePassword({ id: 7 }, 'current-password', 'replacement-password'), + (error: unknown) => error instanceof AppError && error.code === 'AUTH_TOKEN_INVALID', + ); +}); + +test('changePassword is unavailable in platform mode', async () => { + const service = createAuthService(createDependencies({ isPlatform: true })); + + await assert.rejects( + service.changePassword({ id: 7 }, 'current-password', 'replacement-password'), + (error: unknown) => error instanceof AppError + && error.code === 'AUTH_PASSWORD_CHANGE_UNAVAILABLE', + ); +}); + test('refreshSession issues a replacement token for the authenticated user', () => { let tokenUser: { id: number | bigint; username: string } | undefined; const service = createAuthService(createDependencies({ diff --git a/server/modules/database/repositories/app-config.ts b/server/modules/database/repositories/app-config.ts index 691a1b4eb2..e929b35629 100644 --- a/server/modules/database/repositories/app-config.ts +++ b/server/modules/database/repositories/app-config.ts @@ -29,6 +29,15 @@ export const appConfigDb = { } }, + /** Returns a stored value and lets database errors fail the caller. */ + getStrict(key: string): string | null { + const db = getConnection(); + const row = db + .prepare('SELECT value FROM app_config WHERE key = ?') + .get(key) as { value: string } | undefined; + return row?.value ?? null; + }, + /** Inserts or updates a config key (upsert). */ set(key: string, value: string): void { const db = getConnection(); diff --git a/server/modules/database/repositories/users.ts b/server/modules/database/repositories/users.ts index 25bb57bd8c..adfa0a66fe 100644 --- a/server/modules/database/repositories/users.ts +++ b/server/modules/database/repositories/users.ts @@ -66,6 +66,22 @@ export const userDb = { .get(username) as UserRow | undefined; }, + /** Returns the full active user row by ID for authenticated credential checks. */ + getUserAuthById(userId: number): UserRow | undefined { + const db = getConnection(); + return db + .prepare('SELECT * FROM users WHERE id = ? AND is_active = 1') + .get(userId) as UserRow | undefined; + }, + + /** Updates the password hash for an active user. */ + updatePasswordHash(userId: number, passwordHash: string): void { + const db = getConnection(); + db.prepare( + 'UPDATE users SET password_hash = ? WHERE id = ? AND is_active = 1' + ).run(passwordHash, userId); + }, + /** Updates the last_login timestamp. Non-fatal — logs but does not throw. */ updateLastLogin(userId: number): void { try { diff --git a/src/components/auth/context/AuthContext.tsx b/src/components/auth/context/AuthContext.tsx index 126b59b9da..e6d3073a86 100644 --- a/src/components/auth/context/AuthContext.tsx +++ b/src/components/auth/context/AuthContext.tsx @@ -10,6 +10,7 @@ import { } from '../../../utils/api'; import { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants'; import type { + ApiErrorPayload, AuthContextValue, AuthProviderProps, AuthSessionPayload, @@ -270,6 +271,28 @@ export function AuthProvider({ children }: AuthProviderProps) { clearSession(); }, [clearSession]); + const changePassword = useCallback( + async (currentPassword, newPassword) => { + try { + setError(null); + const response = await api.auth.changePassword(currentPassword, newPassword); + const payload = await parseJsonSafely(response); + + if (!response.ok) { + const message = resolveApiErrorMessage(payload, 'Password change failed'); + return { success: false, error: message }; + } + + clearSession(); + return { success: true }; + } catch (caughtError) { + console.error('Change password error:', caughtError); + return { success: false, error: AUTH_ERROR_MESSAGES.networkError }; + } + }, + [clearSession], + ); + const contextValue = useMemo( () => ({ user, @@ -281,12 +304,14 @@ export function AuthProvider({ children }: AuthProviderProps) { login, register, logout, + changePassword, refreshOnboardingStatus, }), [ error, hasCompletedOnboarding, isLoading, + changePassword, login, logout, needsSetup, diff --git a/src/components/auth/types.ts b/src/components/auth/types.ts index e745a3727f..a9b1515636 100644 --- a/src/components/auth/types.ts +++ b/src/components/auth/types.ts @@ -42,6 +42,7 @@ export type AuthContextValue = { login: (username: string, password: string) => Promise; register: (username: string, password: string) => Promise; logout: () => void; + changePassword: (currentPassword: string, newPassword: string) => Promise; refreshOnboardingStatus: () => Promise; }; diff --git a/src/components/settings/constants/constants.ts b/src/components/settings/constants/constants.ts index 8c429083df..cb4bbc416b 100644 --- a/src/components/settings/constants/constants.ts +++ b/src/components/settings/constants/constants.ts @@ -2,6 +2,7 @@ import type { ComponentType } from 'react'; import { Bell, Bot, + CircleUserRound, GitBranch, Info, KeyRound, @@ -28,6 +29,7 @@ export type SettingsMainTabMeta = { }; export const SETTINGS_MAIN_TABS: SettingsMainTabMeta[] = [ + { id: 'account', label: 'Account', keywords: 'account password logout sign out local user', icon: CircleUserRound }, { id: 'agents', label: 'Agents', keywords: 'agents subagents claude code', icon: Bot }, { id: 'appearance', label: 'Appearance', keywords: 'appearance theme dark light language', icon: Palette }, { id: 'git', label: 'Git', keywords: 'git github commits', icon: GitBranch }, diff --git a/src/components/settings/hooks/normalizeMainTab.ts b/src/components/settings/hooks/normalizeMainTab.ts new file mode 100644 index 0000000000..44ef3d105e --- /dev/null +++ b/src/components/settings/hooks/normalizeMainTab.ts @@ -0,0 +1,12 @@ +import type { SettingsMainTab } from '../types/types'; + +const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'account', 'voice', 'about']; + +export const normalizeMainTab = (tab: string): SettingsMainTab => { + // Keep backwards compatibility with older callers that still pass "tools". + if (tab === 'tools') { + return 'agents'; + } + + return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents'; +}; diff --git a/src/components/settings/hooks/useSettingsController.test.ts b/src/components/settings/hooks/useSettingsController.test.ts new file mode 100644 index 0000000000..775034437e --- /dev/null +++ b/src/components/settings/hooks/useSettingsController.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { normalizeMainTab } from './normalizeMainTab'; + +test('normalizes account to the account settings tab', () => { + assert.equal(normalizeMainTab('account'), 'account'); +}); + +test('preserves the voice settings tab', () => { + assert.equal(normalizeMainTab('voice'), 'voice'); +}); + +test('preserves legacy and unknown settings tab fallbacks', () => { + assert.equal(normalizeMainTab('tools'), 'agents'); + assert.equal(normalizeMainTab('unknown'), 'agents'); +}); diff --git a/src/components/settings/hooks/useSettingsController.ts b/src/components/settings/hooks/useSettingsController.ts index cc57d37732..ac312b918e 100644 --- a/src/components/settings/hooks/useSettingsController.ts +++ b/src/components/settings/hooks/useSettingsController.ts @@ -8,6 +8,7 @@ import { DEFAULT_CODE_EDITOR_SETTINGS, DEFAULT_CURSOR_PERMISSIONS, } from '../constants/constants'; +import { normalizeMainTab } from './normalizeMainTab'; import type { AgentProvider, ClaudePermissionsState, @@ -53,17 +54,6 @@ type NotificationPreferencesResponse = { type ActiveLoginProvider = AgentProvider | ''; -const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'browser', 'notifications', 'plugins', 'about']; - -const normalizeMainTab = (tab: string): SettingsMainTab => { - // Keep backwards compatibility with older callers that still pass "tools". - if (tab === 'tools') { - return 'agents'; - } - - return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents'; -}; - const parseJson = (value: string | null, fallback: T): T => { if (!value) { return fallback; diff --git a/src/components/settings/types/types.ts b/src/components/settings/types/types.ts index 514de2f3c7..2318ce6fdd 100644 --- a/src/components/settings/types/types.ts +++ b/src/components/settings/types/types.ts @@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { LLMProvider } from '../../../types/app'; import type { ProviderAuthStatus } from '../../provider-auth/types'; -export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about'; +export type SettingsMainTab = 'account' | 'agents' | 'appearance' | 'git' | 'api' | 'voice' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about'; export type AgentProvider = LLMProvider; export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills'; export type ProjectSortOrder = 'name' | 'date'; diff --git a/src/components/settings/view/Settings.tsx b/src/components/settings/view/Settings.tsx index 37f40bc96d..d49b47ed9e 100644 --- a/src/components/settings/view/Settings.tsx +++ b/src/components/settings/view/Settings.tsx @@ -6,6 +6,7 @@ import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal'; import { Button } from '../../../shared/view/ui'; import SettingsSidebar from '../view/SettingsSidebar'; import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab'; +import AccountSettingsTab from '../view/tabs/AccountSettingsTab'; import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab'; import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab'; import VoiceSettingsTab from '../view/tabs/VoiceSettingsTab'; @@ -161,6 +162,8 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set {/* Content */}
+ {activeTab === 'account' && } + {activeTab === 'appearance' && ( - {t(item.labelKey)} + {item.labelKey ? t(item.labelKey) : item.label} ); })} @@ -74,7 +76,7 @@ export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebar className="flex-shrink-0" > - {t(item.labelKey)} + {item.labelKey ? t(item.labelKey) : item.label} ); })} diff --git a/src/components/settings/view/tabs/AccountSettingsTab.test.ts b/src/components/settings/view/tabs/AccountSettingsTab.test.ts new file mode 100644 index 0000000000..ef49ad3c83 --- /dev/null +++ b/src/components/settings/view/tabs/AccountSettingsTab.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { validatePasswordForm } from './accountSettings'; + +const source = readFileSync(new URL('./AccountSettingsTab.tsx', import.meta.url), 'utf8'); + +test('validates password form input', () => { + assert.equal( + validatePasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }), + 'Fill in all password fields.', + ); + assert.equal( + validatePasswordForm({ currentPassword: 'old-password', newPassword: 'short', confirmPassword: 'short' }), + 'New password must be at least 6 characters.', + ); + assert.equal( + validatePasswordForm({ + currentPassword: 'old-password', + newPassword: 'new-password', + confirmPassword: 'different-password', + }), + 'New passwords do not match.', + ); + assert.equal( + validatePasswordForm({ + currentPassword: 'old-password', + newPassword: 'new-password', + confirmPassword: 'new-password', + }), + null, + ); +}); + +test('announces account errors to assistive technology', () => { + assert.match(source, /role="alert"/); +}); diff --git a/src/components/settings/view/tabs/AccountSettingsTab.tsx b/src/components/settings/view/tabs/AccountSettingsTab.tsx new file mode 100644 index 0000000000..f75c828422 --- /dev/null +++ b/src/components/settings/view/tabs/AccountSettingsTab.tsx @@ -0,0 +1,155 @@ +import { useCallback, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Loader2, LogOut } from 'lucide-react'; + +import { IS_PLATFORM } from '../../../../constants/config'; +import { useAuth } from '../../../auth/context/AuthContext'; +import { Button, Input } from '../../../../shared/view/ui'; +import SettingsCard from '../SettingsCard'; +import SettingsSection from '../SettingsSection'; +import { validatePasswordForm } from './accountSettings'; +import type { PasswordFormState } from './accountSettings'; + +const initialPasswordFormState: PasswordFormState = { + currentPassword: '', + newPassword: '', + confirmPassword: '', +}; + +export default function AccountSettingsTab() { + const { user, logout, changePassword } = useAuth(); + const [passwordForm, setPasswordForm] = useState(initialPasswordFormState); + const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + const updatePasswordField = useCallback((field: keyof PasswordFormState, value: string) => { + setPasswordForm((previous) => ({ ...previous, [field]: value })); + }, []); + + const handlePasswordSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + const validationError = validatePasswordForm(passwordForm); + if (validationError) { + setError(validationError); + return; + } + + setIsSaving(true); + try { + const result = await changePassword(passwordForm.currentPassword, passwordForm.newPassword); + if (!result.success) { + setError(result.error); + return; + } + + setPasswordForm(initialPasswordFormState); + } catch { + setError('Unable to change password. Please try again.'); + } finally { + setIsSaving(false); + } + }, + [changePassword, passwordForm], + ); + + return ( +
+ + + {IS_PLATFORM ? ( +
+ Local account controls are not available in platform mode. +
+ ) : ( +
+
+
+
Signed in as {user?.username || 'local user'}
+

+ Logout removes the saved browser token. Your auth database stays intact. +

+
+ +
+ +
+
+
+

Change Password

+

+ After the password changes, CloudCLI signs you out and rejects older session tokens. +

+
+ +
+
+ + updatePasswordField('currentPassword', event.target.value)} + disabled={isSaving} + /> +
+ +
+ + updatePasswordField('newPassword', event.target.value)} + disabled={isSaving} + /> +
+ +
+ + updatePasswordField('confirmPassword', event.target.value)} + disabled={isSaving} + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/components/settings/view/tabs/accountSettings.ts b/src/components/settings/view/tabs/accountSettings.ts new file mode 100644 index 0000000000..6344e9631e --- /dev/null +++ b/src/components/settings/view/tabs/accountSettings.ts @@ -0,0 +1,23 @@ +export type PasswordFormState = { + currentPassword: string; + newPassword: string; + confirmPassword: string; +}; + +const PASSWORD_MIN_LENGTH = 6; + +export function validatePasswordForm(formState: PasswordFormState): string | null { + if (!formState.currentPassword || !formState.newPassword || !formState.confirmPassword) { + return 'Fill in all password fields.'; + } + + if (formState.newPassword.length < PASSWORD_MIN_LENGTH) { + return `New password must be at least ${PASSWORD_MIN_LENGTH} characters.`; + } + + if (formState.newPassword !== formState.confirmPassword) { + return 'New passwords do not match.'; + } + + return null; +} diff --git a/src/utils/api.js b/src/utils/api.js index 479285920a..e4f27d2314 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -136,6 +136,10 @@ export const api = { refresh: () => authenticatedFetch('/api/auth/refresh', { method: 'POST' }), user: () => authenticatedFetch('/api/auth/user'), logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }), + changePassword: (currentPassword, newPassword) => authenticatedFetch('/api/auth/change-password', { + method: 'POST', + body: JSON.stringify({ currentPassword, newPassword }), + }), }, // Protected endpoints