From c3b1fe8de1ea0e4481bdd7f19e22b66e44402126 Mon Sep 17 00:00:00 2001 From: Elijah Date: Fri, 22 May 2026 18:22:21 -0700 Subject: [PATCH] Implement UI enhancements, change password settings, theme toggle, and custom grid size slider --- backend/handlers/auth.go | 40 +++++++++ backend/main.go | 3 + frontend/src/components/FileExplorer.tsx | 91 +++++++++++++++---- frontend/src/components/SettingsPage.tsx | 110 ++++++++++++++++++++--- frontend/src/lib/api.ts | 8 ++ 5 files changed, 226 insertions(+), 26 deletions(-) diff --git a/backend/handlers/auth.go b/backend/handlers/auth.go index 3521c70..6b236a3 100644 --- a/backend/handlers/auth.go +++ b/backend/handlers/auth.go @@ -245,6 +245,46 @@ func (h *AuthHandler) Disable2FA(c *fiber.Ctx) error { return c.JSON(fiber.Map{"message": "2FA disabled successfully"}) } +// ChangePassword allows an authenticated user to change their password. +// PUT /api/auth/password +func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error { + username := c.Locals("username").(string) + + var req struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + if len(req.NewPassword) < 8 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "new password must be at least 8 characters"}) + } + + var currentHash string + if err := h.DB.QueryRow("SELECT password_hash FROM users WHERE username = ?", username).Scan(¤tHash); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "database error"}) + } + + if !verifyPassword(req.OldPassword, currentHash) { + h.Guard.RecordFailure(c.IP()) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "incorrect current password"}) + } + + newHash, err := hashPassword(req.NewPassword) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash new password"}) + } + + if _, err := h.DB.Exec("UPDATE users SET password_hash = ? WHERE username = ?", newHash, username); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to update password"}) + } + + h.DB.AddAuditLog("password_changed", fmt.Sprintf("User '%s' changed their password", username), c.IP()) + return c.JSON(fiber.Map{"message": "password changed successfully"}) +} + // --- Password Hashing with Argon2 --- func hashPassword(password string) (string, error) { diff --git a/backend/main.go b/backend/main.go index cd345ca..79cb467 100644 --- a/backend/main.go +++ b/backend/main.go @@ -128,6 +128,9 @@ func main() { // Token refresh protected.Post("/auth/refresh", authHandler.Refresh) + // Password management + protected.Put("/auth/password", authHandler.ChangePassword) + // 2FA management protected.Post("/auth/2fa/enable", authHandler.Enable2FA) protected.Post("/auth/2fa/confirm", authHandler.Confirm2FA) diff --git a/frontend/src/components/FileExplorer.tsx b/frontend/src/components/FileExplorer.tsx index a35ea6d..f5847e5 100644 --- a/frontend/src/components/FileExplorer.tsx +++ b/frontend/src/components/FileExplorer.tsx @@ -6,7 +6,8 @@ import { Folder, File, Search, Pin, Trash2, HardDrive, Settings, ChevronRight, Grid3X3, List, Plus, Upload, LogOut, MoreVertical, Star, Download, Pencil, Copy, Move, - FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon + FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon, + Sun, Moon } from 'lucide-react'; import api from '@/lib/api'; import FilePreview from './FilePreview'; @@ -93,11 +94,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const searchTimeoutRef = useRef(undefined); const fileInputRef = useRef(null); + const [gridSize, setGridSize] = useState('200'); + const [theme, setTheme] = useState('dark'); + useEffect(() => { api.getSettings().then((data) => { - const theme = data?.settings?.theme || data?.theme; - if (theme) { - if (theme === 'light') { + const s = data?.settings || data; + if (s) { + if (s.grid_size) setGridSize(s.grid_size); + const fetchedTheme = s.theme || 'dark'; + setTheme(fetchedTheme); + if (fetchedTheme === 'light') { document.documentElement.setAttribute('data-theme', 'light'); document.documentElement.classList.remove('dark'); document.documentElement.style.setProperty('color-scheme', 'light'); @@ -106,11 +113,33 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { document.documentElement.classList.add('dark'); document.documentElement.style.setProperty('color-scheme', 'dark'); } - localStorage.setItem('theme', theme); + localStorage.setItem('theme', fetchedTheme); } }).catch(console.error); }, []); + async function toggleTheme() { + const newTheme = theme === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + if (newTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + document.documentElement.classList.remove('dark'); + document.documentElement.style.setProperty('color-scheme', 'light'); + } else { + document.documentElement.setAttribute('data-theme', 'dark'); + document.documentElement.classList.add('dark'); + document.documentElement.style.setProperty('color-scheme', 'dark'); + } + localStorage.setItem('theme', newTheme); + try { + const data = await api.getSettings(); + const s = data?.settings || data || {}; + await api.updateSettings({ ...s, theme: newTheme }); + } catch (err) { + console.error('Failed to save theme', err); + } + } + // Load files on path change const loadFiles = useCallback(async (path?: string) => { const targetPath = path ?? currentPath; @@ -438,9 +467,18 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { } function getFileIcon(file: FileItem, large = false) { - const sizeClass = large ? "w-12 h-12" : "w-5 h-5"; - if (file.is_dir) return ; + if (file.is_dir) { + if (large) { + return ( + + + + ); + } + return ; + } + const sizeClass = large ? "w-20 h-20" : "w-5 h-5"; const ext = file.name.split('.').pop()?.toLowerCase() || ''; const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext) || file.mime_type?.startsWith('image/'); const isVideo = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'ogg'].includes(ext) || file.mime_type?.startsWith('video/'); @@ -448,7 +486,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { if (ext === 'pdf') { return ( -
+
PDF
); @@ -457,16 +495,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { const fallbackIcon = (() => { if (isImage) { return ( -
- +
+
); } if (isVideo) { return ( -
- +
+
); } @@ -676,6 +714,15 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) { {(activeSection === 'files' || activeSection === 'pinned' || activeSection === 'trash') && ( <> + + + )} {moveFolders.length === 0 ? (
No folders here diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx index cc9ede1..b09bf9c 100644 --- a/frontend/src/components/SettingsPage.tsx +++ b/frontend/src/components/SettingsPage.tsx @@ -7,6 +7,7 @@ import api from '@/lib/api'; interface SettingsData { theme: string; + grid_size: string; thumbnail_images: string; thumbnail_videos: string; max_versions: string; @@ -20,6 +21,13 @@ export default function SettingsPage() { const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [oldPassword, setOldPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [pwdSaving, setPwdSaving] = useState(false); + const [pwdError, setPwdError] = useState(null); + const [pwdSuccess, setPwdSuccess] = useState(false); + useEffect(() => { loadSettings(); }, []); @@ -43,6 +51,7 @@ export default function SettingsPage() { const s = data.settings || data; const loaded = { theme: s.theme || 'dark', + grid_size: s.grid_size || '200', thumbnail_images: s.thumbnail_images || 'true', thumbnail_videos: s.thumbnail_videos || 'true', max_versions: s.max_versions || '1', @@ -76,6 +85,33 @@ export default function SettingsPage() { } } + async function handlePasswordChange(e: React.FormEvent) { + e.preventDefault(); + if (newPassword !== confirmPassword) { + setPwdError("New passwords do not match."); + return; + } + if (newPassword.length < 8) { + setPwdError("Password must be at least 8 characters."); + return; + } + setPwdSaving(true); + setPwdError(null); + setPwdSuccess(false); + try { + await api.changePassword(oldPassword, newPassword); + setPwdSuccess(true); + setOldPassword(''); + setNewPassword(''); + setConfirmPassword(''); + setTimeout(() => setPwdSuccess(false), 3000); + } catch (err: any) { + setPwdError(err.message || 'Failed to change password'); + } finally { + setPwdSaving(false); + } + } + if (loading) { return (
@@ -94,20 +130,74 @@ export default function SettingsPage() { content: (
- - + + setSettings({ ...settings, grid_size: e.target.value })} + className="w-full max-w-xs accent-[var(--color-accent)]" + />
) }, + { + id: 'security', + title: 'Security', + icon: , + content: ( +
+
+ + setOldPassword(e.target.value)} + required + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }} + /> +
+
+ + setNewPassword(e.target.value)} + required + minLength={8} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + className="w-full px-3 py-2 rounded-lg text-sm outline-none" + style={{ backgroundColor: 'var(--color-bg-primary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }} + /> +
+ {pwdError &&

{pwdError}

} + {pwdSuccess &&

Password changed successfully!

} + +
+ ) + }, { id: 'thumbnails', title: 'Media & Thumbnails', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0b1b841..e901b05 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -137,6 +137,14 @@ class ApiClient { this.clearTokens(); } + async changePassword(oldPassword: string, newPassword: string) { + const res = await this.request('/api/auth/password', { + method: 'PUT', + body: { old_password: oldPassword, new_password: newPassword }, + }); + return res.json(); + } + // --- Files --- async listDirectory(path: string = '.') { const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`);