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:
parent
3f81ac1865
commit
08d2933788
5 changed files with 192 additions and 37 deletions
|
|
@ -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
12
frontend/src/app.d.ts
vendored
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 · {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>
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue