Compare commits
4 Commits
3e832eea98
...
cd14be0666
| Author | SHA1 | Date | |
|---|---|---|---|
| cd14be0666 | |||
| 20122a5f53 | |||
|
|
8e74dc9284 | ||
| 1c9741373e |
@@ -31,6 +31,9 @@ jobs:
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Tidy modules
|
||||
run: go mod tidy
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
|
||||
@@ -19,5 +19,14 @@
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sentry/svelte": "^10.49.0",
|
||||
"@skeletonlabs/skeleton": "^4.15.2",
|
||||
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||
"@tanstack/svelte-virtual": "^3.13.24",
|
||||
"@warkypublic/artemis-kit": "file:../../artemis-kit",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"@warkypublic/svelix": "^0.1.31"
|
||||
}
|
||||
}
|
||||
3150
ui/pnpm-lock.yaml
generated
3150
ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount } from 'svelte';
|
||||
import { getApiURL } from '@warkypublic/svelix';
|
||||
import {
|
||||
buildOAuthAuthorizationURL,
|
||||
ensureApiURL,
|
||||
exchangeOAuthCode,
|
||||
GlobalStateStore,
|
||||
setCurrentPath
|
||||
} from './shellState';
|
||||
|
||||
type AccessEntry = {
|
||||
key_id: string;
|
||||
@@ -22,205 +30,367 @@
|
||||
entries: AccessEntry[];
|
||||
};
|
||||
|
||||
let data: StatusResponse | null = null;
|
||||
let loading = true;
|
||||
let error = "";
|
||||
type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const quickLinks = [
|
||||
{ href: "/llm", label: "LLM Instructions" },
|
||||
{ href: "/healthz", label: "Health Check" },
|
||||
{ href: "/readyz", label: "Readiness Check" },
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
description: 'System overview and status snapshots.'
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
label: 'Projects',
|
||||
description: 'First management module for AMCS projects.'
|
||||
},
|
||||
{
|
||||
id: 'thoughts',
|
||||
label: 'Thoughts',
|
||||
description: 'Thought management arrives after projects.',
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
description: 'File inventory and attachment views.',
|
||||
disabled: true
|
||||
}
|
||||
];
|
||||
|
||||
async function loadStatus() {
|
||||
loading = true;
|
||||
error = "";
|
||||
let authMessage = $state('');
|
||||
let authError = $state('');
|
||||
let authBusy = $state(false);
|
||||
let callbackBusy = $state(false);
|
||||
let data = $state<StatusResponse | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let currentPage = $state<'dashboard' | 'projects'>('dashboard');
|
||||
|
||||
ensureApiURL(import.meta.env.VITE_API_URL);
|
||||
|
||||
const isLoggedIn = $derived(GlobalStateStore.isLoggedIn());
|
||||
const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/');
|
||||
const isOAuthCallback = $derived(currentPath === '/oauth/callback');
|
||||
const oauthAuthorizeURL = $derived(`${getApiURL()}/oauth/authorize`);
|
||||
|
||||
async function startOAuthLogin(): Promise<void> {
|
||||
authBusy = true;
|
||||
authError = '';
|
||||
authMessage = '';
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/status");
|
||||
const authorizationURL = await buildOAuthAuthorizationURL();
|
||||
window.location.assign(authorizationURL);
|
||||
} catch (err) {
|
||||
authError = err instanceof Error ? err.message : 'Failed to start OAuth login.';
|
||||
} finally {
|
||||
authBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishOAuthLogin(): Promise<void> {
|
||||
callbackBusy = true;
|
||||
authError = '';
|
||||
authMessage = '';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
const returnedState = params.get('state');
|
||||
const oauthError = params.get('error');
|
||||
|
||||
if (oauthError) {
|
||||
throw new Error(`OAuth login failed: ${oauthError}`);
|
||||
}
|
||||
if (!code || !returnedState) {
|
||||
throw new Error('OAuth callback is missing code or state.');
|
||||
}
|
||||
|
||||
const token = await exchangeOAuthCode(code, returnedState);
|
||||
await GlobalStateStore.getState().login(token, {
|
||||
username: 'OAuth operator'
|
||||
});
|
||||
|
||||
authMessage = 'OAuth login complete. Welcome back.';
|
||||
window.history.replaceState({}, '', '/');
|
||||
await loadStatus();
|
||||
} catch (err) {
|
||||
authError = err instanceof Error ? err.message : 'OAuth callback failed.';
|
||||
} finally {
|
||||
callbackBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await GlobalStateStore.getState().logout();
|
||||
authMessage = 'Logged out.';
|
||||
authError = '';
|
||||
}
|
||||
|
||||
async function loadStatus(): Promise<void> {
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Status request failed with ${response.status}`);
|
||||
}
|
||||
data = (await response.json()) as StatusResponse;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Failed to load status";
|
||||
error = err instanceof Error ? err.message : 'Failed to load status';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
onMount(loadStatus);
|
||||
onMount(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setCurrentPath(window.location.pathname);
|
||||
}
|
||||
|
||||
if (isOAuthCallback) {
|
||||
await finishOAuthLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
await loadStatus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AMCS</title>
|
||||
<title>AMCS Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<main
|
||||
class="mx-auto flex min-h-screen max-w-7xl flex-col px-4 py-6 sm:px-6 lg:px-8"
|
||||
>
|
||||
<section
|
||||
class="overflow-hidden rounded-3xl border border-white/10 bg-slate-900 shadow-2xl shadow-slate-950/40"
|
||||
>
|
||||
<img
|
||||
src="/images/project.jpg"
|
||||
alt="Avelon Memory Crystal"
|
||||
class="h-64 w-full object-cover object-center sm:h-80"
|
||||
/>
|
||||
|
||||
<div class="grid gap-8 p-6 sm:p-8 lg:grid-cols-[1.6fr_1fr] lg:p-10">
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200"
|
||||
>
|
||||
{#if !isLoggedIn}
|
||||
<main class="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-10 sm:px-6 lg:px-8">
|
||||
<section class="grid w-full gap-8 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<div class="rounded-3xl border border-cyan-400/20 bg-slate-900/80 p-8 shadow-2xl shadow-slate-950/40">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-sm font-medium text-cyan-200">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||
Avalon Memory Crystal Server
|
||||
AMCS Control Interface
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
class="text-3xl font-semibold tracking-tight text-white sm:text-4xl"
|
||||
>
|
||||
Avelon Memory Crystal Server (AMCS)
|
||||
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white">
|
||||
{#if isOAuthCallback}
|
||||
Completing login
|
||||
{:else}
|
||||
Login
|
||||
{/if}
|
||||
</h1>
|
||||
<p
|
||||
class="mt-3 max-w-3xl text-base leading-7 text-slate-300 sm:text-lg"
|
||||
>
|
||||
{data?.description ??
|
||||
"AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools."}
|
||||
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300">
|
||||
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth is the front door now,
|
||||
not the old login shortcut.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Primary module</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">Projects</p>
|
||||
<p class="mt-2 text-sm text-slate-400">Projects are the first real admin screen in this rollout.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">OAuth path</p>
|
||||
<p class="mt-2 text-2xl font-semibold text-white">ResolveSpec</p>
|
||||
<p class="mt-2 text-sm text-slate-400">Client registration, authorize, callback, token exchange.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each quickLinks as link}
|
||||
<a
|
||||
class="inline-flex items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20"
|
||||
href={link.href}>{link.label}</a
|
||||
>
|
||||
{/each}
|
||||
{#if data?.oauth_enabled}
|
||||
<a
|
||||
class="inline-flex items-center justify-center rounded-xl border border-violet-300/20 bg-violet-400/10 px-4 py-2 text-sm font-semibold text-violet-100 transition hover:border-violet-300/40 hover:bg-violet-400/20"
|
||||
href="/oauth-authorization-server">OAuth Authorization Server</a
|
||||
>
|
||||
<div class="rounded-3xl border border-white/10 bg-slate-900 p-6 shadow-xl shadow-slate-950/30 sm:p-8">
|
||||
{#if isOAuthCallback}
|
||||
<h2 class="text-xl font-semibold text-white">Authorizing operator session</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-400">
|
||||
Finishing the ResolveSpec handshake and exchanging the returned code for an AMCS token.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-cyan-400/20 bg-cyan-400/5 px-4 py-6 text-sm text-cyan-100">
|
||||
{#if callbackBusy}
|
||||
Working the callback doohickey…
|
||||
{:else if authError}
|
||||
Callback failed. Fix the route or try the login run again.
|
||||
{:else}
|
||||
Callback processed.
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<h2 class="text-xl font-semibold text-white">Operator login</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Authenticate through AMCS ResolveSpec OAuth endpoints.</p>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||
Connected users
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">
|
||||
{data?.connected_count ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||
Known principals
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">
|
||||
{data?.total_known ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">
|
||||
Version
|
||||
</p>
|
||||
<p class="mt-2 break-all text-2xl font-semibold text-white">
|
||||
{data?.version ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex w-full items-center justify-center rounded-xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-3 text-sm font-semibold text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-400/20 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onclick={startOAuthLogin}
|
||||
disabled={authBusy}
|
||||
>
|
||||
{#if authBusy}Starting OAuth login…{:else}Login with ResolveSpec OAuth{/if}
|
||||
</button>
|
||||
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-300">
|
||||
<p class="font-semibold text-white">Routes in play</p>
|
||||
<ul class="mt-3 space-y-2 text-slate-400">
|
||||
<li>• discovery: <code class="text-cyan-100">/api/.well-known/oauth-authorization-server</code></li>
|
||||
<li>• registration: <code class="text-cyan-100">/api/oauth/register</code></li>
|
||||
<li>• authorize: <code class="text-cyan-100">{oauthAuthorizeURL}</code></li>
|
||||
<li>• callback: <code class="text-cyan-100">/oauth/callback</code></li>
|
||||
<li>• token: <code class="text-cyan-100">/api/oauth/token</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
class="space-y-4 rounded-2xl border border-white/10 bg-slate-950/50 p-5"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Build details</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">The same status info.</p>
|
||||
{#if authError}
|
||||
<p class="text-sm text-rose-300">{authError}</p>
|
||||
{/if}
|
||||
{#if authMessage}
|
||||
<p class="text-sm text-emerald-300">{authMessage}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<dl class="space-y-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<dt class="text-slate-500">Build date</dt>
|
||||
<dd class="mt-1 font-medium text-white">
|
||||
{data?.build_date ?? "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Commit</dt>
|
||||
<dd
|
||||
class="mt-1 break-all rounded-lg bg-white/5 px-3 py-2 font-mono text-xs text-cyan-100"
|
||||
>
|
||||
{data?.commit ?? "unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Connected window</dt>
|
||||
<dd class="mt-1 font-medium text-white">
|
||||
{data?.connected_window ?? "last 10 minutes"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mt-6 rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
</main>
|
||||
{:else}
|
||||
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
|
||||
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Recent access</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Authenticated principals AMCS has seen recently.
|
||||
</p>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-cyan-300">AMCS</p>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-white">Admin</h1>
|
||||
<p class="mt-2 text-sm text-slate-400">Origin-style shell, starting with Projects.</p>
|
||||
</div>
|
||||
|
||||
<nav class="mt-8 space-y-2">
|
||||
{#each navItems as item}
|
||||
<button
|
||||
class={`w-full rounded-2xl border px-4 py-3 text-left transition ${item.disabled ? 'cursor-not-allowed border-white/5 bg-white/[0.02] text-slate-600' : currentPage === item.id ? 'border-cyan-300/30 bg-cyan-400/10 text-cyan-100' : 'border-white/10 bg-white/5 text-slate-200 hover:bg-white/10'}`}
|
||||
disabled={item.disabled}
|
||||
onclick={() => {
|
||||
if (!item.disabled && (item.id === 'dashboard' || item.id === 'projects')) {
|
||||
currentPage = item.id;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="text-sm font-semibold">{item.label}</div>
|
||||
<div class="mt-1 text-xs text-slate-400">{item.description}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<button
|
||||
class="mt-8 inline-flex w-full items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
||||
onclick={logout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="px-4 py-6 sm:px-6 lg:px-8">
|
||||
{#if currentPage === 'dashboard'}
|
||||
<section class="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">System overview</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">Current AMCS status behind the admin shell.</p>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-200 transition hover:bg-white/10"
|
||||
on:click={loadStatus}
|
||||
onclick={loadStatus}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div
|
||||
class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400"
|
||||
>
|
||||
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400">
|
||||
Loading status…
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="mt-6 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-6 text-sm text-rose-100"
|
||||
>
|
||||
<div class="mt-6 rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-6 text-sm text-rose-100">
|
||||
<p class="font-semibold">Couldn’t load the status snapshot.</p>
|
||||
<p class="mt-1 text-rose-100/80">{error}</p>
|
||||
</div>
|
||||
{:else if data && data.entries.length === 0}
|
||||
<div
|
||||
class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-400"
|
||||
>
|
||||
No authenticated access recorded yet.
|
||||
</div>
|
||||
{:else if data}
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Connected users</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{data.connected_count}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Known principals</p>
|
||||
<p class="mt-2 text-3xl font-semibold text-white">{data.total_known}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
<p class="text-sm uppercase tracking-[0.2em] text-slate-400">Version</p>
|
||||
<p class="mt-2 break-all text-2xl font-semibold text-white">{data.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-white">Projects</h2>
|
||||
<p class="mt-1 text-sm text-slate-400">First module scaffold. Grid/Form wiring comes next.</p>
|
||||
</div>
|
||||
<span class="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-xs font-medium text-amber-200">
|
||||
Structure phase
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-[1.35fr_0.65fr]">
|
||||
<div class="rounded-2xl border border-dashed border-cyan-400/20 bg-cyan-400/5 p-6">
|
||||
<h3 class="text-lg font-semibold text-white">Project grid placeholder</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-300">
|
||||
This is the landing zone for the Origin-style projects grid using Svelix and GridlerFull.
|
||||
Next pass: wire ResolveSpec-backed project list, row actions, and editor flow.
|
||||
</p>
|
||||
<ul class="mt-4 space-y-2 text-sm text-slate-400">
|
||||
<li>• Project list and search</li>
|
||||
<li>• Project detail/edit drawer or modal</li>
|
||||
<li>• Create/archive actions</li>
|
||||
<li>• Link-outs to related thoughts and skills</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-white/10 bg-white/5 p-6">
|
||||
<h3 class="text-lg font-semibold text-white">Build notes</h3>
|
||||
<dl class="mt-4 space-y-3 text-sm text-slate-300">
|
||||
<div>
|
||||
<dt class="text-slate-500">Auth path</dt>
|
||||
<dd class="mt-1">ResolveSpec OAuth packages</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">Page pattern</dt>
|
||||
<dd class="mt-1">Mapped toward Origin login and shell</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-500">First module</dt>
|
||||
<dd class="mt-1">Projects</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if data && currentPage === 'dashboard' && data.entries.length > 0}
|
||||
<section class="mt-6 rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
|
||||
<h3 class="text-xl font-semibold text-white">Recent access</h3>
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300"
|
||||
>
|
||||
<thead
|
||||
class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
|
||||
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium">Principal</th>
|
||||
<th class="px-4 py-3 font-medium">Last accessed</th>
|
||||
@@ -232,31 +402,20 @@
|
||||
<tbody class="divide-y divide-white/5 bg-slate-950/30">
|
||||
{#each data.entries as entry}
|
||||
<tr class="hover:bg-white/[0.03]">
|
||||
<td class="px-4 py-3 align-top"
|
||||
><code
|
||||
class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100"
|
||||
>{entry.key_id}</code
|
||||
></td
|
||||
>
|
||||
<td class="px-4 py-3 align-top text-slate-200"
|
||||
>{formatDate(entry.last_accessed_at)}</td
|
||||
>
|
||||
<td class="px-4 py-3 align-top"
|
||||
><code class="text-slate-100">{entry.last_path}</code></td
|
||||
>
|
||||
<td class="px-4 py-3 align-top text-slate-400 text-xs max-w-[16rem] truncate"
|
||||
>{entry.user_agent ?? "—"}</td
|
||||
>
|
||||
<td class="px-4 py-3 align-top font-semibold text-white"
|
||||
>{entry.request_count}</td
|
||||
>
|
||||
<td class="px-4 py-3 align-top"><code class="rounded bg-white/5 px-2 py-1 font-mono text-xs text-cyan-100">{entry.key_id}</code></td>
|
||||
<td class="px-4 py-3 align-top text-slate-200">{formatDate(entry.last_accessed_at)}</td>
|
||||
<td class="px-4 py-3 align-top"><code class="text-slate-100">{entry.last_path}</code></td>
|
||||
<td class="max-w-[16rem] truncate px-4 py-3 align-top text-xs text-slate-400">{entry.user_agent ?? '—'}</td>
|
||||
<td class="px-4 py-3 align-top font-semibold text-white">{entry.request_count}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
276
ui/src/shellState.ts
Normal file
276
ui/src/shellState.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { GlobalStateStore } from '@warkypublic/svelix';
|
||||
|
||||
const normalizeApiURL = (url: string): string => url.replace(/\/+$/, '');
|
||||
|
||||
const resolveApiURL = (envURL?: string): string => {
|
||||
const viteEnvURL =
|
||||
envURL?.trim() ||
|
||||
import.meta.env.VITE_API_URL?.trim() ||
|
||||
import.meta.env.VITE_API_BASE_URL?.trim() ||
|
||||
import.meta.env.VITE_URL?.trim();
|
||||
|
||||
if (viteEnvURL) return normalizeApiURL(viteEnvURL);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${window.location.protocol}//${window.location.host}/api`;
|
||||
}
|
||||
|
||||
const stateURL = GlobalStateStore.getState().session.apiURL?.trim();
|
||||
if (stateURL) return normalizeApiURL(stateURL);
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export { GlobalStateStore };
|
||||
|
||||
export type OAuthClientRegistration = {
|
||||
client_id: string;
|
||||
client_name?: string;
|
||||
redirect_uris?: string[];
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
token_endpoint_auth_method?: string;
|
||||
};
|
||||
|
||||
export type OAuthServerMetadata = {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
registration_endpoint: string;
|
||||
scopes_supported?: string[];
|
||||
response_types_supported?: string[];
|
||||
grant_types_supported?: string[];
|
||||
token_endpoint_auth_methods_supported?: string[];
|
||||
code_challenge_methods_supported?: string[];
|
||||
};
|
||||
|
||||
export type OAuthSession = {
|
||||
clientId: string;
|
||||
redirectURI: string;
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const OAUTH_SESSION_KEY = 'amcs.oauth.session';
|
||||
const OAUTH_CLIENT_KEY = 'amcs.oauth.client';
|
||||
const OAUTH_DEFAULT_SCOPE = 'mcp';
|
||||
|
||||
export function ensureApiURL(envURL?: string): string {
|
||||
const resolved = resolveApiURL(envURL);
|
||||
if (!resolved) return '';
|
||||
|
||||
const state = GlobalStateStore.getState();
|
||||
if (state.session.apiURL !== resolved) {
|
||||
state.setApiURL(resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getPublicBaseURL(): string {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
}
|
||||
|
||||
export function getOAuthRedirectURI(): string {
|
||||
const base = getPublicBaseURL();
|
||||
return base ? `${base}/oauth/callback` : '/oauth/callback';
|
||||
}
|
||||
|
||||
function getStorage(storageKey: string): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return window.localStorage.getItem(storageKey);
|
||||
}
|
||||
|
||||
function setStorage(storageKey: string, value: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(storageKey, value);
|
||||
}
|
||||
|
||||
function removeStorage(storageKey: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.removeItem(storageKey);
|
||||
}
|
||||
|
||||
export function readOAuthClient(): OAuthClientRegistration | null {
|
||||
const raw = getStorage(OAUTH_CLIENT_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as OAuthClientRegistration;
|
||||
} catch {
|
||||
removeStorage(OAUTH_CLIENT_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOAuthClient(client: OAuthClientRegistration): void {
|
||||
setStorage(OAUTH_CLIENT_KEY, JSON.stringify(client));
|
||||
}
|
||||
|
||||
export function readOAuthSession(): OAuthSession | null {
|
||||
const raw = getStorage(OAUTH_SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as OAuthSession;
|
||||
} catch {
|
||||
removeStorage(OAUTH_SESSION_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOAuthSession(session: OAuthSession): void {
|
||||
setStorage(OAUTH_SESSION_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearOAuthSession(): void {
|
||||
removeStorage(OAUTH_SESSION_KEY);
|
||||
}
|
||||
|
||||
export function setCurrentPath(pathname: string): void {
|
||||
const state = GlobalStateStore.getState();
|
||||
const current = state.navigation.currentPage ?? {};
|
||||
|
||||
state.setCurrentPage({
|
||||
...current,
|
||||
path: pathname
|
||||
});
|
||||
}
|
||||
|
||||
function createRandomString(length = 48): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(length);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join('');
|
||||
}
|
||||
|
||||
return Array.from({ length }, () => alphabet[Math.floor(Math.random() * alphabet.length)]).join('');
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||
}
|
||||
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
async function sha256(input: string): Promise<string> {
|
||||
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
||||
throw new Error('Secure browser crypto is required for OAuth login.');
|
||||
}
|
||||
|
||||
const data = new TextEncoder().encode(input);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(digest);
|
||||
}
|
||||
|
||||
export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> {
|
||||
const apiURL = ensureApiURL();
|
||||
const response = await fetch(`${apiURL}/.well-known/oauth-authorization-server`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load OAuth metadata (${response.status})`);
|
||||
}
|
||||
|
||||
return (await response.json()) as OAuthServerMetadata;
|
||||
}
|
||||
|
||||
export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadata): Promise<OAuthClientRegistration> {
|
||||
const redirectURI = getOAuthRedirectURI();
|
||||
const existing = readOAuthClient();
|
||||
if (existing?.client_id && existing.redirect_uris?.includes(redirectURI)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const response = await fetch(metadata.registration_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_name: 'AMCS Admin UI',
|
||||
redirect_uris: [redirectURI],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to register OAuth client (${response.status})`);
|
||||
}
|
||||
|
||||
const client = (await response.json()) as OAuthClientRegistration;
|
||||
saveOAuthClient(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function buildOAuthAuthorizationURL(): Promise<string> {
|
||||
const metadata = await fetchOAuthMetadata();
|
||||
const client = await ensureOAuthClientRegistration(metadata);
|
||||
const codeVerifier = createRandomString(96);
|
||||
const codeChallenge = await sha256(codeVerifier);
|
||||
const state = createRandomString(40);
|
||||
const redirectURI = getOAuthRedirectURI();
|
||||
|
||||
saveOAuthSession({
|
||||
clientId: client.client_id,
|
||||
redirectURI,
|
||||
codeVerifier,
|
||||
state,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
const url = new URL(metadata.authorization_endpoint);
|
||||
url.searchParams.set('client_id', client.client_id);
|
||||
url.searchParams.set('redirect_uri', redirectURI);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', OAUTH_DEFAULT_SCOPE);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('code_challenge', codeChallenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeOAuthCode(code: string, returnedState: string): Promise<string> {
|
||||
const session = readOAuthSession();
|
||||
if (!session) {
|
||||
throw new Error('OAuth session is missing. Start login again.');
|
||||
}
|
||||
|
||||
if (session.state !== returnedState) {
|
||||
throw new Error('OAuth state mismatch. Start login again.');
|
||||
}
|
||||
|
||||
const metadata = await fetchOAuthMetadata();
|
||||
const response = await fetch(metadata.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: session.redirectURI,
|
||||
client_id: session.clientId,
|
||||
code_verifier: session.codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
const payload = (await response.json()) as { access_token?: string; error?: string };
|
||||
if (!response.ok || !payload.access_token) {
|
||||
throw new Error(payload.error || `Token exchange failed (${response.status})`);
|
||||
}
|
||||
|
||||
clearOAuthSession();
|
||||
return payload.access_token;
|
||||
}
|
||||
Reference in New Issue
Block a user