From bd5c864e8498af139b0993c90b25a1641992ac70 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 28 Dec 2025 00:43:45 +0100 Subject: [PATCH] feat: add user avatar functionality and update related routes - Implemented avatar upload and deletion in the Auth context. - Updated UserProfile component to handle avatar display and actions. - Modified backend routes to return anonymous user ID when auth is disabled. - Added avatar_url column to users table in the database. - Enhanced UI for user menu and profile modal to support avatar display. - Updated translations for new avatar-related strings. - Improved stock status calculation for medications in the planner. --- .../db/migrations/0012_add_user_avatar.sql | 2 + backend/src/db/migrations/meta/_journal.json | 3 +- backend/src/db/schema.ts | 1 + backend/src/plugins/auth.ts | 43 ++- backend/src/routes/auth.ts | 79 ++++ backend/src/routes/doses.ts | 16 +- backend/src/routes/medications.ts | 24 +- backend/src/routes/settings.ts | 14 +- backend/src/routes/share.ts | 14 +- frontend/src/App.tsx | 77 +++- frontend/src/components/Auth.tsx | 214 ++++++++--- frontend/src/i18n/de.json | 10 +- frontend/src/i18n/en.json | 10 +- frontend/src/styles.css | 351 +++++++++++++++++- 14 files changed, 745 insertions(+), 113 deletions(-) create mode 100644 backend/src/db/migrations/0012_add_user_avatar.sql diff --git a/backend/src/db/migrations/0012_add_user_avatar.sql b/backend/src/db/migrations/0012_add_user_avatar.sql new file mode 100644 index 0000000..e94b81a --- /dev/null +++ b/backend/src/db/migrations/0012_add_user_avatar.sql @@ -0,0 +1,2 @@ +-- Add avatar URL column to users table +ALTER TABLE users ADD COLUMN avatar_url TEXT; diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index aa39f2f..86e3924 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -11,6 +11,7 @@ { "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false }, { "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false }, { "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false }, - { "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false } + { "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false }, + { "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 3434367..28ce20a 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -8,6 +8,7 @@ export const users = sqliteTable("users", { id: integer("id").primaryKey({ autoIncrement: true }), username: text("username", { length: 100 }).notNull().unique(), passwordHash: text("password_hash", { length: 255 }), + avatarUrl: text("avatar_url", { length: 255 }), authProvider: text("auth_provider", { length: 50 }).notNull().default("local"), isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), lastLoginAt: integer("last_login_at", { mode: "timestamp" }), diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts index cc81972..17276ce 100644 --- a/backend/src/plugins/auth.ts +++ b/backend/src/plugins/auth.ts @@ -2,7 +2,45 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; import { env } from "./env.js"; import { db } from "../db/client.js"; import { users } from "../db/schema.js"; -import { sql, count } from "drizzle-orm"; +import { sql, count, eq } from "drizzle-orm"; + +// ============================================================================= +// Anonymous User - Used when AUTH_ENABLED=false +// Uses a fixed high ID (999999999) to never collide with regular users +// ============================================================================= +const ANONYMOUS_USER_ID = 999999999; +const ANONYMOUS_USERNAME = "__anonymous__"; +let anonymousUserVerified = false; + +/** + * Get or create the anonymous user for no-auth mode. + * Uses a fixed ID (999999999) that will never collide with auto-increment IDs. + */ +export async function getAnonymousUserId(): Promise { + // Return cached if already verified + if (anonymousUserVerified) { + return ANONYMOUS_USER_ID; + } + + // Check if anonymous user exists + const [existing] = await db.select().from(users).where(eq(users.id, ANONYMOUS_USER_ID)); + + if (existing) { + anonymousUserVerified = true; + return ANONYMOUS_USER_ID; + } + + // Create anonymous user with fixed ID (SQLite allows explicit ID) + await db.run(sql` + INSERT INTO users (id, username, password_hash, auth_provider, is_active, created_at, updated_at) + VALUES (${ANONYMOUS_USER_ID}, ${ANONYMOUS_USERNAME}, NULL, 'anonymous', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); + + anonymousUserVerified = true; + console.log(`Created anonymous user with fixed ID ${ANONYMOUS_USER_ID} for no-auth mode`); + + return ANONYMOUS_USER_ID; +} // ============================================================================= // Auth State - Computed at runtime @@ -16,7 +54,8 @@ export interface AuthState { } export async function getAuthState(): Promise { - const [result] = await db.select({ count: count() }).from(users); + // Count only real users (not the anonymous user with fixed ID) + const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`); const hasUsers = result.count > 0; return { diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 148ff55..64c40d3 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -329,6 +329,7 @@ export async function authRoutes(app: FastifyInstance) { return { id: user.id, username: user.username, + avatarUrl: user.avatarUrl, authProvider: user.authProvider, createdAt: user.createdAt, lastLoginAt: user.lastLoginAt, @@ -385,4 +386,82 @@ export async function authRoutes(app: FastifyInstance) { return { ok: true, message: "Profile updated" }; }); + + // --------------------------------------------------------------------------- + // POST /auth/avatar - Upload user avatar + // --------------------------------------------------------------------------- + app.post("/auth/avatar", { preHandler: requireAuth }, async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } + + const data = await request.file(); + if (!data) { + return reply.status(400).send({ error: "No file uploaded" }); + } + + // Validate file type + const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"]; + if (!allowedTypes.includes(data.mimetype)) { + return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" }); + } + + // Generate unique filename + const ext = data.filename.split(".").pop() || "jpg"; + const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`; + + // Save file + const fs = await import("fs/promises"); + const path = await import("path"); + const imagesDir = path.join(process.cwd(), "data", "images"); + await fs.mkdir(imagesDir, { recursive: true }); + + const buffer = await data.toBuffer(); + await fs.writeFile(path.join(imagesDir, filename), buffer); + + // Delete old avatar if exists + const [user] = await db.select().from(users).where(eq(users.id, authUser.id)); + if (user?.avatarUrl) { + try { + await fs.unlink(path.join(imagesDir, user.avatarUrl)); + } catch { + // Ignore if file doesn't exist + } + } + + // Update user + await db.update(users).set({ avatarUrl: filename, updatedAt: new Date() }).where(eq(users.id, authUser.id)); + + return { ok: true, avatarUrl: filename }; + }); + + // --------------------------------------------------------------------------- + // DELETE /auth/avatar - Delete user avatar + // --------------------------------------------------------------------------- + app.delete("/auth/avatar", { preHandler: requireAuth }, async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } + + const [user] = await db.select().from(users).where(eq(users.id, authUser.id)); + if (!user?.avatarUrl) { + return reply.status(404).send({ error: "No avatar to delete" }); + } + + // Delete file + const fs = await import("fs/promises"); + const path = await import("path"); + try { + await fs.unlink(path.join(process.cwd(), "data", "images", user.avatarUrl)); + } catch { + // Ignore if file doesn't exist + } + + // Update user + await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id)); + + return { ok: true }; + }); } diff --git a/backend/src/routes/doses.ts b/backend/src/routes/doses.ts index 5d74cbd..2b9d1ab 100644 --- a/backend/src/routes/doses.ts +++ b/backend/src/routes/doses.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { db } from "../db/client.js"; import { doseTracking, shareTokens } from "../db/schema.js"; import { eq, and, gte } from "drizzle-orm"; -import { requireAuth } from "../plugins/auth.js"; +import { requireAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; @@ -19,11 +19,11 @@ const shareDoseSchema = z.object({ }); // Helper to get user ID from request -// Returns a default user ID when auth is disabled -function getUserId(request: any, reply: any): number { - // If auth is disabled, use a default user ID (1) +// Returns anonymous user ID when auth is disabled +async function getUserId(request: any, reply: any): Promise { + // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { - return 1; + return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; @@ -45,7 +45,7 @@ export async function doseRoutes(app: FastifyInstance) { "/doses/taken", { preHandler: requireAuth }, async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); // Get doses from last 30 days (to avoid loading too much data) const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); @@ -76,7 +76,7 @@ export async function doseRoutes(app: FastifyInstance) { "/doses/taken", { preHandler: requireAuth }, async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); const parsed = markDoseSchema.safeParse(request.body); if (!parsed.success) { @@ -119,7 +119,7 @@ export async function doseRoutes(app: FastifyInstance) { "/doses/taken/:doseId", { preHandler: requireAuth }, async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); const { doseId } = request.params; diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index ae40064..ef31f4c 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -6,7 +6,7 @@ import { eq, and } from "drizzle-orm"; import { createWriteStream, existsSync, unlinkSync } from "fs"; import { resolve, extname } from "path"; import { pipeline } from "stream/promises"; -import { requireAuth } from "../plugins/auth.js"; +import { requireAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; @@ -58,11 +58,11 @@ export async function medicationRoutes(app: FastifyInstance) { app.addHook("preHandler", requireAuth); // Helper to get user ID from request - // Returns a default user ID when auth is disabled - function getUserId(request: any, reply: any): number { - // If auth is disabled, use a default user ID (1) + // Returns anonymous user ID when auth is disabled + async function getUserId(request: any, reply: any): Promise { + // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { - return 1; + return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; @@ -75,7 +75,7 @@ export async function medicationRoutes(app: FastifyInstance) { } app.get("/medications", async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); return rows.map((row) => ({ id: row.id, @@ -103,7 +103,7 @@ export async function medicationRoutes(app: FastifyInstance) { const parsed = medicationSchema.safeParse(req.body); if (!parsed.success) return reply.status(400).send(parsed.error.format()); - const userId = getUserId(req, reply); + const userId = await getUserId(req, reply); const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, blisters } = parsed.data; const usageJson = JSON.stringify(blisters.map((s) => s.usage)); const everyJson = JSON.stringify(blisters.map((s) => s.every)); @@ -163,7 +163,7 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = getUserId(req, reply); + const userId = await getUserId(req, reply); // Verify ownership const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); @@ -229,7 +229,7 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = getUserId(req, reply); + const userId = await getUserId(req, reply); // Delete associated image if exists (with ownership check) const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); @@ -250,7 +250,7 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = getUserId(req, reply); + const userId = await getUserId(req, reply); const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); @@ -284,7 +284,7 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const userId = getUserId(req, reply); + const userId = await getUserId(req, reply); const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); @@ -308,7 +308,7 @@ export async function medicationRoutes(app: FastifyInstance) { return reply.badRequest("Invalid date range"); } - const userId = getUserId(req, reply); + const userId = await getUserId(req, reply); const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); const now = new Date(); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 1b8fc48..468f44e 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -3,7 +3,7 @@ import nodemailer from "nodemailer"; import { db } from "../db/client.js"; import { userSettings } from "../db/schema.js"; import { eq } from "drizzle-orm"; -import { requireAuth } from "../plugins/auth.js"; +import { requireAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; import type { Language } from "../i18n/translations.js"; @@ -146,11 +146,11 @@ export async function settingsRoutes(app: FastifyInstance) { app.addHook("preHandler", requireAuth); // Helper to get user ID from request - // Returns a default user ID when auth is disabled - function getUserId(request: any, reply: any): number { - // If auth is disabled, use a default user ID (1) + // Returns anonymous user ID when auth is disabled + async function getUserId(request: any, reply: any): Promise { + // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { - return 1; + return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; @@ -163,7 +163,7 @@ export async function settingsRoutes(app: FastifyInstance) { // Get settings for current user app.get("/settings", async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); const settings = await getOrCreateUserSettings(userId); @@ -201,7 +201,7 @@ export async function settingsRoutes(app: FastifyInstance) { // Update settings for current user app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); const body = request.body; diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index 893a477..7cb7604 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -4,7 +4,7 @@ import { randomBytes } from "crypto"; import { db } from "../db/client.js"; import { medications, shareTokens, userSettings } from "../db/schema.js"; import { eq, and } from "drizzle-orm"; -import { requireAuth, optionalAuth } from "../plugins/auth.js"; +import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; import type { AuthUser } from "../types/fastify.js"; @@ -17,11 +17,11 @@ const createShareSchema = z.object({ }); // Helper to get user ID from request -// Returns a default user ID when auth is disabled -function getUserId(request: any, reply: any): number { - // If auth is disabled, use a default user ID (1) +// Returns anonymous user ID when auth is disabled +async function getUserId(request: any, reply: any): Promise { + // If auth is disabled, use the anonymous user if (!env.AUTH_ENABLED) { - return 1; + return getAnonymousUserId(); } const authUser = request.user as unknown as AuthUser | null; @@ -104,7 +104,7 @@ export async function shareRoutes(app: FastifyInstance) { "/share", { preHandler: requireAuth }, async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); const parsed = createShareSchema.safeParse(request.body); if (!parsed.success) { @@ -156,7 +156,7 @@ export async function shareRoutes(app: FastifyInstance) { "/share/people", { preHandler: requireAuth }, async (request, reply) => { - const userId = getUserId(request, reply); + const userId = await getUserId(request, reply); // Get all unique takenBy values for this user const meds = await db.select({ takenBy: medications.takenBy }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b96aac..cc5274e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -184,7 +184,7 @@ function userStorageKey(userId: number | undefined, key: string): string { function AppContent() { const { t, i18n } = useTranslation(); - const { user, authState } = useAuth(); + const { user, authState, logout } = useAuth(); const [showProfile, setShowProfile] = useState(false); const [meds, setMeds] = useState([]); const [plannerRows, setPlannerRows] = useState([]); @@ -408,6 +408,33 @@ function AppContent() { const coverage = useMemo(() => calculateCoverage(meds, schedule.events, i18n.language, settings.reminderDaysBefore), [meds, schedule.events, i18n.language, settings.reminderDaysBefore]); const depletionByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c.depletionTime])), [coverage.all]); const coverageByMed = useMemo(() => Object.fromEntries(coverage.all.map((c) => [c.name, c])), [coverage.all]); + + // Get worst stock status for a day's medications (for coloring day blocks) + const getDayStockStatus = (dayMeds: { medName: string; lastWhen: number }[]) => { + const statuses = dayMeds.map((item) => { + const cov = coverageByMed[item.medName]; + const depletionTime = depletionByMed[item.medName]; + + // Will be out of stock by this day? + if (typeof depletionTime === "number" && item.lastWhen > depletionTime) { + return "danger"; + } + + if (!cov) return "success"; + const { daysLeft, medsLeft } = cov; + + // Currently out of stock + if (medsLeft <= 0 || daysLeft === 0) return "danger"; + // No schedule (can't calculate) + if (daysLeft === null) return "success"; + // Low stock: < lowStockDays (warning) + if (daysLeft < settings.lowStockDays) return "warning"; + // Normal/High stock + return "success"; + }); + return statuses.includes("danger") ? "danger" : statuses.includes("warning") ? "warning" : "success"; + }; + const groupedSchedule = useMemo(() => { type DoseInfo = { id: string; timeStr: string; when: number; usage: number }; const days = new Map }>(); @@ -906,15 +933,47 @@ function AppContent() { - + {/* Settings button only shown when auth is disabled (no user dropdown available) */} + {!authState?.authEnabled && ( + + )} {authState?.authEnabled && user && ( - +
+ +
+
+ {user.avatarUrl ? ( + {user.username} + ) : ( +
{user.username.charAt(0).toUpperCase()}
+ )} + {user.username} +
+
+ + + +
+
+
)} @@ -1123,9 +1182,10 @@ function AppContent() { const isAutoCollapsed = true; // Past days are always auto-collapsed const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; + const worstStatus = getDayStockStatus(day.meds); return ( -
+
toggleDayCollapse(day.dateStr, isAutoCollapsed)} @@ -1891,9 +1951,10 @@ function AppContent() { const takenCount = allDoseIds.filter((id) => takenDoses.has(id)).length; const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr); const isCollapsed = !isManuallyExpanded; + const worstStatus = getDayStockStatus(day.meds); return ( -
+
toggleDayCollapse(day.dateStr, true)} diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx index c715717..54d3c94 100644 --- a/frontend/src/components/Auth.tsx +++ b/frontend/src/components/Auth.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, createContext, useContext, ReactNode, useCallback } from "react"; +import { useState, useEffect, createContext, useContext, ReactNode, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; // ============================================================================= @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; export interface User { id: number; username: string; + avatarUrl?: string | null; } export interface AuthState { @@ -27,6 +28,8 @@ interface AuthContextType { logout: () => Promise; refreshUser: () => Promise; updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise; + uploadAvatar: (file: File) => Promise; + deleteAvatar: () => Promise; authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise; } @@ -195,6 +198,40 @@ export function AuthProvider({ children }: { children: ReactNode }) { await refreshUser(); } + // Upload avatar + async function uploadAvatar(file: File) { + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch("/api/auth/avatar", { + method: "POST", + credentials: "include", + body: formData, + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Upload failed" })); + throw new Error(err.error || "Upload failed"); + } + + await refreshUser(); + } + + // Delete avatar + async function deleteAvatar() { + const res = await fetch("/api/auth/avatar", { + method: "DELETE", + credentials: "include", + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Delete failed" })); + throw new Error(err.error || "Delete failed"); + } + + await refreshUser(); + } + // Fetch wrapper that automatically refreshes token on 401 const authFetch = useCallback(async (input: RequestInfo | URL, init?: RequestInit): Promise => { const options: RequestInit = { @@ -220,7 +257,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); return ( - + {children} ); @@ -424,13 +461,45 @@ export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () => // ============================================================================= export function UserProfile({ onClose }: { onClose?: () => void }) { const { t } = useTranslation(); - const { user, logout, updateProfile } = useAuth(); + const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuth(); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [loading, setLoading] = useState(false); + const [avatarLoading, setAvatarLoading] = useState(false); + const fileInputRef = useRef(null); + + async function handleAvatarUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + + setAvatarLoading(true); + setError(""); + try { + await uploadAvatar(file); + setSuccess(t("auth.avatarUpdated", "Avatar updated")); + } catch (err) { + setError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setAvatarLoading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + } + + async function handleAvatarDelete() { + setAvatarLoading(true); + setError(""); + try { + await deleteAvatar(); + setSuccess(t("auth.avatarRemoved", "Avatar removed")); + } catch (err) { + setError(err instanceof Error ? err.message : "Delete failed"); + } finally { + setAvatarLoading(false); + } + } async function handleUpdate(e: React.FormEvent) { e.preventDefault(); @@ -442,6 +511,11 @@ export function UserProfile({ onClose }: { onClose?: () => void }) { return; } + if (!currentPassword || !newPassword) { + setError(t("auth.fillAllFields", "Please fill in all password fields")); + return; + } + setLoading(true); try { @@ -460,69 +534,105 @@ export function UserProfile({ onClose }: { onClose?: () => void }) { } } - async function handleLogout() { - await logout(); - onClose?.(); - } - if (!user) return null; + const hasChanges = currentPassword || newPassword || confirmPassword; + return (
-
-

{t("auth.profile", "Profile")}

-
- -
-

{t("auth.username", "Username")}: {user.username}

+
+
+ {user.avatarUrl ? ( + {user.username} + ) : ( +
+ {user.username.charAt(0).toUpperCase()} +
+ )} + +
+ + {user.avatarUrl && ( + + )} +
+
+ {user.username}
- {error &&
{error}
} - {success &&
{success}
} +
+

{t("auth.changePassword", "Change Password")}

+ + {error &&
{error}
} + {success &&
{success}
} -

{t("auth.changePassword", "Change Password")}

+
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + placeholder="••••••••" + /> +
-
- - setCurrentPassword(e.target.value)} - autoComplete="current-password" - /> -
+
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + minLength={8} + placeholder="••••••••" + /> +
-
- - setNewPassword(e.target.value)} - autoComplete="new-password" - minLength={8} - /> -
- -
- - setConfirmPassword(e.target.value)} - autoComplete="new-password" - /> +
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + placeholder="••••••••" + /> +
- -
diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index 5b19b01..2afc153 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -259,7 +259,15 @@ "passwordReset": "Passwort zurückgesetzt", "passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...", "profileUpdated": "Profil erfolgreich aktualisiert", - "rememberMe": "Angemeldet bleiben" + "rememberMe": "Angemeldet bleiben", + "localAccount": "Lokales Konto", + "updatePassword": "Passwort ändern", + "fillAllFields": "Bitte alle Passwortfelder ausfüllen", + "signOut": "Abmelden", + "uploadAvatar": "Avatar hochladen", + "removeAvatar": "Avatar entfernen", + "avatarUpdated": "Avatar aktualisiert", + "avatarRemoved": "Avatar entfernt" }, "common": { "loading": "Wird geladen...", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 4784c2e..d25bdab 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -261,7 +261,15 @@ "passwordReset": "Password Reset", "passwordResetSuccess": "Your password has been reset. Redirecting to login...", "profileUpdated": "Profile updated successfully", - "rememberMe": "Remember me" + "rememberMe": "Remember me", + "localAccount": "Local Account", + "updatePassword": "Update Password", + "fillAllFields": "Please fill in all password fields", + "signOut": "Sign Out", + "uploadAvatar": "Upload avatar", + "removeAvatar": "Remove avatar", + "avatarUpdated": "Avatar updated", + "avatarRemoved": "Avatar removed" }, "common": { "loading": "Loading...", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index bf5e85c..2b15f5f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -493,6 +493,9 @@ textarea { } .day-divider.clickable { cursor: pointer; user-select: none; } .day-divider.clickable:hover { color: var(--accent); } +/* Keep warning/danger colors on hover */ +.day-block.stock-warning .day-divider.clickable:hover { color: var(--warning); } +.day-block.stock-danger .day-divider.clickable:hover { color: var(--danger); } .day-collapse-icon { font-size: 0.7rem; opacity: 0.6; @@ -2624,29 +2627,29 @@ h3 .reminder-icon.info-tooltip { flex: 1; } -/* User Menu Button in Header */ +/* User Menu Dropdown in Header */ +.user-menu { + position: relative; +} + .user-menu-btn { display: flex; align-items: center; - gap: 0.5rem; - background: var(--bg-tertiary); - border: 1px solid var(--border-primary); - border-radius: 100px; - padding: 0.375rem 0.75rem; - color: var(--text-primary); + justify-content: center; + background: transparent; + border: none; + padding: 0; cursor: pointer; - font-size: 0.875rem; - transition: all 0.2s ease; + transition: transform 0.2s ease; } .user-menu-btn:hover { - background: var(--accent-bg); - border-color: var(--accent); + transform: scale(1.05); } .user-menu-btn .user-avatar { - width: 28px; - height: 28px; + width: 36px; + height: 36px; background: var(--accent); color: white; border-radius: 50%; @@ -2654,7 +2657,148 @@ h3 .reminder-icon.info-tooltip { align-items: center; justify-content: center; font-weight: 600; + font-size: 0.9rem; + border: 2px solid transparent; + transition: border-color 0.2s ease; +} + +.user-menu-btn .user-avatar-img { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + border: 2px solid transparent; + transition: border-color 0.2s ease; +} + +.user-menu:hover .user-menu-btn .user-avatar, +.user-menu:hover .user-menu-btn .user-avatar-img { + border-color: var(--accent); +} + +.user-dropdown { + position: absolute; + top: calc(100% + 0.75rem); + right: 0; + width: 260px; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + box-shadow: 0 16px 48px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(255,255,255,0.05); + opacity: 0; + visibility: hidden; + transform: translateY(-8px) scale(0.95); + transition: all 0.2s ease; + z-index: 1000; + overflow: hidden; +} + +[data-theme="light"] .user-dropdown { + background: rgba(255, 255, 255, 0.75); + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 16px 48px rgba(0,0,0,0.15); +} + +.user-menu:hover .user-dropdown { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); +} + +.dropdown-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(147, 51, 234, 0.1) 100%); + border-bottom: 1px solid var(--border-primary); +} + +.dropdown-avatar { + width: 48px; + height: 48px; + background: var(--accent); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.25rem; + flex-shrink: 0; +} + +.dropdown-avatar-img { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.dropdown-user-info { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.dropdown-username { + font-size: 1rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dropdown-role { font-size: 0.75rem; + color: var(--text-secondary); +} + +.dropdown-menu { + padding: 0.5rem; +} + +.dropdown-item { + display: flex; + align-items: center; + gap: 0.875rem; + width: 100%; + padding: 0.75rem 1rem; + border: none; + background: none; + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; + border-radius: 10px; + transition: background 0.15s ease, color 0.15s ease; + text-align: left; +} + +.dropdown-item:hover { + background: rgba(59, 130, 246, 0.15); + color: var(--text-primary); +} + +.dropdown-item:hover svg { + opacity: 1; + color: var(--accent); +} + +.dropdown-item.danger:hover { + background: rgba(239, 68, 68, 0.15); + color: var(--danger); +} + +.dropdown-item svg { + width: 20px; + height: 20px; + flex-shrink: 0; + opacity: 0.7; } @media (max-width: 600px) { @@ -2669,8 +2813,187 @@ h3 .reminder-icon.info-tooltip { /* Profile Modal */ .profile-modal { - max-width: 480px; + max-width: 420px; + padding: 0; + overflow: hidden; +} + +.profile-container { + padding: 0; +} + +.profile-user-section { + display: flex; + align-items: center; + gap: 1rem; padding: 1.5rem; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-primary); +} + +.profile-avatar-wrapper { + position: relative; +} + +.profile-avatar { + width: 64px; + height: 64px; + background: var(--text-tertiary); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 1.75rem; + flex-shrink: 0; +} + +.profile-avatar-img { + width: 64px; + height: 64px; + border-radius: 50%; + object-fit: cover; +} + +.profile-avatar-actions { + position: absolute; + bottom: -4px; + right: -4px; + display: flex; + gap: 0.25rem; +} + +.avatar-btn { + width: 28px; + height: 28px; + min-width: 28px; + min-height: 28px; + border-radius: 50%; + border: 2px solid var(--bg-secondary); + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 0.75rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: all 0.15s ease; +} + +.avatar-btn:hover { + background: var(--accent); + color: white; +} + +.avatar-btn.danger:hover { + background: var(--danger); +} + +.avatar-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.profile-username { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.profile-form { + padding: 1.5rem; +} + +.profile-section { + margin-bottom: 1.5rem; +} + +.profile-section-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 1rem 0; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-primary); +} + +.profile-form .form-group { + margin-bottom: 1rem; +} + +.profile-form .form-group label { + display: block; + font-size: 0.85rem; + font-weight: 500; + margin-bottom: 0.375rem; + color: var(--text-primary); +} + +.profile-form .form-group input { + width: 100%; + padding: 0.625rem 0.875rem; + border: 1px solid var(--border-primary); + border-radius: 8px; + background: var(--bg-input); + color: var(--text-primary); + font-size: 0.9rem; + transition: border-color 0.2s ease; +} + +.profile-form .form-group input:focus { + outline: none; + border-color: var(--accent); +} + +.profile-form .form-group input::placeholder { + color: var(--text-tertiary); +} + +.profile-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + padding-top: 0.5rem; + border-top: 1px solid var(--border-primary); + margin-top: 1rem; +} + +.profile-actions .btn { + padding: 0.5rem 1rem; + font-size: 0.875rem; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.profile-actions .btn-ghost { + background: none; + border: 1px solid var(--border-primary); + color: var(--text-secondary); +} + +.profile-actions .btn-ghost:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.profile-actions .btn-primary { + background: var(--accent); + border: none; + color: white; +} + +.profile-actions .btn-primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.profile-actions .btn-primary:disabled { + opacity: 0.5; + cursor: not-allowed; } /* =============================================================================