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:
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user