diff --git a/.gitignore b/.gitignore index 1c632f7..d17cc3b 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,6 @@ go.work.sum main *.db data + +# Feature specifications +spec.md \ No newline at end of file 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.test.tsx b/app/src/components/settings/account/ProfileSettings.test.tsx index 124022d..4aef72b 100644 --- a/app/src/components/settings/account/ProfileSettings.test.tsx +++ b/app/src/components/settings/account/ProfileSettings.test.tsx @@ -4,6 +4,25 @@ import React from 'react'; import { MantineProvider } from '@mantine/core'; import ProfileSettings from './ProfileSettings'; 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 const TestWrapper = ({ children }: { children: React.ReactNode }) => ( diff --git a/app/src/components/settings/account/ProfileSettings.tsx b/app/src/components/settings/account/ProfileSettings.tsx index 9c85adc..641410f 100644 --- a/app/src/components/settings/account/ProfileSettings.tsx +++ b/app/src/components/settings/account/ProfileSettings.tsx @@ -1,36 +1,66 @@ 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 { 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 Dark Mode + + Sets the default theme for new workspaces + +
+ +
+
+
+
+ ); +}; 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..4b83318 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, }; @@ -186,6 +188,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, __proto__: { malicious: true }, @@ -771,6 +774,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, }; @@ -804,6 +808,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, }); @@ -852,6 +857,7 @@ describe('Models Type Guards', () => { id: 1, email: longString, 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..872537e 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,9 @@ export function isUser(value: unknown): value is User { : true) && 'role' in value && 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 && typeof (value as User).createdAt === 'string' && 'lastWorkspaceId' in value && @@ -309,6 +313,7 @@ export interface UserProfileSettings { email?: string; currentPassword?: string; newPassword?: string; + theme?: Theme; } export interface ProfileSettingsState { diff --git a/server/internal/app/init.go b/server/internal/app/init.go index 1d0bc08..263459e 100644 --- a/server/internal/app/init.go +++ b/server/internal/app/init.go @@ -118,6 +118,7 @@ func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *C DisplayName: "Admin", PasswordHash: string(hashedPassword), Role: models.RoleAdmin, + Theme: "dark", // default theme } 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", "userId", createdUser.ID, - "workspaceId", createdUser.LastWorkspaceID) + "workspaceId", createdUser.LastWorkspaceID, + "theme", createdUser.Theme) return nil } diff --git a/server/internal/db/migrations/postgres/001_initial_schema.up.sql b/server/internal/db/migrations/postgres/001_initial_schema.up.sql index 3d08d60..f0c8e63 100644 --- a/server/internal/db/migrations/postgres/001_initial_schema.up.sql +++ b/server/internal/db/migrations/postgres/001_initial_schema.up.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS users ( display_name TEXT, password_hash TEXT NOT NULL, 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, last_workspace_id INTEGER ); @@ -19,7 +20,7 @@ CREATE TABLE IF NOT EXISTS workspaces ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_opened_file_path TEXT, -- 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, git_enabled BOOLEAN NOT NULL DEFAULT FALSE, git_url TEXT, diff --git a/server/internal/db/migrations/sqlite/001_initial_schema.up.sql b/server/internal/db/migrations/sqlite/001_initial_schema.up.sql index a718161..d33757b 100644 --- a/server/internal/db/migrations/sqlite/001_initial_schema.up.sql +++ b/server/internal/db/migrations/sqlite/001_initial_schema.up.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS users ( display_name TEXT, password_hash TEXT NOT NULL, 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, last_workspace_id INTEGER ); @@ -18,7 +19,7 @@ CREATE TABLE IF NOT EXISTS workspaces ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_opened_file_path TEXT, -- 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, git_enabled BOOLEAN NOT NULL DEFAULT 0, git_url TEXT, diff --git a/server/internal/db/sessions_test.go b/server/internal/db/sessions_test.go index 208d1ae..6fd52c0 100644 --- a/server/internal/db/sessions_test.go +++ b/server/internal/db/sessions_test.go @@ -29,6 +29,7 @@ func TestSessionOperations(t *testing.T) { DisplayName: "Test User", PasswordHash: "hash", Role: "editor", + Theme: "dark", }) if err != nil { t.Fatalf("failed to create test user: %v", err) diff --git a/server/internal/db/struct_query_test.go b/server/internal/db/struct_query_test.go index 71e2c64..d4d4be8 100644 --- a/server/internal/db/struct_query_test.go +++ b/server/internal/db/struct_query_test.go @@ -156,6 +156,7 @@ func TestStructQueries(t *testing.T) { DisplayName: "Struct Query Test", PasswordHash: "hashed_password", Role: models.RoleEditor, + Theme: "dark", } t.Run("InsertStructQuery", func(t *testing.T) { @@ -243,6 +244,7 @@ func TestStructQueries(t *testing.T) { DisplayName: "Struct Query Test 2", PasswordHash: "hashed_password2", Role: models.RoleViewer, + Theme: "light", } createdUser2, err := database.CreateUser(secondUser) @@ -437,6 +439,7 @@ func TestEncryptedFields(t *testing.T) { DisplayName: "Encryption Test", PasswordHash: "hash", Role: models.RoleEditor, + Theme: "dark", }) if err != nil { t.Fatalf("Failed to create test user: %v", err) diff --git a/server/internal/db/system_test.go b/server/internal/db/system_test.go index 38055f7..71a2fe2 100644 --- a/server/internal/db/system_test.go +++ b/server/internal/db/system_test.go @@ -31,12 +31,14 @@ func TestSystemOperations(t *testing.T) { DisplayName: "User 1", PasswordHash: "hash1", Role: "editor", + Theme: "dark", }, { Email: "user2@test.com", DisplayName: "User 2", PasswordHash: "hash2", Role: "viewer", + Theme: "light", }, } diff --git a/server/internal/db/users_test.go b/server/internal/db/users_test.go index 8dfe4f0..cd11fb7 100644 --- a/server/internal/db/users_test.go +++ b/server/internal/db/users_test.go @@ -34,6 +34,7 @@ func TestUserOperations(t *testing.T) { DisplayName: "Test User", PasswordHash: "hashed_password", Role: models.RoleEditor, + Theme: "dark", }, wantErr: false, }, @@ -44,6 +45,7 @@ func TestUserOperations(t *testing.T) { DisplayName: "Another User", PasswordHash: "different_hash", Role: models.RoleViewer, + Theme: "light", }, wantErr: true, errContains: "UNIQUE constraint failed", @@ -108,6 +110,7 @@ func TestUserOperations(t *testing.T) { DisplayName: "Get By ID User", PasswordHash: "hash", Role: models.RoleEditor, + Theme: "dark", }) if err != nil { t.Fatalf("failed to create test user: %v", err) @@ -159,6 +162,7 @@ func TestUserOperations(t *testing.T) { DisplayName: "Get By Email User", PasswordHash: "hash", Role: models.RoleEditor, + Theme: "dark", }) if err != nil { t.Fatalf("failed to create test user: %v", err) @@ -210,6 +214,7 @@ func TestUserOperations(t *testing.T) { DisplayName: "Original Name", PasswordHash: "original_hash", Role: models.RoleEditor, + Theme: "dark", }) if err != nil { t.Fatalf("failed to create test user: %v", err) @@ -249,12 +254,14 @@ func TestUserOperations(t *testing.T) { DisplayName: "User One", PasswordHash: "hash1", Role: models.RoleEditor, + Theme: "dark", }, { Email: "user2@example.com", DisplayName: "User Two", PasswordHash: "hash2", Role: models.RoleViewer, + Theme: "light", }, } @@ -305,6 +312,7 @@ func TestUserOperations(t *testing.T) { DisplayName: "Workspace User", PasswordHash: "hash", Role: models.RoleEditor, + Theme: "dark", }) if err != nil { t.Fatalf("failed to create test user: %v", err) @@ -343,6 +351,7 @@ func TestUserOperations(t *testing.T) { DisplayName: "Delete User", PasswordHash: "hash", Role: models.RoleEditor, + Theme: "dark", }) if err != nil { t.Fatalf("failed to create test user: %v", err) @@ -377,18 +386,21 @@ func TestUserOperations(t *testing.T) { DisplayName: "Admin One", PasswordHash: "hash1", Role: models.RoleAdmin, + Theme: "dark", }, { Email: "admin2@example.com", DisplayName: "Admin Two", PasswordHash: "hash2", Role: models.RoleAdmin, + Theme: "light", }, { Email: "editor@example.com", DisplayName: "Editor", PasswordHash: "hash3", Role: models.RoleEditor, + Theme: "dark", }, } diff --git a/server/internal/db/workspaces_test.go b/server/internal/db/workspaces_test.go index fda1c98..4b58aaf 100644 --- a/server/internal/db/workspaces_test.go +++ b/server/internal/db/workspaces_test.go @@ -26,6 +26,7 @@ func TestWorkspaceOperations(t *testing.T) { DisplayName: "Test User", PasswordHash: "hash", Role: models.RoleEditor, + Theme: "dark", }) if err != nil { t.Fatalf("failed to create test user: %v", err) diff --git a/server/internal/handlers/admin_handlers.go b/server/internal/handlers/admin_handlers.go index 47c9d04..4a727ec 100644 --- a/server/internal/handlers/admin_handlers.go +++ b/server/internal/handlers/admin_handlers.go @@ -22,6 +22,7 @@ type CreateUserRequest struct { DisplayName string `json:"displayName"` Password string `json:"password"` Role models.UserRole `json:"role"` + Theme string `json:"theme,omitempty"` } // UpdateUserRequest holds the request fields for updating a user @@ -30,6 +31,7 @@ type UpdateUserRequest struct { DisplayName string `json:"displayName,omitempty"` Password string `json:"password,omitempty"` Role models.UserRole `json:"role,omitempty"` + Theme string `json:"theme,omitempty"` } // WorkspaceStats holds workspace statistics @@ -164,11 +166,24 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { 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{ Email: req.Email, DisplayName: req.DisplayName, PasswordHash: string(hashedPassword), Role: req.Role, + Theme: theme, } insertedUser, err := h.DB.CreateUser(user) @@ -196,6 +211,7 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc { "newUserID", insertedUser.ID, "email", insertedUser.Email, "role", insertedUser.Role, + "theme", insertedUser.Theme, ) respondJSON(w, insertedUser) } @@ -322,6 +338,17 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc { user.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 != "" { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { diff --git a/server/internal/handlers/integration_test.go b/server/internal/handlers/integration_test.go index 64efca0..1ccf1a4 100644 --- a/server/internal/handlers/integration_test.go +++ b/server/internal/handlers/integration_test.go @@ -213,6 +213,7 @@ func (h *testHarness) createTestUser(t *testing.T, email, password string, role DisplayName: "Test User", PasswordHash: string(hashedPassword), Role: role, + Theme: "dark", } user, err = h.DB.CreateUser(user) diff --git a/server/internal/handlers/user_handlers.go b/server/internal/handlers/user_handlers.go index 9984d80..6db0a1c 100644 --- a/server/internal/handlers/user_handlers.go +++ b/server/internal/handlers/user_handlers.go @@ -16,6 +16,7 @@ type UpdateProfileRequest struct { Email string `json:"email"` CurrentPassword string `json:"currentPassword"` NewPassword string `json:"newPassword"` + Theme string `json:"theme"` } // DeleteAccountRequest represents a user account deletion request @@ -149,6 +150,19 @@ func (h *Handler) UpdateProfile() http.HandlerFunc { 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 if err := h.DB.UpdateUser(user); err != nil { log.Error("failed to update user in database", diff --git a/server/internal/handlers/workspace_handlers.go b/server/internal/handlers/workspace_handlers.go index 788b76f..6643f4d 100644 --- a/server/internal/handlers/workspace_handlers.go +++ b/server/internal/handlers/workspace_handlers.go @@ -87,7 +87,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { "clientIP", r.RemoteAddr, ) - var workspace models.Workspace + var workspace models.Workspace if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { log.Debug("invalid request body received", "error", err.Error(), @@ -104,7 +104,21 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { 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 + // Use user's theme as default if not provided + if workspace.Theme == "" { + workspace.Theme = user.Theme + } if err := h.DB.CreateWorkspace(&workspace); err != nil { log.Error("failed to create workspace in database", "error", err.Error(), @@ -145,6 +159,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc { log.Info("workspace created", "workspaceID", workspace.ID, "workspaceName", workspace.Name, + "theme", workspace.Theme, "gitEnabled", workspace.GitEnabled, ) respondJSON(w, workspace) diff --git a/server/internal/models/user.go b/server/internal/models/user.go index 11d6903..3334e71 100644 --- a/server/internal/models/user.go +++ b/server/internal/models/user.go @@ -25,6 +25,7 @@ type User struct { DisplayName string `json:"displayName" db:"display_name"` PasswordHash string `json:"-" db:"password_hash"` 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"` LastWorkspaceID int `json:"lastWorkspaceId" db:"last_workspace_id"` } diff --git a/server/internal/models/workspace.go b/server/internal/models/workspace.go index 083a14e..fa62927 100644 --- a/server/internal/models/workspace.go +++ b/server/internal/models/workspace.go @@ -13,7 +13,7 @@ type Workspace struct { LastOpenedFilePath string `json:"lastOpenedFilePath" db:"last_opened_file_path"` // 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"` ShowHiddenFiles bool `json:"showHiddenFiles" db:"show_hidden_files"` GitEnabled bool `json:"gitEnabled" db:"git_enabled"` @@ -40,7 +40,7 @@ func (w *Workspace) ValidateGitSettings() error { func (w *Workspace) SetDefaultSettings() { if w.Theme == "" { - w.Theme = "light" + w.Theme = "dark" } w.AutoSave = w.AutoSave || false