Files
verdande/web/src/routes/indstillinger/tokens/+page.svelte
T
Kristian WindandClaude Opus 5 e63d852847 feat(mcp): let the connector address carry its own key
verdande could not be added to Claude at all. The documented setup —
POST /api/v1/mcp with an Authorization header — cannot be configured from
Claude's custom-connector dialog, which asks for a name, a URL and OAuth
client credentials and has no field for a bearer token. The instructions
described something the interface does not offer.

Worse, the obvious guess failed silently in the most misleading way
available: /mcp was not a route, so the SPA fallback answered 200 with a
page of HTML. The connector reported a successful connection and then
failed to parse the response, which reads as a broken client rather than
a missing endpoint.

So /mcp exists now and takes its credential in the query string, exactly
as the calendar feed does and for the same reason: a client that cannot
be told to send a header means the address is the credential. The
request logger records paths without their query strings, so the key does
not reach the log.

It is deliberately outside /api/v1 and accepts the key and nothing else.
Reading a session cookie here would make it a state-changing POST outside
the CSRF check — a cross-site request that acts as whoever is signed in —
and there is a test that fails if that ever becomes true.

The API path is unchanged for anything that can send a header. The tokens
page now shows the connector address next to the token it belongs to,
since that is the one moment the token exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:04:03 +02:00

269 lines
6.8 KiB
Svelte

<script>
/**
* Personal API tokens.
*
* The one screen in the app that shows a secret. It is shown exactly once, and
* the copy has to say so — the server keeps a hash, so there is no later screen
* that could reveal it even if this one offered to.
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
let tokens = $state([]);
let loading = $state(true);
let name = $state('');
let expiresInDays = $state(0);
let errors = $state({});
let creating = $state(false);
/** The plaintext of the token just minted. Held only in this variable. */
let fresh = $state(null);
let copied = $state(false);
let connectorCopied = $state(false);
/** What Claude's connector dialog needs, which is a URL and nothing else. */
let connectorURL = $derived(fresh ? `${location.origin}/mcp?key=${fresh.token}` : '');
$effect(() => {
api
.listTokens()
.then((r) => (tokens = r.tokens))
.catch((e) => app.toast(humanMessage(e)))
.finally(() => (loading = false));
});
async function create(event) {
event.preventDefault();
errors = {};
creating = true;
try {
const created = await api.createToken(name.trim(), Number(expiresInDays));
fresh = created;
copied = false;
name = '';
expiresInDays = 0;
tokens = [{ ...created, token: undefined }, ...tokens];
} catch (e) {
errors = e.fields ?? {};
if (!Object.keys(errors).length) app.toast(humanMessage(e));
} finally {
creating = false;
}
}
async function copy() {
try {
await navigator.clipboard.writeText(fresh.token);
copied = true;
} catch {
// Insecure context, or permission refused. The token is on screen and
// selectable, so this is a convenience that failed, not the feature.
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
}
}
async function copyConnector() {
try {
await navigator.clipboard.writeText(connectorURL);
connectorCopied = true;
} catch {
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
}
}
async function revoke(token) {
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
return;
const previous = tokens;
tokens = tokens.filter((t) => t.id !== token.id);
try {
await api.deleteToken(token.id);
} catch (e) {
tokens = previous;
app.toast(humanMessage(e));
}
}
const date = (iso) =>
iso ? new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' }) : null;
const expired = (iso) => iso && new Date(iso) < new Date();
</script>
<section class="panel">
<header>
<h2>API-tokens</h2>
<p class="hint">
Det, et script, en CalDAV-klient eller en MCP-forbindelse logger ind med. En
token bærer dine rettigheder præcist — den kan intet, du ikke selv kan.
</p>
</header>
{#if fresh}
<div class="fresh">
<p class="hint">
<strong>Kopiér den nu.</strong> Den vises kun denne ene gang — serveren har
kun dens hash.
</p>
<p class="mono value">{fresh.token}</p>
<div class="row">
<button class="primary" onclick={copy}>Kopiér</button>
{#if copied}<span class="saved">Kopieret.</span>{/if}
<button class="secondary" onclick={() => (fresh = null)}>Færdig</button>
</div>
<!-- Shown here and nowhere else, because it contains the token, and the
token exists only in this moment. Claude's connector dialog takes a
URL and nothing else — there is no field for a header — so this is
the form it has to be in. -->
<div class="also">
<p class="hint">
Skal du bruge den som <a href="/indstillinger/integrationer">MCP-forbindelse</a>
i Claude? Så er det denne adresse, dialogen skal have:
</p>
<p class="mono value">{connectorURL}</p>
<div class="row">
<button class="secondary" onclick={copyConnector}>Kopiér adressen</button>
{#if connectorCopied}<span class="saved">Kopieret.</span>{/if}
</div>
</div>
</div>
{/if}
<form onsubmit={create}>
<div class="field">
<label for="token-name">Navn</label>
<input
id="token-name"
bind:value={name}
placeholder="fx min bærbare, eller backup-scriptet"
aria-invalid={errors.name ? 'true' : undefined}
/>
{#if errors.name}<p class="error">{errors.name}</p>{/if}
</div>
<div class="field">
<label for="token-expiry">Udløber</label>
<select id="token-expiry" bind:value={expiresInDays}>
<option value={0}>Aldrig</option>
<option value={30}>Om 30 dage</option>
<option value={90}>Om 90 dage</option>
<option value={365}>Om et år</option>
</select>
<p class="hint">
En kalenderklient, der har abonneret i årevis, har brug for
&ldquo;aldrig&rdquo;. Et script, du kører én gang, har ikke.
</p>
{#if errors.expires_in_days}<p class="error">{errors.expires_in_days}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={creating}>Lav en token</button>
</div>
</form>
{#if loading}
<p class="empty">…</p>
{:else if tokens.length === 0}
<p class="empty">Ingen tokens endnu.</p>
{:else}
<ul class="list">
{#each tokens as token (token.id)}
<li>
<div class="what">
<span class="token-name">{token.name}</span>
<span class="meta mono">
{token.prefix}…
{#if token.last_used_at}
· sidst brugt {date(token.last_used_at)}
{:else}
· aldrig brugt
{/if}
{#if token.expires_at}
· {expired(token.expires_at) ? 'udløbet' : 'udløber'}
{date(token.expires_at)}
{/if}
</span>
</div>
<button class="revoke" onclick={() => revoke(token)} aria-label="Tilbagekald {token.name}">
Tilbagekald
</button>
</li>
{/each}
</ul>
{/if}
</section>
<style>
form {
display: flex;
flex-direction: column;
gap: var(--s4);
}
.fresh {
display: flex;
flex-direction: column;
gap: var(--s3);
padding: var(--s4);
background: var(--accent-sunken);
border: 1px solid var(--accent);
border-radius: var(--radius);
}
.value {
margin: 0;
padding: var(--s3);
background: var(--surface-sunken);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow-wrap: anywhere;
user-select: all;
}
.also {
display: flex;
flex-direction: column;
gap: var(--s2);
padding-top: var(--s3);
border-top: 1px solid var(--accent);
}
.also a {
color: inherit;
}
.what {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.token-name {
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.meta {
color: var(--ink-faint);
}
.revoke {
flex: none;
font-size: var(--text-xs);
color: var(--ink-faint);
padding: var(--s1) var(--s2);
border-radius: var(--radius-sm);
transition: color var(--fast) var(--ease);
}
.revoke:hover {
color: var(--danger);
background: var(--danger-sunken);
}
</style>