feat: add expiration date to share tokens and enhance error handling for expired links

This commit is contained in:
Daniel Volz
2025-12-28 17:47:49 +01:00
parent 0e52a03f7a
commit abffd66e9c
10 changed files with 182 additions and 10 deletions
+9 -2
View File
@@ -209,7 +209,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
## ⚠️ Database Migrations (CRITICAL)
**When adding/modifying database columns, ALWAYS:**
**When adding/modifying database columns or tables, ALWAYS do ALL of the following:**
1. **Update schema**: `backend/src/db/schema.ts`
2. **Create migration file**: `backend/src/db/migrations/XXXX_description.sql`
@@ -221,8 +221,15 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
```json
{ "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false }
```
4. **Update migrate.ts**: `backend/src/db/migrate.ts`
- This file contains `CREATE TABLE IF NOT EXISTS` statements for fresh database starts
- Add new columns to the relevant table or add new tables
- Without this, fresh installs will be missing the new columns/tables!
**Why this matters**: The dev database might get updated manually, but production will break without proper migration files. This causes `SQLITE_ERROR: no such column` errors in prod.
**Why this matters**:
- Migration SQL files: Required for upgrading existing databases
- migrate.ts: Required for fresh database starts (creates tables with all columns)
- Forgetting either causes `SQLITE_ERROR: no such column` errors
## File Locations
+13
View File
@@ -17,7 +17,9 @@ async function main() {
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
avatar_url text,
auth_provider text NOT NULL DEFAULT 'local',
oidc_subject text,
is_active integer NOT NULL DEFAULT 1,
last_login_at integer,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
@@ -66,6 +68,7 @@ async function main() {
normal_stock_days integer NOT NULL DEFAULT 90,
high_stock_days integer NOT NULL DEFAULT 180,
language text NOT NULL DEFAULT 'en',
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
@@ -91,6 +94,16 @@ async function main() {
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`;
@@ -0,0 +1,3 @@
-- Add expiration date to share tokens (default 1 year from creation)
-- NULL means no expiration (for backwards compatibility with existing tokens)
ALTER TABLE share_tokens ADD COLUMN expires_at INTEGER;
+2 -1
View File
@@ -14,6 +14,7 @@
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false },
{ "idx": 12, "version": 1, "when": 1735800000, "tag": "0012_add_user_avatar", "breakpoint": false },
{ "idx": 13, "version": 1, "when": 1735900000, "tag": "0013_add_oidc_subject", "breakpoint": false },
{ "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false }
{ "idx": 14, "version": 1, "when": 1735400000, "tag": "0014_add_stock_calculation_mode", "breakpoint": false },
{ "idx": 15, "version": 1, "when": 1735400001, "tag": "0015_add_share_token_expiry", "breakpoint": false }
]
}
+1
View File
@@ -102,6 +102,7 @@ export const shareTokens = sqliteTable("share_tokens", {
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
});
// =============================================================================
+26 -2
View File
@@ -2,12 +2,15 @@ import { FastifyInstance } from "fastify";
import { z } from "zod";
import { randomBytes } from "crypto";
import { db } from "../db/client.js";
import { medications, shareTokens, userSettings } from "../db/schema.js";
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
// Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
// =============================================================================
// Validation Schemas
// =============================================================================
@@ -45,7 +48,23 @@ export async function shareRoutes(app: FastifyInstance) {
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND"
});
}
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
});
}
// Get user settings for stock thresholds
@@ -133,6 +152,9 @@ export async function shareRoutes(app: FastifyInstance) {
// Generate unique token (8 bytes = 16 hex chars)
const token = randomBytes(8).toString("hex");
// Set expiration date (1 year from now)
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
// Create share token
await db.insert(shareTokens).values({
@@ -140,11 +162,13 @@ export async function shareRoutes(app: FastifyInstance) {
token,
takenBy,
scheduleDays,
expiresAt,
});
return {
token,
shareUrl: `/share/${token}`,
expiresAt: expiresAt.toISOString(),
};
}
);
+62 -3
View File
@@ -3181,15 +3181,38 @@ type SharedScheduleData = {
};
};
type ExpiredLinkData = {
ownerUsername: string;
takenBy: string;
expiredAt: string;
};
function SharedSchedule() {
const { token } = useParams<{ token: string }>();
const { t, i18n } = useTranslation();
const [data, setData] = useState<SharedScheduleData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
const [showPastDays, setShowPastDays] = useState(false);
const [theme, setTheme] = useState<"light" | "dark">(() => {
if (typeof window !== "undefined") {
return (localStorage.getItem("theme") as "light" | "dark") || "dark";
}
return "dark";
});
// Apply theme to document
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
function toggleTheme() {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
}
// Collapsed days state for SharedSchedule (token-specific localStorage)
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
@@ -3332,17 +3355,27 @@ function SharedSchedule() {
if (res.ok) {
const json = await res.json();
setData(json);
} else if (res.status === 410) {
// Link expired - get owner info
const json = await res.json();
setExpiredData({
ownerUsername: json.ownerUsername,
takenBy: json.takenBy,
expiredAt: json.expiredAt,
});
} else if (res.status === 404) {
setError(t('share.notFound'));
} else {
setError("Share link not found or expired");
setError(t('share.error'));
}
} catch {
setError("Failed to load schedule");
setError(t('share.error'));
} finally {
setLoading(false);
}
}
fetchData();
}, [token]);
}, [token, t]);
// Build schedule from medications
const schedule = useMemo(() => {
@@ -3498,6 +3531,27 @@ function SharedSchedule() {
);
}
if (expiredData) {
return (
<div className="shared-schedule-page">
<div className="shared-schedule-error expired">
<h1>💊 MedAssist</h1>
<div className="expired-icon"></div>
<h2>{t('share.expired.title')}</h2>
<p className="expired-message">
{t('share.expired.message', { takenBy: expiredData.takenBy })}
</p>
<p className="expired-contact">
{t('share.expired.contact', { username: expiredData.ownerUsername })}
</p>
<p className="expired-date">
{t('share.expired.expiredOn', { date: new Date(expiredData.expiredAt).toLocaleDateString(i18n.language) })}
</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="shared-schedule-page">
@@ -3514,6 +3568,11 @@ function SharedSchedule() {
<div className="shared-schedule-container">
<header className="shared-schedule-header">
<h1>💊 {t('share.scheduleFor')} {data.takenBy}</h1>
<div className="shared-schedule-header-actions">
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? t('tooltips.lightMode') : t('tooltips.darkMode')}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
</div>
<p className="shared-schedule-period">
{t('share.period')}: {data.scheduleDays === 30 ? t('dashboard.schedules.1month') : data.scheduleDays === 90 ? t('dashboard.schedules.3months') : t('dashboard.schedules.6months')}
</p>
+9 -1
View File
@@ -330,6 +330,14 @@
"scheduleFor": "Zeitplan für",
"period": "Zeitraum",
"noSchedule": "Keine geplanten Einnahmen gefunden.",
"generatedBy": "Erstellt von"
"generatedBy": "Erstellt von",
"notFound": "Teilen-Link nicht gefunden",
"error": "Zeitplan konnte nicht geladen werden",
"expired": {
"title": "Link abgelaufen",
"message": "Dieser Teilen-Link für den Medikamentenplan von {{takenBy}} ist abgelaufen.",
"contact": "Bitte kontaktiere {{username}} um einen neuen Link anzufordern.",
"expiredOn": "Abgelaufen am: {{date}}"
}
}
}
+9 -1
View File
@@ -332,6 +332,14 @@
"scheduleFor": "Schedule for",
"period": "Period",
"noSchedule": "No scheduled doses found.",
"generatedBy": "Generated by"
"generatedBy": "Generated by",
"notFound": "Share link not found",
"error": "Failed to load schedule",
"expired": {
"title": "Link Expired",
"message": "This share link for {{takenBy}}'s medication schedule has expired.",
"contact": "Please contact {{username}} to request a new link.",
"expiredOn": "Expired on: {{date}}"
}
}
}
+48
View File
@@ -3581,11 +3581,59 @@ h3 .reminder-icon.info-tooltip {
font-size: 1.125rem;
}
/* Expired link styling */
.shared-schedule-error.expired {
max-width: 500px;
margin: 0 auto;
}
.shared-schedule-error .expired-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.shared-schedule-error.expired h2 {
font-size: 1.5rem;
color: var(--warning);
margin-bottom: 1.5rem;
}
.shared-schedule-error .expired-message {
font-size: 1.125rem;
color: var(--text-primary);
margin-bottom: 1rem;
line-height: 1.5;
}
.shared-schedule-error .expired-contact {
font-size: 1rem;
color: var(--text-secondary);
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: 8px;
border: 1px solid var(--border-primary);
}
.shared-schedule-error .expired-date {
font-size: 0.875rem;
color: var(--text-muted);
}
.shared-schedule-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-primary);
position: relative;
}
.shared-schedule-header-actions {
position: absolute;
top: 0;
right: 0;
display: flex;
gap: 0.5rem;
}
.shared-schedule-header h1 {