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.
This commit is contained in:
Daniel Volz
2025-12-28 00:43:45 +01:00
parent be68fb5dad
commit bd5c864e84
14 changed files with 745 additions and 113 deletions
@@ -0,0 +1,2 @@
-- Add avatar URL column to users table
ALTER TABLE users ADD COLUMN avatar_url TEXT;
+2 -1
View File
@@ -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 }
]
}
+1
View File
@@ -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" }),
+41 -2
View File
@@ -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<number> {
// 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<AuthState> {
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 {
+79
View File
@@ -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 };
});
}
+8 -8
View File
@@ -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<number> {
// 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;
+12 -12
View File
@@ -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<number> {
// 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();
+7 -7
View File
@@ -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<number> {
// 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;
+7 -7
View File
@@ -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<number> {
// 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 })
+69 -8
View File
@@ -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<Medication[]>([]);
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
@@ -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<string, { dateStr: string; date: Date; isPast: boolean; meds: Map<string, { medName: string; total: number; doses: DoseInfo[]; lastWhen: number }> }>();
@@ -906,15 +933,47 @@ function AppContent() {
<button className={currentPath === "/medications" ? "pill primary" : "pill"} onClick={() => navigate("/medications")}>{t('nav.medications')}</button>
<button className={currentPath === "/planner" ? "pill primary" : "pill"} onClick={() => navigate("/planner")}>{t('nav.planner')}</button>
</div>
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}></button>
{/* Settings button only shown when auth is disabled (no user dropdown available) */}
{!authState?.authEnabled && (
<button className={`icon-btn ${currentPath === "/settings" ? "active" : ""}`} onClick={() => navigate("/settings")} title={t('nav.settings')}></button>
)}
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
{authState?.authEnabled && user && (
<button className="user-menu-btn" onClick={() => setShowProfile(true)} title={t('auth.profile', 'Profile')}>
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
<span>{user.username}</span>
</button>
<div className="user-menu">
<button className="user-menu-btn">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="user-avatar-img" />
) : (
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
)}
</button>
<div className="user-dropdown">
<div className="dropdown-header">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="dropdown-avatar-img" />
) : (
<div className="dropdown-avatar">{user.username.charAt(0).toUpperCase()}</div>
)}
<span className="dropdown-username">{user.username}</span>
</div>
<div className="dropdown-menu">
<button className="dropdown-item" onClick={() => setShowProfile(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{t('auth.profile', 'Profile')}
</button>
<button className="dropdown-item" onClick={() => navigate('/settings')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{t('nav.settings', 'Settings')}
</button>
<button className="dropdown-item danger" onClick={() => logout()}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
{t('auth.signOut', 'Sign Out')}
</button>
</div>
</div>
</div>
)}
</div>
</header>
@@ -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 (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => 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 (
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""}`}>
<div key={day.dateStr} className={`day-block past ${isCollapsed ? "collapsed" : ""} ${allDayTaken ? "all-taken" : ""} stock-${worstStatus}`}>
<div
className="day-divider clickable"
onClick={() => toggleDayCollapse(day.dateStr, true)}
+162 -52
View File
@@ -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<void>;
refreshUser: () => Promise<void>;
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
uploadAvatar: (file: File) => Promise<void>;
deleteAvatar: () => Promise<void>;
authFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}
@@ -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<Response> => {
const options: RequestInit = {
@@ -220,7 +257,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, []);
return (
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, authFetch }}>
<AuthContext.Provider value={{ user, authState, loading, authError, login, register, logout, refreshUser, updateProfile, uploadAvatar, deleteAvatar, authFetch }}>
{children}
</AuthContext.Provider>
);
@@ -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<HTMLInputElement>(null);
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="profile-container">
<div className="profile-header">
<h2>{t("auth.profile", "Profile")}</h2>
</div>
<div className="profile-info">
<p><strong>{t("auth.username", "Username")}:</strong> {user.username}</p>
<div className="profile-user-section">
<div className="profile-avatar-wrapper">
{user.avatarUrl ? (
<img src={`/api/images/${user.avatarUrl}`} alt={user.username} className="profile-avatar-img" />
) : (
<div className="profile-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
)}
<input
type="file"
ref={fileInputRef}
onChange={handleAvatarUpload}
accept="image/jpeg,image/png,image/webp,image/gif"
style={{ display: "none" }}
/>
<div className="profile-avatar-actions">
<button
type="button"
className="avatar-btn"
onClick={() => fileInputRef.current?.click()}
disabled={avatarLoading}
title={t("auth.uploadAvatar", "Upload avatar")}
>
📷
</button>
{user.avatarUrl && (
<button
type="button"
className="avatar-btn danger"
onClick={handleAvatarDelete}
disabled={avatarLoading}
title={t("auth.removeAvatar", "Remove avatar")}
>
🗑
</button>
)}
</div>
</div>
<span className="profile-username">{user.username}</span>
</div>
<form onSubmit={handleUpdate} className="profile-form">
{error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>}
<div className="profile-section">
<h3 className="profile-section-title">{t("auth.changePassword", "Change Password")}</h3>
{error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>}
<h3>{t("auth.changePassword", "Change Password")}</h3>
<div className="form-group">
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
<input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
placeholder="••••••••"
/>
</div>
<div className="form-group">
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
<input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div className="form-group">
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
placeholder="••••••••"
/>
</div>
<div className="form-group">
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="form-group">
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
<input
id="confirm-new-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
<div className="form-group">
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
<input
id="confirm-new-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
placeholder="••••••••"
/>
</div>
</div>
<div className="profile-actions">
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? t("common.loading", "Loading...") : t("common.save", "Save")}
<button type="button" className="btn btn-ghost" onClick={onClose}>
{t("common.cancel", "Cancel")}
</button>
<button type="button" className="btn btn-danger" onClick={handleLogout}>
{t("auth.logout", "Logout")}
<button type="submit" className="btn btn-primary" disabled={loading || !hasChanges}>
{loading ? t("common.saving", "Saving...") : t("auth.updatePassword", "Update Password")}
</button>
</div>
</form>
+9 -1
View File
@@ -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...",
+9 -1
View File
@@ -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...",
+337 -14
View File
@@ -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;
}
/* =============================================================================