Mottaksflaten v0: vis noder koblet til innlogget bruker (oppgave 3.4)

Henter brukerens node_id fra maskinrommets /me-endepunkt ved innlogging
(via Authentik access_token), cacher i JWT-sesjon. Mottaksflaten viser
alle noder brukeren har edges til (begge retninger), sortert nyeste først,
med tittel, utdrag, node_kind-merke og edge-type-merker.

Endringer:
- auth.ts: fetchNodeId() kaller maskinrommet /me ved sign-in
- app.d.ts: utvider JWT og Session med node_id/nodeId
- +page.svelte: erstatter dashboard med mottaksflate-visning
- .env.example: MASKINROMMET_URL for server-side API-kall

Krever at brukeren logger ut og inn igjen for å hente node_id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 14:13:36 +01:00
parent 3f81ac1865
commit 08d2933788
5 changed files with 192 additions and 37 deletions

View file

@ -5,6 +5,9 @@ AUTHENTIK_CLIENT_SECRET=
AUTH_SECRET= AUTH_SECRET=
AUTH_TRUST_HOST=true AUTH_TRUST_HOST=true
# Maskinrommet API
MASKINROMMET_URL=https://api.sidelinja.org
# SpacetimeDB (sanntids WebSocket-tilkobling) # SpacetimeDB (sanntids WebSocket-tilkobling)
VITE_SPACETIMEDB_URL=wss://sidelinja.org/spacetime VITE_SPACETIMEDB_URL=wss://sidelinja.org/spacetime
VITE_SPACETIMEDB_MODULE=synops VITE_SPACETIMEDB_MODULE=synops

12
frontend/src/app.d.ts vendored
View file

@ -1,8 +1,6 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// Types are augmented by @auth/sveltekit (see node_modules/@auth/sveltekit/dist/types.d.ts) // Types are augmented by @auth/sveltekit (see node_modules/@auth/sveltekit/dist/types.d.ts)
import type { Session } from '@auth/core/types';
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
@ -12,10 +10,18 @@ declare global {
} }
} }
// Extend JWT token with authentik_sub // Extend JWT token with custom fields
declare module '@auth/core/jwt' { declare module '@auth/core/jwt' {
interface JWT { interface JWT {
authentik_sub?: string; authentik_sub?: string;
node_id?: string | null;
}
}
// Extend Session with node_id for frontend use
declare module '@auth/core/types' {
interface Session {
nodeId?: string;
} }
} }

View file

@ -10,6 +10,27 @@ interface AuthentikProfile {
groups: string[]; groups: string[];
} }
/**
* Fetch the user's node_id from maskinrommet using the Authentik access token.
* Called once at sign-in so the node_id is cached in the JWT for future requests.
*/
async function fetchNodeId(accessToken: string): Promise<string | null> {
const url = env.MASKINROMMET_URL ?? 'https://api.sidelinja.org';
try {
const res = await fetch(`${url}/me`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (res.ok) {
const data = await res.json();
return data.node_id ?? null;
}
console.error(`[auth] /me returned ${res.status}: ${await res.text()}`);
} catch (e) {
console.error('[auth] Failed to fetch node_id from maskinrommet:', e);
}
return null;
}
export const { handle, signIn, signOut } = SvelteKitAuth({ export const { handle, signIn, signOut } = SvelteKitAuth({
trustHost: true, trustHost: true,
providers: [ providers: [
@ -36,12 +57,16 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
} satisfies OIDCConfig<AuthentikProfile> } satisfies OIDCConfig<AuthentikProfile>
], ],
callbacks: { callbacks: {
jwt({ token, profile }) { async jwt({ token, account, profile }) {
// profile is only available on initial sign-in, not on refresh. // profile is only available on initial sign-in, not on refresh.
// Store authentik_sub in the token so it persists across refreshes. // Store authentik_sub in the token so it persists across refreshes.
if (profile?.sub) { if (profile?.sub) {
token.authentik_sub = profile.sub; token.authentik_sub = profile.sub;
} }
// On initial sign-in, fetch node_id from maskinrommet
if (account?.access_token && !token.node_id) {
token.node_id = await fetchNodeId(account.access_token);
}
return token; return token;
}, },
session({ session, token }) { session({ session, token }) {
@ -49,6 +74,9 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
// Use Authentik sub as user ID (not @auth/sveltekit's internal ID) // Use Authentik sub as user ID (not @auth/sveltekit's internal ID)
session.user.id = (token.authentik_sub ?? token.sub) as string; session.user.id = (token.authentik_sub ?? token.sub) as string;
} }
// Expose node_id so frontend can filter edges
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(session as any).nodeId = token.node_id as string | undefined;
return session; return session;
} }
} }

View file

@ -2,42 +2,161 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { signOut } from '@auth/sveltekit/client'; import { signOut } from '@auth/sveltekit/client';
import { connectionState, nodeStore, edgeStore } from '$lib/spacetime'; import { connectionState, nodeStore, edgeStore } from '$lib/spacetime';
import type { Node } from '$lib/spacetime';
const nodeId = $derived(($page.data.session as Record<string, unknown> | undefined)?.nodeId as string | undefined);
const connected = $derived(connectionState.current === 'connected');
/**
* Find all nodes connected to the user via edges (either direction),
* excluding the user's own node and system edges.
* Sorted by created_at descending (newest first).
*/
const mottaksnoder = $derived.by(() => {
if (!nodeId || !connected) return [];
// Collect node IDs connected to the user
const connectedNodeIds = new Set<string>();
// Edges where user is source (e.g., user --owner--> thing)
for (const edge of edgeStore.bySource(nodeId)) {
if (!edge.system) connectedNodeIds.add(edge.targetId);
}
// Edges where user is target (e.g., thing --mentions--> user)
for (const edge of edgeStore.byTarget(nodeId)) {
if (!edge.system) connectedNodeIds.add(edge.sourceId);
}
// Resolve to nodes, excluding the user's own node
const nodes: Node[] = [];
for (const id of connectedNodeIds) {
if (id === nodeId) continue;
const node = nodeStore.get(id);
if (node) nodes.push(node);
}
// Sort by created_at descending
nodes.sort((a, b) => {
const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n;
const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n;
return tb > ta ? 1 : tb < ta ? -1 : 0;
});
return nodes;
});
/** Truncate content to a short excerpt */
function excerpt(content: string, maxLen = 140): string {
if (!content) return '';
if (content.length <= maxLen) return content;
return content.slice(0, maxLen).trimEnd() + '…';
}
/** Format node_kind as a readable label */
function kindLabel(kind: string): string {
const labels: Record<string, string> = {
content: 'Innhold',
person: 'Person',
team: 'Team',
collection: 'Samling',
communication: 'Samtale',
topic: 'Tema',
media: 'Media'
};
return labels[kind] ?? kind;
}
/** Get edge types between the user and a node */
function edgeTypes(targetNodeId: string): string[] {
if (!nodeId) return [];
const edges = edgeStore.between(nodeId, targetNodeId);
return edges.filter((e) => !e.system).map((e) => e.edgeType);
}
</script> </script>
<div class="flex min-h-screen items-center justify-center bg-gray-50"> <div class="min-h-screen bg-gray-50">
<div class="text-center space-y-4"> <!-- Header -->
<h1 class="text-4xl font-bold text-gray-900">Synops</h1> <header class="border-b border-gray-200 bg-white">
<p class="text-gray-600">Plattform for redaksjonelt arbeid</p> <div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
{#if $page.data.session?.user} <h1 class="text-lg font-semibold text-gray-900">Synops</h1>
<p class="text-sm text-gray-500"> <div class="flex items-center gap-3">
Innlogget som <span class="font-medium text-gray-700">{$page.data.session.user.name ?? $page.data.session.user.email}</span> {#if connected}
</p> <span class="text-xs text-green-600">Tilkoblet</span>
{:else if connectionState.current === 'connecting'}
<div class="mt-4 text-left inline-block rounded-lg border border-gray-200 bg-white p-4 text-sm shadow-sm"> <span class="text-xs text-yellow-600">Kobler til…</span>
<p class="font-medium text-gray-700"> {:else}
SpacetimeDB: <span class="text-xs text-gray-400">{connectionState.current}</span>
<span class="ml-1" class:text-green-600={connectionState.current === 'connected'} {/if}
class:text-yellow-600={connectionState.current === 'connecting'} {#if $page.data.session?.user}
class:text-red-600={connectionState.current === 'error'} <span class="text-sm text-gray-500">{$page.data.session.user.name}</span>
class:text-gray-400={connectionState.current === 'disconnected'}> <button
{connectionState.current} onclick={() => signOut()}
</span> class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"
</p> >
{#if connectionState.current === 'connected'} Logg ut
<p class="mt-1 text-gray-500"> </button>
{nodeStore.count} noder &middot; {edgeStore.count} edges
</p>
{/if} {/if}
</div> </div>
</div>
</header>
<div class="mt-2"> <!-- Main content -->
<button <main class="mx-auto max-w-3xl px-4 py-6">
onclick={() => signOut()} <h2 class="mb-4 text-xl font-semibold text-gray-800">Mottak</h2>
class="rounded-lg bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300"
> {#if !connected}
Logg ut <p class="text-sm text-gray-400">Venter på tilkobling…</p>
</button> {:else if !nodeId}
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
<p class="font-medium">Bruker-ID ikke tilgjengelig</p>
<p class="mt-1">
Kunne ikke hente node-ID fra maskinrommet. Prøv å logge ut og inn igjen.
</p>
</div> </div>
{:else if mottaksnoder.length === 0}
<p class="text-sm text-gray-400">Ingen noder å vise ennå.</p>
{:else}
<ul class="space-y-2">
{#each mottaksnoder as node (node.id)}
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="font-medium text-gray-900">
{node.title || 'Uten tittel'}
</h3>
{#if node.content}
<p class="mt-1 text-sm text-gray-500">
{excerpt(node.content)}
</p>
{/if}
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<span
class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500"
>
{kindLabel(node.nodeKind)}
</span>
{#each edgeTypes(node.id) as et}
<span
class="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-600"
>
{et}
</span>
{/each}
</div>
</div>
</li>
{/each}
</ul>
{/if} {/if}
</div>
<!-- Debug info (small, bottom) -->
{#if connected}
<p class="mt-8 text-xs text-gray-300">
{nodeStore.count} noder · {edgeStore.count} edges
{#if nodeId}· node: {nodeId.slice(0, 8)}{/if}
</p>
{/if}
</main>
</div> </div>

View file

@ -63,8 +63,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 3.1 SvelteKit-prosjekt: opprett `frontend/` med TypeScript, TailwindCSS. PWA-manifest. Lokal dev med HMR. - [x] 3.1 SvelteKit-prosjekt: opprett `frontend/` med TypeScript, TailwindCSS. PWA-manifest. Lokal dev med HMR.
- [x] 3.2 Authentik login: OIDC-flow (authorization code + PKCE). Session-håndtering. Redirect til login ved 401. - [x] 3.2 Authentik login: OIDC-flow (authorization code + PKCE). Session-håndtering. Redirect til login ved 401.
- [x] 3.3 STDB WebSocket-klient: abonner på noder og edges. Reaktiv Svelte-store som oppdateres ved endringer. - [x] 3.3 STDB WebSocket-klient: abonner på noder og edges. Reaktiv Svelte-store som oppdateres ved endringer.
- [~] 3.4 Mottaksflaten v0: vis noder med edge til innlogget bruker, sortert på `created_at`. Enkel liste med tittel og utdrag. - [x] 3.4 Mottaksflaten v0: vis noder med edge til innlogget bruker, sortert på `created_at`. Enkel liste med tittel og utdrag.
> Påbegynt: 2026-03-17T14:03
- [ ] 3.5 TipTap-editor: enkel preset (tekst, markdown, lenker). Send `create_node`-intensjon til maskinrommet ved submit. - [ ] 3.5 TipTap-editor: enkel preset (tekst, markdown, lenker). Send `create_node`-intensjon til maskinrommet ved submit.
- [ ] 3.6 Sanntidstest: åpne to faner, skriv i én, se noden dukke opp i den andre via STDB. - [ ] 3.6 Sanntidstest: åpne to faner, skriv i én, se noden dukke opp i den andre via STDB.