feat: shareable shopping list links with token-based guest access

- Generate temporary share links (default 24h) that allow unauthenticated
  users to view and edit the shopping list
- Share token management modal: create, copy, delete, and adjust TTL
- Token auth bypasses hooks middleware for /cospend/list routes only
- Guest users see only the Liste nav item, other cospend tabs are hidden
- All list API endpoints accept ?token= query param as alternative auth
- MongoDB TTL index auto-expires tokens
This commit is contained in:
2026-04-08 09:04:58 +02:00
parent 423877073d
commit e92fc1cc25
13 changed files with 573 additions and 33 deletions
+13 -4
View File
@@ -31,18 +31,25 @@ export function createShoppingSync() {
let items: ShoppingItem[] = $state([]);
let status: SyncStatus = $state('idle');
let version = $state(0);
let shareToken: string | null = null;
let eventSource: EventSource | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectDelay = 1000;
let _applying = false;
function apiUrl(path: string): string {
if (!shareToken) return path;
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}token=${encodeURIComponent(shareToken)}`;
}
async function pushToServer() {
if (_applying) return;
status = 'syncing';
try {
const res = await fetch('/api/cospend/list', {
const res = await fetch(apiUrl('/api/cospend/list'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -94,7 +101,7 @@ export function createShoppingSync() {
}
try {
eventSource = new EventSource('/api/cospend/list/stream');
eventSource = new EventSource(apiUrl('/api/cospend/list/stream'));
eventSource.addEventListener('update', (e) => {
try {
@@ -141,9 +148,10 @@ export function createShoppingSync() {
status = 'idle';
}
async function init() {
async function init(token?: string | null) {
if (token) shareToken = token;
try {
const res = await fetch('/api/cospend/list');
const res = await fetch(apiUrl('/api/cospend/list'));
if (!res.ok) {
status = 'offline';
return;
@@ -201,6 +209,7 @@ export function createShoppingSync() {
get items() { return items; },
get status() { return status; },
get version() { return version; },
apiUrl,
init,
addItem,
toggleItem,
+51
View File
@@ -0,0 +1,51 @@
/**
* Shared auth for shopping list endpoints.
* Accepts either a logged-in session or a valid share token.
*/
import { dbConnect } from '$utils/db';
import { ShoppingShareToken } from '$models/ShoppingShareToken';
import crypto from 'crypto';
/** Returns a nickname string if authorized, null otherwise */
export async function getShoppingUser(
locals: App.Locals,
url: URL
): Promise<string | null> {
// Check session first
const auth = await locals.auth();
if (auth?.user?.nickname) return auth.user.nickname;
// Check share token
const token = url.searchParams.get('token');
if (!token) return null;
await dbConnect();
const doc = await ShoppingShareToken.findOne({
token,
expiresAt: { $gt: new Date() }
}).lean();
return doc ? `guest` : null;
}
/** Check if a share token is valid (for hooks middleware) */
export async function validateShareToken(token: string): Promise<boolean> {
await dbConnect();
const doc = await ShoppingShareToken.findOne({
token,
expiresAt: { $gt: new Date() }
}).lean();
return !!doc;
}
/** Generate a new share token (24h TTL) */
export async function createShareToken(createdBy: string): Promise<{ token: string; expiresAt: Date }> {
await dbConnect();
const token = crypto.randomBytes(24).toString('base64url');
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await ShoppingShareToken.create({ token, expiresAt, createdBy });
return { token, expiresAt };
}