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:
+18
-5
@@ -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
@@ -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
|
||||
);
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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" };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+20
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user