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_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
|
||||
|
|
|
|||
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
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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({
|
||||
trustHost: true,
|
||||
providers: [
|
||||
|
|
@ -36,12 +57,16 @@ export const { handle, signIn, signOut } = SvelteKitAuth({
|
|||
} satisfies OIDCConfig<AuthentikProfile>
|
||||
],
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<div class="text-center space-y-4">
|
||||
<h1 class="text-4xl font-bold text-gray-900">Synops</h1>
|
||||
<p class="text-gray-600">Plattform for redaksjonelt arbeid</p>
|
||||
{#if $page.data.session?.user}
|
||||
<p class="text-sm text-gray-500">
|
||||
Innlogget som <span class="font-medium text-gray-700">{$page.data.session.user.name ?? $page.data.session.user.email}</span>
|
||||
</p>
|
||||
|
||||
<div class="mt-4 text-left inline-block rounded-lg border border-gray-200 bg-white p-4 text-sm shadow-sm">
|
||||
<p class="font-medium text-gray-700">
|
||||
SpacetimeDB:
|
||||
<span class="ml-1" class:text-green-600={connectionState.current === 'connected'}
|
||||
class:text-yellow-600={connectionState.current === 'connecting'}
|
||||
class:text-red-600={connectionState.current === 'error'}
|
||||
class:text-gray-400={connectionState.current === 'disconnected'}>
|
||||
{connectionState.current}
|
||||
</span>
|
||||
</p>
|
||||
{#if connectionState.current === 'connected'}
|
||||
<p class="mt-1 text-gray-500">
|
||||
{nodeStore.count} noder · {edgeStore.count} edges
|
||||
</p>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||
<h1 class="text-lg font-semibold text-gray-900">Synops</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if connected}
|
||||
<span class="text-xs text-green-600">Tilkoblet</span>
|
||||
{:else if connectionState.current === 'connecting'}
|
||||
<span class="text-xs text-yellow-600">Kobler til…</span>
|
||||
{:else}
|
||||
<span class="text-xs text-gray-400">{connectionState.current}</span>
|
||||
{/if}
|
||||
{#if $page.data.session?.user}
|
||||
<span class="text-sm text-gray-500">{$page.data.session.user.name}</span>
|
||||
<button
|
||||
onclick={() => signOut()}
|
||||
class="rounded bg-gray-100 px-3 py-1 text-xs text-gray-600 hover:bg-gray-200"
|
||||
>
|
||||
Logg ut
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-2">
|
||||
<button
|
||||
onclick={() => signOut()}
|
||||
class="rounded-lg bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||
>
|
||||
Logg ut
|
||||
</button>
|
||||
<!-- Main content -->
|
||||
<main class="mx-auto max-w-3xl px-4 py-6">
|
||||
<h2 class="mb-4 text-xl font-semibold text-gray-800">Mottak</h2>
|
||||
|
||||
{#if !connected}
|
||||
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
|
||||
{: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>
|
||||
{: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}
|
||||
</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>
|
||||
|
|
|
|||
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.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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue