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
+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 };
}
}