Merge pull request #68 from lordmathis/feat/user-theme

Add user theme setting
This commit is contained in:
2025-11-03 23:43:23 +01:00
committed by GitHub
32 changed files with 268 additions and 41 deletions

3
.gitignore vendored
View File

@@ -162,3 +162,6 @@ go.work.sum
main main
*.db *.db
data data
# Feature specifications
spec.md

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import DeleteUserModal from './DeleteUserModal'; import DeleteUserModal from './DeleteUserModal';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock notifications // Mock notifications
vi.mock('@mantine/notifications', () => ({ vi.mock('@mantine/notifications', () => ({
@@ -36,6 +36,7 @@ describe('DeleteUserModal', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import EditUserModal from './EditUserModal'; import EditUserModal from './EditUserModal';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock notifications // Mock notifications
vi.mock('@mantine/notifications', () => ({ vi.mock('@mantine/notifications', () => ({
@@ -36,6 +36,7 @@ describe('EditUserModal', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -187,6 +188,7 @@ describe('EditUserModal', () => {
email: 'newuser@example.com', email: 'newuser@example.com',
displayName: 'New User', displayName: 'New User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
}; };
rerender( rerender(

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../test/utils'; import { render } from '../../test/utils';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import { UserRole } from '../../types/models'; import { UserRole, Theme } from '../../types/models';
// Mock the contexts // Mock the contexts
vi.mock('../../contexts/AuthContext', () => ({ vi.mock('../../contexts/AuthContext', () => ({
@@ -37,6 +37,7 @@ describe('UserMenu', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -53,6 +54,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
}); });
@@ -84,6 +86,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(
@@ -145,6 +148,7 @@ describe('UserMenu', () => {
id: mockUser.id, id: mockUser.id,
email: mockUser.email, email: mockUser.email,
role: mockUser.role, role: mockUser.role,
theme: mockUser.theme,
createdAt: mockUser.createdAt, createdAt: mockUser.createdAt,
lastWorkspaceId: mockUser.lastWorkspaceId, lastWorkspaceId: mockUser.lastWorkspaceId,
}; };
@@ -157,6 +161,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(

View File

@@ -89,6 +89,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
email: user.email, email: user.email,
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
theme: user.theme,
}; };
dispatch({ dispatch({
type: SettingsActionType.INIT_SETTINGS, type: SettingsActionType.INIT_SETTINGS,
@@ -107,6 +108,13 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
}); });
}; };
const handleThemeChange = (theme: string): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { theme } as UserProfileSettings,
});
};
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
const updates: UserProfileSettings = {}; const updates: UserProfileSettings = {};
const needsPasswordConfirmation = const needsPasswordConfirmation =
@@ -117,6 +125,14 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
updates.displayName = state.localSettings.displayName || ''; 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 // Handle password change
if (state.localSettings.newPassword) { if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) { if (!state.localSettings.currentPassword) {
@@ -216,6 +232,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
<ProfileSettings <ProfileSettings
settings={state.localSettings} settings={state.localSettings}
onInputChange={handleInputChange} onInputChange={handleInputChange}
onThemeChange={handleThemeChange}
/> />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>

View File

@@ -4,6 +4,25 @@ import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import ProfileSettings from './ProfileSettings'; import ProfileSettings from './ProfileSettings';
import type { UserProfileSettings } from '@/types/models'; import type { UserProfileSettings } from '@/types/models';
import { Theme, UserRole, type User } from '@/types/models';
// Mock user for AuthContext
const mockUser: User = {
id: 1,
email: 'test@example.com',
displayName: 'Test User',
role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
};
// Mock the auth context
vi.mock('../../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
}),
}));
// Helper wrapper component for testing // Helper wrapper component for testing
const TestWrapper = ({ children }: { children: React.ReactNode }) => ( const TestWrapper = ({ children }: { children: React.ReactNode }) => (

View File

@@ -1,16 +1,30 @@
import React from 'react'; import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core'; import { Box, Stack, TextInput, Group, Text, Switch } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models'; import { useAuth } from '@/contexts/AuthContext';
import { Theme, type UserProfileSettings } from '@/types/models';
interface ProfileSettingsProps { interface ProfileSettingsProps {
settings: UserProfileSettings; settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void; onInputChange: (key: keyof UserProfileSettings, value: string) => void;
onThemeChange?: (theme: Theme) => void;
} }
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
settings, settings,
onInputChange, onInputChange,
}) => ( 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 (
<Box> <Box>
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
@@ -29,8 +43,24 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
placeholder="Enter email" placeholder="Enter email"
data-testid="email-input" data-testid="email-input"
/> />
<Box mb="md">
<Group justify="space-between" align="center">
<div>
<Text size="sm">Default Dark Mode</Text>
<Text size="xs" c="dimmed">
Sets the default theme for new workspaces
</Text>
</div>
<Switch
checked={currentTheme === Theme.Dark}
onChange={handleThemeToggle}
data-testid="theme-toggle"
/>
</Group>
</Box>
</Stack> </Stack>
</Box> </Box>
); );
};
export default ProfileSettings; export default ProfileSettings;

View File

@@ -3,7 +3,7 @@ import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import AdminDashboard from './AdminDashboard'; import AdminDashboard from './AdminDashboard';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock the auth context // Mock the auth context
const mockCurrentUser: User = { const mockCurrentUser: User = {
@@ -11,6 +11,7 @@ const mockCurrentUser: User = {
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import AdminUsersTab from './AdminUsersTab'; import AdminUsersTab from './AdminUsersTab';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock the user admin hook // Mock the user admin hook
const mockCreate = vi.fn(); const mockCreate = vi.fn();
@@ -123,6 +123,7 @@ describe('AdminUsersTab', () => {
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -134,6 +135,7 @@ describe('AdminUsersTab', () => {
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-15T00:00:00Z', createdAt: '2024-01-15T00:00:00Z',
lastWorkspaceId: 2, lastWorkspaceId: 2,
}, },
@@ -142,6 +144,7 @@ describe('AdminUsersTab', () => {
email: 'viewer@example.com', email: 'viewer@example.com',
displayName: 'Viewer User', displayName: 'Viewer User',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-02-01T00:00:00Z', createdAt: '2024-02-01T00:00:00Z',
lastWorkspaceId: 3, lastWorkspaceId: 3,
}, },

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act, waitFor } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { AuthProvider, useAuth } from './AuthContext'; 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 // Set up mocks before imports are used
vi.mock('@/api/auth', () => { vi.mock('@/api/auth', () => {
@@ -42,6 +42,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -12,7 +12,8 @@ import {
refreshToken as apiRefreshToken, refreshToken as apiRefreshToken,
getCurrentUser, getCurrentUser,
} from '@/api/auth'; } 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 { interface AuthContextType {
user: User | null; user: User | null;
@@ -22,6 +23,7 @@ interface AuthContextType {
logout: () => Promise<void>; logout: () => Promise<void>;
refreshToken: () => Promise<boolean>; refreshToken: () => Promise<boolean>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
updateProfile: (updates: UserProfileSettings) => Promise<User>;
} }
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
@@ -109,6 +111,31 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
} }
}, []); }, []);
const updateProfile = useCallback(
async (updates: UserProfileSettings): Promise<User> => {
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 = { const value: AuthContextType = {
user, user,
loading, loading,
@@ -117,6 +144,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
logout, logout,
refreshToken, refreshToken,
refreshUser, refreshUser,
updateProfile,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -4,6 +4,7 @@ import { useAdminData } from './useAdminData';
import * as adminApi from '@/api/admin'; import * as adminApi from '@/api/admin';
import { import {
UserRole, UserRole,
Theme,
type SystemStats, type SystemStats,
type User, type User,
type WorkspaceStats, type WorkspaceStats,
@@ -35,6 +36,7 @@ const mockUsers: User[] = [
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -43,6 +45,7 @@ const mockUsers: User[] = [
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
lastWorkspaceId: 2, lastWorkspaceId: 2,
}, },

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
import { useProfileSettings } from './useProfileSettings'; import { useProfileSettings } from './useProfileSettings';
import * as userApi from '@/api/user'; import * as userApi from '@/api/user';
import type { UpdateProfileRequest } from '@/types/api'; import type { UpdateProfileRequest } from '@/types/api';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock dependencies // Mock dependencies
vi.mock('@/api/user'); vi.mock('@/api/user');
@@ -22,6 +22,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
import { useUserAdmin } from './useUserAdmin'; import { useUserAdmin } from './useUserAdmin';
import * as adminApi from '@/api/admin'; import * as adminApi from '@/api/admin';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api'; import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock dependencies // Mock dependencies
vi.mock('@/api/admin'); vi.mock('@/api/admin');
@@ -35,6 +35,7 @@ const mockUsers: User[] = [
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -43,6 +44,7 @@ const mockUsers: User[] = [
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -112,6 +114,7 @@ describe('useUserAdmin', () => {
email: 'newuser@example.com', email: 'newuser@example.com',
displayName: 'New User', displayName: 'New User',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z', createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -124,6 +127,7 @@ describe('useUserAdmin', () => {
displayName: 'New User', displayName: 'New User',
password: 'password123', password: 'password123',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -152,6 +156,7 @@ describe('useUserAdmin', () => {
displayName: 'Test User', displayName: 'Test User',
password: 'password123', password: 'password123',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -179,6 +184,7 @@ describe('useUserAdmin', () => {
displayName: 'Test User', displayName: 'Test User',
password: 'password123', password: 'password123',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -204,6 +210,7 @@ describe('useUserAdmin', () => {
email: user.email, email: user.email,
displayName: 'Updated Editor', displayName: 'Updated Editor',
role: user.role, role: user.role,
theme: user.theme,
createdAt: user.createdAt, createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId, lastWorkspaceId: user.lastWorkspaceId,
}; };
@@ -238,6 +245,7 @@ describe('useUserAdmin', () => {
email: 'newemail@example.com', email: 'newemail@example.com',
displayName: user.displayName || '', displayName: user.displayName || '',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: user.createdAt, createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId, lastWorkspaceId: user.lastWorkspaceId,
}; };
@@ -248,6 +256,7 @@ describe('useUserAdmin', () => {
const updateRequest: UpdateUserRequest = { const updateRequest: UpdateUserRequest = {
email: 'newemail@example.com', email: 'newemail@example.com',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
}; };
let updateResult: boolean | undefined; let updateResult: boolean | undefined;
@@ -436,6 +445,7 @@ describe('useUserAdmin', () => {
displayName: 'Test', displayName: 'Test',
password: 'pass', password: 'pass',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}); });
}); });
@@ -474,6 +484,7 @@ describe('useUserAdmin', () => {
displayName: 'Test', displayName: 'Test',
password: 'pass', password: 'pass',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}); });
}); });
@@ -500,6 +511,7 @@ describe('useUserAdmin', () => {
email: 'user1@example.com', email: 'user1@example.com',
displayName: 'User 1', displayName: 'User 1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z', createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}) })
@@ -508,6 +520,7 @@ describe('useUserAdmin', () => {
email: 'user2@example.com', email: 'user2@example.com',
displayName: 'User 2', displayName: 'User 2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-04T00:00:00Z', createdAt: '2024-01-04T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}); });
@@ -520,12 +533,14 @@ describe('useUserAdmin', () => {
displayName: 'User 1', displayName: 'User 1',
password: 'pass1', password: 'pass1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}, },
{ {
email: 'user2@example.com', email: 'user2@example.com',
displayName: 'User 2', displayName: 'User 2',
password: 'pass2', password: 'pass2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}, },
]; ];
@@ -555,12 +570,14 @@ describe('useUserAdmin', () => {
displayName: 'Success User', displayName: 'Success User',
password: 'pass1', password: 'pass1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}, },
{ {
email: 'fail@example.com', email: 'fail@example.com',
displayName: 'Fail User', displayName: 'Fail User',
password: 'pass2', password: 'pass2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}, },
]; ];

View File

@@ -9,7 +9,7 @@ import {
type SaveFileResponse, type SaveFileResponse,
type UploadFilesResponse, type UploadFilesResponse,
} from './api'; } from './api';
import { UserRole, type User } from './models'; import { UserRole, Theme, type User } from './models';
// Mock user data for testing // Mock user data for testing
const mockUser: User = { const mockUser: User = {
@@ -17,6 +17,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -1,4 +1,4 @@
import { isUser, type User, type UserRole } from './models'; import { isUser, type User, type UserRole, type Theme } from './models';
declare global { declare global {
interface Window { interface Window {
@@ -55,6 +55,7 @@ export interface CreateUserRequest {
displayName: string; displayName: string;
password: string; password: string;
role: UserRole; role: UserRole;
theme?: Theme;
} }
// UpdateUserRequest holds the request fields for updating a user // UpdateUserRequest holds the request fields for updating a user
@@ -63,6 +64,7 @@ export interface UpdateUserRequest {
displayName?: string; displayName?: string;
password?: string; password?: string;
role?: UserRole; role?: UserRole;
theme?: Theme;
} }
export interface LookupResponse { export interface LookupResponse {
@@ -126,6 +128,7 @@ export interface UpdateProfileRequest {
email?: string; email?: string;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
theme?: Theme;
} }
// DeleteAccountRequest represents a user account deletion request // DeleteAccountRequest represents a user account deletion request

View File

@@ -63,6 +63,7 @@ describe('Models Type Guards', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -76,6 +77,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -186,6 +188,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
__proto__: { malicious: true }, __proto__: { malicious: true },
@@ -771,6 +774,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -804,6 +808,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}); });
@@ -852,6 +857,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: longString, email: longString,
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,6 +8,7 @@ export interface User {
email: string; email: string;
displayName?: string; displayName?: string;
role: UserRole; role: UserRole;
theme: Theme;
createdAt: string; createdAt: string;
lastWorkspaceId: number; lastWorkspaceId: number;
} }
@@ -28,6 +29,9 @@ export function isUser(value: unknown): value is User {
: true) && : true) &&
'role' in value && 'role' in value &&
isUserRole((value as User).role) && isUserRole((value as User).role) &&
'theme' in value &&
typeof (value as User).theme === 'string' &&
Object.values(Theme).includes((value as User).theme) &&
'createdAt' in value && 'createdAt' in value &&
typeof (value as User).createdAt === 'string' && typeof (value as User).createdAt === 'string' &&
'lastWorkspaceId' in value && 'lastWorkspaceId' in value &&
@@ -309,6 +313,7 @@ export interface UserProfileSettings {
email?: string; email?: string;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
theme?: Theme;
} }
export interface ProfileSettingsState { export interface ProfileSettingsState {

View File

@@ -118,6 +118,7 @@ func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *C
DisplayName: "Admin", DisplayName: "Admin",
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "dark", // default theme
} }
createdUser, err := database.CreateUser(adminUser) createdUser, err := database.CreateUser(adminUser)
@@ -132,7 +133,8 @@ func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *C
logging.Info("admin user setup completed", logging.Info("admin user setup completed",
"userId", createdUser.ID, "userId", createdUser.ID,
"workspaceId", createdUser.LastWorkspaceID) "workspaceId", createdUser.LastWorkspaceID,
"theme", createdUser.Theme)
return nil return nil
} }

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT, display_name TEXT,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER last_workspace_id INTEGER
); );
@@ -19,7 +20,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT, last_opened_file_path TEXT,
-- Settings fields -- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT FALSE, auto_save BOOLEAN NOT NULL DEFAULT FALSE,
git_enabled BOOLEAN NOT NULL DEFAULT FALSE, git_enabled BOOLEAN NOT NULL DEFAULT FALSE,
git_url TEXT, git_url TEXT,

View File

@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT, display_name TEXT,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER last_workspace_id INTEGER
); );
@@ -18,7 +19,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT, last_opened_file_path TEXT,
-- Settings fields -- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT 0, auto_save BOOLEAN NOT NULL DEFAULT 0,
git_enabled BOOLEAN NOT NULL DEFAULT 0, git_enabled BOOLEAN NOT NULL DEFAULT 0,
git_url TEXT, git_url TEXT,

View File

@@ -29,6 +29,7 @@ func TestSessionOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hash", PasswordHash: "hash",
Role: "editor", Role: "editor",
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)

View File

@@ -156,6 +156,7 @@ func TestStructQueries(t *testing.T) {
DisplayName: "Struct Query Test", DisplayName: "Struct Query Test",
PasswordHash: "hashed_password", PasswordHash: "hashed_password",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
} }
t.Run("InsertStructQuery", func(t *testing.T) { t.Run("InsertStructQuery", func(t *testing.T) {
@@ -243,6 +244,7 @@ func TestStructQueries(t *testing.T) {
DisplayName: "Struct Query Test 2", DisplayName: "Struct Query Test 2",
PasswordHash: "hashed_password2", PasswordHash: "hashed_password2",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
} }
createdUser2, err := database.CreateUser(secondUser) createdUser2, err := database.CreateUser(secondUser)
@@ -437,6 +439,7 @@ func TestEncryptedFields(t *testing.T) {
DisplayName: "Encryption Test", DisplayName: "Encryption Test",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("Failed to create test user: %v", err) t.Fatalf("Failed to create test user: %v", err)

View File

@@ -31,12 +31,14 @@ func TestSystemOperations(t *testing.T) {
DisplayName: "User 1", DisplayName: "User 1",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: "editor", Role: "editor",
Theme: "dark",
}, },
{ {
Email: "user2@test.com", Email: "user2@test.com",
DisplayName: "User 2", DisplayName: "User 2",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: "viewer", Role: "viewer",
Theme: "light",
}, },
} }

View File

@@ -34,6 +34,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hashed_password", PasswordHash: "hashed_password",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
wantErr: false, wantErr: false,
}, },
@@ -44,6 +45,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Another User", DisplayName: "Another User",
PasswordHash: "different_hash", PasswordHash: "different_hash",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
}, },
wantErr: true, wantErr: true,
errContains: "UNIQUE constraint failed", errContains: "UNIQUE constraint failed",
@@ -108,6 +110,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Get By ID User", DisplayName: "Get By ID User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -159,6 +162,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Get By Email User", DisplayName: "Get By Email User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -210,6 +214,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Original Name", DisplayName: "Original Name",
PasswordHash: "original_hash", PasswordHash: "original_hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -249,12 +254,14 @@ func TestUserOperations(t *testing.T) {
DisplayName: "User One", DisplayName: "User One",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
{ {
Email: "user2@example.com", Email: "user2@example.com",
DisplayName: "User Two", DisplayName: "User Two",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
}, },
} }
@@ -305,6 +312,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Workspace User", DisplayName: "Workspace User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -343,6 +351,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Delete User", DisplayName: "Delete User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -377,18 +386,21 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Admin One", DisplayName: "Admin One",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "dark",
}, },
{ {
Email: "admin2@example.com", Email: "admin2@example.com",
DisplayName: "Admin Two", DisplayName: "Admin Two",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "light",
}, },
{ {
Email: "editor@example.com", Email: "editor@example.com",
DisplayName: "Editor", DisplayName: "Editor",
PasswordHash: "hash3", PasswordHash: "hash3",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
} }

View File

@@ -26,6 +26,7 @@ func TestWorkspaceOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)

View File

@@ -22,6 +22,7 @@ type CreateUserRequest struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Password string `json:"password"` Password string `json:"password"`
Role models.UserRole `json:"role"` Role models.UserRole `json:"role"`
Theme string `json:"theme,omitempty"`
} }
// UpdateUserRequest holds the request fields for updating a user // UpdateUserRequest holds the request fields for updating a user
@@ -30,6 +31,7 @@ type UpdateUserRequest struct {
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Role models.UserRole `json:"role,omitempty"` Role models.UserRole `json:"role,omitempty"`
Theme string `json:"theme,omitempty"`
} }
// WorkspaceStats holds workspace statistics // WorkspaceStats holds workspace statistics
@@ -164,11 +166,24 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
return return
} }
// Handle theme with validation and default
theme := req.Theme
if theme == "" {
theme = "dark" // Default theme
} else if theme != "light" && theme != "dark" {
// Invalid theme, fallback to dark
log.Debug("invalid theme value in user creation, falling back to dark",
"theme", theme,
)
theme = "dark"
}
user := &models.User{ user := &models.User{
Email: req.Email, Email: req.Email,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: req.Role, Role: req.Role,
Theme: theme,
} }
insertedUser, err := h.DB.CreateUser(user) insertedUser, err := h.DB.CreateUser(user)
@@ -196,6 +211,7 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
"newUserID", insertedUser.ID, "newUserID", insertedUser.ID,
"email", insertedUser.Email, "email", insertedUser.Email,
"role", insertedUser.Role, "role", insertedUser.Role,
"theme", insertedUser.Theme,
) )
respondJSON(w, insertedUser) respondJSON(w, insertedUser)
} }
@@ -322,6 +338,17 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc {
user.Role = req.Role user.Role = req.Role
updates["role"] = req.Role updates["role"] = req.Role
} }
if req.Theme != "" {
// Validate theme value, fallback to "dark" if invalid
if req.Theme != "light" && req.Theme != "dark" {
log.Debug("invalid theme value, falling back to dark",
"theme", req.Theme,
)
req.Theme = "dark"
}
user.Theme = req.Theme
updates["theme"] = req.Theme
}
if req.Password != "" { if req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {

View File

@@ -213,6 +213,7 @@ func (h *testHarness) createTestUser(t *testing.T, email, password string, role
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: role, Role: role,
Theme: "dark",
} }
user, err = h.DB.CreateUser(user) user, err = h.DB.CreateUser(user)

View File

@@ -16,6 +16,7 @@ type UpdateProfileRequest struct {
Email string `json:"email"` Email string `json:"email"`
CurrentPassword string `json:"currentPassword"` CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
Theme string `json:"theme"`
} }
// DeleteAccountRequest represents a user account deletion request // DeleteAccountRequest represents a user account deletion request
@@ -149,6 +150,19 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
updates["displayNameChanged"] = true updates["displayNameChanged"] = true
} }
// Update theme if provided
if req.Theme != "" {
// Validate theme value, fallback to "dark" if invalid
if req.Theme != "light" && req.Theme != "dark" {
log.Debug("invalid theme value, falling back to dark",
"theme", req.Theme,
)
req.Theme = "dark"
}
user.Theme = req.Theme
updates["themeChanged"] = true
}
// Update user in database // Update user in database
if err := h.DB.UpdateUser(user); err != nil { if err := h.DB.UpdateUser(user); err != nil {
log.Error("failed to update user in database", log.Error("failed to update user in database",

View File

@@ -104,7 +104,21 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
return return
} }
// Get user to access their theme preference
user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil {
log.Error("failed to fetch user from database",
"error", err.Error(),
)
respondError(w, "Failed to get user", http.StatusInternalServerError)
return
}
workspace.UserID = ctx.UserID workspace.UserID = ctx.UserID
// Use user's theme as default if not provided
if workspace.Theme == "" {
workspace.Theme = user.Theme
}
if err := h.DB.CreateWorkspace(&workspace); err != nil { if err := h.DB.CreateWorkspace(&workspace); err != nil {
log.Error("failed to create workspace in database", log.Error("failed to create workspace in database",
"error", err.Error(), "error", err.Error(),
@@ -145,6 +159,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
log.Info("workspace created", log.Info("workspace created",
"workspaceID", workspace.ID, "workspaceID", workspace.ID,
"workspaceName", workspace.Name, "workspaceName", workspace.Name,
"theme", workspace.Theme,
"gitEnabled", workspace.GitEnabled, "gitEnabled", workspace.GitEnabled,
) )
respondJSON(w, workspace) respondJSON(w, workspace)

View File

@@ -25,6 +25,7 @@ type User struct {
DisplayName string `json:"displayName" db:"display_name"` DisplayName string `json:"displayName" db:"display_name"`
PasswordHash string `json:"-" db:"password_hash"` PasswordHash string `json:"-" db:"password_hash"`
Role UserRole `json:"role" db:"role" validate:"required,oneof=admin editor viewer"` Role UserRole `json:"role" db:"role" validate:"required,oneof=admin editor viewer"`
Theme string `json:"theme" db:"theme" validate:"required,oneof=light dark"`
CreatedAt time.Time `json:"createdAt" db:"created_at,default"` CreatedAt time.Time `json:"createdAt" db:"created_at,default"`
LastWorkspaceID int `json:"lastWorkspaceId" db:"last_workspace_id"` LastWorkspaceID int `json:"lastWorkspaceId" db:"last_workspace_id"`
} }

View File

@@ -13,7 +13,7 @@ type Workspace struct {
LastOpenedFilePath string `json:"lastOpenedFilePath" db:"last_opened_file_path"` LastOpenedFilePath string `json:"lastOpenedFilePath" db:"last_opened_file_path"`
// Integrated settings // Integrated settings
Theme string `json:"theme" db:"theme" validate:"oneof=light dark"` Theme string `json:"theme" db:"theme" validate:"required,oneof=light dark"`
AutoSave bool `json:"autoSave" db:"auto_save"` AutoSave bool `json:"autoSave" db:"auto_save"`
ShowHiddenFiles bool `json:"showHiddenFiles" db:"show_hidden_files"` ShowHiddenFiles bool `json:"showHiddenFiles" db:"show_hidden_files"`
GitEnabled bool `json:"gitEnabled" db:"git_enabled"` GitEnabled bool `json:"gitEnabled" db:"git_enabled"`
@@ -40,7 +40,7 @@ func (w *Workspace) ValidateGitSettings() error {
func (w *Workspace) SetDefaultSettings() { func (w *Workspace) SetDefaultSettings() {
if w.Theme == "" { if w.Theme == "" {
w.Theme = "light" w.Theme = "dark"
} }
w.AutoSave = w.AutoSave || false w.AutoSave = w.AutoSave || false