feat(auth): implement user authentication and profile management

- Added authentication context and provider to manage user state.
- Created login and registration forms with validation and error handling.
- Implemented user profile component for updating user information and changing passwords.
- Introduced user settings in the database for notification preferences.
- Updated translations for authentication-related strings in English and German.
- Enhanced styles for authentication components and user profile.
- Added middleware for optional and required authentication checks.
This commit is contained in:
Daniel Volz
2025-12-26 19:57:35 +01:00
parent 5900fddb2d
commit a7f9f90db4
20 changed files with 2020 additions and 402 deletions
+18 -5
View File
@@ -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=
+40 -46
View File
@@ -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);
}
@@ -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
);
+2 -1
View File
@@ -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 }
]
}
+50 -20
View File
@@ -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`),
});
+19 -4
View File
@@ -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,
+112
View File
@@ -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<AuthState> {
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();
});
}
+29 -4
View File
@@ -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<typeof EnvSchema>;
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;
+362 -24
View File
@@ -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<typeof registerSchema> }>("/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<typeof loginSchema> }>("/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<typeof updateProfileSchema> }>("/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<typeof users.$inferInsert> = {
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" };
});
}
+50 -17
View File
@@ -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,
};
});
+31 -8
View File
@@ -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
+173 -123
View File
@@ -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<UserSettings> {
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<UserSettings[]> {
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<string, string> = {};
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 };
}
}
@@ -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<void> {
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<void> {
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;
+42 -75
View File
@@ -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<LowStockItem[]> {
const rows = await db.select().from(medications).orderBy(medications.id);
async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: number, language: Language): Promise<LowStockItem[]> {
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<void> {
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<void> {
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,
+20
View File
@@ -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<string, unknown>;
user: Record<string, unknown>;
}
}
+234 -58
View File
@@ -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<Medication[]>([]);
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("plannerRows");
if (saved) {
try { return JSON.parse(saved); } catch { return []; }
}
return (
<AuthProvider>
<AppRouter />
</AuthProvider>
);
}
function AppRouter() {
const { user, authState, loading } = useAuth();
const location = useLocation();
const navigate = useNavigate();
// Show loading while checking auth state
if (loading) {
return (
<div className="auth-container">
<div className="auth-card" style={{ textAlign: "center" }}>
<h1 className="auth-title">💊 MedAssist</h1>
<p>Loading...</p>
</div>
</div>
);
}
// If auth is enabled
if (authState?.authEnabled) {
// Need to register first user
if (authState.needsSetup) {
return <AuthPage />;
}
return [];
});
// Not logged in
if (!user) {
return <AuthPage />;
}
}
// Auth disabled or user is logged in - show main app
return <AppContent />;
}
// =============================================================================
// 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<Medication[]>([]);
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
const [plannerLoading, setPlannerLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(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<Medication | null>(null);
const [showImageLightbox, setShowImageLightbox] = useState(false);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [scheduleDays, setScheduleDays] = useState<number>(() => {
const stored = localStorage.getItem("scheduleDays");
return stored ? Number(stored) : 30;
});
const [scheduleDays, setScheduleDays] = useState<number>(30);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
// Track taken doses (stored in localStorage)
const [takenDoses, setTakenDoses] = useState<Set<string>>(() => {
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() {
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
{authState?.authEnabled && user && (
<button className="user-menu-btn" onClick={() => setShowProfile(true)} title={t('auth.profile', 'Profile')}>
<span className="user-avatar">{user.username.charAt(0).toUpperCase()}</span>
<span>{user.username}</span>
</button>
)}
</div>
</header>
{/* Profile Modal */}
{showProfile && (
<div className="modal-overlay" onClick={() => setShowProfile(false)}>
<div className="modal-content profile-modal" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={() => setShowProfile(false)}>×</button>
<UserProfile onClose={() => setShowProfile(false)} />
</div>
</div>
)}
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={
@@ -642,10 +735,11 @@ export default function App() {
return (
<>
<div className="table table-6">
<div className="table table-7">
<div className="table-head">
<span>{t('table.name')}</span>
<span>{t('table.currentPills')}</span>
<span>{t('table.fullBlisters')}</span>
<span>{t('table.openBlister')}</span>
<span>{t('table.daysLeft')}</span>
<span>{t('table.status')}</span>
<span>{t('table.runsOut')}</span>
@@ -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 (
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<span data-label={t('table.name')} className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.takenBy && <span className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}</span>}{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}{med?.notes && <span className="notes-icon info-tooltip" data-tooltip={t('tooltips.hasNotes')}>📝</span>}</span>
<span data-label={t('table.pills')} className={textClass}>{formatNumber(row.medsLeft)}</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)}</span>
<span data-label={t('table.days')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.status')} className={`status-chip ${status.className}`}>{t(status.label)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
@@ -691,10 +792,11 @@ export default function App() {
<h2>{t('dashboard.overview.title')}</h2>
<span className="pill neutral">{t('dashboard.overview.badge')}</span>
</div>
<div className="table table-6">
<div className="table table-7">
<div className="table-head">
<span>{t('table.name')}</span>
<span>{t('table.currentPills')}</span>
<span>{t('table.fullBlisters')}</span>
<span>{t('table.openBlister')}</span>
<span>{t('table.daysLeft')}</span>
<span>{t('table.runsOut')}</span>
<span>{t('table.expiry')}</span>
@@ -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 (
<div key={row.name} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<span data-label={t('table.name')} className="cell-with-avatar"><MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />{row.name}{med?.takenBy && <span className="taken-by-badge clickable" onClick={(e) => { e.stopPropagation(); setSelectedUser(med.takenBy!); }}>{med.takenBy}</span>}{med?.intakeRemindersEnabled && <span className="reminder-icon info-tooltip" data-tooltip={t('tooltips.intakeReminders')}>🔔</span>}{med?.notes && <span className="notes-icon info-tooltip" data-tooltip={t('tooltips.hasNotes')}>📝</span>}</span>
<span data-label={t('table.pills')} className={textClass}>{formatNumber(row.medsLeft)}</span>
<span data-label={t('table.fullBlisters')} className={textClass}>{formatFullBlisters(stock.fullBlisters, t)}</span>
<span data-label={t('table.openBlister')} className={textClass}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, med?.tabsPerStrip ?? 1, t)}</span>
<span data-label={t('table.daysLeft')} className={textClass}>{formatNumber(row.daysLeft)}</span>
<span data-label={t('table.runsOut')}>{row.depletionDate ?? "-"}</span>
<span data-label={t('table.expiry')} className={expiryClass}>{med?.expiryDate ? new Date(med.expiryDate).toLocaleDateString(i18n.language, { day: "2-digit", month: "short", year: "2-digit" }) : "-"}</span>
@@ -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));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
@@ -1005,7 +1114,9 @@ export default function App() {
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong> {t('common.pills')}</span>
<span data-label={t('planner.table.blisters')}>{row.stripsNeeded} × {row.stripSize}</span>
<span data-label={t('planner.table.available')}>{row.stripsAvailable} {t('common.blisters')}</span>
<span data-label={t('planner.table.available')}>
{row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`}
</span>
<span data-label={t('table.status')} className={row.enough ? "status-chip success" : "status-chip danger"}>{row.enough ? t('status.enough') : t('status.outOfStock')}</span>
</div>
);
@@ -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));
}}
>
<option value={30}>{t('dashboard.schedules.1month')}</option>
@@ -1429,14 +1540,24 @@ export default function App() {
<h3>{t('modal.stockInfo')}</h3>
{(() => {
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 (
<div className="med-detail-grid">
<div className="med-detail-item">
<span className="med-detail-label">{t('modal.currentStock')}</span>
<span className={`med-detail-value ${textClass}`}>{formatNumber(currentStock)}/{formatNumber(selectedMed.count)}</span>
<span className="med-detail-label">{t('table.fullBlisters')}</span>
<span className={`med-detail-value ${textClass}`}>{formatFullBlisters(stock.fullBlisters, t)}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t('table.openBlister')}</span>
<span className={`med-detail-value ${textClass}`}>{formatOpenBlisterAndLoose(stock.openBlisterPills, stock.loosePills, selectedMed.tabsPerStrip ?? 1, t)}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t('modal.packs')}</span>
@@ -1450,10 +1571,6 @@ export default function App() {
<span className="med-detail-label">{t('modal.pillsPerBlister')}</span>
<span className="med-detail-value">{selectedMed.tabsPerStrip ?? 1}</span>
</div>
<div className="med-detail-item">
<span className="med-detail-label">{t('modal.loosePills')}</span>
<span className="med-detail-value">{selectedMed.looseTablets ?? 0}</span>
</div>
{selectedMed.pillWeightMg && (
<div className="med-detail-item">
<span className="med-detail-label">{t('modal.pillWeight')}</span>
@@ -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();
+476
View File
@@ -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<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
updateProfile: (data: { currentPassword?: string; newPassword?: string }) => Promise<void>;
}
// =============================================================================
// Context
// =============================================================================
const AuthContext = createContext<AuthContextType | null>(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<User | null>(null);
const [authState, setAuthState] = useState<AuthState | null>(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 (
<AuthContext.Provider value={{ user, authState, loading, login, register, logout, refreshUser, updateProfile }}>
{children}
</AuthContext.Provider>
);
}
// =============================================================================
// 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 (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">💊 MedAssist</h1>
<h2 className="auth-subtitle">{t("auth.login", "Login")}</h2>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="auth-error">{error}</div>}
<div className="form-group">
<label htmlFor="username">{t("auth.username", "Username")}</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">{t("auth.password", "Password")}</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? t("common.loading", "Loading...") : t("auth.login", "Login")}
</button>
</form>
{authState?.registrationEnabled && onSwitchToRegister && (
<div className="auth-links">
<button type="button" className="auth-link-btn" onClick={onSwitchToRegister}>
{t("auth.createAccount", "Create account")}
</button>
</div>
)}
</div>
</div>
);
}
// =============================================================================
// 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 (
<div className="auth-container">
<div className="auth-card">
<h1 className="auth-title">💊 MedAssist</h1>
<h2 className="auth-subtitle">
{t("auth.register", "Create Account")}
</h2>
<form onSubmit={handleSubmit} className="auth-form">
{error && <div className="auth-error">{error}</div>}
<div className="form-group">
<label htmlFor="username">{t("auth.username", "Username")} *</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => 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")}
/>
</div>
<div className="form-group">
<label htmlFor="password">{t("auth.password", "Password")} *</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
maxLength={128}
/>
</div>
<div className="form-group">
<label htmlFor="confirmPassword">{t("auth.confirmPassword", "Confirm Password")} *</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
<button type="submit" className="btn btn-primary auth-submit" disabled={loading}>
{loading ? t("common.loading", "Loading...") : t("auth.register", "Create Account")}
</button>
</form>
{onSwitchToLogin && (
<div className="auth-links">
<button type="button" className="auth-link-btn" onClick={onSwitchToLogin}>
{t("auth.alreadyHaveAccount", "Already have an account? Login")}
</button>
</div>
)}
</div>
</div>
);
}
// =============================================================================
// 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 (
<div className="profile-container">
<div className="profile-header">
<h2>{t("auth.profile", "Profile")}</h2>
</div>
<div className="profile-info">
<p><strong>{t("auth.username", "Username")}:</strong> {user.username}</p>
</div>
<form onSubmit={handleUpdate} className="profile-form">
{error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>}
<h3>{t("auth.changePassword", "Change Password")}</h3>
<div className="form-group">
<label htmlFor="current-password">{t("auth.currentPassword", "Current Password")}</label>
<input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div className="form-group">
<label htmlFor="new-password">{t("auth.newPassword", "New Password")}</label>
<input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="form-group">
<label htmlFor="confirm-new-password">{t("auth.confirmPassword", "Confirm Password")}</label>
<input
id="confirm-new-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="profile-actions">
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? t("common.loading", "Loading...") : t("common.save", "Save")}
</button>
<button type="button" className="btn btn-danger" onClick={handleLogout}>
{t("auth.logout", "Logout")}
</button>
</div>
</form>
</div>
);
}
// =============================================================================
// 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 (
<RegisterForm
onSuccess={() => setMode("login")}
onSwitchToLogin={() => setMode("login")}
/>
);
}
return (
<LoginForm
onSwitchToRegister={authState?.registrationEnabled ? () => setMode("register") : undefined}
/>
);
}
+38
View File
@@ -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"
}
}
+38
View File
@@ -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"
}
}
+225
View File
@@ -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;
}