feat(auth): implement user authentication and profile management

- Added authentication context and provider to manage user state.
- Created login and registration forms with validation and error handling.
- Implemented user profile component for updating user information and changing passwords.
- Introduced user settings in the database for notification preferences.
- Updated translations for authentication-related strings in English and German.
- Enhanced styles for authentication components and user profile.
- Added middleware for optional and required authentication checks.
This commit is contained in:
Daniel Volz
2025-12-26 19:57:35 +01:00
parent 5900fddb2d
commit a7f9f90db4
20 changed files with 2020 additions and 402 deletions
+40 -46
View File
@@ -11,32 +11,66 @@ async function main() {
const client = createClient({ url });
// Create tables directly
// Create tables - fresh schema without roles, with per-user settings
const sql = `
CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
email text NOT NULL UNIQUE,
password_hash text NOT NULL,
role text NOT NULL DEFAULT 'user',
username text NOT NULL UNIQUE,
password_hash text,
auth_provider text NOT NULL DEFAULT 'local',
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
name text NOT NULL UNIQUE,
user_id integer NOT NULL,
name text NOT NULL,
generic_name text,
taken_by text,
count integer NOT NULL DEFAULT 0,
strips integer NOT NULL DEFAULT 0,
pack_count integer NOT NULL DEFAULT 1,
strips_per_pack integer NOT NULL DEFAULT 1,
tabs_per_strip integer NOT NULL DEFAULT 1,
loose_tablets integer NOT NULL DEFAULT 0,
pill_weight_mg integer,
usage_json text NOT NULL DEFAULT '[]',
every_json text NOT NULL DEFAULT '[]',
start_json text NOT NULL DEFAULT '[]',
strip_size integer NOT NULL DEFAULT 1,
image_url text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
expiry_date text,
notes text,
intake_reminders_enabled integer NOT NULL DEFAULT 0,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
language text NOT NULL DEFAULT 'en',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS refresh_tokens (
@@ -49,20 +83,6 @@ async function main() {
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY AUTOINCREMENT,
smtp_host text,
smtp_port integer,
smtp_user text,
smtp_pass_encrypted text,
smtp_from text,
smtp_secure integer NOT NULL DEFAULT 0,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
reminder_days_before integer NOT NULL DEFAULT 7,
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
);
`;
// Execute each statement separately
@@ -73,32 +93,6 @@ async function main() {
await client.execute(stmt);
}
// Run migrations for existing databases
console.log("Running migrations for existing databases...");
const migrations = [
{ column: "image_url", sql: "ALTER TABLE medications ADD COLUMN image_url TEXT" },
{ column: "expiry_date", sql: "ALTER TABLE medications ADD COLUMN expiry_date TEXT" },
{ column: "notes", sql: "ALTER TABLE medications ADD COLUMN notes TEXT" },
{ column: "generic_name", sql: "ALTER TABLE medications ADD COLUMN generic_name TEXT" },
{ column: "intake_reminders_enabled", sql: "ALTER TABLE medications ADD COLUMN intake_reminders_enabled INTEGER NOT NULL DEFAULT 0" },
{ column: "pill_weight_mg", sql: "ALTER TABLE medications ADD COLUMN pill_weight_mg INTEGER" },
{ column: "taken_by", sql: "ALTER TABLE medications ADD COLUMN taken_by TEXT" },
];
for (const migration of migrations) {
try {
await client.execute(migration.sql);
console.log(`Added ${migration.column} column`);
} catch (e: any) {
if (e.message?.includes("duplicate column") || e.message?.includes("already exists")) {
console.log(`${migration.column} column already exists, skipping`);
} else {
throw e;
}
}
}
console.log("Database setup complete!");
process.exit(0);
}
@@ -0,0 +1,28 @@
-- Add user_id to medications (for existing databases)
-- First, add the column as nullable
ALTER TABLE medications ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
-- Create user_settings table for per-user notification settings
CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
notification_email text,
email_stock_reminders integer NOT NULL DEFAULT 1,
email_intake_reminders integer NOT NULL DEFAULT 1,
shoutrrr_enabled integer NOT NULL DEFAULT 0,
shoutrrr_url text,
shoutrrr_stock_reminders integer NOT NULL DEFAULT 1,
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
language text NOT NULL DEFAULT 'en',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
+2 -1
View File
@@ -9,6 +9,7 @@
{ "idx": 6, "version": 1, "when": 1735200000, "tag": "0006_add_generic_name", "breakpoint": false },
{ "idx": 7, "version": 1, "when": 1735300000, "tag": "0007_add_intake_reminders", "breakpoint": false },
{ "idx": 8, "version": 1, "when": 1735400000, "tag": "0008_add_pill_weight", "breakpoint": false },
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false }
{ "idx": 9, "version": 1, "when": 1735500000, "tag": "0009_add_taken_by", "breakpoint": false },
{ "idx": 10, "version": 1, "when": 1735600000, "tag": "0010_add_user_settings", "breakpoint": false }
]
}
+50 -20
View File
@@ -1,18 +1,27 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
// =============================================================================
// Users - Simple auth, no roles (every user is equal)
// =============================================================================
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email", { length: 255 }).notNull().unique(),
passwordHash: text("password_hash", { length: 255 }).notNull(),
role: text("role", { length: 50 }).notNull().default("user"),
username: text("username", { length: 100 }).notNull().unique(),
passwordHash: text("password_hash", { length: 255 }),
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastLoginAt: integer("last_login_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Medications - Per user
// =============================================================================
export const medications = sqliteTable("medications", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name", { length: 100 }).notNull().unique(),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
genericName: text("generic_name", { length: 100 }),
takenBy: text("taken_by", { length: 100 }),
count: integer("count").notNull().default(0),
@@ -33,6 +42,42 @@ export const medications = sqliteTable("medications", {
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// User Settings - Per user (email, push, thresholds, language)
// =============================================================================
export const userSettings = sqliteTable("user_settings", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
// Email notifications
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
notificationEmail: text("notification_email"),
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
// Push notifications (shoutrrr/ntfy)
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
shoutrrrUrl: text("shoutrrr_url"),
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
// Reminder settings
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
// Stock thresholds (days)
lowStockDays: integer("low_stock_days").notNull().default(30),
normalStockDays: integer("normal_stock_days").notNull().default(90),
highStockDays: integer("high_stock_days").notNull().default(180),
// UI preferences
language: text("language", { length: 10 }).notNull().default("en"),
// Last notification tracking
lastAutoEmailSent: text("last_auto_email_sent"),
lastNotificationType: text("last_notification_type"),
lastNotificationChannel: text("last_notification_channel"),
// Timestamps
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Refresh Tokens - For JWT rotation
// =============================================================================
export const refreshTokens = sqliteTable("refresh_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -42,18 +87,3 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const settings = sqliteTable("settings", {
id: integer("id").primaryKey({ autoIncrement: true }),
smtpHost: text("smtp_host"),
smtpPort: integer("smtp_port"),
smtpUser: text("smtp_user"),
smtpPassEncrypted: text("smtp_pass_encrypted"),
smtpFrom: text("smtp_from"),
smtpSecure: integer("smtp_secure", { mode: "boolean" }).notNull().default(false),
// Email notification settings
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
notificationEmail: text("notification_email"),
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});