diff --git a/frontend/.env.example b/frontend/.env.example index cbd800d..4a79fb3 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -5,6 +5,9 @@ AUTHENTIK_CLIENT_SECRET= AUTH_SECRET= AUTH_TRUST_HOST=true +# Maskinrommet API +MASKINROMMET_URL=https://api.sidelinja.org + # SpacetimeDB (sanntids WebSocket-tilkobling) VITE_SPACETIMEDB_URL=wss://sidelinja.org/spacetime VITE_SPACETIMEDB_MODULE=synops diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index f8af4cb..6e40cf6 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,8 +1,6 @@ // 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) -import type { Session } from '@auth/core/types'; - declare global { namespace App { // 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' { interface JWT { authentik_sub?: string; + node_id?: string | null; + } +} + +// Extend Session with node_id for frontend use +declare module '@auth/core/types' { + interface Session { + nodeId?: string; } } diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index 7f6d1ec..a88d826 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -10,6 +10,27 @@ interface AuthentikProfile { 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 { + 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({ trustHost: true, providers: [ @@ -36,12 +57,16 @@ export const { handle, signIn, signOut } = SvelteKitAuth({ } satisfies OIDCConfig ], callbacks: { - jwt({ token, profile }) { + async jwt({ token, account, profile }) { // profile is only available on initial sign-in, not on refresh. // Store authentik_sub in the token so it persists across refreshes. if (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; }, 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) 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; } } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 9d9bf0f..48b6418 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -2,42 +2,161 @@ import { page } from '$app/stores'; import { signOut } from '@auth/sveltekit/client'; import { connectionState, nodeStore, edgeStore } from '$lib/spacetime'; + import type { Node } from '$lib/spacetime'; + + const nodeId = $derived(($page.data.session as Record | 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(); + + // 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 = { + 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); + } -
-
-

Synops

-

Plattform for redaksjonelt arbeid

- {#if $page.data.session?.user} -

- Innlogget som {$page.data.session.user.name ?? $page.data.session.user.email} -

- -
-

- SpacetimeDB: - - {connectionState.current} - -

- {#if connectionState.current === 'connected'} -

- {nodeStore.count} noder · {edgeStore.count} edges -

+
+ +
+
+

Synops

+
+ {#if connected} + Tilkoblet + {:else if connectionState.current === 'connecting'} + Kobler til… + {:else} + {connectionState.current} + {/if} + {#if $page.data.session?.user} + {$page.data.session.user.name} + {/if}
+
+
-
- + +
+

Mottak

+ + {#if !connected} +

Venter på tilkobling…

+ {:else if !nodeId} +
+

Bruker-ID ikke tilgjengelig

+

+ Kunne ikke hente node-ID fra maskinrommet. Prøv å logge ut og inn igjen. +

+ {:else if mottaksnoder.length === 0} +

Ingen noder å vise ennå.

+ {:else} +
    + {#each mottaksnoder as node (node.id)} +
  • +
    +
    +

    + {node.title || 'Uten tittel'} +

    + {#if node.content} +

    + {excerpt(node.content)} +

    + {/if} +
    +
    + + {kindLabel(node.nodeKind)} + + {#each edgeTypes(node.id) as et} + + {et} + + {/each} +
    +
    +
  • + {/each} +
{/if} -
+ + + {#if connected} +

+ {nodeStore.count} noder · {edgeStore.count} edges + {#if nodeId}· node: {nodeId.slice(0, 8)}…{/if} +

+ {/if} +
diff --git a/tasks.md b/tasks.md index 948682a..e772331 100644 --- a/tasks.md +++ b/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.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. -- [~] 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 +- [x] 3.4 Mottaksflaten v0: vis noder med edge til innlogget bruker, sortert på `created_at`. Enkel liste med tittel og utdrag. - [ ] 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.