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 })