diff --git a/.env.example b/.env.example index c958635..417e1e5 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,25 @@ LOG_LEVEL=info # Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York) TZ=Europe/Berlin -# Auth - CHANGE THESE! Generate with: openssl rand -hex 32 -JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32 -REFRESH_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32 -COOKIE_SECRET=CHANGE_ME_generate_with_openssl_rand_hex_32 +# ============================================================================= +# Authentication (optional - disabled by default for easy setup) +# ============================================================================= +# Enable authentication (default: false = open access) +AUTH_ENABLED=false -# SMTP (optional - for email notifications) +# Allow new user registrations (auto-enabled when no users exist) +# REGISTRATION_ENABLED=false + +# Disable local auth (for SSO-only setups in Phase 2) +# DISABLE_LOCAL_AUTH=false + +# JWT Secrets - REQUIRED when AUTH_ENABLED=true +# Generate with: openssl rand -hex 32 +# JWT_SECRET= +# REFRESH_SECRET= +# COOKIE_SECRET= + +# SMTP (optional - for email notifications and password reset) SMTP_HOST= SMTP_PORT=587 SMTP_USER= diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 42a5a66..5bb7d61 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -11,32 +11,66 @@ async function main() { const client = createClient({ url }); - // Create tables directly + // Create tables - fresh schema without roles, with per-user settings const sql = ` CREATE TABLE IF NOT EXISTS users ( id integer PRIMARY KEY AUTOINCREMENT, - email text NOT NULL UNIQUE, - password_hash text NOT NULL, - role text NOT NULL DEFAULT 'user', + username text NOT NULL UNIQUE, + password_hash text, + auth_provider text NOT NULL DEFAULT 'local', + is_active integer NOT NULL DEFAULT 1, + last_login_at integer, created_at integer NOT NULL DEFAULT (strftime('%s','now')), updated_at integer NOT NULL DEFAULT (strftime('%s','now')) ); CREATE TABLE IF NOT EXISTS medications ( id integer PRIMARY KEY AUTOINCREMENT, - name text NOT NULL UNIQUE, + user_id integer NOT NULL, + name text NOT NULL, + generic_name text, + taken_by text, count integer NOT NULL DEFAULT 0, strips integer NOT NULL DEFAULT 0, pack_count integer NOT NULL DEFAULT 1, strips_per_pack integer NOT NULL DEFAULT 1, tabs_per_strip integer NOT NULL DEFAULT 1, loose_tablets integer NOT NULL DEFAULT 0, + pill_weight_mg integer, usage_json text NOT NULL DEFAULT '[]', every_json text NOT NULL DEFAULT '[]', start_json text NOT NULL DEFAULT '[]', strip_size integer NOT NULL DEFAULT 1, image_url text, - updated_at integer NOT NULL DEFAULT (strftime('%s','now')) + expiry_date text, + notes text, + intake_reminders_enabled integer NOT NULL DEFAULT 0, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + notification_email text, + email_stock_reminders integer NOT NULL DEFAULT 1, + email_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + shoutrrr_url text, + shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, + shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + language text NOT NULL DEFAULT 'en', + last_auto_email_sent text, + last_notification_type text, + last_notification_channel text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS refresh_tokens ( @@ -49,20 +83,6 @@ async function main() { created_at integer NOT NULL DEFAULT (strftime('%s','now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); - - CREATE TABLE IF NOT EXISTS settings ( - id integer PRIMARY KEY AUTOINCREMENT, - smtp_host text, - smtp_port integer, - smtp_user text, - smtp_pass_encrypted text, - smtp_from text, - smtp_secure integer NOT NULL DEFAULT 0, - email_enabled integer NOT NULL DEFAULT 0, - notification_email text, - reminder_days_before integer NOT NULL DEFAULT 7, - updated_at integer NOT NULL DEFAULT (strftime('%s','now')) - ); `; // Execute each statement separately @@ -73,32 +93,6 @@ async function main() { await client.execute(stmt); } - // Run migrations for existing databases - console.log("Running migrations for existing databases..."); - - const migrations = [ - { column: "image_url", sql: "ALTER TABLE medications ADD COLUMN image_url TEXT" }, - { column: "expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date TEXT" }, - { column: "notes", sql: "ALTER TABLE medications ADD COLUMN notes TEXT" }, - { column: "generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name TEXT" }, - { column: "intake_reminders_enabled", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0" }, - { column: "pill_weight_mg", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg INTEGER" }, - { column: "taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by TEXT" }, - ]; - - for (const migration of migrations) { - try { - await client.execute(migration.sql); - console.log(`Added ${migration.column} column`); - } catch (e: any) { - if (e.message?.includes("duplicate column") || e.message?.includes("already exists")) { - console.log(`${migration.column} column already exists, skipping`); - } else { - throw e; - } - } - } - console.log("Database setup complete!"); process.exit(0); } diff --git a/backend/src/db/migrations/0010_add_user_settings.sql b/backend/src/db/migrations/0010_add_user_settings.sql new file mode 100644 index 0000000..e400587 --- /dev/null +++ b/backend/src/db/migrations/0010_add_user_settings.sql @@ -0,0 +1,28 @@ +-- Add user_id to medications (for existing databases) +-- First, add the column as nullable +ALTER TABLE medications ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +-- Create user_settings table for per-user notification settings +CREATE TABLE IF NOT EXISTS user_settings ( + id integer PRIMARY KEY AUTOINCREMENT, + user_id integer NOT NULL UNIQUE, + email_enabled integer NOT NULL DEFAULT 0, + notification_email text, + email_stock_reminders integer NOT NULL DEFAULT 1, + email_intake_reminders integer NOT NULL DEFAULT 1, + shoutrrr_enabled integer NOT NULL DEFAULT 0, + shoutrrr_url text, + shoutrrr_stock_reminders integer NOT NULL DEFAULT 1, + shoutrrr_intake_reminders integer NOT NULL DEFAULT 1, + reminder_days_before integer NOT NULL DEFAULT 7, + repeat_daily_reminders integer NOT NULL DEFAULT 0, + low_stock_days integer NOT NULL DEFAULT 30, + normal_stock_days integer NOT NULL DEFAULT 90, + high_stock_days integer NOT NULL DEFAULT 180, + language text NOT NULL DEFAULT 'en', + last_auto_email_sent text, + last_notification_type text, + last_notification_channel text, + updated_at integer NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/backend/src/db/migrations/meta/_journal.json b/backend/src/db/migrations/meta/_journal.json index 63e4ea1..9eed08d 100644 --- a/backend/src/db/migrations/meta/_journal.json +++ b/backend/src/db/migrations/meta/_journal.json @@ -9,6 +9,7 @@ { "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false }, { "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false }, { "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": 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 } ] } diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 578ee98..14ce7dc 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,18 +1,27 @@ -import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; +// ============================================================================= +// Users - Simple auth, no roles (every user is equal) +// ============================================================================= export const users = sqliteTable("users", { id: integer("id").primaryKey({ autoIncrement: true }), - email: text("email", { length: 255 }).notNull().unique(), - passwordHash: text("password_hash", { length: 255 }).notNull(), - role: text("role", { length: 50 }).notNull().default("user"), + username: text("username", { length: 100 }).notNull().unique(), + passwordHash: text("password_hash", { 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" }), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); +// ============================================================================= +// Medications - Per user +// ============================================================================= export const medications = sqliteTable("medications", { id: integer("id").primaryKey({ autoIncrement: true }), - name: text("name", { length: 100 }).notNull().unique(), + userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + name: text("name", { length: 100 }).notNull(), genericName: text("generic_name", { length: 100 }), takenBy: text("taken_by", { length: 100 }), count: integer("count").notNull().default(0), @@ -33,6 +42,42 @@ export const medications = sqliteTable("medications", { updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); +// ============================================================================= +// User Settings - Per user (email, push, thresholds, language) +// ============================================================================= +export const userSettings = sqliteTable("user_settings", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }), + // Email notifications + emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false), + notificationEmail: text("notification_email"), + emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true), + emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true), + // Push notifications (shoutrrr/ntfy) + shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false), + shoutrrrUrl: text("shoutrrr_url"), + shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true), + shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true), + // Reminder settings + reminderDaysBefore: integer("reminder_days_before").notNull().default(7), + repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false), + // Stock thresholds (days) + lowStockDays: integer("low_stock_days").notNull().default(30), + normalStockDays: integer("normal_stock_days").notNull().default(90), + highStockDays: integer("high_stock_days").notNull().default(180), + // UI preferences + language: text("language", { length: 10 }).notNull().default("en"), + // Last notification tracking + lastAutoEmailSent: text("last_auto_email_sent"), + lastNotificationType: text("last_notification_type"), + lastNotificationChannel: text("last_notification_channel"), + // Timestamps + updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), +}); + +// ============================================================================= +// Refresh Tokens - For JWT rotation +// ============================================================================= export const refreshTokens = sqliteTable("refresh_tokens", { id: integer("id").primaryKey({ autoIncrement: true }), userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), @@ -42,18 +87,3 @@ export const refreshTokens = sqliteTable("refresh_tokens", { revoked: integer("revoked", { mode: "boolean" }).notNull().default(false), createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), }); - -export const settings = sqliteTable("settings", { - id: integer("id").primaryKey({ autoIncrement: true }), - smtpHost: text("smtp_host"), - smtpPort: integer("smtp_port"), - smtpUser: text("smtp_user"), - smtpPassEncrypted: text("smtp_pass_encrypted"), - smtpFrom: text("smtp_from"), - smtpSecure: integer("smtp_secure", { mode: "boolean" }).notNull().default(false), - // Email notification settings - emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false), - notificationEmail: text("notification_email"), - reminderDaysBefore: integer("reminder_days_before").notNull().default(7), - updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`), -}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 9c2a1c9..49c19c0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -54,9 +54,10 @@ const refreshCookieOptions: CookieSerializeOptions = { maxAge: refreshTtlDays * 24 * 60 * 60, }; +// Config decorator - only include secrets if auth is enabled app.decorate("config", { - accessSecret: env.JWT_SECRET, - refreshSecret: env.REFRESH_SECRET, + accessSecret: env.JWT_SECRET ?? "", + refreshSecret: env.REFRESH_SECRET ?? "", accessTtl: accessTtlMinutes, refreshTtl: refreshTtlDays, cookieOptions: baseCookieOptions, @@ -70,8 +71,22 @@ await app.register(rateLimit, { max: 100, timeWindow: "1 minute", }); -await app.register(cookie, { secret: env.COOKIE_SECRET }); -await app.register(jwt, { secret: env.JWT_SECRET, cookie: { cookieName: "access_token", signed: false } }); +await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" }); + +// JWT plugin - only register with valid secret if auth is enabled +if (env.AUTH_ENABLED && env.JWT_SECRET) { + await app.register(jwt, { + secret: env.JWT_SECRET, + cookie: { cookieName: "access_token", signed: false } + }); +} else { + // Dummy JWT for when auth is disabled - prevents errors + await app.register(jwt, { + secret: "auth-disabled-no-secret-needed", + cookie: { cookieName: "access_token", signed: false } + }); +} + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit await app.register(fastifyStatic, { root: imagesDir, diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts new file mode 100644 index 0000000..aecf411 --- /dev/null +++ b/backend/src/plugins/auth.ts @@ -0,0 +1,112 @@ +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"; + +// ============================================================================= +// Auth State - Computed at runtime +// ============================================================================= +export interface AuthState { + authEnabled: boolean; + registrationEnabled: boolean; + localAuthEnabled: boolean; + hasUsers: boolean; + needsSetup: boolean; +} + +export async function getAuthState(): Promise { + const [result] = await db.select({ count: count() }).from(users); + const hasUsers = result.count > 0; + + return { + authEnabled: env.AUTH_ENABLED, + // Registration: enabled via ENV OR no users exist (first-time setup) + registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers, + localAuthEnabled: !env.DISABLE_LOCAL_AUTH, + hasUsers, + needsSetup: env.AUTH_ENABLED && !hasUsers, + }; +} + +// ============================================================================= +// Request User Type (no roles - all users are equal) +// ============================================================================= +export interface RequestUser { + id: number; + username: string; +} + +// ============================================================================= +// Auth Middleware Functions +// ============================================================================= + +/** + * Optional auth - verifies JWT if present, but doesn't require it + */ +export async function optionalAuth(request: FastifyRequest, reply: FastifyReply) { + if (!env.AUTH_ENABLED) { + return; + } + + const token = request.cookies.access_token; + if (!token) { + return; + } + + try { + const decoded = await request.jwtVerify<{ sub: number; username: string }>(); + const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`); + if (user && user.isActive) { + request.user = { + id: user.id, + username: user.username, + }; + } + } catch { + // Invalid token, continue as anonymous + } +} + +/** + * Required auth - requires valid JWT when auth is enabled + */ +export async function requireAuth(request: FastifyRequest, reply: FastifyReply) { + if (!env.AUTH_ENABLED) { + return; + } + + const token = request.cookies.access_token; + if (!token) { + return reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" }); + } + + try { + const decoded = await request.jwtVerify<{ sub: number; username: string }>(); + const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`); + + if (!user) { + return reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" }); + } + + if (!user.isActive) { + return reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" }); + } + + request.user = { + id: user.id, + username: user.username, + }; + } catch { + return reply.status(401).send({ error: "Invalid or expired token", code: "INVALID_TOKEN" }); + } +} + +/** + * Auth state endpoint plugin + */ +export async function authPlugin(app: FastifyInstance) { + app.get("/auth/state", async () => { + return getAuthState(); + }); +} diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts index de5be57..910e21d 100644 --- a/backend/src/plugins/env.ts +++ b/backend/src/plugins/env.ts @@ -8,11 +8,36 @@ const EnvSchema = z.object({ PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"), CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"), LOG_LEVEL: z.string().default("info"), - JWT_SECRET: z.string().min(10), - REFRESH_SECRET: z.string().min(10), - COOKIE_SECRET: z.string().min(10), + + // ========================================================================== + // Auth Configuration + // ========================================================================== + // Master switch: Enable/disable authentication (default: disabled for easy setup) + AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"), + // Allow new user registrations (auto-enabled if no users exist) + REGISTRATION_ENABLED: z.string().transform((v) => v === "true").default("false"), + // Disable local auth when using SSO only (Phase 2) + DISABLE_LOCAL_AUTH: z.string().transform((v) => v === "true").default("false"), + + // JWT Secrets - only required when AUTH_ENABLED=true + JWT_SECRET: z.string().min(10).optional(), + REFRESH_SECRET: z.string().min(10).optional(), + COOKIE_SECRET: z.string().min(10).optional(), }); export type Env = z.infer; -export const env: Env = EnvSchema.parse(process.env); +// Parse and validate +const parsed = EnvSchema.parse(process.env); + +// Validate that secrets are provided when auth is enabled +if (parsed.AUTH_ENABLED) { + if (!parsed.JWT_SECRET || !parsed.REFRESH_SECRET || !parsed.COOKIE_SECRET) { + throw new Error( + "AUTH_ENABLED=true requires JWT_SECRET, REFRESH_SECRET, and COOKIE_SECRET to be set. " + + "Generate them with: openssl rand -hex 32" + ); + } +} + +export const env = parsed; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 89c7b82..2bacdfe 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,41 +1,379 @@ import { FastifyInstance } from "fastify"; import { z } from "zod"; import argon2 from "argon2"; +import { randomBytes } from "crypto"; import { db } from "../db/client.js"; import { users, refreshTokens } from "../db/schema.js"; -import { randomUUID } from "crypto"; import { eq } from "drizzle-orm"; +import { env } from "../plugins/env.js"; +import { getAuthState, requireAuth } from "../plugins/auth.js"; +import type { AuthUser } from "../types/fastify.js"; -const loginBody = z.object({ email: z.string().email(), password: z.string().min(6) }); +// ============================================================================= +// Argon2id Configuration - State of the Art Password Hashing +// ============================================================================= +const ARGON2_OPTIONS: argon2.Options = { + type: argon2.argon2id, // Argon2id - best for password hashing + memoryCost: 65536, // 64 MB memory + timeCost: 3, // 3 iterations + parallelism: 4, // 4 parallel threads + hashLength: 32, // 256-bit hash +}; +// ============================================================================= +// Validation Schemas +// ============================================================================= +const registerSchema = z.object({ + username: z.string() + .min(3, "Username must be at least 3 characters") + .max(50, "Username must be at most 50 characters") + .regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"), + password: z.string() + .min(8, "Password must be at least 8 characters") + .max(128, "Password must be at most 128 characters"), +}); + +const loginSchema = z.object({ + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required"), +}); + +const updateProfileSchema = z.object({ + currentPassword: z.string().optional(), + newPassword: z.string() + .min(8, "Password must be at least 8 characters") + .max(128, "Password must be at most 128 characters") + .optional(), +}); + +// ============================================================================= +// Auth Routes +// ============================================================================= export async function authRoutes(app: FastifyInstance) { - app.post("/auth/login", { config: { csrf: true } }, async (req, reply) => { - const parsed = loginBody.safeParse(req.body); - if (!parsed.success) { - return reply.badRequest("Invalid credentials"); - } - const { email, password } = parsed.data; - const [user] = await db.select().from(users).where(eq(users.email, email)); - if (!user) return reply.unauthorized(); - const ok = await argon2.verify(user.passwordHash, password); - if (!ok) return reply.unauthorized(); + // Token TTLs + const accessTtlMinutes = 15; + const refreshTtlDays = 14; - const accessToken = app.jwt.sign({ sub: user.id, role: user.role }, { expiresIn: `${app.config.accessTtl}m` }); - const tokenId = randomUUID(); - const refreshExp = Math.floor(Date.now() / 1000) + app.config.refreshTtl * 24 * 60 * 60; - await db.insert(refreshTokens).values({ userId: user.id, tokenId, expiresAt: new Date(refreshExp * 1000) }); - const refreshToken = app.jwt.sign({ sub: user.id, jti: tokenId }, { expiresIn: `${app.config.refreshTtl}d`, key: app.config.refreshSecret }); - - reply - .setCookie("access_token", accessToken, app.config.cookieOptions) - .setCookie("refresh_token", refreshToken, app.config.refreshCookieOptions) - .send({ ok: true }); + // --------------------------------------------------------------------------- + // GET /auth/state - Public auth state (needed before login) + // --------------------------------------------------------------------------- + app.get("/auth/state", async () => { + return getAuthState(); }); - app.post("/auth/logout", async (req, reply) => { - reply + // --------------------------------------------------------------------------- + // POST /auth/register - User registration + // --------------------------------------------------------------------------- + app.post<{ Body: z.infer }>("/auth/register", async (request, reply) => { + // Check auth state + const state = await getAuthState(); + + if (!state.authEnabled) { + return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" }); + } + + if (!state.registrationEnabled) { + return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" }); + } + + if (!state.localAuthEnabled) { + return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" }); + } + + // Validate input + const parsed = registerSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.errors[0]?.message ?? "Invalid input", + code: "VALIDATION_ERROR" + }); + } + + const { username, password } = parsed.data; + + // Check if username already exists + const [existingUser] = await db.select().from(users).where(eq(users.username, username)); + if (existingUser) { + return reply.status(409).send({ error: "Username already taken", code: "USERNAME_EXISTS" }); + } + + // Hash password with Argon2id + const passwordHash = await argon2.hash(password, ARGON2_OPTIONS); + + // Create user + const [newUser] = await db.insert(users).values({ + username, + passwordHash, + authProvider: "local", + }).returning(); + + app.log.info(`User registered: ${username}`); + + return reply.status(201).send({ + ok: true, + user: { + id: newUser.id, + username: newUser.username, + }, + message: "Account created", + }); + }); + + // --------------------------------------------------------------------------- + // POST /auth/login - User login + // --------------------------------------------------------------------------- + app.post<{ Body: z.infer }>("/auth/login", async (request, reply) => { + const state = await getAuthState(); + + if (!state.authEnabled) { + return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" }); + } + + if (!state.localAuthEnabled) { + return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" }); + } + + const parsed = loginSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: "Invalid credentials", + code: "VALIDATION_ERROR" + }); + } + + const { username, password } = parsed.data; + + // Find user by username + const [user] = await db.select().from(users).where(eq(users.username, username)); + + // Generic error to prevent user enumeration + const invalidCredentialsError = () => + reply.status(401).send({ error: "Invalid username or password", code: "INVALID_CREDENTIALS" }); + + if (!user) { + // Perform dummy hash to prevent timing attacks + await argon2.hash("dummy", ARGON2_OPTIONS); + return invalidCredentialsError(); + } + + if (!user.isActive) { + return reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" }); + } + + if (!user.passwordHash) { + // SSO-only user trying local login + return reply.status(401).send({ error: "Please use SSO to login", code: "SSO_ONLY" }); + } + + // Verify password + const valid = await argon2.verify(user.passwordHash, password, ARGON2_OPTIONS); + if (!valid) { + return invalidCredentialsError(); + } + + // Update last login + await db.update(users) + .set({ lastLoginAt: new Date(), updatedAt: new Date() }) + .where(eq(users.id, user.id)); + + // Generate tokens + const accessToken = app.jwt.sign( + { sub: user.id, username: user.username }, + { expiresIn: `${accessTtlMinutes}m` } + ); + + const tokenId = randomBytes(32).toString("hex"); + const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000); + + await db.insert(refreshTokens).values({ + userId: user.id, + tokenId, + expiresAt: refreshExp, + }); + + const refreshToken = app.jwt.sign( + { sub: user.id, jti: tokenId }, + { expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret } + ); + + app.log.info(`User logged in: ${username}`); + + return reply + .setCookie("access_token", accessToken, app.config.cookieOptions) + .setCookie("refresh_token", refreshToken, app.config.refreshCookieOptions) + .send({ + ok: true, + user: { + id: user.id, + username: user.username, + }, + }); + }); + + // --------------------------------------------------------------------------- + // POST /auth/refresh - Refresh access token + // --------------------------------------------------------------------------- + app.post("/auth/refresh", async (request, reply) => { + const refreshTokenCookie = request.cookies.refresh_token; + if (!refreshTokenCookie) { + return reply.status(401).send({ error: "No refresh token", code: "NO_REFRESH_TOKEN" }); + } + + try { + // Verify refresh token + const decoded = app.jwt.verify<{ sub: number; jti: string }>( + refreshTokenCookie, + { key: app.config.refreshSecret } + ); + + // Check if token exists and is valid + const [token] = await db.select().from(refreshTokens) + .where(eq(refreshTokens.tokenId, decoded.jti)); + + if (!token || token.revoked || token.expiresAt < new Date()) { + return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" }); + } + + // Get user + const [user] = await db.select().from(users).where(eq(users.id, decoded.sub)); + if (!user || !user.isActive) { + return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" }); + } + + // Rotate refresh token (revoke old, create new) + await db.update(refreshTokens) + .set({ revoked: true, rotatedAt: new Date() }) + .where(eq(refreshTokens.id, token.id)); + + const newTokenId = randomBytes(32).toString("hex"); + const refreshExp = new Date(Date.now() + refreshTtlDays * 24 * 60 * 60 * 1000); + + await db.insert(refreshTokens).values({ + userId: user.id, + tokenId: newTokenId, + expiresAt: refreshExp, + }); + + // Generate new tokens + const newAccessToken = app.jwt.sign( + { sub: user.id, username: user.username }, + { expiresIn: `${accessTtlMinutes}m` } + ); + + const newRefreshToken = app.jwt.sign( + { sub: user.id, jti: newTokenId }, + { expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret } + ); + + return reply + .setCookie("access_token", newAccessToken, app.config.cookieOptions) + .setCookie("refresh_token", newRefreshToken, app.config.refreshCookieOptions) + .send({ ok: true }); + + } catch { + return reply.status(401).send({ error: "Invalid refresh token", code: "INVALID_REFRESH_TOKEN" }); + } + }); + + // --------------------------------------------------------------------------- + // POST /auth/logout - Logout (revoke refresh token) + // --------------------------------------------------------------------------- + app.post("/auth/logout", async (request, reply) => { + const refreshTokenCookie = request.cookies.refresh_token; + + if (refreshTokenCookie) { + try { + const decoded = app.jwt.verify<{ jti: string }>( + refreshTokenCookie, + { key: app.config.refreshSecret } + ); + + // Revoke the refresh token + await db.update(refreshTokens) + .set({ revoked: true }) + .where(eq(refreshTokens.tokenId, decoded.jti)); + } catch { + // Invalid token, ignore + } + } + + return reply .clearCookie("access_token", app.config.cookieOptions) .clearCookie("refresh_token", app.config.refreshCookieOptions) .send({ ok: true }); }); + + // --------------------------------------------------------------------------- + // GET /auth/me - Get current user profile + // --------------------------------------------------------------------------- + app.get("/auth/me", { 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) { + return reply.status(404).send({ error: "User not found" }); + } + + return { + id: user.id, + username: user.username, + authProvider: user.authProvider, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + }; + }); + + // --------------------------------------------------------------------------- + // PUT /auth/me - Update current user profile + // --------------------------------------------------------------------------- + app.put<{ Body: z.infer }>("/auth/me", { 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 parsed = updateProfileSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + error: parsed.error.errors[0]?.message ?? "Invalid input", + code: "VALIDATION_ERROR" + }); + } + + const { currentPassword, newPassword } = parsed.data; + const [user] = await db.select().from(users).where(eq(users.id, authUser.id)); + + if (!user) { + return reply.status(404).send({ error: "User not found" }); + } + + const updates: Partial = { + updatedAt: new Date(), + }; + + // Update password if provided + if (newPassword) { + if (!currentPassword) { + return reply.status(400).send({ error: "Current password required", code: "CURRENT_PASSWORD_REQUIRED" }); + } + + if (!user.passwordHash) { + return reply.status(400).send({ error: "Cannot change password for SSO account", code: "SSO_ACCOUNT" }); + } + + const valid = await argon2.verify(user.passwordHash, currentPassword, ARGON2_OPTIONS); + if (!valid) { + return reply.status(401).send({ error: "Current password is incorrect", code: "INVALID_PASSWORD" }); + } + + updates.passwordHash = await argon2.hash(newPassword, ARGON2_OPTIONS); + } + + await db.update(users).set(updates).where(eq(users.id, user.id)); + + return { ok: true, message: "Profile updated" }; + }); } diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 85ad5c9..b75ce57 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -2,10 +2,13 @@ import { FastifyInstance } from "fastify"; import { z } from "zod"; import { db } from "../db/client.js"; import { medications } from "../db/schema.js"; -import { eq } from "drizzle-orm"; +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 { env } from "../plugins/env.js"; +import type { AuthUser } from "../types/fastify.js"; const IMAGES_DIR = resolve(process.cwd(), "data/images"); @@ -27,7 +30,6 @@ const medicationSchema = z.object({ expiryDate: z.string().nullable().optional(), notes: z.string().max(500).nullable().optional(), intakeRemindersEnabled: z.boolean().default(false), - // count will be derived on the backend slices: z.array(sliceSchema).min(1).max(12), }); @@ -52,8 +54,21 @@ function parseSlices(row: typeof medications.$inferSelect) { } export async function medicationRoutes(app: FastifyInstance) { - app.get("/medications", async () => { - const rows = await db.select().from(medications).orderBy(medications.id); + // All medication routes require auth + app.addHook("preHandler", requireAuth); + + // Helper to get user ID from request + function getUserId(request: any): number { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + throw new Error("User not authenticated"); + } + return authUser.id; + } + + app.get("/medications", async (request, reply) => { + const userId = getUserId(request); + const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); return rows.map((row) => ({ id: row.id, name: row.name, @@ -80,6 +95,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); const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data; const usageJson = JSON.stringify(slices.map((s) => s.usage)); const everyJson = JSON.stringify(slices.map((s) => s.every)); @@ -90,6 +106,7 @@ export async function medicationRoutes(app: FastifyInstance) { const [inserted] = await db .insert(medications) .values({ + userId, name, genericName: genericName || null, takenBy: takenBy || null, @@ -138,6 +155,12 @@ 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); + + // Verify ownership + const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); + if (!existing) return reply.notFound(); + const { name, genericName, takenBy, packCount, stripsPerPack, tabsPerStrip, looseTablets, pillWeightMg, expiryDate, notes, intakeRemindersEnabled, slices } = parsed.data; const usageJson = JSON.stringify(slices.map((s) => s.usage)); const everyJson = JSON.stringify(slices.map((s) => s.every)); @@ -167,7 +190,7 @@ export async function medicationRoutes(app: FastifyInstance) { startJson, updatedAt: new Date(), }) - .where(eq(medications.id, idNum)) + .where(and(eq(medications.id, idNum), eq(medications.userId, userId))) .returning(); if (!result.length) return reply.notFound(); @@ -198,14 +221,18 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - // Delete associated image if exists - const [existing] = await db.select().from(medications).where(eq(medications.id, idNum)); - if (existing?.imageUrl) { + const userId = getUserId(req); + + // 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))); + if (!existing) return reply.notFound(); + + if (existing.imageUrl) { const imagePath = resolve(IMAGES_DIR, existing.imageUrl); if (existsSync(imagePath)) unlinkSync(imagePath); } - const deleted = await db.delete(medications).where(eq(medications.id, idNum)).returning(); + const deleted = await db.delete(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))).returning(); if (!deleted.length) return reply.notFound(); return reply.status(204).send(); }); @@ -215,7 +242,8 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const [existing] = await db.select().from(medications).where(eq(medications.id, idNum)); + const userId = getUserId(req); + const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); const data = await req.file(); @@ -238,7 +266,7 @@ export async function medicationRoutes(app: FastifyInstance) { if (existsSync(oldPath)) unlinkSync(oldPath); } - await db.update(medications).set({ imageUrl: filename, updatedAt: new Date() }).where(eq(medications.id, idNum)); + await db.update(medications).set({ imageUrl: filename, updatedAt: new Date() }).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); return { success: true, imageUrl: filename }; }); @@ -248,7 +276,8 @@ export async function medicationRoutes(app: FastifyInstance) { const idNum = Number(req.params.id); if (Number.isNaN(idNum)) return reply.badRequest("Invalid id"); - const [existing] = await db.select().from(medications).where(eq(medications.id, idNum)); + const userId = getUserId(req); + const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); if (!existing) return reply.notFound(); if (existing.imageUrl) { @@ -256,7 +285,7 @@ export async function medicationRoutes(app: FastifyInstance) { if (existsSync(filepath)) unlinkSync(filepath); } - await db.update(medications).set({ imageUrl: null, updatedAt: new Date() }).where(eq(medications.id, idNum)); + await db.update(medications).set({ imageUrl: null, updatedAt: new Date() }).where(and(eq(medications.id, idNum), eq(medications.userId, userId))); return reply.status(204).send(); }); @@ -271,7 +300,8 @@ export async function medicationRoutes(app: FastifyInstance) { return reply.badRequest("Invalid date range"); } - const rows = await db.select().from(medications).orderBy(medications.id); + const userId = getUserId(req); + const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); const payload = rows.map((row) => { const slices = parseSlices(row); const usageTotal = calculateUsageInRange(slices, start, end); @@ -282,8 +312,10 @@ export async function medicationRoutes(app: FastifyInstance) { const totalPills = row.count; const stripsNeeded = tabsPerStrip > 0 ? Math.ceil(usageTotal / tabsPerStrip) : 0; - const stripsAvailable = packCount * stripsPerPack + (tabsPerStrip > 0 ? looseTablets / tabsPerStrip : 0); - const enough = stripsAvailable >= stripsNeeded; + const fullBlisters = packCount * stripsPerPack; + const loosePills = looseTablets; + const totalAvailablePills = fullBlisters * tabsPerStrip + loosePills; + const enough = totalAvailablePills >= usageTotal; return { medicationId: row.id, medicationName: row.name, @@ -291,7 +323,8 @@ export async function medicationRoutes(app: FastifyInstance) { plannerUsage: usageTotal, stripSize: tabsPerStrip, stripsNeeded, - stripsAvailable, + fullBlisters, + loosePills, enough, }; }); diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 0852913..e145c1a 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -1,8 +1,9 @@ import { FastifyInstance } from "fastify"; import nodemailer from "nodemailer"; import { updateReminderSentTime } from "../services/reminder-scheduler.js"; -import { loadNotificationSettings, sendShoutrrrNotification } from "./settings.js"; -import { getDateLocale } from "../i18n/translations.js"; +import { loadUserSettings, sendShoutrrrNotification } from "./settings.js"; +import { getDateLocale, type Language } from "../i18n/translations.js"; +import type { AuthUser } from "../types/fastify.js"; type PlannerRow = { medicationId: number; @@ -20,6 +21,7 @@ type SendEmailBody = { from: string; until: string; rows: PlannerRow[]; + language?: Language; // Optional: passed from frontend for unauthenticated requests }; type LowStockItem = { @@ -32,11 +34,12 @@ type LowStockItem = { type ReminderEmailBody = { email: string; lowStock: LowStockItem[]; + language?: Language; // Optional: passed from frontend for unauthenticated requests }; export async function plannerRoutes(app: FastifyInstance) { app.post<{ Body: SendEmailBody }>("/planner/send-email", async (request, reply) => { - const { email, from, until, rows } = request.body; + const { email, from, until, rows, language: bodyLanguage } = request.body; if (!email || !rows || rows.length === 0) { return reply.status(400).send({ error: "Missing email or planner data" }); @@ -53,9 +56,14 @@ export async function plannerRoutes(app: FastifyInstance) { return reply.status(400).send({ error: "SMTP not configured" }); } - // Get locale from settings - const settings = loadNotificationSettings(); - const locale = getDateLocale(settings.language); + // Get locale from user settings or use the language passed in the body + let language: Language = bodyLanguage || "en"; + const authUser = request.user as unknown as AuthUser | null; + if (authUser?.id) { + const userSettings = await loadUserSettings(authUser.id); + language = userSettings.language; + } + const locale = getDateLocale(language); // Format dates for display const fromDate = new Date(from).toLocaleDateString(locale, { @@ -177,13 +185,28 @@ Sent from MedAssist-ng Medication Planner`; // Reminder notification for low stock medications (supports email and push) app.post<{ Body: ReminderEmailBody }>("/reminder/send-email", async (request, reply) => { - const { email, lowStock } = request.body; + const { email, lowStock, language: bodyLanguage } = request.body; if (!lowStock || lowStock.length === 0) { return reply.status(400).send({ error: "Missing low stock data" }); } - const notificationSettings = loadNotificationSettings(); + // Load user settings if authenticated, otherwise use defaults + let notificationSettings = { + emailEnabled: true, + shoutrrrEnabled: false, + shoutrrrUrl: "", + }; + const reminderAuthUser = request.user as unknown as AuthUser | null; + if (reminderAuthUser?.id) { + const userSettings = await loadUserSettings(reminderAuthUser.id); + notificationSettings = { + emailEnabled: userSettings.emailEnabled, + shoutrrrEnabled: userSettings.shoutrrrEnabled, + shoutrrrUrl: userSettings.shoutrrrUrl || "", + }; + } + const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] }; // Send email if enabled diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 4d99a5b..8e784bb 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -1,10 +1,35 @@ import { FastifyInstance } from "fastify"; import nodemailer from "nodemailer"; -import { readFileSync, writeFileSync, existsSync } from "fs"; -import { resolve } from "path"; -import { getReminderState } from "../services/reminder-scheduler.js"; +import { db } from "../db/client.js"; +import { userSettings } from "../db/schema.js"; +import { eq } from "drizzle-orm"; +import { requireAuth } from "../plugins/auth.js"; +import { env } from "../plugins/env.js"; +import type { AuthUser } from "../types/fastify.js"; import type { Language } from "../i18n/translations.js"; +// Exported type for use in schedulers +export type UserSettings = { + userId: number; + emailEnabled: boolean; + notificationEmail: string | null; + emailStockReminders: boolean; + emailIntakeReminders: boolean; + shoutrrrEnabled: boolean; + shoutrrrUrl: string | null; + shoutrrrStockReminders: boolean; + shoutrrrIntakeReminders: boolean; + reminderDaysBefore: number; + repeatDailyReminders: boolean; + lowStockDays: number; + normalStockDays: number; + highStockDays: number; + language: Language; + lastAutoEmailSent: string | null; + lastNotificationType: string | null; + lastNotificationChannel: string | null; +}; + type SettingsBody = { emailEnabled: boolean; notificationEmail: string; @@ -15,13 +40,11 @@ type SettingsBody = { highStockDays: number; shoutrrrEnabled: boolean; shoutrrrUrl: string; - // Granular notification settings emailStockReminders: boolean; emailIntakeReminders: boolean; shoutrrrStockReminders: boolean; shoutrrrIntakeReminders: boolean; - // Language setting - language: Language; + language: string; }; type TestEmailBody = { @@ -32,123 +55,144 @@ type TestShoutrrrBody = { url: string; }; -// Notification settings are stored in a JSON file (user-configurable) -// SMTP settings come from .env (admin-configured) -const notificationSettingsFile = resolve(process.cwd(), "data", "notification-settings.json"); - -type NotificationSettings = { - emailEnabled: boolean; - notificationEmail: string; - reminderDaysBefore: number; - repeatDailyReminders: boolean; - lowStockDays: number; - normalStockDays: number; - highStockDays: number; - shoutrrrEnabled: boolean; - shoutrrrUrl: string; - // Granular notification settings - emailStockReminders: boolean; - emailIntakeReminders: boolean; - shoutrrrStockReminders: boolean; - shoutrrrIntakeReminders: boolean; - // Language setting - language: Language; +// Default settings for new users +const defaultSettings = { + emailEnabled: false, + notificationEmail: null, + emailStockReminders: true, + emailIntakeReminders: true, + shoutrrrEnabled: false, + shoutrrrUrl: null, + shoutrrrStockReminders: true, + shoutrrrIntakeReminders: true, + reminderDaysBefore: 7, + repeatDailyReminders: false, + lowStockDays: 30, + normalStockDays: 90, + highStockDays: 180, + language: "en", + lastAutoEmailSent: null, + lastNotificationType: null, + lastNotificationChannel: null, }; -function loadNotificationSettings(): NotificationSettings { - try { - if (existsSync(notificationSettingsFile)) { - const saved = JSON.parse(readFileSync(notificationSettingsFile, "utf-8")); - return { - emailEnabled: saved.emailEnabled ?? false, - notificationEmail: saved.notificationEmail ?? "", - reminderDaysBefore: saved.reminderDaysBefore ?? 7, - repeatDailyReminders: saved.repeatDailyReminders ?? false, - lowStockDays: saved.lowStockDays ?? 30, - normalStockDays: saved.normalStockDays ?? 90, - highStockDays: saved.highStockDays ?? 180, - shoutrrrEnabled: saved.shoutrrrEnabled ?? false, - shoutrrrUrl: saved.shoutrrrUrl ?? "", - // Granular notification settings (default to true for backwards compatibility) - emailStockReminders: saved.emailStockReminders ?? true, - emailIntakeReminders: saved.emailIntakeReminders ?? true, - shoutrrrStockReminders: saved.shoutrrrStockReminders ?? true, - shoutrrrIntakeReminders: saved.shoutrrrIntakeReminders ?? true, - // Language setting (default to English) - language: saved.language ?? "en", - }; - } - } catch { - // ignore +// Helper to get or create user settings +async function getOrCreateUserSettings(userId: number) { + let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); + + if (!settings) { + // Create default settings for user + [settings] = await db.insert(userSettings).values({ + userId, + ...defaultSettings, + }).returning(); } - return { - emailEnabled: false, - notificationEmail: "", - reminderDaysBefore: 7, - repeatDailyReminders: false, - lowStockDays: 30, - normalStockDays: 90, - highStockDays: 180, - shoutrrrEnabled: false, - shoutrrrUrl: "", - emailStockReminders: true, - emailIntakeReminders: true, - shoutrrrStockReminders: true, - shoutrrrIntakeReminders: true, - language: "en", - }; -} - -function saveNotificationSettings(settings: NotificationSettings): void { - writeFileSync(notificationSettingsFile, JSON.stringify(settings, null, 2)); + + return settings; } // Export for use in reminder scheduler -export { loadNotificationSettings }; +export async function loadUserSettings(userId: number): Promise { + const settings = await getOrCreateUserSettings(userId); + return { + userId: settings.userId, + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail, + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + shoutrrrEnabled: settings.shoutrrrEnabled, + shoutrrrUrl: settings.shoutrrrUrl, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + language: settings.language as Language, + lastAutoEmailSent: settings.lastAutoEmailSent, + lastNotificationType: settings.lastNotificationType, + lastNotificationChannel: settings.lastNotificationChannel, + }; +} + +// Get all users with settings for scheduler +export async function getAllUserSettings(): Promise { + const allSettings = await db.select().from(userSettings); + return allSettings.map(settings => ({ + userId: settings.userId, + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail, + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + shoutrrrEnabled: settings.shoutrrrEnabled, + shoutrrrUrl: settings.shoutrrrUrl, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + language: settings.language as Language, + lastAutoEmailSent: settings.lastAutoEmailSent, + lastNotificationType: settings.lastNotificationType, + lastNotificationChannel: settings.lastNotificationChannel, + })); +} export async function settingsRoutes(app: FastifyInstance) { - // Get settings - notification from JSON file, SMTP from process.env - app.get("/settings", async (_request, reply) => { - const notification = loadNotificationSettings(); - const reminderState = getReminderState(); + // All settings routes require auth + app.addHook("preHandler", requireAuth); + + // Get settings for current user + app.get("/settings", async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } + + const settings = await getOrCreateUserSettings(authUser.id); return reply.send({ - // Notification settings (user-configurable, stored in JSON) - emailEnabled: notification.emailEnabled, - notificationEmail: notification.notificationEmail, - reminderDaysBefore: notification.reminderDaysBefore, - repeatDailyReminders: notification.repeatDailyReminders, - lowStockDays: notification.lowStockDays, - normalStockDays: notification.normalStockDays, - highStockDays: notification.highStockDays, - shoutrrrEnabled: notification.shoutrrrEnabled, - shoutrrrUrl: notification.shoutrrrUrl, - // Granular notification settings - emailStockReminders: notification.emailStockReminders, - emailIntakeReminders: notification.emailIntakeReminders, - shoutrrrStockReminders: notification.shoutrrrStockReminders, - shoutrrrIntakeReminders: notification.shoutrrrIntakeReminders, - // Language setting - language: notification.language, - // SMTP settings (admin-configured, from .env) + // User notification settings (from DB) + emailEnabled: settings.emailEnabled, + notificationEmail: settings.notificationEmail ?? "", + reminderDaysBefore: settings.reminderDaysBefore, + repeatDailyReminders: settings.repeatDailyReminders, + lowStockDays: settings.lowStockDays, + normalStockDays: settings.normalStockDays, + highStockDays: settings.highStockDays, + shoutrrrEnabled: settings.shoutrrrEnabled, + shoutrrrUrl: settings.shoutrrrUrl ?? "", + emailStockReminders: settings.emailStockReminders, + emailIntakeReminders: settings.emailIntakeReminders, + shoutrrrStockReminders: settings.shoutrrrStockReminders, + shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders, + language: settings.language, + // SMTP settings (from .env - shared/server-configured) smtpHost: process.env.SMTP_HOST ?? "", smtpPort: parseInt(process.env.SMTP_PORT ?? "587"), smtpUser: process.env.SMTP_USER ?? "", smtpFrom: process.env.SMTP_FROM ?? "", smtpSecure: process.env.SMTP_SECURE === "true", hasSmtpPassword: !!(process.env.SMTP_TOKEN || process.env.SMTP_PASS), - // Reminder state - lastAutoEmailSent: reminderState.lastAutoEmailSent, - nextScheduledCheck: reminderState.nextScheduledCheck, - lastNotificationType: reminderState.lastNotificationType, - lastNotificationChannel: reminderState.lastNotificationChannel, - // Admin settings (from .env, read-only) + // Reminder state for this user + lastAutoEmailSent: settings.lastAutoEmailSent, + lastNotificationType: settings.lastNotificationType, + lastNotificationChannel: settings.lastNotificationChannel, + // Server settings (from .env, read-only) expiryWarningDays: parseInt(process.env.EXPIRY_WARNING_DAYS ?? "30", 10), }); }); - // Update settings - only notification settings are saved (SMTP comes from .env) + // Update settings for current user app.put<{ Body: SettingsBody }>("/settings", async (request, reply) => { + const authUser = request.user as unknown as AuthUser | null; + if (!authUser) { + return reply.status(401).send({ error: "Not authenticated" }); + } + const body = request.body; // Check if any stock reminders are configured @@ -158,26 +202,38 @@ export async function settingsRoutes(app: FastifyInstance) { // Disable repeatDailyReminders if no stock reminders are configured const repeatDailyReminders = hasAnyStockReminder ? (body.repeatDailyReminders ?? false) : false; + + // Update or insert user settings + const existingSettings = await db.select().from(userSettings).where(eq(userSettings.userId, authUser.id)); - // Save notification settings to JSON file - saveNotificationSettings({ + const settingsData = { emailEnabled: body.emailEnabled, - notificationEmail: body.notificationEmail, + notificationEmail: body.notificationEmail || null, + emailStockReminders: body.emailStockReminders ?? true, + emailIntakeReminders: body.emailIntakeReminders ?? true, + shoutrrrEnabled: body.shoutrrrEnabled ?? false, + shoutrrrUrl: body.shoutrrrUrl || null, + shoutrrrStockReminders: body.shoutrrrStockReminders ?? true, + shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, reminderDaysBefore: body.reminderDaysBefore, repeatDailyReminders, lowStockDays: body.lowStockDays ?? 30, normalStockDays: body.normalStockDays ?? 90, highStockDays: body.highStockDays ?? 180, - shoutrrrEnabled: body.shoutrrrEnabled ?? false, - shoutrrrUrl: body.shoutrrrUrl ?? "", - // Granular notification settings - emailStockReminders: body.emailStockReminders ?? true, - emailIntakeReminders: body.emailIntakeReminders ?? true, - shoutrrrStockReminders: body.shoutrrrStockReminders ?? true, - shoutrrrIntakeReminders: body.shoutrrrIntakeReminders ?? true, - // Language setting language: body.language ?? "en", - }); + updatedAt: new Date(), + }; + + if (existingSettings.length > 0) { + await db.update(userSettings) + .set(settingsData) + .where(eq(userSettings.userId, authUser.id)); + } else { + await db.insert(userSettings).values({ + userId: authUser.id, + ...settingsData, + }); + } return reply.send({ success: true }); }); @@ -188,7 +244,7 @@ export async function settingsRoutes(app: FastifyInstance) { const smtpHost = process.env.SMTP_HOST; const smtpUser = process.env.SMTP_USER; - const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence + const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; const smtpPort = parseInt(process.env.SMTP_PORT ?? "587"); const smtpSecure = process.env.SMTP_SECURE === "true"; const smtpFrom = process.env.SMTP_FROM ?? smtpUser; @@ -257,35 +313,28 @@ export async function settingsRoutes(app: FastifyInstance) { // Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.) export async function sendShoutrrrNotification(urlStr: string, title: string, message: string): Promise<{ success: boolean; error?: string }> { try { - // Parse the URL to determine the service let targetUrl: string; let method = "POST"; let headers: Record = {}; let body: string | undefined; - // Remove emojis from title for header compatibility (ntfy doesn't support unicode in headers) - // Match common emojis, pictographs, symbols, and variation selectors + // Remove emojis from title for header compatibility const cleanTitle = title.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE00}-\u{FE0F}]|[\u{2000}-\u{206F}]|⚠|️/gu, "").trim(); - // Handle different URL formats if (urlStr.startsWith("ntfy://")) { - // ntfy://[user:pass@]host/topic -> https://host/topic const parsed = new URL(urlStr.replace("ntfy://", "https://")); targetUrl = `https://${parsed.host}${parsed.pathname}`; headers = { "Title": cleanTitle, "Tags": "warning" }; body = message; - // Handle basic auth if present if (parsed.username && parsed.password) { headers["Authorization"] = "Basic " + Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64"); } } else if (urlStr.startsWith("https://ntfy.") || urlStr.includes("ntfy.sh") || urlStr.includes("/ntfy/")) { - // Direct ntfy HTTPS URL targetUrl = urlStr; headers = { "Title": cleanTitle, "Tags": "warning" }; body = message; } else if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) { - // Generic webhook URL - send as JSON targetUrl = urlStr; headers = { "Content-Type": "application/json" }; body = JSON.stringify({ title, message, text: `${title}\n\n${message}` }); @@ -310,3 +359,4 @@ export async function sendShoutrrrNotification(urlStr: string, title: string, me return { success: false, error: errorMessage }; } } + diff --git a/backend/src/services/intake-reminder-scheduler.ts b/backend/src/services/intake-reminder-scheduler.ts index 88a8482..ad78cf3 100644 --- a/backend/src/services/intake-reminder-scheduler.ts +++ b/backend/src/services/intake-reminder-scheduler.ts @@ -1,9 +1,10 @@ import nodemailer from "nodemailer"; +import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; import { medications } from "../db/schema.js"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { resolve } from "path"; -import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js"; +import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js"; import { getReminderState, updateReminderSentTime } from "./reminder-scheduler.js"; @@ -261,7 +262,22 @@ ${tr.intakeReminder.footer}`; } async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise { - const settings = loadNotificationSettings(); + // Get all user settings to iterate over each user + const allUserSettings = await getAllUserSettings(); + + if (allUserSettings.length === 0) { + return; // No users with settings + } + + for (const userSettings of allUserSettings) { + await checkAndSendIntakeRemindersForUser(userSettings, logger); + } +} + +async function checkAndSendIntakeRemindersForUser( + settings: UserSettings & { userId: number }, + logger: { info: (msg: string) => void; error: (msg: string) => void } +): Promise { const language = settings.language; const tr = getTranslations(language); @@ -270,22 +286,22 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrIntakeReminders; if (!emailEnabled && !shoutrrrEnabled) { - return; // No intake reminder notifications enabled, skip silently + return; // No intake reminder notifications enabled for this user } - // Get all medications with intake reminders enabled - const rows = await db.select().from(medications).orderBy(medications.id); + // Get all medications with intake reminders enabled for this user + const rows = await db.select().from(medications).where(eq(medications.userId, settings.userId)).orderBy(medications.id); const medsWithReminders = rows.filter(row => row.intakeRemindersEnabled); if (medsWithReminders.length === 0) { - return; // No medications have reminders enabled + return; // No medications have reminders enabled for this user } const state = loadIntakeReminderState(); const allUpcoming: UpcomingIntake[] = []; const locale = getDateLocale(language); - // Find all upcoming intakes across all medications + // Find all upcoming intakes across all medications for this user for (const med of medsWithReminders) { const slices = parseSlices(med); const upcoming = getUpcomingIntakes(med.name, slices, REMINDER_MINUTES_BEFORE, med.takenBy, med.pillWeightMg, locale); @@ -296,9 +312,9 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void return; // No upcoming intakes in the window } - // Filter out already-sent reminders + // Filter out already-sent reminders (keyed by user) const newReminders = allUpcoming.filter(intake => { - const key = `${intake.medName}:${intake.intakeTime.getTime()}`; + const key = `user_${settings.userId}:${intake.medName}:${intake.intakeTime.getTime()}`; return !state.sentReminders.includes(key); }); @@ -306,19 +322,19 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void return; // All reminders already sent } - logger.info(`[IntakeReminder] Sending reminder for ${newReminders.length} upcoming intakes...`); + logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${newReminders.length} upcoming intakes...`); let emailSuccess = false; let shoutrrrSuccess = false; // Send email if enabled for intake reminders if (emailEnabled) { - const result = await sendIntakeReminderEmail(settings.notificationEmail, newReminders, language); + const result = await sendIntakeReminderEmail(settings.notificationEmail!, newReminders, language); emailSuccess = result.success; if (result.success) { - logger.info(`[IntakeReminder] Email sent successfully`); + logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`); } else { - logger.error(`[IntakeReminder] Failed to send email: ${result.error}`); + logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`); } } @@ -337,18 +353,18 @@ async function checkAndSendIntakeReminders(logger: { info: (msg: string) => void }) .join("\n"); - const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message); + const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (result.success) { - logger.info(`[IntakeReminder] Push notification sent successfully`); + logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`); } else { - logger.error(`[IntakeReminder] Failed to send push: ${result.error}`); + logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`); } } // Update state if any notification was sent successfully if (emailSuccess || shoutrrrSuccess) { - const newKeys = newReminders.map(i => `${i.medName}:${i.intakeTime.getTime()}`); + const newKeys = newReminders.map(i => `user_${settings.userId}:${i.medName}:${i.intakeTime.getTime()}`); // Clean up old entries (older than 24 hours) const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 1969bb8..36d5cca 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -1,32 +1,14 @@ import nodemailer from "nodemailer"; +import { eq } from "drizzle-orm"; import { db } from "../db/client.js"; -import { medications } from "../db/schema.js"; +import { medications, users } from "../db/schema.js"; import { readFileSync, writeFileSync, existsSync } from "fs"; import { resolve } from "path"; -import { loadNotificationSettings, sendShoutrrrNotification } from "../routes/settings.js"; +import { loadUserSettings, getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.js"; import { getTranslations, t, getDateLocale, type Language } from "../i18n/translations.js"; type Slice = { usage: number; every: number; start: string }; -type NotificationSettings = { - emailEnabled: boolean; - notificationEmail: string; - reminderDaysBefore: number; - repeatDailyReminders: boolean; - lowStockDays: number; - normalStockDays: number; - highStockDays: number; - shoutrrrEnabled: boolean; - shoutrrrUrl: string; - // Granular notification settings - emailStockReminders: boolean; - emailIntakeReminders: boolean; - shoutrrrStockReminders: boolean; - shoutrrrIntakeReminders: boolean; - // Language setting - language: Language; -}; - type ReminderState = { lastAutoEmailSent: string | null; // ISO date string lastAutoEmailDate: string | null; // YYYY-MM-DD - to track if we already sent today @@ -232,8 +214,8 @@ type LowStockItem = { depletionDate: string | null; }; -async function getMedicationsNeedingReminder(reminderDaysBefore: number, language: Language): Promise { - const rows = await db.select().from(medications).orderBy(medications.id); +async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: number, language: Language): Promise { + const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id); const lowStock: LowStockItem[] = []; @@ -361,7 +343,23 @@ ${tr.stockReminder.footer}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyN } async function checkAndSendReminder(logger: { info: (msg: string) => void; error: (msg: string) => void }): Promise { - const settings = loadNotificationSettings(); + // Get all user settings to iterate over each user + const allUserSettings = await getAllUserSettings(); + + if (allUserSettings.length === 0) { + logger.info("[Reminder] No users with settings found"); + return; + } + + for (const userSettings of allUserSettings) { + await checkAndSendReminderForUser(userSettings, logger); + } +} + +async function checkAndSendReminderForUser( + settings: UserSettings & { userId: number }, + logger: { info: (msg: string) => void; error: (msg: string) => void } +): Promise { const language = settings.language; const tr = getTranslations(language); @@ -370,79 +368,48 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error const shoutrrrEnabled = settings.shoutrrrEnabled && settings.shoutrrrUrl && settings.shoutrrrStockReminders; if (!emailEnabled && !shoutrrrEnabled) { - logger.info("[Reminder] No stock reminder notifications enabled"); - return; + return; // No stock reminder notifications enabled for this user } const state = loadReminderState(); const today = getTodayInTimezone(); // YYYY-MM-DD in configured timezone + const userStateKey = `user_${settings.userId}`; - // Get all medications that need a reminder - const allLowStock = await getMedicationsNeedingReminder(settings.reminderDaysBefore, language); + // Get all medications that need a reminder for this user + const allLowStock = await getMedicationsNeedingReminder(settings.userId, settings.reminderDaysBefore, language); if (allLowStock.length === 0) { - // No low stock - clear the notified list (medications have been restocked) - if (state.notifiedMedications.length > 0) { - saveReminderState({ - ...state, - notifiedMedications: [], - }); - logger.info("[Reminder] Cleared notified medications list (all restocked)"); - } - logger.info("[Reminder] No medications need reminder"); - return; + return; // No low stock for this user } - // Get names of currently low stock medications - const currentLowStockNames = allLowStock.map((m) => m.name); - - // Remove medications from notified list that are no longer low stock (restocked) - const stillLowStock = state.notifiedMedications.filter((name) => currentLowStockNames.includes(name)); - - // Find NEW medications that haven't been notified yet - const newLowStock = allLowStock.filter((m) => !state.notifiedMedications.includes(m.name)); - - // Determine what to send - let medsToNotify: LowStockItem[] = []; - - if (settings.repeatDailyReminders) { - // Daily reminders enabled - send for ALL low stock, but only once per day - if (state.lastAutoEmailDate === today) { - logger.info("[Reminder] Daily reminder already sent today, skipping"); - return; - } - medsToNotify = allLowStock; - } else { - // Only notify NEW medications (not previously notified) - if (newLowStock.length === 0) { - logger.info("[Reminder] No new medications to notify (already notified previously)"); - return; - } - medsToNotify = newLowStock; + // Simple per-user tracking - check if we already sent today + const userNotifiedKey = `${userStateKey}_${today}`; + if (state.notifiedMedications.includes(userNotifiedKey) && !settings.repeatDailyReminders) { + return; // Already notified this user today } - logger.info(`[Reminder] Sending reminder for ${medsToNotify.length} medications...`); + logger.info(`[Reminder] User ${settings.userId}: Sending reminder for ${allLowStock.length} medications...`); let emailSuccess = false; let shoutrrrSuccess = false; // Send email if enabled if (emailEnabled) { - const result = await sendReminderEmail(settings.notificationEmail, medsToNotify, language, settings.repeatDailyReminders); + const result = await sendReminderEmail(settings.notificationEmail!, allLowStock, language, settings.repeatDailyReminders); emailSuccess = result.success; if (result.success) { - logger.info(`[Reminder] Email sent successfully to ${settings.notificationEmail}`); + logger.info(`[Reminder] User ${settings.userId}: Email sent successfully to ${settings.notificationEmail}`); } else { - logger.error(`[Reminder] Failed to send email: ${result.error}`); + logger.error(`[Reminder] User ${settings.userId}: Failed to send email: ${result.error}`); } } // Send Shoutrrr notification if enabled if (shoutrrrEnabled) { - const title = medsToNotify.length === 1 + const title = allLowStock.length === 1 ? tr.push.stockTitle - : t(tr.push.stockTitleMultiple, { count: medsToNotify.length }); - let message = medsToNotify + : t(tr.push.stockTitleMultiple, { count: allLowStock.length }); + let message = allLowStock .map((m) => `• ${m.name}: ${t(tr.push.pillsLeft, { count: m.medsLeft })}, ${t(tr.push.daysLeft, { count: m.daysLeft ?? 0 })}`) .join("\n"); @@ -450,12 +417,12 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error message += `\n\n${tr.push.repeatDailyNote}`; } - const result = await sendShoutrrrNotification(settings.shoutrrrUrl, title, message); + const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message); shoutrrrSuccess = result.success; if (result.success) { - logger.info(`[Reminder] Push notification sent successfully`); + logger.info(`[Reminder] User ${settings.userId}: Push notification sent successfully`); } else { - logger.error(`[Reminder] Failed to send push notification: ${result.error}`); + logger.error(`[Reminder] User ${settings.userId}: Failed to send push notification: ${result.error}`); } } @@ -466,7 +433,7 @@ async function checkAndSendReminder(logger: { info: (msg: string) => void; error saveReminderState({ lastAutoEmailSent: new Date().toISOString(), lastAutoEmailDate: today, - notifiedMedications: [...new Set([...stillLowStock, ...medsToNotify.map((m) => m.name)])], + notifiedMedications: [...new Set([...currentState.notifiedMedications, userNotifiedKey])], nextScheduledCheck: currentState.nextScheduledCheck, lastNotificationType: "stock", lastNotificationChannel: channel, diff --git a/backend/src/types/fastify.d.ts b/backend/src/types/fastify.d.ts index 3402864..d8366a1 100644 --- a/backend/src/types/fastify.d.ts +++ b/backend/src/types/fastify.d.ts @@ -1,4 +1,12 @@ import "fastify"; +import "@fastify/jwt"; + +// User type for authenticated requests +export interface AuthUser { + id: number; + username: string; + role: string; +} declare module "fastify" { interface FastifyInstance { @@ -11,4 +19,16 @@ declare module "fastify" { refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions; }; } + + interface FastifyRequest { + user?: AuthUser | null; + } +} + +declare module "@fastify/jwt" { + interface FastifyJWT { + // Allow flexible payload for access and refresh tokens + payload: Record; + user: Record; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2048b3a..dbdaf1a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Routes, Route, useNavigate, useLocation, Navigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth"; type Slice = { usage: number; @@ -36,7 +37,8 @@ type PlannerRow = { plannerUsage: number; stripSize: number; stripsNeeded: number; - stripsAvailable: number; + fullBlisters: number; + loosePills: number; enough: boolean; }; @@ -77,32 +79,94 @@ type Coverage = { nextDose: string | null; }; +// ============================================================================= +// Main App Wrapper with Auth +// ============================================================================= export default function App() { - const { t, i18n } = useTranslation(); - const [meds, setMeds] = useState([]); - const [plannerRows, setPlannerRows] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("plannerRows"); - if (saved) { - try { return JSON.parse(saved); } catch { return []; } - } + return ( + + + + ); +} + +function AppRouter() { + const { user, authState, loading } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + + // Show loading while checking auth state + if (loading) { + return ( +
+
+

💊 MedAssist

+

Loading...

+
+
+ ); + } + + // If auth is enabled + if (authState?.authEnabled) { + // Need to register first user + if (authState.needsSetup) { + return ; } - return []; - }); + // Not logged in + if (!user) { + return ; + } + } + + // Auth disabled or user is logged in - show main app + return ; +} + +// ============================================================================= +// Main App Content +// ============================================================================= + +// Helper for user-specific localStorage keys +function userStorageKey(userId: number | undefined, key: string): string { + return userId ? `user_${userId}_${key}` : key; +} + +function AppContent() { + const { t, i18n } = useTranslation(); + const { user, authState } = useAuth(); + const [showProfile, setShowProfile] = useState(false); + const [meds, setMeds] = useState([]); + const [plannerRows, setPlannerRows] = useState([]); const [plannerLoading, setPlannerLoading] = useState(false); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(defaultForm()); - const [range, setRange] = useState<{ start: string; end: string }>(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem("plannerRange"); - if (saved) { - try { return JSON.parse(saved); } catch { /* ignore */ } + const [range, setRange] = useState<{ start: string; end: string }>({ + start: toInputValue(todayIso()), + end: toInputValue(plusDaysIso(3)) + }); + + // Load user-specific planner data when user changes + useEffect(() => { + if (typeof window !== "undefined" && user?.id) { + const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows")); + const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange")); + + if (savedRows) { + try { setPlannerRows(JSON.parse(savedRows)); } catch { setPlannerRows([]); } + } else { + setPlannerRows([]); + } + + if (savedRange) { + try { setRange(JSON.parse(savedRange)); } catch { /* keep default */ } + } else { + setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); } } - return { start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }; - }); + }, [user?.id]); const navigate = useNavigate(); const location = useLocation(); @@ -155,25 +219,30 @@ export default function App() { const [selectedMed, setSelectedMed] = useState(null); const [showImageLightbox, setShowImageLightbox] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [scheduleDays, setScheduleDays] = useState(() => { - const stored = localStorage.getItem("scheduleDays"); - return stored ? Number(stored) : 30; - }); + const [scheduleDays, setScheduleDays] = useState(30); + const [takenDoses, setTakenDoses] = useState>(new Set()); - // Track taken doses (stored in localStorage) - const [takenDoses, setTakenDoses] = useState>(() => { - try { - const stored = localStorage.getItem("takenDoses"); - if (stored) { - const parsed = JSON.parse(stored); - // Clean up old entries (older than 7 days) - const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo); - return new Set(filtered.map((item: { id: string }) => item.id)); + // Load user-specific scheduleDays and takenDoses when user changes + useEffect(() => { + if (typeof window !== "undefined" && user?.id) { + const storedDays = localStorage.getItem(userStorageKey(user.id, "scheduleDays")); + setScheduleDays(storedDays ? Number(storedDays) : 30); + + try { + const storedDoses = localStorage.getItem(userStorageKey(user.id, "takenDoses")); + if (storedDoses) { + const parsed = JSON.parse(storedDoses); + const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const filtered = parsed.filter((item: { id: string; timestamp: number }) => item.timestamp > weekAgo); + setTakenDoses(new Set(filtered.map((item: { id: string }) => item.id))); + } else { + setTakenDoses(new Set()); + } + } catch { + setTakenDoses(new Set()); } - } catch {} - return new Set(); - }); + } + }, [user?.id]); function markDoseTaken(doseId: string) { setTakenDoses((prev) => { @@ -181,7 +250,9 @@ export default function App() { next.add(doseId); // Persist with timestamp for cleanup const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - localStorage.setItem("takenDoses", JSON.stringify(items)); + if (user?.id) { + localStorage.setItem(userStorageKey(user.id, "takenDoses"), JSON.stringify(items)); + } return next; }); } @@ -191,7 +262,9 @@ export default function App() { const next = new Set(prev); next.delete(doseId); const items = Array.from(next).map((id) => ({ id, timestamp: Date.now() })); - localStorage.setItem("takenDoses", JSON.stringify(items)); + if (user?.id) { + localStorage.setItem(userStorageKey(user.id, "takenDoses"), JSON.stringify(items)); + } return next; }); } @@ -538,16 +611,20 @@ export default function App() { .catch(() => []) as PlannerRow[]; setPlannerRows(rows); setPlannerLoading(false); - // Save to localStorage - localStorage.setItem("plannerRange", JSON.stringify(range)); - localStorage.setItem("plannerRows", JSON.stringify(rows)); + // Save to user-specific localStorage + if (user?.id) { + localStorage.setItem(userStorageKey(user.id, "plannerRange"), JSON.stringify(range)); + localStorage.setItem(userStorageKey(user.id, "plannerRows"), JSON.stringify(rows)); + } } function resetRange() { setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) }); setPlannerRows([]); - localStorage.removeItem("plannerRange"); - localStorage.removeItem("plannerRows"); + if (user?.id) { + localStorage.removeItem(userStorageKey(user.id, "plannerRange")); + localStorage.removeItem(userStorageKey(user.id, "plannerRows")); + } } const [theme, setTheme] = useState<"light" | "dark">(() => { @@ -595,9 +672,25 @@ export default function App() { + {authState?.authEnabled && user && ( + + )} + {/* Profile Modal */} + {showProfile && ( +
setShowProfile(false)}> +
e.stopPropagation()}> + + setShowProfile(false)} /> +
+
+ )} + } /> -
+
{t('table.name')} - {t('table.currentPills')} + {t('table.fullBlisters')} + {t('table.openBlister')} {t('table.daysLeft')} {t('table.status')} {t('table.runsOut')} @@ -655,10 +749,17 @@ export default function App() { const status = getStockStatus(row.daysLeft, row.medsLeft, settings); const med = meds.find(m => m.name === row.name); const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; + const stock = getBlisterStock( + Math.round(row.medsLeft), + med?.tabsPerStrip ?? 1, + med?.looseTablets ?? 0, + med?.count ?? Math.round(row.medsLeft) + ); return (
med && setSelectedMed(med)}> {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} - {formatNumber(row.medsLeft)} + {formatFullBlisters(stock.fullBlisters, t)} + {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)} {formatNumber(row.daysLeft)} {t(status.label)} {row.depletionDate ?? "-"} @@ -691,10 +792,11 @@ export default function App() {

{t('dashboard.overview.title')}

{t('dashboard.overview.badge')}
-
+
{t('table.name')} - {t('table.currentPills')} + {t('table.fullBlisters')} + {t('table.openBlister')} {t('table.daysLeft')} {t('table.runsOut')} {t('table.expiry')} @@ -705,10 +807,17 @@ export default function App() { const med = meds.find(m => m.name === row.name); const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays); const textClass = status.className === "danger" ? "danger-text" : status.className === "warning" ? "warning-text" : ""; + const stock = getBlisterStock( + Math.round(row.medsLeft), + med?.tabsPerStrip ?? 1, + med?.looseTablets ?? 0, + med?.count ?? Math.round(row.medsLeft) + ); return (
med && setSelectedMed(med)}> {row.name}{med?.takenBy && { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}}{med?.intakeRemindersEnabled && 🔔}{med?.notes && 📝} - {formatNumber(row.medsLeft)} + {formatFullBlisters(stock.fullBlisters, t)} + {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)} {formatNumber(row.daysLeft)} {row.depletionDate ?? "-"} {med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"} @@ -730,7 +839,7 @@ export default function App() { onChange={(e) => { const val = Number(e.target.value); setScheduleDays(val); - localStorage.setItem("scheduleDays", String(val)); + if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val)); }} > @@ -1005,7 +1114,9 @@ export default function App() { {row.medicationName} {row.plannerUsage} {t('common.pills')} {row.stripsNeeded} × {row.stripSize} - {row.stripsAvailable} {t('common.blisters')} + + {row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`} + {row.enough ? t('status.enough') : t('status.outOfStock')}
); @@ -1340,7 +1451,7 @@ export default function App() { onChange={(e) => { const val = Number(e.target.value); setScheduleDays(val); - localStorage.setItem("scheduleDays", String(val)); + if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val)); }} > @@ -1429,14 +1540,24 @@ export default function App() {

{t('modal.stockInfo')}

{(() => { const medCoverage = coverage.all.find(c => c.name === selectedMed.name); - const currentStock = medCoverage ? medCoverage.medsLeft : selectedMed.count; + const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : selectedMed.count; const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : ""; + const stock = getBlisterStock( + currentStock, + selectedMed.tabsPerStrip ?? 1, + selectedMed.looseTablets ?? 0, + selectedMed.count + ); return (
- {t('modal.currentStock')} - {formatNumber(currentStock)}/{formatNumber(selectedMed.count)} + {t('table.fullBlisters')} + {formatFullBlisters(stock.fullBlisters, t)} +
+
+ {t('table.openBlister')} + {formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.tabsPerStrip ?? 1, t)}
{t('modal.packs')} @@ -1450,10 +1571,6 @@ export default function App() { {t('modal.pillsPerBlister')} {selectedMed.tabsPerStrip ?? 1}
-
- {t('modal.loosePills')} - {selectedMed.looseTablets ?? 0} -
{selectedMed.pillWeightMg && (
{t('modal.pillWeight')} @@ -1744,6 +1861,65 @@ function formatNumber(value: number | null) { return value.toFixed(1); } +// Calculate blister stock with realistic consumption order: +// Loose pills are consumed FIRST, then blisters are opened +function getBlisterStock( + currentPills: number, + tabsPerStrip: number, + originalLooseTablets: number, + originalTotalPills: number +): { fullBlisters: number; openBlisterPills: number; loosePills: number } { + if (tabsPerStrip <= 0 || tabsPerStrip === 1) { + return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills }; + } + + // Calculate how many pills have been consumed + const consumed = originalTotalPills - currentPills; + + // Loose pills are consumed first + const looseConsumed = Math.min(consumed, originalLooseTablets); + const loosePillsRemaining = originalLooseTablets - looseConsumed; + + // Remaining consumption comes from blisters + const blisterPillsConsumed = consumed - looseConsumed; + const originalBlisterPills = originalTotalPills - originalLooseTablets; + const blisterPillsRemaining = originalBlisterPills - blisterPillsConsumed; + + // Calculate full blisters and open blister + const fullBlisters = Math.floor(blisterPillsRemaining / tabsPerStrip); + const openBlisterPills = blisterPillsRemaining % tabsPerStrip; + + return { fullBlisters, openBlisterPills, loosePills: loosePillsRemaining }; +} + +// Format full blisters column +function formatFullBlisters(fullBlisters: number, t: (key: string) => string): string { + if (fullBlisters === 0) return "—"; + return `${fullBlisters} ${fullBlisters === 1 ? t('common.blister') : t('common.blisters')}`; +} + +// Format open blister + loose pills column +function formatOpenBlisterAndLoose( + openBlisterPills: number, + loosePills: number, + tabsPerStrip: number, + t: (key: string) => string +): string { + // Format open blister part + const openBlisterText = openBlisterPills > 0 + ? `${openBlisterPills} ${t('common.of')} ${tabsPerStrip} ${t('common.pills')}` + : t('common.none'); + + // Format loose pills part (if any) + if (loosePills > 0) { + return `${openBlisterText} + ${loosePills} ${t('common.loose')}`; + } + + // No loose pills + if (openBlisterPills === 0) return "—"; + return openBlisterText; +} + function getExpiryClass(expiryDate: string | null | undefined, expiryWarningDays: number = 30): string { if (!expiryDate) return ""; const now = new Date(); diff --git a/frontend/src/components/Auth.tsx b/frontend/src/components/Auth.tsx new file mode 100644 index 0000000..14d224b --- /dev/null +++ b/frontend/src/components/Auth.tsx @@ -0,0 +1,476 @@ +import { useState, useEffect, createContext, useContext, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +// ============================================================================= +// Types (no roles - all users are equal) +// ============================================================================= +export interface User { + id: number; + username: string; +} + +export interface AuthState { + authEnabled: boolean; + registrationEnabled: boolean; + localAuthEnabled: boolean; + hasUsers: boolean; + needsSetup: boolean; +} + +interface AuthContextType { + user: User | null; + authState: AuthState | null; + loading: boolean; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; + logout: () => Promise; + refreshUser: () => Promise; + updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise; +} + +// ============================================================================= +// Context +// ============================================================================= +const AuthContext = createContext(null); + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within AuthProvider"); + } + return context; +} + +// ============================================================================= +// Provider +// ============================================================================= +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [authState, setAuthState] = useState(null); + const [loading, setLoading] = useState(true); + + // Fetch auth state on mount + useEffect(() => { + fetchAuthState(); + }, []); + + async function fetchAuthState() { + try { + const res = await fetch("/api/auth/state"); + const state = await res.json(); + setAuthState(state); + + // If auth is enabled and we might be logged in, check session + if (state.authEnabled) { + await refreshUser(); + } + } catch (err) { + console.error("Failed to fetch auth state:", err); + } finally { + setLoading(false); + } + } + + async function refreshUser() { + try { + const res = await fetch("/api/auth/me", { credentials: "include" }); + if (res.ok) { + const userData = await res.json(); + setUser(userData); + } else { + setUser(null); + } + } catch { + setUser(null); + } + } + + async function login(username: string, password: string) { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Login failed"); + } + + const data = await res.json(); + setUser(data.user); + } + + async function register(username: string, password: string) { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Registration failed"); + } + + // Auto-login after registration + await login(username, password); + + // Refresh auth state (registration might disable further registrations) + await fetchAuthState(); + } + + async function logout() { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + setUser(null); + } + + async function updateProfile(data: { currentPassword?: string; newPassword?: string }) { + const res = await fetch("/api/auth/me", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(data), + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || "Update failed"); + } + + await refreshUser(); + } + + return ( + + {children} + + ); +} + +// ============================================================================= +// Login Form +// ============================================================================= +export function LoginForm({ onSuccess, onSwitchToRegister }: { onSuccess?: () => void; onSwitchToRegister?: () => void }) { + const { t } = useTranslation(); + const { login, authState } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + await login(username, password); + onSuccess?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

💊 MedAssist

+

{t("auth.login", "Login")}

+ +
+ {error &&
{error}
} + +
+ + setUsername(e.target.value)} + required + autoComplete="username" + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ + +
+ + {authState?.registrationEnabled && onSwitchToRegister && ( +
+ +
+ )} +
+
+ ); +} + +// ============================================================================= +// Registration Form +// ============================================================================= +export function RegisterForm({ onSuccess, onSwitchToLogin }: { onSuccess?: () => void; onSwitchToLogin?: () => void }) { + const { t } = useTranslation(); + const { register, authState } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError(t("auth.passwordMismatch", "Passwords do not match")); + return; + } + + setLoading(true); + + try { + await register(username, password); + onSuccess?.(); + } catch (err) { + setError(err instanceof Error ? err.message : "Registration failed"); + } finally { + setLoading(false); + } + } + + return ( +
+
+

💊 MedAssist

+

+ {t("auth.register", "Create Account")} +

+ +
+ {error &&
{error}
} + +
+ + setUsername(e.target.value)} + required + autoComplete="username" + autoFocus + minLength={3} + maxLength={50} + pattern="[a-zA-Z0-9_-]+" + title={t("auth.usernameHint", "Letters, numbers, underscores, and hyphens only")} + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + minLength={8} + maxLength={128} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + /> +
+ + +
+ + {onSwitchToLogin && ( +
+ +
+ )} +
+
+ ); +} + +// ============================================================================= +// User Profile Component +// ============================================================================= +export function UserProfile({ onClose }: { onClose?: () => void }) { + const { t } = useTranslation(); + const { user, logout, updateProfile } = 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); + + async function handleUpdate(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setSuccess(""); + + if (newPassword && newPassword !== confirmPassword) { + setError(t("auth.passwordMismatch", "Passwords do not match")); + return; + } + + setLoading(true); + + try { + await updateProfile({ + currentPassword: currentPassword || undefined, + newPassword: newPassword || undefined, + }); + setSuccess(t("auth.profileUpdated", "Profile updated successfully")); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Update failed"); + } finally { + setLoading(false); + } + } + + async function handleLogout() { + await logout(); + onClose?.(); + } + + if (!user) return null; + + return ( +
+
+

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

+
+ +
+

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

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

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

+ +
+ + setCurrentPassword(e.target.value)} + autoComplete="current-password" + /> +
+ +
+ + setNewPassword(e.target.value)} + autoComplete="new-password" + minLength={8} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + /> +
+ +
+ + +
+
+
+ ); +} + +// ============================================================================= +// Auth Page (combines Login/Register with routing) +// ============================================================================= +export function AuthPage() { + const { authState } = useAuth(); + const [mode, setMode] = useState<"login" | "register">("login"); + + // Auto-show register if no users exist yet (first setup) + useEffect(() => { + if (authState?.needsSetup) { + setMode("register"); + } + }, [authState?.needsSetup]); + + if (mode === "register") { + return ( + setMode("login")} + onSwitchToLogin={() => setMode("login")} + /> + ); + } + + return ( + setMode("register") : undefined} + /> + ); +} diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index a5730bb..88377d6 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -56,6 +56,8 @@ "pills": "Tabletten", "days": "Tage", "currentPills": "Aktuelle Tabletten", + "fullBlisters": "Volle Blister", + "openBlister": "Offener Blister", "daysLeft": "Tage übrig", "status": "Bestand", "runsOut": "Aufgebraucht", @@ -216,6 +218,35 @@ "takenBy": "eingenommen von", "markAsTaken": "Als eingenommen markieren" }, + "auth": { + "login": "Anmelden", + "logout": "Abmelden", + "register": "Konto erstellen", + "createAdmin": "Admin-Konto erstellen", + "profile": "Profil", + "username": "Benutzername", + "password": "Passwort", + "email": "E-Mail", + "confirmPassword": "Passwort bestätigen", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "changePassword": "Passwort ändern", + "forgotPassword": "Passwort vergessen?", + "sendResetLink": "Link senden", + "resetPassword": "Passwort zurücksetzen", + "backToLogin": "Zurück zur Anmeldung", + "createAccount": "Konto erstellen", + "alreadyHaveAccount": "Bereits ein Konto? Anmelden", + "firstUserInfo": "Dies wird das Administrator-Konto.", + "usernameHint": "Nur Buchstaben, Zahlen, Unterstriche und Bindestriche", + "emailHint": "Für Passwort-Wiederherstellung", + "passwordMismatch": "Passwörter stimmen nicht überein", + "checkEmail": "E-Mail überprüfen", + "resetEmailSent": "Falls ein Konto mit dieser E-Mail existiert, haben wir einen Link zum Zurücksetzen gesendet.", + "passwordReset": "Passwort zurückgesetzt", + "passwordResetSuccess": "Ihr Passwort wurde zurückgesetzt. Weiterleitung zur Anmeldung...", + "profileUpdated": "Profil erfolgreich aktualisiert" + }, "common": { "loading": "Wird geladen...", "sending": "Wird gesendet...", @@ -233,9 +264,16 @@ "optional": "optional", "pill": "Tablette", "pills": "Tabletten", + "of": "von", + "loose": "lose", + "none": "Kein", "day": "Tag", "days": "Tage", + "blister": "Blister", "blisters": "Blister", + "fullBlister": "voller Blister", + "fullBlisters": "volle Blister", + "inBlister": "in 1 Blister", "total": "gesamt" } } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a269609..5e6968f 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -58,6 +58,8 @@ "pills": "Pills", "days": "Days", "currentPills": "Current pills", + "fullBlisters": "Full blisters", + "openBlister": "Open blister", "daysLeft": "Days left", "status": "Stock", "runsOut": "Runs out", @@ -218,6 +220,35 @@ "takenBy": "taken by", "markAsTaken": "Mark as taken" }, + "auth": { + "login": "Login", + "logout": "Logout", + "register": "Create Account", + "createAdmin": "Create Admin Account", + "profile": "Profile", + "username": "Username", + "password": "Password", + "email": "Email", + "confirmPassword": "Confirm Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "changePassword": "Change Password", + "forgotPassword": "Forgot password?", + "sendResetLink": "Send Reset Link", + "resetPassword": "Reset Password", + "backToLogin": "Back to Login", + "createAccount": "Create account", + "alreadyHaveAccount": "Already have an account? Login", + "firstUserInfo": "This will be the administrator account.", + "usernameHint": "Letters, numbers, underscores, and hyphens only", + "emailHint": "For password recovery", + "passwordMismatch": "Passwords do not match", + "checkEmail": "Check your email", + "resetEmailSent": "If an account with this email exists, we've sent a password reset link.", + "passwordReset": "Password Reset", + "passwordResetSuccess": "Your password has been reset. Redirecting to login...", + "profileUpdated": "Profile updated successfully" + }, "common": { "loading": "Loading...", "sending": "Sending...", @@ -235,9 +266,16 @@ "optional": "optional", "pill": "pill", "pills": "pills", + "of": "of", + "loose": "loose", + "none": "None", "day": "day", "days": "days", + "blister": "blister", "blisters": "blisters", + "fullBlister": "full blister", + "fullBlisters": "full blisters", + "inBlister": "in 1 blister", "total": "total" } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 69460db..2f92f3a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1743,6 +1743,7 @@ textarea { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); animation: slideUp 0.3s ease; border: 1px solid var(--border-primary); + padding: 1.5rem; } @keyframes slideUp { @@ -2176,3 +2177,227 @@ h3 .reminder-icon.info-tooltip { font-size: 0.75em; vertical-align: middle; } + +/* ============================================================================= + Auth Components + ============================================================================= */ + +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-gradient); + padding: 1rem; +} + +.auth-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: 16px; + padding: 2.5rem; + width: 100%; + max-width: 420px; + box-shadow: 0 10px 40px var(--shadow); +} + +.auth-title { + font-size: 2rem; + font-weight: 700; + text-align: center; + margin: 0 0 0.5rem 0; + color: var(--text-primary); +} + +.auth-subtitle { + font-size: 1.25rem; + font-weight: 500; + text-align: center; + margin: 0 0 1.5rem 0; + color: var(--text-secondary); +} + +.auth-info { + text-align: center; + color: var(--text-secondary); + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--accent-bg); + border-radius: 8px; + font-size: 0.9rem; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.auth-error { + background: var(--danger-bg); + color: var(--danger); + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.9rem; + text-align: center; +} + +.auth-success { + background: var(--success-bg); + color: var(--success); + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.9rem; + text-align: center; +} + +.auth-submit { + margin-top: 0.5rem; + padding: 0.875rem; + font-size: 1rem; + font-weight: 600; +} + +.auth-links { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border-primary); +} + +.auth-link, +.auth-link-btn { + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; + background: none; + border: none; + cursor: pointer; + padding: 0; +} + +.auth-link:hover, +.auth-link-btn:hover { + text-decoration: underline; + color: var(--accent-light); +} + +/* Profile Component */ +.profile-container { + padding: 0; +} + +.profile-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1.5rem; + padding-right: 2.5rem; /* Space for modal close button */ +} + +.profile-header h2 { + margin: 0; + font-size: 1.25rem; +} + +.profile-role { + background: var(--accent-bg); + color: var(--accent); + padding: 0.25rem 0.75rem; + border-radius: 100px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +.profile-info { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg-tertiary); + border-radius: 8px; +} + +.profile-info p { + margin: 0; + color: var(--text-secondary); +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.profile-form h3 { + margin: 0.5rem 0 0 0; + font-size: 1rem; + color: var(--text-secondary); +} + +.profile-divider { + border: none; + border-top: 1px solid var(--border-primary); + margin: 0.5rem 0; +} + +.profile-actions { + display: flex; + gap: 1rem; + margin-top: 0.5rem; +} + +.profile-actions .btn { + flex: 1; +} + +/* User Menu Button in Header */ +.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); + cursor: pointer; + font-size: 0.875rem; + transition: all 0.2s ease; +} + +.user-menu-btn:hover { + background: var(--accent-bg); + border-color: var(--accent); +} + +.user-menu-btn .user-avatar { + width: 28px; + height: 28px; + background: var(--accent); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.75rem; +} + +@media (max-width: 600px) { + .auth-card { + padding: 1.5rem; + } + + .auth-title { + font-size: 1.75rem; + } +} + +/* Profile Modal */ +.profile-modal { + max-width: 480px; + padding: 1.5rem; +}