diff --git a/app/src/components/modals/user/DeleteUserModal.test.tsx b/app/src/components/modals/user/DeleteUserModal.test.tsx index e7d60b5..581f185 100644 --- a/app/src/components/modals/user/DeleteUserModal.test.tsx +++ b/app/src/components/modals/user/DeleteUserModal.test.tsx @@ -8,7 +8,7 @@ import { import React from 'react'; import { MantineProvider } from '@mantine/core'; import DeleteUserModal from './DeleteUserModal'; -import { UserRole, type User } from '@/types/models'; +import { UserRole, Theme, type User } from '@/types/models'; // Mock notifications vi.mock('@mantine/notifications', () => ({ @@ -36,6 +36,7 @@ describe('DeleteUserModal', () => { email: 'test@example.com', displayName: 'Test User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; diff --git a/app/src/components/modals/user/EditUserModal.test.tsx b/app/src/components/modals/user/EditUserModal.test.tsx index ee335da..a6f091a 100644 --- a/app/src/components/modals/user/EditUserModal.test.tsx +++ b/app/src/components/modals/user/EditUserModal.test.tsx @@ -8,7 +8,7 @@ import { import React from 'react'; import { MantineProvider } from '@mantine/core'; import EditUserModal from './EditUserModal'; -import { UserRole, type User } from '@/types/models'; +import { UserRole, Theme, type User } from '@/types/models'; // Mock notifications vi.mock('@mantine/notifications', () => ({ @@ -36,6 +36,7 @@ describe('EditUserModal', () => { email: 'test@example.com', displayName: 'Test User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; @@ -187,6 +188,7 @@ describe('EditUserModal', () => { email: 'newuser@example.com', displayName: 'New User', role: UserRole.Admin, + theme: Theme.Dark, }; rerender( diff --git a/app/src/components/navigation/UserMenu.test.tsx b/app/src/components/navigation/UserMenu.test.tsx index a1f744b..abf7a77 100644 --- a/app/src/components/navigation/UserMenu.test.tsx +++ b/app/src/components/navigation/UserMenu.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../test/utils'; import UserMenu from './UserMenu'; -import { UserRole } from '../../types/models'; +import { UserRole, Theme } from '../../types/models'; // Mock the contexts vi.mock('../../contexts/AuthContext', () => ({ @@ -37,6 +37,7 @@ describe('UserMenu', () => { email: 'test@example.com', displayName: 'Test User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; @@ -53,6 +54,7 @@ describe('UserMenu', () => { login: vi.fn(), refreshToken: vi.fn(), refreshUser: vi.fn(), + updateProfile: vi.fn(), }); }); @@ -84,6 +86,7 @@ describe('UserMenu', () => { login: vi.fn(), refreshToken: vi.fn(), refreshUser: vi.fn(), + updateProfile: vi.fn(), }); const { getByLabelText, getByText } = render( @@ -145,6 +148,7 @@ describe('UserMenu', () => { id: mockUser.id, email: mockUser.email, role: mockUser.role, + theme: mockUser.theme, createdAt: mockUser.createdAt, lastWorkspaceId: mockUser.lastWorkspaceId, }; @@ -157,6 +161,7 @@ describe('UserMenu', () => { login: vi.fn(), refreshToken: vi.fn(), refreshUser: vi.fn(), + updateProfile: vi.fn(), }); const { getByLabelText, getByText } = render( diff --git a/app/src/components/settings/account/AccountSettings.tsx b/app/src/components/settings/account/AccountSettings.tsx index c4c87b6..aeed722 100644 --- a/app/src/components/settings/account/AccountSettings.tsx +++ b/app/src/components/settings/account/AccountSettings.tsx @@ -89,6 +89,7 @@ const AccountSettings: React.FC = ({ email: user.email, currentPassword: '', newPassword: '', + theme: user.theme, }; dispatch({ type: SettingsActionType.INIT_SETTINGS, @@ -107,6 +108,13 @@ const AccountSettings: React.FC = ({ }); }; + const handleThemeChange = (theme: string): void => { + dispatch({ + type: SettingsActionType.UPDATE_LOCAL_SETTINGS, + payload: { theme } as UserProfileSettings, + }); + }; + const handleSubmit = async (): Promise => { const updates: UserProfileSettings = {}; const needsPasswordConfirmation = @@ -117,6 +125,14 @@ const AccountSettings: React.FC = ({ updates.displayName = state.localSettings.displayName || ''; } + // Add theme if changed + if ( + state.localSettings.theme && + state.localSettings.theme !== state.initialSettings.theme + ) { + updates.theme = state.localSettings.theme; + } + // Handle password change if (state.localSettings.newPassword) { if (!state.localSettings.currentPassword) { @@ -216,6 +232,7 @@ const AccountSettings: React.FC = ({ diff --git a/app/src/components/settings/account/ProfileSettings.tsx b/app/src/components/settings/account/ProfileSettings.tsx index 9c85adc..c53085b 100644 --- a/app/src/components/settings/account/ProfileSettings.tsx +++ b/app/src/components/settings/account/ProfileSettings.tsx @@ -1,36 +1,70 @@ import React from 'react'; -import { Box, Stack, TextInput } from '@mantine/core'; -import type { UserProfileSettings } from '@/types/models'; +import { Box, Stack, TextInput, Group, Text, Switch } from '@mantine/core'; +import { IconMoon, IconSun } from '@tabler/icons-react'; +import { useAuth } from '@/contexts/AuthContext'; +import { Theme, type UserProfileSettings } from '@/types/models'; interface ProfileSettingsProps { settings: UserProfileSettings; onInputChange: (key: keyof UserProfileSettings, value: string) => void; + onThemeChange?: (theme: Theme) => void; } const ProfileSettings: React.FC = ({ settings, onInputChange, -}) => ( - - - onInputChange('displayName', e.currentTarget.value)} - placeholder="Enter display name" - data-testid="display-name-input" - /> - onInputChange('email', e.currentTarget.value)} - placeholder="Enter email" - data-testid="email-input" - /> - - -); + onThemeChange, +}) => { + const { user } = useAuth(); + const currentTheme = settings.theme || user?.theme || Theme.Dark; + + const handleThemeToggle = () => { + const newTheme = currentTheme === Theme.Dark ? Theme.Light : Theme.Dark; + if (onThemeChange) { + onThemeChange(newTheme); + } + }; + + return ( + + + onInputChange('displayName', e.currentTarget.value)} + placeholder="Enter display name" + data-testid="display-name-input" + /> + onInputChange('email', e.currentTarget.value)} + placeholder="Enter email" + data-testid="email-input" + /> + +
+ + Default Theme + + + Sets the default theme for new workspaces + +
+ } + offLabel={} + data-testid="theme-toggle" + /> +
+
+
+ ); +}; export default ProfileSettings; diff --git a/app/src/components/settings/admin/AdminDashboard.test.tsx b/app/src/components/settings/admin/AdminDashboard.test.tsx index fd439a5..101b53b 100644 --- a/app/src/components/settings/admin/AdminDashboard.test.tsx +++ b/app/src/components/settings/admin/AdminDashboard.test.tsx @@ -3,7 +3,7 @@ import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; import React from 'react'; import { MantineProvider } from '@mantine/core'; import AdminDashboard from './AdminDashboard'; -import { UserRole, type User } from '@/types/models'; +import { UserRole, Theme, type User } from '@/types/models'; // Mock the auth context const mockCurrentUser: User = { @@ -11,6 +11,7 @@ const mockCurrentUser: User = { email: 'admin@example.com', displayName: 'Admin User', role: UserRole.Admin, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; diff --git a/app/src/components/settings/admin/AdminUsersTab.test.tsx b/app/src/components/settings/admin/AdminUsersTab.test.tsx index 79c6ce5..68afa27 100644 --- a/app/src/components/settings/admin/AdminUsersTab.test.tsx +++ b/app/src/components/settings/admin/AdminUsersTab.test.tsx @@ -8,7 +8,7 @@ import { import React from 'react'; import { MantineProvider } from '@mantine/core'; import AdminUsersTab from './AdminUsersTab'; -import { UserRole, type User } from '@/types/models'; +import { UserRole, Theme, type User } from '@/types/models'; // Mock the user admin hook const mockCreate = vi.fn(); @@ -123,6 +123,7 @@ describe('AdminUsersTab', () => { email: 'admin@example.com', displayName: 'Admin User', role: UserRole.Admin, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; @@ -134,6 +135,7 @@ describe('AdminUsersTab', () => { email: 'editor@example.com', displayName: 'Editor User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-15T00:00:00Z', lastWorkspaceId: 2, }, @@ -142,6 +144,7 @@ describe('AdminUsersTab', () => { email: 'viewer@example.com', displayName: 'Viewer User', role: UserRole.Viewer, + theme: Theme.Dark, createdAt: '2024-02-01T00:00:00Z', lastWorkspaceId: 3, }, diff --git a/app/src/contexts/AuthContext.test.tsx b/app/src/contexts/AuthContext.test.tsx index 460fff5..d12326b 100644 --- a/app/src/contexts/AuthContext.test.tsx +++ b/app/src/contexts/AuthContext.test.tsx @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import React from 'react'; import { AuthProvider, useAuth } from './AuthContext'; -import { UserRole, type User } from '@/types/models'; +import { UserRole, Theme, type User } from '@/types/models'; // Set up mocks before imports are used vi.mock('@/api/auth', () => { @@ -42,6 +42,7 @@ const mockUser: User = { email: 'test@example.com', displayName: 'Test User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx index 866d2f5..6d6db5f 100644 --- a/app/src/contexts/AuthContext.tsx +++ b/app/src/contexts/AuthContext.tsx @@ -12,7 +12,8 @@ import { refreshToken as apiRefreshToken, getCurrentUser, } from '@/api/auth'; -import type { User } from '@/types/models'; +import { updateProfile as apiUpdateProfile } from '@/api/user'; +import type { User, UserProfileSettings } from '@/types/models'; interface AuthContextType { user: User | null; @@ -22,6 +23,7 @@ interface AuthContextType { logout: () => Promise; refreshToken: () => Promise; refreshUser: () => Promise; + updateProfile: (updates: UserProfileSettings) => Promise; } const AuthContext = createContext(null); @@ -109,6 +111,31 @@ export const AuthProvider: React.FC = ({ children }) => { } }, []); + const updateProfile = useCallback( + async (updates: UserProfileSettings): Promise => { + try { + const updatedUser = await apiUpdateProfile(updates); + setUser(updatedUser); + notifications.show({ + title: 'Success', + message: 'Profile updated successfully', + color: 'green', + }); + return updatedUser; + } catch (error) { + console.error('Failed to update profile:', error); + notifications.show({ + title: 'Error', + message: + error instanceof Error ? error.message : 'Failed to update profile', + color: 'red', + }); + throw error; + } + }, + [] + ); + const value: AuthContextType = { user, loading, @@ -117,6 +144,7 @@ export const AuthProvider: React.FC = ({ children }) => { logout, refreshToken, refreshUser, + updateProfile, }; return {children}; diff --git a/app/src/hooks/useAdminData.test.ts b/app/src/hooks/useAdminData.test.ts index 1d2153c..a794698 100644 --- a/app/src/hooks/useAdminData.test.ts +++ b/app/src/hooks/useAdminData.test.ts @@ -4,6 +4,7 @@ import { useAdminData } from './useAdminData'; import * as adminApi from '@/api/admin'; import { UserRole, + Theme, type SystemStats, type User, type WorkspaceStats, @@ -35,6 +36,7 @@ const mockUsers: User[] = [ email: 'admin@example.com', displayName: 'Admin User', role: UserRole.Admin, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }, @@ -43,6 +45,7 @@ const mockUsers: User[] = [ email: 'editor@example.com', displayName: 'Editor User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-02T00:00:00Z', lastWorkspaceId: 2, }, diff --git a/app/src/hooks/useProfileSettings.test.ts b/app/src/hooks/useProfileSettings.test.ts index 449d25b..1f7e051 100644 --- a/app/src/hooks/useProfileSettings.test.ts +++ b/app/src/hooks/useProfileSettings.test.ts @@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'; import { useProfileSettings } from './useProfileSettings'; import * as userApi from '@/api/user'; import type { UpdateProfileRequest } from '@/types/api'; -import { UserRole, type User } from '@/types/models'; +import { UserRole, Theme, type User } from '@/types/models'; // Mock dependencies vi.mock('@/api/user'); @@ -22,6 +22,7 @@ const mockUser: User = { email: 'test@example.com', displayName: 'Test User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; diff --git a/app/src/hooks/useUserAdmin.test.ts b/app/src/hooks/useUserAdmin.test.ts index 80c7103..9d64ab2 100644 --- a/app/src/hooks/useUserAdmin.test.ts +++ b/app/src/hooks/useUserAdmin.test.ts @@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react'; import { useUserAdmin } from './useUserAdmin'; import * as adminApi from '@/api/admin'; import type { CreateUserRequest, UpdateUserRequest } from '@/types/api'; -import { UserRole, type User } from '@/types/models'; +import { UserRole, Theme, type User } from '@/types/models'; // Mock dependencies vi.mock('@/api/admin'); @@ -35,6 +35,7 @@ const mockUsers: User[] = [ email: 'admin@example.com', displayName: 'Admin User', role: UserRole.Admin, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }, @@ -43,6 +44,7 @@ const mockUsers: User[] = [ email: 'editor@example.com', displayName: 'Editor User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-02T00:00:00Z', lastWorkspaceId: 1, }, @@ -112,6 +114,7 @@ describe('useUserAdmin', () => { email: 'newuser@example.com', displayName: 'New User', role: UserRole.Viewer, + theme: Theme.Dark, createdAt: '2024-01-03T00:00:00Z', lastWorkspaceId: 1, }; @@ -124,6 +127,7 @@ describe('useUserAdmin', () => { displayName: 'New User', password: 'password123', role: UserRole.Viewer, + theme: Theme.Dark, }; let createResult: boolean | undefined; @@ -152,6 +156,7 @@ describe('useUserAdmin', () => { displayName: 'Test User', password: 'password123', role: UserRole.Editor, + theme: Theme.Dark, }; let createResult: boolean | undefined; @@ -179,6 +184,7 @@ describe('useUserAdmin', () => { displayName: 'Test User', password: 'password123', role: UserRole.Editor, + theme: Theme.Dark, }; let createResult: boolean | undefined; @@ -204,6 +210,7 @@ describe('useUserAdmin', () => { email: user.email, displayName: 'Updated Editor', role: user.role, + theme: user.theme, createdAt: user.createdAt, lastWorkspaceId: user.lastWorkspaceId, }; @@ -238,6 +245,7 @@ describe('useUserAdmin', () => { email: 'newemail@example.com', displayName: user.displayName || '', role: UserRole.Admin, + theme: Theme.Dark, createdAt: user.createdAt, lastWorkspaceId: user.lastWorkspaceId, }; @@ -248,6 +256,7 @@ describe('useUserAdmin', () => { const updateRequest: UpdateUserRequest = { email: 'newemail@example.com', role: UserRole.Admin, + theme: Theme.Dark, }; let updateResult: boolean | undefined; @@ -436,6 +445,7 @@ describe('useUserAdmin', () => { displayName: 'Test', password: 'pass', role: UserRole.Viewer, + theme: Theme.Dark, }); }); @@ -474,6 +484,7 @@ describe('useUserAdmin', () => { displayName: 'Test', password: 'pass', role: UserRole.Viewer, + theme: Theme.Dark, }); }); @@ -500,6 +511,7 @@ describe('useUserAdmin', () => { email: 'user1@example.com', displayName: 'User 1', role: UserRole.Viewer, + theme: Theme.Dark, createdAt: '2024-01-03T00:00:00Z', lastWorkspaceId: 1, }) @@ -508,6 +520,7 @@ describe('useUserAdmin', () => { email: 'user2@example.com', displayName: 'User 2', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-04T00:00:00Z', lastWorkspaceId: 1, }); @@ -520,12 +533,14 @@ describe('useUserAdmin', () => { displayName: 'User 1', password: 'pass1', role: UserRole.Viewer, + theme: Theme.Dark, }, { email: 'user2@example.com', displayName: 'User 2', password: 'pass2', role: UserRole.Editor, + theme: Theme.Dark, }, ]; @@ -555,12 +570,14 @@ describe('useUserAdmin', () => { displayName: 'Success User', password: 'pass1', role: UserRole.Viewer, + theme: Theme.Dark, }, { email: 'fail@example.com', displayName: 'Fail User', password: 'pass2', role: UserRole.Editor, + theme: Theme.Dark, }, ]; diff --git a/app/src/types/api.test.ts b/app/src/types/api.test.ts index 54270ae..4da7647 100644 --- a/app/src/types/api.test.ts +++ b/app/src/types/api.test.ts @@ -9,7 +9,7 @@ import { type SaveFileResponse, type UploadFilesResponse, } from './api'; -import { UserRole, type User } from './models'; +import { UserRole, Theme, type User } from './models'; // Mock user data for testing const mockUser: User = { @@ -17,6 +17,7 @@ const mockUser: User = { email: 'test@example.com', displayName: 'Test User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; diff --git a/app/src/types/api.ts b/app/src/types/api.ts index d60b71d..e812ca7 100644 --- a/app/src/types/api.ts +++ b/app/src/types/api.ts @@ -1,4 +1,4 @@ -import { isUser, type User, type UserRole } from './models'; +import { isUser, type User, type UserRole, type Theme } from './models'; declare global { interface Window { @@ -55,6 +55,7 @@ export interface CreateUserRequest { displayName: string; password: string; role: UserRole; + theme?: Theme; } // UpdateUserRequest holds the request fields for updating a user @@ -63,6 +64,7 @@ export interface UpdateUserRequest { displayName?: string; password?: string; role?: UserRole; + theme?: Theme; } export interface LookupResponse { @@ -126,6 +128,7 @@ export interface UpdateProfileRequest { email?: string; currentPassword?: string; newPassword?: string; + theme?: Theme; } // DeleteAccountRequest represents a user account deletion request diff --git a/app/src/types/models.test.ts b/app/src/types/models.test.ts index ca9fa3a..8a27ea9 100644 --- a/app/src/types/models.test.ts +++ b/app/src/types/models.test.ts @@ -63,6 +63,7 @@ describe('Models Type Guards', () => { email: 'test@example.com', displayName: 'Test User', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; @@ -76,6 +77,7 @@ describe('Models Type Guards', () => { id: 1, email: 'test@example.com', role: UserRole.Editor, + theme: Theme.Dark, createdAt: '2024-01-01T00:00:00Z', lastWorkspaceId: 1, }; diff --git a/app/src/types/models.ts b/app/src/types/models.ts index 89a8967..364c1d0 100644 --- a/app/src/types/models.ts +++ b/app/src/types/models.ts @@ -8,6 +8,7 @@ export interface User { email: string; displayName?: string; role: UserRole; + theme: Theme; createdAt: string; lastWorkspaceId: number; } @@ -28,6 +29,8 @@ export function isUser(value: unknown): value is User { : true) && 'role' in value && isUserRole((value as User).role) && + 'theme' in value && + (value as User).theme in Theme && 'createdAt' in value && typeof (value as User).createdAt === 'string' && 'lastWorkspaceId' in value && @@ -309,6 +312,7 @@ export interface UserProfileSettings { email?: string; currentPassword?: string; newPassword?: string; + theme?: Theme; } export interface ProfileSettingsState {