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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 09:04:58 +02:00
parent 52d278bcd8
commit ddb3f9e5cd
13 changed files with 573 additions and 33 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.8.0",
"version": "1.9.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -39,6 +39,16 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
// Protect cospend routes and API endpoints
if (event.url.pathname.startsWith('/cospend') || event.url.pathname.startsWith('/api/cospend')) {
if (!session) {
// Allow share-token access to shopping list routes
const isShoppingRoute = event.url.pathname.startsWith('/cospend/list') || event.url.pathname.startsWith('/api/cospend/list');
const shareToken = event.url.searchParams.get('token');
if (isShoppingRoute && shareToken) {
const { validateShareToken } = await import('$lib/server/shoppingAuth');
if (await validateShareToken(shareToken)) {
return resolve(event);
}
}
// For API routes, return 401 instead of redirecting
if (event.url.pathname.startsWith('/api/cospend')) {
error(401, {

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,

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 };
}

View File

@@ -0,0 +1,20 @@
import mongoose from 'mongoose';
export interface IShoppingShareToken {
token: string;
expiresAt: Date;
createdBy: string;
}
const ShoppingShareTokenSchema = new mongoose.Schema(
{
token: { type: String, required: true, unique: true, index: true },
expiresAt: { type: Date, required: true, index: { expireAfterSeconds: 0 } },
createdBy: { type: String, required: true },
},
{ timestamps: true }
);
export const ShoppingShareToken =
mongoose.models.ShoppingShareToken ||
mongoose.model<IShoppingShareToken>('ShoppingShareToken', ShoppingShareTokenSchema);

View File

@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { ShoppingList } from '$models/ShoppingList';
import { broadcast } from '$lib/server/shoppingSSE';
import { getShoppingUser } from '$lib/server/shoppingAuth';
async function getOrCreateList() {
let list = await ShoppingList.findOne().lean();
@@ -14,9 +15,9 @@ async function getOrCreateList() {
}
// GET /api/cospend/list — fetch current shopping list
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
export const GET: RequestHandler = async ({ locals, url }) => {
const user = await getShoppingUser(locals, url);
if (!user) throw error(401, 'Not logged in');
await dbConnect();
const list = await getOrCreateList();
@@ -24,9 +25,9 @@ export const GET: RequestHandler = async ({ locals }) => {
};
// PUT /api/cospend/list — update shopping list with version conflict detection
export const PUT: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
export const PUT: RequestHandler = async ({ request, locals, url }) => {
const user = await getShoppingUser(locals, url);
if (!user) throw error(401, 'Not logged in');
await dbConnect();
const data = await request.json();
@@ -59,9 +60,9 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
};
// DELETE /api/cospend/list — clear all items
export const DELETE: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
export const DELETE: RequestHandler = async ({ locals, url }) => {
const user = await getShoppingUser(locals, url);
if (!user) throw error(401, 'Not logged in');
await dbConnect();
const existing = await getOrCreateList();

View File

@@ -1,11 +1,12 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { categorizeItem } from '$lib/server/shoppingCategorizer';
import { getShoppingUser } from '$lib/server/shoppingAuth';
// POST /api/cospend/list/categorize — categorize a shopping item by name
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
export const POST: RequestHandler = async ({ request, locals, url }) => {
const user = await getShoppingUser(locals, url);
if (!user) throw error(401, 'Not logged in');
const { name } = await request.json();
if (!name || typeof name !== 'string') {

View File

@@ -2,11 +2,12 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { ShoppingItemCategory } from '$models/ShoppingItemCategory';
import { dbConnect } from '$utils/db';
import { getShoppingUser } from '$lib/server/shoppingAuth';
// POST /api/cospend/list/categorize/override — manually set category + icon for an item name
export const POST: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
export const POST: RequestHandler = async ({ request, locals, url }) => {
const user = await getShoppingUser(locals, url);
if (!user) throw error(401, 'Not logged in');
const { name, category, icon } = await request.json();
if (!name || typeof name !== 'string') throw error(400, 'name is required');

View File

@@ -0,0 +1,66 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createShareToken } from '$lib/server/shoppingAuth';
import { ShoppingShareToken } from '$models/ShoppingShareToken';
import { dbConnect } from '$utils/db';
// GET /api/cospend/list/share — list all active share tokens
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
await dbConnect();
const tokens = await ShoppingShareToken.find({ expiresAt: { $gt: new Date() } })
.sort({ createdAt: -1 })
.lean();
return json(tokens.map(t => ({
id: t._id.toString(),
token: t.token,
expiresAt: t.expiresAt,
createdBy: t.createdBy,
createdAt: t.createdAt,
})));
};
// POST /api/cospend/list/share — create a new share token
export const POST: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const { token, expiresAt } = await createShareToken(auth.user.nickname);
return json({ token, expiresAt: expiresAt.toISOString() });
};
// PATCH /api/cospend/list/share — update a token's expiry
export const PATCH: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const { id, expiresAt } = await request.json();
if (!id || !expiresAt) throw error(400, 'id and expiresAt required');
await dbConnect();
const doc = await ShoppingShareToken.findByIdAndUpdate(
id,
{ expiresAt: new Date(expiresAt) },
{ returnDocument: 'after', lean: true }
);
if (!doc) throw error(404, 'Token not found');
return json({ ok: true });
};
// DELETE /api/cospend/list/share — revoke a token
export const DELETE: RequestHandler = async ({ request, locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
const { id } = await request.json();
if (!id) throw error(400, 'id required');
await dbConnect();
await ShoppingShareToken.findByIdAndDelete(id);
return json({ ok: true });
};

View File

@@ -1,11 +1,12 @@
import type { RequestHandler } from './$types';
import { error } from '@sveltejs/kit';
import { addConnection, removeConnection } from '$lib/server/shoppingSSE';
import { getShoppingUser } from '$lib/server/shoppingAuth';
// GET /api/cospend/list/stream — SSE endpoint for live shopping list updates
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
if (!auth?.user?.nickname) throw error(401, 'Not logged in');
export const GET: RequestHandler = async ({ locals, url }) => {
const user = await getShoppingUser(locals, url);
if (!user) throw error(401, 'Not logged in');
const encoder = new TextEncoder();
let controllerRef: ReadableStreamDefaultController<Uint8Array>;

View File

@@ -15,6 +15,7 @@
/** @type {string | null} */
let paymentId = $state(null);
let user = $state(data.session?.user);
let isGuest = $derived(!data.session?.user);
$effect(() => {
// Check if URL contains payment view route OR if we have paymentId in state
@@ -58,10 +59,14 @@
<Header>
{#snippet links()}
<ul class="site_header">
<li style="--active-fill: var(--nord9)"><a href="/cospend/dash" class:active={isActive('/cospend/dash')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li>
{#if !isGuest}
<li style="--active-fill: var(--nord9)"><a href="/cospend/dash" class:active={isActive('/cospend/dash')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li>
{/if}
<li style="--active-fill: var(--nord13)"><a href="/cospend/list" class:active={isActive('/cospend/list')}><ShoppingCart size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Liste</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/cospend/payments" class:active={isActive('/cospend/payments')}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">All Payments</span></a></li>
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/cospend/recurring" class:active={isActive('/cospend/recurring')}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">Recurring</span></a></li>
{#if !isGuest}
<li style="--active-fill: var(--nord14)"><a href="/cospend/payments" class:active={isActive('/cospend/payments')}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">All Payments</span></a></li>
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/cospend/recurring" class:active={isActive('/cospend/recurring')}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">Recurring</span></a></li>
{/if}
</ul>
{/snippet}

View File

@@ -1,8 +1,17 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { getShoppingUser } from '$lib/server/shoppingAuth';
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ locals, url }) => {
const session = await locals.auth();
const token = url.searchParams.get('token');
// Allow access with valid share token even without session
if (!session && token) {
const user = await getShoppingUser(locals, url);
if (user) return { session: null, shareToken: token };
}
if (!session) throw redirect(302, '/login');
return { session };
return { session, shareToken: null };
};

View File

@@ -9,8 +9,12 @@
import { SvelteSet } from 'svelte/reactivity';
import catalogData from '$lib/data/shoppingCatalog.json';
import { Share2, X, Copy, Check } from '@lucide/svelte';
let { data } = $props();
let user = $derived(data.session?.user?.nickname || '');
let user = $derived(data.session?.user?.nickname || 'guest');
let shareToken = $derived(data.shareToken);
let isGuest = $derived(!data.session);
const sync = getShoppingSync();
/** @type {Record<string, { icon: typeof Plus, color: string }>} */
@@ -106,7 +110,7 @@
let checkedCount = $derived(sync.items.filter(i => i.checked).length);
let totalCount = $derived(sync.items.length);
onMount(() => { sync.init(); });
onMount(() => { sync.init(shareToken); });
onDestroy(() => { sync.disconnect(); });
async function addItem() {
@@ -126,7 +130,7 @@
try {
const cleanName = parseQuantity(name).name;
console.log(`[shopping] Categorizing "${cleanName}" (item ${itemId})...`);
const res = await fetch('/api/cospend/list/categorize', {
const res = await fetch(sync.apiUrl('/api/cospend/list/categorize'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: cleanName })
@@ -192,12 +196,120 @@
editSaving = false;
}
// --- Share links ---
let showShareModal = $state(false);
/** @type {{ id: string, token: string, expiresAt: string, createdBy: string, createdAt: string }[]} */
let shareTokens = $state([]);
let shareLoading = $state(false);
/** @type {string | null} */
let copiedId = $state(null);
let showCopyToast = $state(false);
async function openShareModal() {
showShareModal = true;
await loadShareTokens();
}
async function loadShareTokens() {
shareLoading = true;
try {
const res = await fetch('/api/cospend/list/share');
if (res.ok) shareTokens = await res.json();
} catch (err) {
console.error('[shopping] Load tokens error:', err);
} finally {
shareLoading = false;
}
}
/** @param {string} expiresAt */
function formatTTL(expiresAt) {
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return 'abgelaufen';
const mins = Math.round(diff / 60000);
if (mins < 60) return `${mins} Min.`;
const hours = Math.round(diff / 3600000);
if (hours < 24) return `${hours} Std.`;
const days = Math.round(diff / 86400000);
return `${days} Tag${days > 1 ? 'e' : ''}`;
}
const TTL_OPTIONS = [
{ label: '1 Stunde', ms: 1 * 60 * 60 * 1000 },
{ label: '6 Stunden', ms: 6 * 60 * 60 * 1000 },
{ label: '24 Stunden', ms: 24 * 60 * 60 * 1000 },
{ label: '3 Tage', ms: 3 * 24 * 60 * 60 * 1000 },
{ label: '7 Tage', ms: 7 * 24 * 60 * 60 * 1000 },
];
/**
* @param {string} id
* @param {Event} e
*/
function onTTLChange(id, e) {
const ms = Number(/** @type {HTMLSelectElement} */ (e.currentTarget).value);
const newExpiry = new Date(Date.now() + ms).toISOString();
updateTokenExpiry(id, newExpiry);
}
async function createNewToken() {
try {
const res = await fetch('/api/cospend/list/share', { method: 'POST' });
if (res.ok) await loadShareTokens();
} catch (err) {
console.error('[shopping] Create token error:', err);
}
}
/** @param {{ id: string, token: string }} t */
async function copyTokenLink(t) {
const url = new URL('/cospend/list', window.location.origin);
url.searchParams.set('token', t.token);
await navigator.clipboard.writeText(url.toString());
copiedId = t.id;
showCopyToast = true;
setTimeout(() => { copiedId = null; showCopyToast = false; }, 2000);
}
/** @param {string} id */
async function deleteToken(id) {
try {
await fetch('/api/cospend/list/share', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
shareTokens = shareTokens.filter(t => t.id !== id);
} catch (err) {
console.error('[shopping] Delete token error:', err);
}
}
/**
* @param {string} id
* @param {string} newExpiry - ISO date string
*/
async function updateTokenExpiry(id, newExpiry) {
try {
await fetch('/api/cospend/list/share', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, expiresAt: newExpiry })
});
shareTokens = shareTokens.map(t =>
t.id === id ? { ...t, expiresAt: newExpiry } : t
);
} catch (err) {
console.error('[shopping] Update token error:', err);
}
}
async function saveEdit() {
if (!editingItem) return;
editSaving = true;
const cleanName = parseQuantity(editingItem.name).name;
try {
await fetch('/api/cospend/list/categorize/override', {
await fetch(sync.apiUrl('/api/cospend/list/categorize/override'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: cleanName, category: editCategory, icon: editIcon || null })
@@ -214,7 +326,14 @@
<div class="shopping-page">
<header class="page-header">
<h1>Einkaufsliste <SyncIndicator status={sync.status} /></h1>
<div class="header-row">
<h1>Einkaufsliste <SyncIndicator status={sync.status} /></h1>
{#if !isGuest}
<button class="btn-share" onclick={openShareModal} title="Teilen">
<Share2 size={16} />
</button>
{/if}
</div>
{#if totalCount > 0}
<p class="subtitle">{checkedCount} / {totalCount} erledigt</p>
{/if}
@@ -349,6 +468,68 @@
</div>
{/if}
{#if showShareModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="edit-backdrop" onclick={() => { showShareModal = false; }}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="edit-modal share-modal" onclick={(e) => e.stopPropagation()}>
<div class="share-header">
<h3>Geteilte Links</h3>
<button class="close-button" onclick={() => { showShareModal = false; }}>
<X size={18} />
</button>
</div>
<p class="share-desc">Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.</p>
{#if shareLoading}
<p class="share-loading">Laden...</p>
{:else if shareTokens.length === 0}
<p class="share-empty">Keine aktiven Links.</p>
{:else}
<div class="token-list">
{#each shareTokens as t (t.id)}
<div class="token-item">
<div class="token-info">
<span class="token-created-by">{t.createdBy}</span>
<div class="token-expiry-row">
<span class="token-ttl">noch {formatTTL(t.expiresAt)}</span>
<select class="token-ttl-select" onchange={(e) => onTTLChange(t.id, e)}>
<option value="" disabled selected>Ändern</option>
{#each TTL_OPTIONS as opt}
<option value={opt.ms}>{opt.label}</option>
{/each}
</select>
</div>
</div>
<div class="token-actions">
<button class="btn-token-copy" onclick={() => copyTokenLink(t)} title="Link kopieren">
{#if copiedId === t.id}<Check size={14} />{:else}<Copy size={14} />{/if}
</button>
<button class="btn-token-delete" onclick={() => deleteToken(t.id)} title="Löschen">
<X size={14} />
</button>
</div>
</div>
{/each}
</div>
{/if}
<button class="btn-new-token" onclick={createNewToken}>
<Plus size={14} />
Neuen Link erstellen
</button>
</div>
</div>
{/if}
{#if showCopyToast}
<div class="copy-toast" transition:slide={{ duration: 150 }}>
<Check size={14} /> Kopiert
</div>
{/if}
<style>
.shopping-page {
max-width: 700px;
@@ -360,11 +541,34 @@
text-align: center;
margin-bottom: 1.5rem;
}
.header-row {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.btn-share {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
border-radius: 8px;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 150ms;
}
.btn-share:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
@@ -751,4 +955,166 @@
opacity: 0.5;
cursor: default;
}
/* Share modal */
.share-modal {
max-width: 440px;
}
.share-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.share-header h3 {
margin: 0;
}
.close-button {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: 6px;
display: flex;
}
.close-button:hover {
color: var(--color-text-primary);
background: var(--color-bg-elevated);
}
.share-desc {
color: var(--color-text-secondary);
font-size: 0.85rem;
margin: 0 0 1rem;
}
.share-loading, .share-empty {
color: var(--color-text-secondary);
font-size: 0.85rem;
text-align: center;
padding: 1rem 0;
margin: 0;
}
.token-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.token-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: var(--color-bg-tertiary);
border-radius: 10px;
}
.token-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.token-created-by {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-primary);
}
.token-expiry-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.token-ttl {
font-size: 0.72rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.token-ttl-select {
font-size: 0.68rem;
padding: 0.1rem 0.25rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
cursor: pointer;
}
.token-actions {
display: flex;
align-items: center;
gap: 0.3rem;
flex-shrink: 0;
}
.btn-token-copy {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 150ms;
}
.btn-token-copy:hover {
color: var(--nord10);
background: color-mix(in srgb, var(--nord10) 10%, transparent);
}
.btn-token-delete {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 150ms;
}
.btn-token-delete:hover {
color: var(--nord11);
background: color-mix(in srgb, var(--nord11) 10%, transparent);
}
.btn-new-token {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
width: 100%;
padding: 0.5rem;
border: 1px dashed var(--color-border);
border-radius: 10px;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 150ms;
}
.btn-new-token:hover {
color: var(--nord10);
border-color: var(--nord10);
background: color-mix(in srgb, var(--nord10) 5%, transparent);
}
.copy-toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.4rem 0.8rem;
border-radius: 100px;
background: var(--nord14);
color: var(--nord0);
font-size: 0.8rem;
font-weight: 600;
z-index: 200;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
</style>