2 Commits

Author SHA1 Message Date
efdc42cbd7 Add theme support to user settings and related components 2025-10-28 23:14:45 +01:00
3926954b74 Add theme to user preferences 2025-10-28 20:05:12 +01:00
25 changed files with 228 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

@@ -1,16 +1,31 @@
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 { IconMoon, IconSun } from '@tabler/icons-react';
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 +44,27 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
placeholder="Enter email" placeholder="Enter email"
data-testid="email-input" data-testid="email-input"
/> />
<Group justify="space-between" align="flex-start">
<div>
<Text size="sm" fw={500}>
Default Theme
</Text>
<Text size="xs" c="dimmed">
Sets the default theme for new workspaces
</Text>
</div>
<Switch
checked={currentTheme === Theme.Dark}
onChange={handleThemeToggle}
size="lg"
onLabel={<IconMoon size={16} />}
offLabel={<IconSun size={16} />}
data-testid="theme-toggle"
/>
</Group>
</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,
}; };

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,8 @@ 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 &&
(value as User).theme in 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 +312,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

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

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