feat: add expiration date to share tokens and enhance error handling for expired links
This commit is contained in:
@@ -209,7 +209,7 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
|||||||
|
|
||||||
## ⚠️ Database Migrations (CRITICAL)
|
## ⚠️ 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`
|
1. **Update schema**: `backend/src/db/schema.ts`
|
||||||
2. **Create migration file**: `backend/src/db/migrations/XXXX_description.sql`
|
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
|
```json
|
||||||
{ "idx": X, "version": 1, "when": TIMESTAMP, "tag": "XXXX_description", "breakpoint": false }
|
{ "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
|
## File Locations
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ async function main() {
|
|||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
username text NOT NULL UNIQUE,
|
username text NOT NULL UNIQUE,
|
||||||
password_hash text,
|
password_hash text,
|
||||||
|
avatar_url text,
|
||||||
auth_provider text NOT NULL DEFAULT 'local',
|
auth_provider text NOT NULL DEFAULT 'local',
|
||||||
|
oidc_subject text,
|
||||||
is_active integer NOT NULL DEFAULT 1,
|
is_active integer NOT NULL DEFAULT 1,
|
||||||
last_login_at integer,
|
last_login_at integer,
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
@@ -66,6 +68,7 @@ async function main() {
|
|||||||
normal_stock_days integer NOT NULL DEFAULT 90,
|
normal_stock_days integer NOT NULL DEFAULT 90,
|
||||||
high_stock_days integer NOT NULL DEFAULT 180,
|
high_stock_days integer NOT NULL DEFAULT 180,
|
||||||
language text NOT NULL DEFAULT 'en',
|
language text NOT NULL DEFAULT 'en',
|
||||||
|
stock_calculation_mode text NOT NULL DEFAULT 'automatic',
|
||||||
last_auto_email_sent text,
|
last_auto_email_sent text,
|
||||||
last_notification_type text,
|
last_notification_type text,
|
||||||
last_notification_channel text,
|
last_notification_channel text,
|
||||||
@@ -91,6 +94,16 @@ async function main() {
|
|||||||
taken_by text NOT NULL,
|
taken_by text NOT NULL,
|
||||||
schedule_days integer NOT NULL DEFAULT 30,
|
schedule_days integer NOT NULL DEFAULT 30,
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
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
|
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;
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
{ "idx": 11, "version": 1, "when": 1735700000, "tag": "0011_add_dose_tracking", "breakpoint": false },
|
{ "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": 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": 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 }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const shareTokens = sqliteTable("share_tokens", {
|
|||||||
takenBy: text("taken_by", { length: 100 }).notNull(),
|
takenBy: text("taken_by", { length: 100 }).notNull(),
|
||||||
scheduleDays: integer("schedule_days").notNull().default(30),
|
scheduleDays: integer("schedule_days").notNull().default(30),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { FastifyInstance } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { db } from "../db/client.js";
|
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 { eq, and } from "drizzle-orm";
|
||||||
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
|
import { requireAuth, optionalAuth, getAnonymousUserId } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import type { AuthUser } from "../types/fastify.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
|
// Validation Schemas
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -45,7 +48,23 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Find share token
|
// Find share token
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
if (!share) {
|
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
|
// Get user settings for stock thresholds
|
||||||
@@ -133,6 +152,9 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Generate unique token (8 bytes = 16 hex chars)
|
// Generate unique token (8 bytes = 16 hex chars)
|
||||||
const token = randomBytes(8).toString("hex");
|
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
|
// Create share token
|
||||||
await db.insert(shareTokens).values({
|
await db.insert(shareTokens).values({
|
||||||
@@ -140,11 +162,13 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
token,
|
token,
|
||||||
takenBy,
|
takenBy,
|
||||||
scheduleDays,
|
scheduleDays,
|
||||||
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
shareUrl: `/share/${token}`,
|
shareUrl: `/share/${token}`,
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
+62
-3
@@ -3181,15 +3181,38 @@ type SharedScheduleData = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ExpiredLinkData = {
|
||||||
|
ownerUsername: string;
|
||||||
|
takenBy: string;
|
||||||
|
expiredAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
function SharedSchedule() {
|
function SharedSchedule() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const [data, setData] = useState<SharedScheduleData | null>(null);
|
const [data, setData] = useState<SharedScheduleData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
||||||
const [showPastDays, setShowPastDays] = useState(false);
|
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)
|
// Collapsed days state for SharedSchedule (token-specific localStorage)
|
||||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||||
@@ -3332,17 +3355,27 @@ function SharedSchedule() {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
setData(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 {
|
} else {
|
||||||
setError("Share link not found or expired");
|
setError(t('share.error'));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load schedule");
|
setError(t('share.error'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [token]);
|
}, [token, t]);
|
||||||
|
|
||||||
// Build schedule from medications
|
// Build schedule from medications
|
||||||
const schedule = useMemo(() => {
|
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) {
|
if (error || !data) {
|
||||||
return (
|
return (
|
||||||
<div className="shared-schedule-page">
|
<div className="shared-schedule-page">
|
||||||
@@ -3514,6 +3568,11 @@ function SharedSchedule() {
|
|||||||
<div className="shared-schedule-container">
|
<div className="shared-schedule-container">
|
||||||
<header className="shared-schedule-header">
|
<header className="shared-schedule-header">
|
||||||
<h1>💊 {t('share.scheduleFor')} {data.takenBy}</h1>
|
<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">
|
<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')}
|
{t('share.period')}: {data.scheduleDays === 30 ? t('dashboard.schedules.1month') : data.scheduleDays === 90 ? t('dashboard.schedules.3months') : t('dashboard.schedules.6months')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -330,6 +330,14 @@
|
|||||||
"scheduleFor": "Zeitplan für",
|
"scheduleFor": "Zeitplan für",
|
||||||
"period": "Zeitraum",
|
"period": "Zeitraum",
|
||||||
"noSchedule": "Keine geplanten Einnahmen gefunden.",
|
"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}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,14 @@
|
|||||||
"scheduleFor": "Schedule for",
|
"scheduleFor": "Schedule for",
|
||||||
"period": "Period",
|
"period": "Period",
|
||||||
"noSchedule": "No scheduled doses found.",
|
"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}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3581,11 +3581,59 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
font-size: 1.125rem;
|
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 {
|
.shared-schedule-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
padding-bottom: 1.5rem;
|
padding-bottom: 1.5rem;
|
||||||
border-bottom: 1px solid var(--border-primary);
|
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 {
|
.shared-schedule-header h1 {
|
||||||
|
|||||||
Reference in New Issue
Block a user