feat(ui): implement OAuth login flow and dashboard components
Some checks failed
CI / build-and-test (push) Failing after -32m0s

* Add OAuth login handling in app and UI components
* Create new components for login and dashboard pages
* Refactor sidebar and navigation structure
* Introduce types for access entries and status responses
This commit is contained in:
2026-04-26 09:12:46 +02:00
parent bdc78cc2a3
commit 71845d38d3
22 changed files with 1440 additions and 334 deletions

268
internal/app/admin.go Normal file
View File

@@ -0,0 +1,268 @@
package app
import (
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
"github.com/google/uuid"
)
type adminHandlers struct {
db *store.DB
logger *slog.Logger
}
func newAdminHandlers(db *store.DB, logger *slog.Logger) *adminHandlers {
return &adminHandlers{db: db, logger: logger}
}
func (h *adminHandlers) register(mux *http.ServeMux, middleware func(http.Handler) http.Handler) {
handle := func(pattern string, fn http.HandlerFunc) {
mux.Handle(pattern, middleware(fn))
}
handle("GET /api/admin/projects", h.listProjects)
handle("POST /api/admin/projects", h.createProject)
handle("GET /api/admin/thoughts", h.listThoughts)
handle("GET /api/admin/thoughts/{id}", h.getThought)
handle("DELETE /api/admin/thoughts/{id}", h.deleteThought)
handle("POST /api/admin/thoughts/{id}/archive", h.archiveThought)
handle("GET /api/admin/skills", h.listSkills)
handle("DELETE /api/admin/skills/{id}", h.deleteSkill)
handle("GET /api/admin/guardrails", h.listGuardrails)
handle("DELETE /api/admin/guardrails/{id}", h.deleteGuardrail)
handle("GET /api/admin/files", h.listFiles)
handle("GET /api/admin/stats", h.stats)
}
// --- Projects ---
func (h *adminHandlers) listProjects(w http.ResponseWriter, r *http.Request) {
projects, err := h.db.ListProjects(r.Context())
if err != nil {
h.internalError(w, "list projects", err)
return
}
writeJSON(w, projects)
}
func (h *adminHandlers) createProject(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if strings.TrimSpace(body.Name) == "" {
http.Error(w, "name is required", http.StatusBadRequest)
return
}
project, err := h.db.CreateProject(r.Context(), body.Name, body.Description)
if err != nil {
h.internalError(w, "create project", err)
return
}
w.WriteHeader(http.StatusCreated)
writeJSON(w, project)
}
// --- Thoughts ---
func (h *adminHandlers) listThoughts(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := 50
if l := q.Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = min(n, 200)
}
}
query := strings.TrimSpace(q.Get("q"))
includeArchived := q.Get("include_archived") == "true"
var projectID *uuid.UUID
if pid := q.Get("project_id"); pid != "" {
if id, err := uuid.Parse(pid); err == nil {
projectID = &id
}
}
if query != "" {
results, err := h.db.SearchThoughtsText(r.Context(), query, limit, projectID, nil)
if err != nil {
h.internalError(w, "search thoughts", err)
return
}
writeJSON(w, results)
return
}
thoughts, err := h.db.ListThoughts(r.Context(), ext.ListFilter{
Limit: limit,
ProjectID: projectID,
IncludeArchived: includeArchived,
})
if err != nil {
h.internalError(w, "list thoughts", err)
return
}
writeJSON(w, thoughts)
}
func (h *adminHandlers) getThought(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(w, r.PathValue("id"))
if !ok {
return
}
thought, err := h.db.GetThought(r.Context(), id)
if err != nil {
h.internalError(w, "get thought", err)
return
}
writeJSON(w, thought)
}
func (h *adminHandlers) deleteThought(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(w, r.PathValue("id"))
if !ok {
return
}
if err := h.db.DeleteThought(r.Context(), id); err != nil {
h.internalError(w, "delete thought", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *adminHandlers) archiveThought(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(w, r.PathValue("id"))
if !ok {
return
}
if err := h.db.ArchiveThought(r.Context(), id); err != nil {
h.internalError(w, "archive thought", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Skills ---
func (h *adminHandlers) listSkills(w http.ResponseWriter, r *http.Request) {
tag := r.URL.Query().Get("tag")
skills, err := h.db.ListSkills(r.Context(), tag)
if err != nil {
h.internalError(w, "list skills", err)
return
}
writeJSON(w, skills)
}
func (h *adminHandlers) deleteSkill(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(w, r.PathValue("id"))
if !ok {
return
}
if err := h.db.RemoveSkill(r.Context(), id); err != nil {
h.internalError(w, "delete skill", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Guardrails ---
func (h *adminHandlers) listGuardrails(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
guardrails, err := h.db.ListGuardrails(r.Context(), q.Get("tag"), q.Get("severity"))
if err != nil {
h.internalError(w, "list guardrails", err)
return
}
writeJSON(w, guardrails)
}
func (h *adminHandlers) deleteGuardrail(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(w, r.PathValue("id"))
if !ok {
return
}
if err := h.db.RemoveGuardrail(r.Context(), id); err != nil {
h.internalError(w, "delete guardrail", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Files ---
func (h *adminHandlers) listFiles(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
limit := 100
if l := q.Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 {
limit = min(n, 500)
}
}
filter := ext.StoredFileFilter{Limit: limit}
if pid := q.Get("project_id"); pid != "" {
if id, err := uuid.Parse(pid); err == nil {
filter.ProjectID = &id
}
}
if tid := q.Get("thought_id"); tid != "" {
if id, err := uuid.Parse(tid); err == nil {
filter.ThoughtID = &id
}
}
filter.Kind = q.Get("kind")
files, err := h.db.ListStoredFiles(r.Context(), filter)
if err != nil {
h.internalError(w, "list files", err)
return
}
writeJSON(w, files)
}
// --- Stats ---
func (h *adminHandlers) stats(w http.ResponseWriter, r *http.Request) {
stats, err := h.db.Stats(r.Context())
if err != nil {
h.internalError(w, "stats", err)
return
}
writeJSON(w, stats)
}
// --- Helpers ---
func (h *adminHandlers) internalError(w http.ResponseWriter, op string, err error) {
h.logger.Error("admin handler error", slog.String("op", op), slog.String("error", err.Error()))
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func parseUUID(w http.ResponseWriter, s string) (uuid.UUID, bool) {
id, err := uuid.Parse(s)
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return uuid.UUID{}, false
}
return id, true
}

View File

@@ -92,12 +92,12 @@ func Run(ctx context.Context, configPath string) error {
return err return err
} }
} }
tokenStore = auth.NewTokenStore(0)
if len(cfg.Auth.OAuth.Clients) > 0 { if len(cfg.Auth.OAuth.Clients) > 0 {
oauthRegistry, err = auth.NewOAuthRegistry(cfg.Auth.OAuth.Clients) oauthRegistry, err = auth.NewOAuthRegistry(cfg.Auth.OAuth.Clients)
if err != nil { if err != nil {
return err return err
} }
tokenStore = auth.NewTokenStore(0)
} }
authCodes := auth.NewAuthCodeStore() authCodes := auth.NewAuthCodeStore()
dynClients := auth.NewDynamicClientStore() dynClients := auth.NewDynamicClientStore()
@@ -186,7 +186,7 @@ func Run(ctx context.Context, configPath string) error {
func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, bgEmbeddings *ai.EmbeddingRunner, bgMetadata *ai.MetadataRunner, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) { func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, bgEmbeddings *ai.EmbeddingRunner, bgMetadata *ai.MetadataRunner, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) {
mux := http.NewServeMux() mux := http.NewServeMux()
accessTracker := auth.NewAccessTracker() accessTracker := auth.NewAccessTracker()
oauthEnabled := oauthRegistry != nil && tokenStore != nil oauthEnabled := oauthRegistry != nil
authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger) authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger)
filesTool := tools.NewFilesTool(db, activeProjects) filesTool := tools.NewFilesTool(db, activeProjects)
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger) enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
@@ -227,16 +227,13 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
mux.Handle(cfg.MCP.SSEPath, authMiddleware(mcpHandlers.SSE)) mux.Handle(cfg.MCP.SSEPath, authMiddleware(mcpHandlers.SSE))
logger.Info("SSE transport enabled", slog.String("sse_path", cfg.MCP.SSEPath)) logger.Info("SSE transport enabled", slog.String("sse_path", cfg.MCP.SSEPath))
} }
newAdminHandlers(db, logger).register(mux, authMiddleware)
mux.Handle("/files", authMiddleware(fileHandler(filesTool))) mux.Handle("/files", authMiddleware(fileHandler(filesTool)))
mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool))) mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool)))
if oauthEnabled {
mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler()) mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler())
mux.HandleFunc("/oauth-authorization-server", oauthMetadataHandler()) mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
mux.HandleFunc("/oauth/register", oauthRegisterHandler(dynClients, logger)) mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
mux.HandleFunc("/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger)) mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
mux.HandleFunc("/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
mux.HandleFunc("/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
}
mux.HandleFunc("/favicon.ico", serveFavicon) mux.HandleFunc("/favicon.ico", serveFavicon)
mux.HandleFunc("/images/project.jpg", serveHomeImage) mux.HandleFunc("/images/project.jpg", serveHomeImage)
mux.HandleFunc("/images/icon.png", serveIcon) mux.HandleFunc("/images/icon.png", serveIcon)

View File

@@ -67,9 +67,9 @@ func oauthMetadataHandler() http.HandlerFunc {
base := serverBaseURL(r) base := serverBaseURL(r)
meta := oauthServerMetadata{ meta := oauthServerMetadata{
Issuer: base, Issuer: base,
AuthorizationEndpoint: base + "/authorize", AuthorizationEndpoint: base + "/api/oauth/authorize",
TokenEndpoint: base + "/oauth/token", TokenEndpoint: base + "/api/oauth/token",
RegistrationEndpoint: base + "/oauth/register", RegistrationEndpoint: base + "/api/oauth/register",
ScopesSupported: []string{"mcp"}, ScopesSupported: []string{"mcp"},
ResponseTypesSupported: []string{"code"}, ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "client_credentials"}, GrantTypesSupported: []string{"authorization_code", "client_credentials"},
@@ -244,6 +244,10 @@ func oauthTokenHandler(oauthRegistry *auth.OAuthRegistry, tokenStore *auth.Token
switch r.FormValue("grant_type") { switch r.FormValue("grant_type") {
case "client_credentials": case "client_credentials":
if oauthRegistry == nil {
writeTokenError(w, "unsupported_grant_type", http.StatusBadRequest)
return
}
handleClientCredentials(w, r, oauthRegistry, tokenStore, log) handleClientCredentials(w, r, oauthRegistry, tokenStore, log)
case "authorization_code": case "authorization_code":
handleAuthorizationCode(w, r, authCodes, tokenStore, log) handleAuthorizationCode(w, r, authCodes, tokenStore, log)
@@ -334,7 +338,7 @@ button{padding:.5rem 1.2rem;margin-right:.5rem;cursor:pointer;font-size:1rem}
<body> <body>
<h2>Authorize Access</h2> <h2>Authorize Access</h2>
<p><strong>%s</strong> is requesting access to this AMCS server.</p> <p><strong>%s</strong> is requesting access to this AMCS server.</p>
<form method=POST action=/oauth/authorize> <form method=POST action=/api/oauth/authorize>
<input type=hidden name=client_id value="%s"> <input type=hidden name=client_id value="%s">
<input type=hidden name=redirect_uri value="%s"> <input type=hidden name=redirect_uri value="%s">
<input type=hidden name=state value="%s"> <input type=hidden name=state value="%s">

View File

@@ -90,8 +90,6 @@ func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFu
if serveUIAsset(w, r, requestPath) { if serveUIAsset(w, r, requestPath) {
return return
} }
http.NotFound(w, r)
return
} }
serveUIIndex(w, r) serveUIIndex(w, r)

View File

@@ -1,67 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getApiURL } from '@warkypublic/svelix'; import LoginPage from './components/auth/LoginPage.svelte';
import AdminShell from './components/shell/AdminShell.svelte';
import type { ShellPage, StatusResponse } from './types';
import { fromStore } from 'svelte/store';
import { import {
buildOAuthAuthorizationURL, buildOAuthAuthorizationURL,
ensureApiURL, ensureApiURL,
exchangeOAuthCode, exchangeOAuthCode,
GlobalStateStore, GlobalStateStore,
isLoggedInStore,
setCurrentPath setCurrentPath
} from './shellState'; } from './shellState';
type AccessEntry = {
key_id: string;
last_accessed_at: string;
last_path: string;
user_agent: string;
request_count: number;
};
type StatusResponse = {
title: string;
description: string;
version: string;
build_date: string;
commit: string;
connected_count: number;
total_known: number;
connected_window: string;
oauth_enabled: boolean;
entries: AccessEntry[];
};
type NavItem = {
id: string;
label: string;
description: string;
disabled?: boolean;
};
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
}
];
let authMessage = $state(''); let authMessage = $state('');
let authError = $state(''); let authError = $state('');
let authBusy = $state(false); let authBusy = $state(false);
@@ -69,14 +20,13 @@
let data = $state<StatusResponse | null>(null); let data = $state<StatusResponse | null>(null);
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
let currentPage = $state<'dashboard' | 'projects'>('dashboard'); let currentPage = $state<ShellPage>('dashboard');
ensureApiURL(import.meta.env.VITE_API_URL); ensureApiURL(import.meta.env.VITE_API_URL);
const isLoggedIn = $derived(GlobalStateStore.isLoggedIn()); const isLoggedIn = fromStore(isLoggedInStore);
const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/'); const currentPath = $derived(typeof window !== 'undefined' ? window.location.pathname : '/');
const isOAuthCallback = $derived(currentPath === '/oauth/callback'); const isOAuthCallback = $derived(currentPath === '/oauth/callback');
const oauthAuthorizeURL = $derived(`${getApiURL()}/oauth/authorize`);
async function startOAuthLogin(): Promise<void> { async function startOAuthLogin(): Promise<void> {
authBusy = true; authBusy = true;
@@ -112,9 +62,7 @@
} }
const token = await exchangeOAuthCode(code, returnedState); const token = await exchangeOAuthCode(code, returnedState);
await GlobalStateStore.getState().login(token, { await GlobalStateStore.getState().login(token, { username: 'OAuth operator' });
username: 'OAuth operator'
});
authMessage = 'OAuth login complete. Welcome back.'; authMessage = 'OAuth login complete. Welcome back.';
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
@@ -149,10 +97,6 @@
} }
} }
function formatDate(value: string): string {
return new Date(value).toLocaleString();
}
onMount(async () => { onMount(async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setCurrentPath(window.location.pathname); setCurrentPath(window.location.pathname);
@@ -163,7 +107,7 @@
return; return;
} }
if (isLoggedIn) { if (isLoggedIn.current) {
await loadStatus(); await loadStatus();
} }
}); });
@@ -174,248 +118,24 @@
</svelte:head> </svelte:head>
<div class="min-h-screen bg-slate-950 text-slate-100"> <div class="min-h-screen bg-slate-950 text-slate-100">
{#if !isLoggedIn} {#if !isLoggedIn.current}
<main class="mx-auto flex min-h-screen max-w-6xl items-center px-4 py-10 sm:px-6 lg:px-8"> <LoginPage
<section class="grid w-full gap-8 lg:grid-cols-[1.15fr_0.85fr]"> {isOAuthCallback}
<div class="rounded-3xl border border-cyan-400/20 bg-slate-900/80 p-8 shadow-2xl shadow-slate-950/40"> {callbackBusy}
<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"> {authBusy}
<span class="h-2 w-2 rounded-full bg-emerald-400"></span> {authError}
AMCS Control Interface {authMessage}
</div> onstartLogin={startOAuthLogin}
<h1 class="mt-6 text-4xl font-semibold tracking-tight text-white"> />
{#if isOAuthCallback}
Completing login
{:else} {:else}
Login <AdminShell
{/if} {currentPage}
</h1> {data}
<p class="mt-3 max-w-2xl text-base leading-7 text-slate-300"> {loading}
Origin-style operator access for the AMCS admin interface. ResolveSpec OAuth is the front door now, {error}
not the old login shortcut. onlogout={logout}
</p> onnavigate={(page) => { currentPage = page; }}
onrefresh={loadStatus}
<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="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="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>
{#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>
{/if}
</div>
</section>
</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>
<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"
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">
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">
<p class="font-semibold">Couldnt load the status snapshot.</p>
<p class="mt-1 text-rose-100/80">{error}</p>
</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">
<tr>
<th class="px-4 py-3 font-medium">Principal</th>
<th class="px-4 py-3 font-medium">Last accessed</th>
<th class="px-4 py-3 font-medium">Last path</th>
<th class="px-4 py-3 font-medium">Agent</th>
<th class="px-4 py-3 font-medium">Requests</th>
</tr>
</thead>
<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="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>
</section>
{/if}
</main>
</div>
{/if} {/if}
</div> </div>

81
ui/src/api.ts Normal file
View File

@@ -0,0 +1,81 @@
import { GlobalStateStore } from './shellState';
function authHeaders(): HeadersInit {
const token = GlobalStateStore.getState().session.authToken;
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(path, { headers: authHeaders() });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
async function del(path: string): Promise<void> {
const res = await fetch(path, { method: 'DELETE', headers: authHeaders() });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export const api = {
projects: {
list: () => get<import('./types').ProjectSummary[]>('/api/admin/projects'),
create: (name: string, description: string) =>
post<import('./types').Project>('/api/admin/projects', { name, description })
},
thoughts: {
list: (params: { q?: string; project_id?: string; limit?: number; include_archived?: boolean }) => {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.project_id) qs.set('project_id', params.project_id);
if (params.limit) qs.set('limit', String(params.limit));
if (params.include_archived) qs.set('include_archived', 'true');
return get<(import('./types').Thought | import('./types').SearchResult)[]>(
`/api/admin/thoughts${qs.size ? '?' + qs : ''}`
);
},
get: (id: string) => get<import('./types').Thought>(`/api/admin/thoughts/${id}`),
delete: (id: string) => del(`/api/admin/thoughts/${id}`),
archive: (id: string) => post<void>(`/api/admin/thoughts/${id}/archive`, {})
},
skills: {
list: (tag?: string) => {
const qs = tag ? `?tag=${encodeURIComponent(tag)}` : '';
return get<import('./types').AgentSkill[]>(`/api/admin/skills${qs}`);
},
delete: (id: string) => del(`/api/admin/skills/${id}`)
},
guardrails: {
list: (params?: { tag?: string; severity?: string }) => {
const qs = new URLSearchParams();
if (params?.tag) qs.set('tag', params.tag);
if (params?.severity) qs.set('severity', params.severity);
return get<import('./types').AgentGuardrail[]>(
`/api/admin/guardrails${qs.size ? '?' + qs : ''}`
);
},
delete: (id: string) => del(`/api/admin/guardrails/${id}`)
},
files: {
list: (params?: { project_id?: string; thought_id?: string; kind?: string }) => {
const qs = new URLSearchParams();
if (params?.project_id) qs.set('project_id', params.project_id);
if (params?.thought_id) qs.set('thought_id', params.thought_id);
if (params?.kind) qs.set('kind', params.kind);
return get<import('./types').StoredFile[]>(
`/api/admin/files${qs.size ? '?' + qs : ''}`
);
}
},
stats: () => get<import('./types').ThoughtStats>('/api/admin/stats')
};

View File

@@ -0,0 +1,34 @@
<script lang="ts">
const { isOAuthCallback }: { isOAuthCallback: boolean } = $props();
</script>
<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>
AMCS Control Interface
</div>
<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-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>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import LoginInfoPanel from './LoginInfoPanel.svelte';
import LoginPanel from './LoginPanel.svelte';
const {
isOAuthCallback,
callbackBusy,
authBusy,
authError,
authMessage,
onstartLogin
}: {
isOAuthCallback: boolean;
callbackBusy: boolean;
authBusy: boolean;
authError: string;
authMessage: string;
onstartLogin: () => void;
} = $props();
</script>
<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]">
<LoginInfoPanel {isOAuthCallback} />
<LoginPanel
{isOAuthCallback}
{callbackBusy}
{authBusy}
{authError}
{authMessage}
{onstartLogin}
/>
</section>
</main>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { getApiURL } from '@warkypublic/svelix';
const {
isOAuthCallback,
callbackBusy,
authBusy,
authError,
authMessage,
onstartLogin
}: {
isOAuthCallback: boolean;
callbackBusy: boolean;
authBusy: boolean;
authError: string;
authMessage: string;
onstartLogin: () => void;
} = $props();
const oauthAuthorizeURL = `${getApiURL()}/oauth/authorize`;
</script>
<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="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={onstartLogin}
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">/.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>
{#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>
{/if}
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import type { AccessEntry } from '../../types';
const { entries }: { entries: AccessEntry[] } = $props();
function formatDate(value: string): string {
return new Date(value).toLocaleString();
}
</script>
<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">
<tr>
<th class="px-4 py-3 font-medium">Principal</th>
<th class="px-4 py-3 font-medium">Last accessed</th>
<th class="px-4 py-3 font-medium">Last path</th>
<th class="px-4 py-3 font-medium">Agent</th>
<th class="px-4 py-3 font-medium">Requests</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each 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="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>
</section>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import type { StatusResponse } from '../../types';
import AccessTable from './AccessTable.svelte';
import StatusCards from './StatusCards.svelte';
const {
data,
loading,
error,
onrefresh
}: {
data: StatusResponse | null;
loading: boolean;
error: string;
onrefresh: () => void;
} = $props();
</script>
<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"
onclick={onrefresh}
>
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">
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">
<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}
<StatusCards {data} />
{/if}
</section>
{#if data && data.entries.length > 0}
<AccessTable entries={data.entries} />
{/if}

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { StatusResponse } from '../../types';
const { data }: { data: StatusResponse } = $props();
</script>
<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>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { StoredFile } from '../../types';
let files = $state<StoredFile[]>([]);
let loading = $state(true);
let error = $state('');
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(value: string) {
return new Date(value).toLocaleString();
}
async function load() {
loading = true;
error = '';
try {
files = await api.files.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load files';
} finally {
loading = false;
}
}
onMount(load);
</script>
<div class="space-y-4">
<div class="flex items-end justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Files</h2>
<p class="mt-1 text-sm text-slate-400">{files.length} file{files.length !== 1 ? 's' : ''}</p>
</div>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
{#if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if files.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No files stored.</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Name</th>
<th class="px-4 py-3 text-left font-medium">Type</th>
<th class="px-4 py-3 text-left font-medium">Kind</th>
<th class="px-4 py-3 text-right font-medium">Size</th>
<th class="px-4 py-3 text-right font-medium">Uploaded</th>
<th class="px-4 py-3 text-right font-medium">Download</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each files as f}
<tr class="hover:bg-white/[0.03]">
<td class="max-w-xs truncate px-4 py-3 font-medium text-white">{f.name}</td>
<td class="px-4 py-3 text-slate-400 text-xs">{f.media_type}</td>
<td class="px-4 py-3">
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">{f.kind || '—'}</span>
</td>
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{formatBytes(f.size_bytes)}</td>
<td class="px-4 py-3 text-right text-slate-400">{formatDate(f.created_at)}</td>
<td class="px-4 py-3 text-right">
<a
href={`/files/${f.id}`}
target="_blank"
rel="noreferrer"
class="text-xs text-cyan-400 hover:text-cyan-300"
></a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { AgentGuardrail } from '../../types';
const severityColour: Record<string, string> = {
low: 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200',
medium: 'border-amber-400/20 bg-amber-400/10 text-amber-200',
high: 'border-orange-400/20 bg-orange-400/10 text-orange-200',
critical: 'border-rose-400/20 bg-rose-400/10 text-rose-200'
};
let guardrails = $state<AgentGuardrail[]>([]);
let loading = $state(true);
let error = $state('');
let busy = $state<string | null>(null);
async function load() {
loading = true;
error = '';
try {
guardrails = await api.guardrails.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load guardrails';
} finally {
loading = false;
}
}
async function remove(id: string, name: string) {
if (!confirm(`Delete guardrail "${name}"?`)) return;
busy = id;
try {
await api.guardrails.delete(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
} finally {
busy = null;
}
}
onMount(load);
</script>
<div class="space-y-4">
<div class="flex items-end justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Guardrails</h2>
<p class="mt-1 text-sm text-slate-400">{guardrails.length} guardrail{guardrails.length !== 1 ? 's' : ''}</p>
</div>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
{#if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if guardrails.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No guardrails registered.</div>
{:else}
<div class="space-y-3">
{#each guardrails as g}
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="flex items-center gap-2">
<p class="font-semibold text-white">{g.name}</p>
<span class={`rounded-full border px-2 py-0.5 text-xs font-medium ${severityColour[g.severity] ?? severityColour.medium}`}>
{g.severity}
</span>
</div>
{#if g.description}
<p class="mt-1 text-sm text-slate-400">{g.description}</p>
{/if}
{#if g.tags?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each g.tags as tag}
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
{/each}
</div>
{/if}
</div>
<button
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
onclick={() => remove(g.id, g.name)}
disabled={busy === g.id}
>Delete</button>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{g.content}</pre>
</details>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { ProjectSummary } from '../../types';
let projects = $state<ProjectSummary[]>([]);
let loading = $state(true);
let error = $state('');
let creating = $state(false);
let showCreate = $state(false);
let newName = $state('');
let newDesc = $state('');
let createError = $state('');
async function load() {
loading = true;
error = '';
try {
projects = await api.projects.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load projects';
} finally {
loading = false;
}
}
async function create() {
if (!newName.trim()) return;
creating = true;
createError = '';
try {
await api.projects.create(newName.trim(), newDesc.trim());
newName = '';
newDesc = '';
showCreate = false;
await load();
} catch (e) {
createError = e instanceof Error ? e.message : 'Failed to create project';
} finally {
creating = false;
}
}
function formatDate(value: string) {
return new Date(value).toLocaleDateString();
}
onMount(load);
</script>
<div class="space-y-6">
<div class="flex items-end justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Projects</h2>
<p class="mt-1 text-sm text-slate-400">{projects.length} project{projects.length !== 1 ? 's' : ''}</p>
</div>
<div class="flex gap-2">
<button
class="inline-flex items-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"
onclick={load}
>Refresh</button>
<button
class="inline-flex items-center rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20"
onclick={() => { showCreate = !showCreate; }}
>New project</button>
</div>
</div>
{#if showCreate}
<div class="rounded-2xl border border-cyan-400/20 bg-slate-900 p-5">
<h3 class="text-sm font-semibold text-white">Create project</h3>
<div class="mt-3 space-y-3">
<input
type="text"
placeholder="Name"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
bind:value={newName}
/>
<input
type="text"
placeholder="Description (optional)"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
bind:value={newDesc}
/>
{#if createError}<p class="text-xs text-rose-300">{createError}</p>{/if}
<div class="flex gap-2">
<button
class="rounded-xl border border-cyan-300/30 bg-cyan-400/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:bg-cyan-400/20 disabled:opacity-50"
onclick={create}
disabled={creating || !newName.trim()}
>{creating ? 'Creating…' : 'Create'}</button>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/10"
onclick={() => { showCreate = false; createError = ''; }}
>Cancel</button>
</div>
</div>
</div>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">
Loading…
</div>
{:else if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{:else if projects.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">
No projects yet.
</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Name</th>
<th class="px-4 py-3 text-left font-medium">Description</th>
<th class="px-4 py-3 text-right font-medium">Thoughts</th>
<th class="px-4 py-3 text-right font-medium">Last active</th>
<th class="px-4 py-3 text-right font-medium">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each projects as p}
<tr class="hover:bg-white/[0.03]">
<td class="px-4 py-3 font-medium text-white">{p.name}</td>
<td class="max-w-xs truncate px-4 py-3 text-slate-400">{p.description || '—'}</td>
<td class="px-4 py-3 text-right tabular-nums text-slate-200">{p.thought_count}</td>
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.last_active_at)}</td>
<td class="px-4 py-3 text-right text-slate-400">{formatDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import type { ShellPage, StatusResponse } from '../../types';
import FilesPage from '../files/FilesPage.svelte';
import GuardrailsPage from '../guardrails/GuardrailsPage.svelte';
import DashboardPage from '../dashboard/DashboardPage.svelte';
import ProjectsPage from '../projects/ProjectsPage.svelte';
import SkillsPage from '../skills/SkillsPage.svelte';
import ThoughtsPage from '../thoughts/ThoughtsPage.svelte';
import AppSidebar from './AppSidebar.svelte';
const {
currentPage,
data,
loading,
error,
onlogout,
onnavigate,
onrefresh
}: {
currentPage: ShellPage;
data: StatusResponse | null;
loading: boolean;
error: string;
onlogout: () => void;
onnavigate: (page: ShellPage) => void;
onrefresh: () => void;
} = $props();
</script>
<div class="grid min-h-screen lg:grid-cols-[17rem_1fr]">
<AppSidebar {currentPage} {onnavigate} {onlogout} />
<main class="px-4 py-6 sm:px-6 lg:px-8">
{#if currentPage === 'dashboard'}
<DashboardPage {data} {loading} {error} {onrefresh} />
{:else if currentPage === 'projects'}
<ProjectsPage />
{:else if currentPage === 'thoughts'}
<ThoughtsPage />
{:else if currentPage === 'skills'}
<SkillsPage />
{:else if currentPage === 'guardrails'}
<GuardrailsPage />
{:else if currentPage === 'files'}
<FilesPage />
{/if}
</main>
</div>

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import type { NavItem, ShellPage } from '../../types';
const {
currentPage,
onnavigate,
onlogout
}: {
currentPage: ShellPage;
onnavigate: (page: ShellPage) => void;
onlogout: () => void;
} = $props();
const navItems: NavItem[] = [
{ id: 'dashboard', label: 'Dashboard', description: 'System overview and status.' },
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
{ id: 'files', label: 'Files', description: 'Stored file inventory.' }
];
</script>
<aside class="border-r border-white/10 bg-slate-900/90 p-6">
<div>
<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">Memory server control panel.</p>
</div>
<nav class="mt-8 space-y-1">
{#each navItems as item}
<button
class={`w-full rounded-2xl border px-4 py-3 text-left transition ${currentPage === item.id ? 'border-cyan-300/30 bg-cyan-400/10 text-cyan-100' : 'border-transparent text-slate-200 hover:border-white/10 hover:bg-white/5'}`}
onclick={() => onnavigate(item.id as ShellPage)}
>
<div class="text-sm font-semibold">{item.label}</div>
<div class="mt-0.5 text-xs text-slate-500">{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={onlogout}
>
Logout
</button>
</aside>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { AgentSkill } from '../../types';
let skills = $state<AgentSkill[]>([]);
let loading = $state(true);
let error = $state('');
let busy = $state<string | null>(null);
async function load() {
loading = true;
error = '';
try {
skills = await api.skills.list();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load skills';
} finally {
loading = false;
}
}
async function remove(id: string, name: string) {
if (!confirm(`Delete skill "${name}"?`)) return;
busy = id;
try {
await api.skills.delete(id);
await load();
} catch (e) {
error = e instanceof Error ? e.message : 'Delete failed';
} finally {
busy = null;
}
}
onMount(load);
</script>
<div class="space-y-4">
<div class="flex items-end justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Skills</h2>
<p class="mt-1 text-sm text-slate-400">{skills.length} skill{skills.length !== 1 ? 's' : ''}</p>
</div>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
{#if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if skills.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No skills registered.</div>
{:else}
<div class="space-y-3">
{#each skills as skill}
<div class="rounded-2xl border border-white/10 bg-white/5 p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="font-semibold text-white">{skill.name}</p>
{#if skill.description}
<p class="mt-1 text-sm text-slate-400">{skill.description}</p>
{/if}
{#if skill.tags?.length}
<div class="mt-2 flex flex-wrap gap-1">
{#each skill.tags as tag}
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">{tag}</span>
{/each}
</div>
{/if}
</div>
<button
class="shrink-0 text-xs text-rose-400 hover:text-rose-300 disabled:opacity-40"
onclick={() => remove(skill.id, skill.name)}
disabled={busy === skill.id}
>Delete</button>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-xs text-slate-500 hover:text-slate-300">View content</summary>
<pre class="mt-2 overflow-x-auto rounded-xl bg-slate-950/60 p-3 text-xs text-slate-300 whitespace-pre-wrap">{skill.content}</pre>
</details>
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '../../api';
import type { Thought, SearchResult } from '../../types';
type Row = Thought | SearchResult;
let rows = $state<Row[]>([]);
let loading = $state(true);
let error = $state('');
let query = $state('');
let includeArchived = $state(false);
let actionBusy = $state<string | null>(null);
let actionError = $state('');
let searchTimer: ReturnType<typeof setTimeout>;
async function load() {
loading = true;
error = '';
try {
rows = await api.thoughts.list({ q: query || undefined, include_archived: includeArchived || undefined, limit: 100 }) as Row[];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load thoughts';
} finally {
loading = false;
}
}
function onQueryInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(load, 350);
}
async function archive(id: string) {
actionBusy = id;
actionError = '';
try {
await api.thoughts.archive(id);
await load();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Archive failed';
} finally {
actionBusy = null;
}
}
async function remove(id: string) {
if (!confirm('Permanently delete this thought?')) return;
actionBusy = id;
actionError = '';
try {
await api.thoughts.delete(id);
await load();
} catch (e) {
actionError = e instanceof Error ? e.message : 'Delete failed';
} finally {
actionBusy = null;
}
}
function isArchived(row: Row): boolean {
return 'archived_at' in row && !!row.archived_at;
}
function content(row: Row): string {
return row.content.length > 120 ? row.content.slice(0, 120) + '…' : row.content;
}
function formatDate(value: string) {
return new Date(value).toLocaleString();
}
onMount(load);
</script>
<div class="space-y-4">
<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">Thoughts</h2>
<p class="mt-1 text-sm text-slate-400">{rows.length} result{rows.length !== 1 ? 's' : ''}</p>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-slate-400">
<input type="checkbox" class="accent-cyan-400" bind:checked={includeArchived} onchange={load} />
Archived
</label>
<button
class="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-200 transition hover:bg-white/10"
onclick={load}
>Refresh</button>
</div>
</div>
<input
type="search"
placeholder="Search thoughts…"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-cyan-400/40 focus:outline-none"
bind:value={query}
oninput={onQueryInput}
/>
{#if actionError}
<p class="text-sm text-rose-300">{actionError}</p>
{/if}
{#if loading}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-400">Loading…</div>
{:else if error}
<div class="rounded-2xl border border-rose-400/30 bg-rose-400/10 px-4 py-4 text-sm text-rose-100">{error}</div>
{:else if rows.length === 0}
<div class="rounded-2xl border border-dashed border-white/10 bg-slate-950/40 py-12 text-center text-slate-500">No thoughts found.</div>
{:else}
<div class="overflow-hidden rounded-2xl border border-white/10">
<table class="min-w-full divide-y divide-white/10 text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.18em] text-slate-500">
<tr>
<th class="px-4 py-3 text-left font-medium">Content</th>
<th class="px-4 py-3 text-left font-medium">Type</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-right font-medium">Created</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each rows as row}
<tr class={`hover:bg-white/[0.03] ${isArchived(row) ? 'opacity-50' : ''}`}>
<td class="max-w-sm px-4 py-3 align-top">
<p class="text-white">{content(row)}</p>
{#if row.metadata.topics?.length}
<p class="mt-1 text-xs text-slate-500">{row.metadata.topics.slice(0,3).join(', ')}</p>
{/if}
</td>
<td class="px-4 py-3 align-top">
<span class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-300">
{row.metadata.type || '—'}
</span>
</td>
<td class="px-4 py-3 align-top text-xs text-slate-400">
{isArchived(row) ? 'archived' : (row.metadata.metadata_status || 'active')}
</td>
<td class="px-4 py-3 align-top text-right text-slate-400">{formatDate(row.created_at)}</td>
<td class="px-4 py-3 align-top text-right">
<div class="flex justify-end gap-2">
{#if !isArchived(row)}
<button
class="text-xs text-slate-400 underline-offset-2 hover:text-slate-200"
onclick={() => archive(row.id)}
disabled={actionBusy === row.id}
>Archive</button>
{/if}
<button
class="text-xs text-rose-400 underline-offset-2 hover:text-rose-300"
onclick={() => remove(row.id)}
disabled={actionBusy === row.id}
>Delete</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -1,4 +1,4 @@
import { GlobalStateStore } from '@warkypublic/svelix'; import { GlobalStateStore, isLoggedInStore } from '@warkypublic/svelix';
const normalizeApiURL = (url: string): string => url.replace(/\/+$/, ''); const normalizeApiURL = (url: string): string => url.replace(/\/+$/, '');
@@ -21,7 +21,7 @@ const resolveApiURL = (envURL?: string): string => {
return ''; return '';
}; };
export { GlobalStateStore }; export { GlobalStateStore, isLoggedInStore };
export type OAuthClientRegistration = { export type OAuthClientRegistration = {
client_id: string; client_id: string;
@@ -174,8 +174,8 @@ async function sha256(input: string): Promise<string> {
} }
export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> { export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> {
const apiURL = ensureApiURL(); const base = getPublicBaseURL();
const response = await fetch(`${apiURL}/.well-known/oauth-authorization-server`); const response = await fetch(`${base}/.well-known/oauth-authorization-server`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load OAuth metadata (${response.status})`); throw new Error(`Failed to load OAuth metadata (${response.status})`);
} }
@@ -185,10 +185,6 @@ export async function fetchOAuthMetadata(): Promise<OAuthServerMetadata> {
export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadata): Promise<OAuthClientRegistration> { export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadata): Promise<OAuthClientRegistration> {
const redirectURI = getOAuthRedirectURI(); const redirectURI = getOAuthRedirectURI();
const existing = readOAuthClient();
if (existing?.client_id && existing.redirect_uris?.includes(redirectURI)) {
return existing;
}
const response = await fetch(metadata.registration_endpoint, { const response = await fetch(metadata.registration_endpoint, {
method: 'POST', method: 'POST',
@@ -214,6 +210,7 @@ export async function ensureOAuthClientRegistration(metadata: OAuthServerMetadat
} }
export async function buildOAuthAuthorizationURL(): Promise<string> { export async function buildOAuthAuthorizationURL(): Promise<string> {
removeStorage(OAUTH_CLIENT_KEY);
const metadata = await fetchOAuthMetadata(); const metadata = await fetchOAuthMetadata();
const client = await ensureOAuthClientRegistration(metadata); const client = await ensureOAuthClientRegistration(metadata);
const codeVerifier = createRandomString(96); const codeVerifier = createRandomString(96);

111
ui/src/types.ts Normal file
View File

@@ -0,0 +1,111 @@
export type AccessEntry = {
key_id: string;
last_accessed_at: string;
last_path: string;
user_agent: string;
request_count: number;
};
export type StatusResponse = {
title: string;
description: string;
version: string;
build_date: string;
commit: string;
connected_count: number;
total_known: number;
connected_window: string;
oauth_enabled: boolean;
entries: AccessEntry[];
};
export type NavItem = {
id: string;
label: string;
description: string;
disabled?: boolean;
};
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'skills' | 'guardrails' | 'files';
export type Project = {
id: string;
name: string;
description: string;
created_at: string;
last_active_at: string;
};
export type ProjectSummary = Project & {
thought_count: number;
};
export type ThoughtMetadata = {
people: string[];
action_items: string[];
dates_mentioned: string[];
topics: string[];
type: string;
source: string;
metadata_status: string;
metadata_error?: string;
};
export type Thought = {
id: string;
content: string;
metadata: ThoughtMetadata;
project_id?: string;
archived_at?: string;
created_at: string;
updated_at: string;
};
export type SearchResult = {
id: string;
content: string;
metadata: ThoughtMetadata;
similarity: number;
created_at: string;
};
export type AgentSkill = {
id: string;
name: string;
description: string;
content: string;
tags: string[];
created_at: string;
updated_at: string;
};
export type AgentGuardrail = {
id: string;
name: string;
description: string;
content: string;
severity: 'low' | 'medium' | 'high' | 'critical';
tags: string[];
created_at: string;
updated_at: string;
};
export type StoredFile = {
id: string;
thought_id?: string;
project_id?: string;
name: string;
media_type: string;
kind: string;
size_bytes: number;
sha256: string;
created_at: string;
updated_at: string;
};
export type ThoughtStats = {
total_count: number;
type_counts: Record<string, number>;
top_topics: { key: string; count: number }[];
top_people: { key: string; count: number }[];
};

View File

@@ -18,9 +18,6 @@ export default defineConfig({
'/favicon.ico': backendTarget, '/favicon.ico': backendTarget,
'/mcp': backendTarget, '/mcp': backendTarget,
'/files': backendTarget, '/files': backendTarget,
'/oauth-authorization-server': backendTarget,
'/authorize': backendTarget,
'/oauth': backendTarget,
'/.well-known': backendTarget '/.well-known': backendTarget
} }
}, },