Skip to content

Commit bee5fdb

Browse files
committed
feat(awareness-service): public SvelteKit portal
Add the portal: W3DS QR login, an access application form, a consumer dashboard (API key rotation, webhook subscription management, recent delivery status), an admin queue to approve/reject applications, and a dead-letter view with replay. Mirrors the enotary SvelteKit + Tailwind stack.
1 parent 31d7e45 commit bee5fdb

15 files changed

Lines changed: 917 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "awareness-portal",
3+
"private": true,
4+
"version": "0.0.1",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite dev --host",
8+
"build": "vite build",
9+
"preview": "vite preview",
10+
"prepare": "svelte-kit sync || echo ''",
11+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
12+
},
13+
"devDependencies": {
14+
"@sveltejs/adapter-node": "^5.2.12",
15+
"@sveltejs/kit": "^2.16.0",
16+
"@sveltejs/vite-plugin-svelte": "^5.0.0",
17+
"@tailwindcss/vite": "^4.0.0",
18+
"svelte": "^5.0.0",
19+
"svelte-check": "^4.0.0",
20+
"tailwindcss": "^4.0.0",
21+
"typescript": "^5.0.0",
22+
"vite": "^6.2.6"
23+
},
24+
"dependencies": {
25+
"svelte-qrcode": "^1.0.1"
26+
}
27+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@import "tailwindcss";
2+
3+
body {
4+
font-family:
5+
Inter,
6+
system-ui,
7+
-apple-system,
8+
sans-serif;
9+
background: #f8fafc;
10+
color: #111827;
11+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare global {
2+
namespace App {}
3+
}
4+
5+
export {};
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<link rel="icon" href="data:," />
6+
<meta name="viewport" content="width=device-width, initial-scale=1" />
7+
<title>Awareness as a Service</title>
8+
%sveltekit.head%
9+
</head>
10+
<body data-sveltekit-preload-data="hover">
11+
<div style="display: contents">%sveltekit.body%</div>
12+
</body>
13+
</html>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { env } from "$env/dynamic/public";
2+
3+
/** Base URL of the AaaS API. Falls back to the default local dev port. */
4+
export const API_BASE =
5+
env.PUBLIC_AWARENESS_API_URL ?? "http://localhost:4100";
6+
7+
export interface ApiError {
8+
error: string;
9+
}
10+
11+
/** Thin fetch wrapper that attaches the bearer token and parses JSON. */
12+
export async function api<T = unknown>(
13+
path: string,
14+
options: {
15+
method?: string;
16+
body?: unknown;
17+
token?: string | null;
18+
} = {},
19+
): Promise<T> {
20+
const headers: Record<string, string> = {
21+
"Content-Type": "application/json",
22+
};
23+
if (options.token) {
24+
headers.Authorization = `Bearer ${options.token}`;
25+
}
26+
27+
const res = await fetch(`${API_BASE}${path}`, {
28+
method: options.method ?? "GET",
29+
headers,
30+
body: options.body ? JSON.stringify(options.body) : undefined,
31+
});
32+
33+
const text = await res.text();
34+
const data = text ? JSON.parse(text) : {};
35+
if (!res.ok) {
36+
throw new Error((data as ApiError).error ?? `request failed (${res.status})`);
37+
}
38+
return data as T;
39+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { browser } from "$app/environment";
2+
import { writable } from "svelte/store";
3+
4+
const STORAGE_KEY = "aaas_session_token";
5+
6+
/** The W3DS portal session JWT, persisted in localStorage. */
7+
export const sessionToken = writable<string | null>(
8+
browser ? localStorage.getItem(STORAGE_KEY) : null,
9+
);
10+
11+
if (browser) {
12+
sessionToken.subscribe((token) => {
13+
if (token) localStorage.setItem(STORAGE_KEY, token);
14+
else localStorage.removeItem(STORAGE_KEY);
15+
});
16+
}
17+
18+
export function logout(): void {
19+
sessionToken.set(null);
20+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script lang="ts">
2+
import "../app.css";
3+
import { sessionToken, logout } from "$lib/session";
4+
5+
let { children } = $props();
6+
let loggedIn = $derived($sessionToken !== null);
7+
</script>
8+
9+
<div class="min-h-screen">
10+
<header class="border-b border-gray-200 bg-white">
11+
<nav class="mx-auto flex max-w-5xl items-center gap-6 px-6 py-4">
12+
<a href="/" class="text-lg font-semibold text-gray-900">
13+
Awareness<span class="text-indigo-600">Service</span>
14+
</a>
15+
<div class="flex-1"></div>
16+
{#if loggedIn}
17+
<a href="/dashboard" class="text-sm text-gray-600 hover:text-gray-900">Dashboard</a>
18+
<a href="/apply" class="text-sm text-gray-600 hover:text-gray-900">Apply</a>
19+
<a href="/admin" class="text-sm text-gray-600 hover:text-gray-900">Admin</a>
20+
<button
21+
class="text-sm text-gray-600 hover:text-gray-900"
22+
onclick={logout}
23+
>
24+
Log out
25+
</button>
26+
{/if}
27+
</nav>
28+
</header>
29+
<main class="mx-auto max-w-5xl px-6 py-8">
30+
{@render children()}
31+
</main>
32+
</div>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<script lang="ts">
2+
import { onDestroy } from "svelte";
3+
import { goto } from "$app/navigation";
4+
import QrCode from "svelte-qrcode";
5+
import { api } from "$lib/api";
6+
import { sessionToken } from "$lib/session";
7+
8+
let uri = $state<string | null>(null);
9+
let session = $state<string | null>(null);
10+
let error = $state<string | null>(null);
11+
let polling = $state(false);
12+
let pollTimer: ReturnType<typeof setInterval> | undefined;
13+
14+
async function startLogin() {
15+
error = null;
16+
try {
17+
const offer = await api<{ uri: string; session: string }>(
18+
"/api/auth/offer",
19+
{ method: "POST" },
20+
);
21+
uri = offer.uri;
22+
session = offer.session;
23+
polling = true;
24+
pollTimer = setInterval(pollSession, 2500);
25+
} catch (e) {
26+
error = e instanceof Error ? e.message : "failed to start login";
27+
}
28+
}
29+
30+
async function pollSession() {
31+
if (!session) return;
32+
try {
33+
const result = await api<
34+
{ status: "pending" } | { status: "authenticated"; token: string }
35+
>(`/api/auth/session/${session}`);
36+
if (result.status === "authenticated") {
37+
clearInterval(pollTimer);
38+
sessionToken.set(result.token);
39+
goto("/dashboard");
40+
}
41+
} catch {
42+
// keep polling; transient errors are expected
43+
}
44+
}
45+
46+
onDestroy(() => clearInterval(pollTimer));
47+
</script>
48+
49+
<section class="mx-auto max-w-xl text-center">
50+
<h1 class="text-3xl font-bold text-gray-900">Awareness as a Service</h1>
51+
<p class="mt-3 text-gray-600">
52+
Query MetaEnvelope awareness packets and register webhook subscriptions
53+
scoped by ontology and eVault. Sign in with your W3DS identity to apply
54+
for access.
55+
</p>
56+
57+
{#if error}
58+
<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700">{error}</p>
59+
{/if}
60+
61+
{#if !uri}
62+
<button
63+
class="mt-8 rounded-lg bg-indigo-600 px-6 py-3 font-medium text-white hover:bg-indigo-700"
64+
onclick={startLogin}
65+
>
66+
Sign in with W3DS
67+
</button>
68+
{:else}
69+
<div class="mt-8 flex flex-col items-center gap-4">
70+
<p class="text-sm text-gray-600">
71+
Scan with your eID wallet to sign in.
72+
</p>
73+
<div class="rounded-lg bg-white p-4 shadow">
74+
<QrCode value={uri} size={220} />
75+
</div>
76+
{#if polling}
77+
<p class="text-sm text-gray-400">Waiting for signature…</p>
78+
{/if}
79+
</div>
80+
{/if}
81+
</section>
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
<script lang="ts">
2+
import { onMount } from "svelte";
3+
import { goto } from "$app/navigation";
4+
import { get } from "svelte/store";
5+
import { api } from "$lib/api";
6+
import { sessionToken } from "$lib/session";
7+
8+
interface Application {
9+
id: string;
10+
status: string;
11+
justification: string | null;
12+
requestedOntologies: string[];
13+
consumer?: {
14+
ename: string;
15+
name: string | null;
16+
contactEmail: string | null;
17+
webhookBaseUrl: string | null;
18+
};
19+
}
20+
21+
let applications = $state<Application[]>([]);
22+
let error = $state<string | null>(null);
23+
let loading = $state(true);
24+
let notAdmin = $state(false);
25+
26+
const token = () => get(sessionToken);
27+
28+
async function load() {
29+
loading = true;
30+
error = null;
31+
try {
32+
const result = await api<{ applications: Application[] }>(
33+
"/api/admin/applications?status=pending",
34+
{ token: token() },
35+
);
36+
applications = result.applications;
37+
} catch (e) {
38+
const msg = e instanceof Error ? e.message : "failed to load";
39+
if (msg.includes("admin")) notAdmin = true;
40+
else error = msg;
41+
} finally {
42+
loading = false;
43+
}
44+
}
45+
46+
async function review(id: string, action: "approve" | "reject") {
47+
try {
48+
await api(`/api/admin/applications/${id}/${action}`, {
49+
method: "POST",
50+
token: token(),
51+
});
52+
await load();
53+
} catch (e) {
54+
error = e instanceof Error ? e.message : "review failed";
55+
}
56+
}
57+
58+
onMount(() => {
59+
if (!token()) {
60+
goto("/");
61+
return;
62+
}
63+
void load();
64+
});
65+
</script>
66+
67+
<section>
68+
<div class="flex items-center justify-between">
69+
<h1 class="text-2xl font-bold text-gray-900">Pending applications</h1>
70+
<a href="/admin/dead-letters" class="text-sm text-indigo-600 hover:underline">
71+
Dead letters →
72+
</a>
73+
</div>
74+
75+
{#if notAdmin}
76+
<p class="mt-6 rounded bg-amber-50 px-4 py-3 text-sm text-amber-700">
77+
Your eName is not in the admin allowlist.
78+
</p>
79+
{:else if error}
80+
<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700">{error}</p>
81+
{:else if loading}
82+
<p class="mt-4 text-gray-500">Loading…</p>
83+
{:else}
84+
<ul class="mt-6 space-y-4">
85+
{#each applications as app (app.id)}
86+
<li class="rounded-lg border border-gray-200 bg-white p-5">
87+
<div class="flex items-start justify-between">
88+
<div>
89+
<p class="font-semibold text-gray-900">
90+
{app.consumer?.name ?? app.consumer?.ename}
91+
</p>
92+
<p class="text-sm text-gray-500">{app.consumer?.ename}</p>
93+
<p class="text-sm text-gray-500">
94+
{app.consumer?.contactEmail ?? "no email"} ·
95+
{app.consumer?.webhookBaseUrl ?? "no webhook URL"}
96+
</p>
97+
</div>
98+
<div class="flex gap-2">
99+
<button
100+
class="rounded bg-green-600 px-3 py-1.5 text-sm text-white"
101+
onclick={() => review(app.id, "approve")}
102+
>
103+
Approve
104+
</button>
105+
<button
106+
class="rounded bg-red-600 px-3 py-1.5 text-sm text-white"
107+
onclick={() => review(app.id, "reject")}
108+
>
109+
Reject
110+
</button>
111+
</div>
112+
</div>
113+
{#if app.justification}
114+
<p class="mt-3 text-sm text-gray-700">{app.justification}</p>
115+
{/if}
116+
{#if app.requestedOntologies.length}
117+
<p class="mt-2 text-xs text-gray-500">
118+
Requested: {app.requestedOntologies.join(", ")}
119+
</p>
120+
{/if}
121+
</li>
122+
{:else}
123+
<li class="text-gray-400">No pending applications.</li>
124+
{/each}
125+
</ul>
126+
{/if}
127+
</section>

0 commit comments

Comments
 (0)