auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
All checks were successful
build-and-push / build (push) Successful in 18s
release-client / build-release (push) Successful in 48s

Ersätter den delade AGENT_HELM_TOKEN med en riktig auth-modell:

- Webben loggar in med lokala konton ELLER OIDC (Authentik/Keycloak/…), valt
  per server. Inloggning ger en HMAC-signerad cookie; WS autentiseras via cookien.
- Klienten (daemon) kopplas via device-flödet (RFC 8628-likt): `agent-helm connect`
  skriver ut en URL, du loggar in + godkänner i webben, och får ett klient-token
  som identifierar din användare. Token sparas i ~/.config/agent-helm/.
- Sessioner är per användare (web ser bara sina egna; admin ser alla). Flera
  klient-instanser = flera parallella sessioner; --new tvingar ny, annars resume.
- Identitet är utbytbar (lokal/OIDC) men device-flödet + sessionerna är alltid
  agent-helms egna → funkar för vem som helst som hostar detta, med eller utan IdP.
- Bootstrap-admin via env (engångs) eller first-run-setup i UI:t. Klient-creds
  kan listas/återkallas; minimal admin-UI (skapa användare, konfigurera OIDC).

Nya filer: server/store.ts (JSON-store, scrypt, cookies), server/oidc.ts
(auth-code+PKCE), web/lib/api.ts. Verifierat end-to-end lokalt (login → device
→ approve → daemon-WS → web ser sessionen; web utan cookie nekas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4
This commit is contained in:
2026-06-29 01:21:29 +02:00
parent 98c4d13360
commit 94259b3456
12 changed files with 1704 additions and 403 deletions

View File

@@ -1,24 +1,47 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, computed, onMounted } from "vue";
import { createHelmClient } from "./lib/helm";
import TerminalView from "./components/TerminalView.vue";
import * as api from "./lib/api";
import type { ClientCredDto } from "./lib/api";
import type { MeResponse, PublicConfig } from "@agent-helm/shared";
// I dev: peka mot lokal server. I prod (serverad av servern bakom NPM): samma
// origin, så bygget funkar på vilken host som helst (t.ex. wss://rcai.brasse-pc.eu).
const sameOriginWs = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
const defaultUrl =
import.meta.env.VITE_SERVER_WS || (import.meta.env.DEV ? "ws://localhost:8787" : sameOriginWs);
const defaultToken = import.meta.env.VITE_AGENT_HELM_TOKEN ?? "";
type View = "loading" | "login" | "setup" | "device" | "app";
const view = ref<View>("loading");
const config = ref<PublicConfig | null>(null);
const meUser = ref<MeResponse | null>(null);
const authError = ref<string | null>(null);
const busy = ref(false);
const url = ref(localStorage.getItem("helm.url") ?? defaultUrl);
const token = ref(localStorage.getItem("helm.token") ?? defaultToken);
// Inloggningsformulär
const username = ref("");
const password = ref("");
const displayName = ref("");
// Device-consent (klienten skickade hit dig via copy-paste-URL:en)
const isDeviceRoute = location.pathname === "/device";
const deviceCode = ref(new URLSearchParams(location.search).get("code") ?? "");
const deviceClient = ref<string | null>(null);
const deviceState = ref<"loading" | "pending" | "done" | "denied" | "error">("loading");
const deviceMsg = ref("");
// WS mot control-plane (same-origin /ws → cookie-auth)
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`;
const client = createHelmClient();
const { connected, connecting, error, sessions, approvals } = client;
const selected = ref<string | null>(null);
const sidebarOpen = ref(true);
// Inställningar/admin-panel
const panelOpen = ref(false);
const clients = ref<ClientCredDto[]>([]);
const adminUsers = ref<MeResponse[]>([]);
const newUser = ref({ username: "", password: "", displayName: "", isAdmin: false });
const oidcForm = ref({ issuer: "", clientId: "", clientSecret: "", label: "OIDC", allowedGroup: "" });
const panelMsg = ref("");
const activeMeta = computed(() => sessions.value.find((s) => s.sessionId === selected.value) ?? null);
function sessionTitle(id: string): string {
return sessions.value.find((s) => s.sessionId === id)?.title ?? id.slice(0, 8);
}
@@ -32,41 +55,232 @@ function fmtInput(v: unknown): string {
}
return s.length > 600 ? `${s.slice(0, 600)}` : s;
}
const activeMeta = computed(
() => sessions.value.find((s) => s.sessionId === selected.value) ?? null,
);
function connect(): void {
localStorage.setItem("helm.url", url.value);
localStorage.setItem("helm.token", token.value);
client.connect(url.value, token.value);
}
function select(id: string): void {
selected.value = id;
if (window.matchMedia("(max-width: 720px)").matches) sidebarOpen.value = false;
}
/* ----------------------------------- boot ---------------------------------- */
async function boot(): Promise<void> {
try {
config.value = await api.getConfig();
} catch {
authError.value = "Kunde inte nå servern.";
view.value = "login";
return;
}
meUser.value = await api.getMe();
if (isDeviceRoute) {
if (!meUser.value) {
view.value = config.value.setupComplete === false && config.value.authMode === "local" ? "setup" : "login";
return;
}
view.value = "device";
void loadDevice();
return;
}
if (meUser.value) enterApp();
else if (!config.value.setupComplete && config.value.authMode === "local") view.value = "setup";
else view.value = "login";
}
function enterApp(): void {
view.value = "app";
client.connect(wsUrl);
}
function afterAuth(): void {
if (isDeviceRoute) {
view.value = "device";
void loadDevice();
} else enterApp();
}
async function doLogin(): Promise<void> {
busy.value = true;
authError.value = null;
try {
meUser.value = await api.login(username.value, password.value);
afterAuth();
} catch (e) {
authError.value = (e as Error).message;
} finally {
busy.value = false;
}
}
async function doSetup(): Promise<void> {
busy.value = true;
authError.value = null;
try {
meUser.value = await api.setup(username.value, password.value, displayName.value);
afterAuth();
} catch (e) {
authError.value = (e as Error).message;
} finally {
busy.value = false;
}
}
function oidcLogin(): void {
const next = isDeviceRoute ? location.pathname + location.search : "/";
location.href = `/auth/oidc/login?next=${encodeURIComponent(next)}`;
}
async function doLogout(): Promise<void> {
await api.logout();
client.disconnect();
meUser.value = null;
panelOpen.value = false;
view.value = config.value?.authMode === "local" ? "login" : "login";
}
/* --------------------------------- device ---------------------------------- */
async function loadDevice(): Promise<void> {
if (!deviceCode.value) {
deviceState.value = "pending";
return;
}
deviceState.value = "loading";
try {
const info = await api.deviceInfo(deviceCode.value);
deviceClient.value = info.clientName;
deviceState.value =
info.status === "approved" ? "done" : info.status === "denied" ? "denied" : "pending";
if (info.status === "approved") deviceMsg.value = "Klienten är redan godkänd.";
} catch (e) {
deviceState.value = "error";
deviceMsg.value = (e as Error).message;
}
}
async function approveDevice(decision: "allow" | "deny"): Promise<void> {
busy.value = true;
try {
await api.deviceApprove(deviceCode.value, decision);
deviceState.value = decision === "allow" ? "done" : "denied";
} catch (e) {
deviceState.value = "error";
deviceMsg.value = (e as Error).message;
} finally {
busy.value = false;
}
}
/* ------------------------------ inställningar ------------------------------- */
async function openPanel(): Promise<void> {
panelOpen.value = true;
panelMsg.value = "";
try {
clients.value = (await api.listClients()).clients;
if (meUser.value?.isAdmin) adminUsers.value = (await api.adminListUsers()).users;
} catch (e) {
panelMsg.value = (e as Error).message;
}
}
async function revokeClient(id: string): Promise<void> {
await api.revokeClient(id);
clients.value = (await api.listClients()).clients;
}
async function createUser(): Promise<void> {
panelMsg.value = "";
try {
await api.adminCreateUser(newUser.value.username, newUser.value.password, newUser.value.displayName, newUser.value.isAdmin);
adminUsers.value = (await api.adminListUsers()).users;
newUser.value = { username: "", password: "", displayName: "", isAdmin: false };
panelMsg.value = "Användare skapad.";
} catch (e) {
panelMsg.value = (e as Error).message;
}
}
async function deleteUser(id: string): Promise<void> {
await api.adminDeleteUser(id);
adminUsers.value = (await api.adminListUsers()).users;
}
async function saveOidc(): Promise<void> {
panelMsg.value = "";
try {
await api.adminSetOidc({ ...oidcForm.value, allowedGroup: oidcForm.value.allowedGroup || undefined });
await api.adminSetAuthMode("oidc");
panelMsg.value = "OIDC sparat + aktiverat. Logga ut för att testa.";
} catch (e) {
panelMsg.value = (e as Error).message;
}
}
async function setMode(mode: "local" | "oidc"): Promise<void> {
await api.adminSetAuthMode(mode);
config.value = await api.getConfig();
panelMsg.value = `Auth-läge: ${mode}.`;
}
function fmtTime(t: number): string {
return new Date(t).toLocaleString();
}
function goHome(): void {
location.href = "/";
}
onMounted(boot);
</script>
<template>
<!-- Inloggning -->
<div v-if="!connected" class="login">
<!-- Laddar -->
<div v-if="view === 'loading'" class="login"><div class="card"><p class="sub">Laddar </p></div></div>
<!-- First-run: skapa admin -->
<div v-else-if="view === 'setup'" class="login">
<div class="card">
<h1>agent-helm</h1>
<p class="sub">Fjärrstyr dina agent-sessioner</p>
<label>
Server (WebSocket)
<input v-model="url" autocapitalize="off" autocorrect="off" spellcheck="false" />
</label>
<label>
Token
<input v-model="token" type="password" placeholder="delad token" @keyup.enter="connect" />
</label>
<button class="primary" :disabled="connecting" @click="connect">
{{ connecting ? "Ansluter" : "Anslut" }}
<p class="sub">Första start skapa ett admin-konto</p>
<label>Användarnamn<input v-model="username" autocapitalize="off" autocorrect="off" spellcheck="false" /></label>
<label>Visningsnamn<input v-model="displayName" placeholder="(valfritt)" /></label>
<label>Lösenord<input v-model="password" type="password" @keyup.enter="doSetup" /></label>
<button class="primary" :disabled="busy || !username || !password" @click="doSetup">
{{ busy ? "Skapar" : "Skapa admin & logga in" }}
</button>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="authError" class="err">{{ authError }}</p>
</div>
</div>
<!-- Inloggning -->
<div v-else-if="view === 'login'" class="login">
<div class="card">
<h1>agent-helm</h1>
<p class="sub">Logga in för att styra dina agent-sessioner</p>
<template v-if="config && config.authMode === 'oidc'">
<button class="primary" @click="oidcLogin">{{ config.oidcLabel || "Logga in med OIDC" }}</button>
</template>
<template v-else>
<label>Användarnamn<input v-model="username" autocapitalize="off" autocorrect="off" spellcheck="false" /></label>
<label>Lösenord<input v-model="password" type="password" placeholder="lösenord" @keyup.enter="doLogin" /></label>
<button class="primary" :disabled="busy || !username || !password" @click="doLogin">
{{ busy ? "Loggar in" : "Logga in" }}
</button>
</template>
<p v-if="authError" class="err">{{ authError }}</p>
</div>
</div>
<!-- Device-consent -->
<div v-else-if="view === 'device'" class="login">
<div class="card">
<h1>Koppla klient</h1>
<template v-if="deviceState === 'loading'"><p class="sub">Hämtar </p></template>
<template v-else-if="deviceState === 'pending'">
<p class="sub" v-if="!deviceCode">Klistra in koden från klienten:</p>
<label v-if="!deviceCode">Kod<input v-model="deviceCode" placeholder="XXXX-XXXX" @blur="loadDevice" /></label>
<p v-else>
Godkänn att klienten <b>{{ deviceClient }}</b> kopplas till ditt konto
(<code>{{ meUser?.displayName }}</code>)?
</p>
<div class="row" v-if="deviceCode">
<button class="deny" :disabled="busy" @click="approveDevice('deny')">Neka</button>
<button class="primary" :disabled="busy" @click="approveDevice('allow')">Godkänn</button>
</div>
</template>
<template v-else-if="deviceState === 'done'">
<p class="ok"> Klienten är godkänd. Den ansluter automatiskt du kan stänga fönstret.</p>
<button class="primary" @click="goHome">Till mina sessioner</button>
</template>
<template v-else-if="deviceState === 'denied'"><p class="err">Klienten nekades.</p></template>
<template v-else><p class="err">{{ deviceMsg || "Något gick fel." }}</p></template>
</div>
</div>
@@ -90,6 +304,11 @@ function select(id: string): void {
</li>
<li v-if="sessions.length === 0" class="empty">Inga anslutna sessioner än</li>
</ul>
<footer class="userbar">
<span class="who">{{ meUser?.displayName }}</span>
<button class="link" @click="openPanel">Inställningar</button>
<button class="link" @click="doLogout">Logga ut</button>
</footer>
</aside>
<main class="stage">
@@ -101,10 +320,12 @@ function select(id: string): void {
:session-id="selected"
:meta="activeMeta"
/>
<div v-else class="placeholder">Välj en session till vänster</div>
<div v-else class="placeholder">
{{ connected ? "Välj en session till vänster" : connecting ? "Ansluter…" : error || "Frånkopplad" }}
</div>
</main>
<!-- Strukturerade godkännanden (BeforeTool-hook) -->
<!-- Godkännanden -->
<div v-if="approvals.length" class="approvals">
<div v-for="a in approvals" :key="a.approvalId" class="approval-card">
<div class="ac-head">
@@ -119,19 +340,71 @@ function select(id: string): void {
</div>
</div>
</div>
<!-- Inställningar / admin -->
<div v-if="panelOpen" class="modal" @click.self="panelOpen = false">
<div class="sheet">
<header><h2>Inställningar</h2><button class="link" @click="panelOpen = false">Stäng</button></header>
<section>
<h3>Mina klienter</h3>
<p v-if="!clients.length" class="sub">Inga klienter kopplade än.</p>
<ul class="clients">
<li v-for="c in clients" :key="c.id" :class="{ revoked: c.revoked }">
<span class="title">{{ c.name }}</span>
<span class="meta">sedd {{ fmtTime(c.lastSeenAt) }}</span>
<button v-if="!c.revoked" class="deny sm" @click="revokeClient(c.id)">Återkalla</button>
<span v-else class="badge">återkallad</span>
</li>
</ul>
</section>
<template v-if="meUser?.isAdmin">
<section>
<h3>Användare (admin)</h3>
<ul class="clients">
<li v-for="u in adminUsers" :key="u.userId">
<span class="title">{{ u.displayName }} <small>@{{ u.username }}</small></span>
<span class="meta">{{ u.isAdmin ? "admin" : "" }}</span>
<button v-if="u.userId !== meUser?.userId" class="deny sm" @click="deleteUser(u.userId)">Ta bort</button>
</li>
</ul>
<div class="form">
<input v-model="newUser.username" placeholder="användarnamn" />
<input v-model="newUser.displayName" placeholder="visningsnamn" />
<input v-model="newUser.password" type="password" placeholder="lösenord" />
<label class="chk"><input type="checkbox" v-model="newUser.isAdmin" /> admin</label>
<button class="primary sm" @click="createUser">Skapa</button>
</div>
</section>
<section>
<h3>OIDC (admin)</h3>
<p class="sub">Läge: <b>{{ config?.authMode }}</b>
<button class="link" @click="setMode(config?.authMode === 'oidc' ? 'local' : 'oidc')">byt</button>
</p>
<div class="form col">
<input v-model="oidcForm.issuer" placeholder="issuer (…/application/o/agent-helm/)" />
<input v-model="oidcForm.clientId" placeholder="client_id" />
<input v-model="oidcForm.clientSecret" type="password" placeholder="client_secret" />
<input v-model="oidcForm.label" placeholder="knapptext" />
<input v-model="oidcForm.allowedGroup" placeholder="tillåten grupp (valfritt)" />
<button class="primary sm" @click="saveOidc">Spara OIDC & aktivera</button>
</div>
</section>
</template>
<p v-if="panelMsg" class="ok">{{ panelMsg }}</p>
</div>
</div>
</div>
</template>
<style scoped>
/* Inloggning */
.login {
display: grid;
place-items: center;
height: 100%;
padding: 1rem;
}
/* Inloggning / kort */
.login { display: grid; place-items: center; height: 100%; padding: 1rem; }
.card {
width: min(380px, 100%);
width: min(400px, 100%);
display: flex;
flex-direction: column;
gap: 0.85rem;
@@ -141,7 +414,7 @@ function select(id: string): void {
border-radius: 0.9rem;
}
.card h1 { margin: 0; font-size: 1.4rem; }
.card .sub { margin: -0.6rem 0 0.4rem; color: var(--muted); font-size: 0.9rem; }
.card .sub { margin: -0.4rem 0 0.2rem; color: var(--muted); font-size: 0.9rem; }
.card label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.8rem; color: var(--muted); }
.card input {
padding: 0.6rem 0.7rem;
@@ -151,6 +424,9 @@ function select(id: string): void {
color: var(--text);
font: inherit;
}
.card code { background: var(--panel-2); padding: 0.05rem 0.3rem; border-radius: 0.3rem; }
.row { display: flex; gap: 0.6rem; }
.row > button { flex: 1; }
.primary {
margin-top: 0.4rem;
padding: 0.7rem;
@@ -159,47 +435,21 @@ function select(id: string): void {
background: var(--accent);
color: #06121f;
font-weight: 700;
cursor: pointer;
}
.primary:disabled { opacity: 0.6; }
.deny { padding: 0.7rem; border: 1px solid var(--border); border-radius: 0.55rem; background: var(--panel-2); color: var(--red); font-weight: 700; cursor: pointer; }
.err { color: var(--red); font-size: 0.85rem; margin: 0; }
.ok { color: var(--green); font-size: 0.9rem; margin: 0.3rem 0 0; }
/* Layout */
.app { display: flex; height: 100%; }
.sidebar {
width: 260px;
flex: 0 0 260px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar { width: 260px; flex: 0 0 260px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
.sidebar header { display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1rem; border-bottom: 1px solid var(--border); }
.sidebar .brand { font-weight: 700; }
.sidebar .count {
font-size: 0.75rem;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.05rem 0.5rem;
}
.sidebar ul { list-style: none; margin: 0; padding: 0.5rem; overflow-y: auto; }
.sidebar li {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.6rem 0.65rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.88rem;
}
.sidebar .count { font-size: 0.75rem; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 0.05rem 0.5rem; }
.sidebar ul { list-style: none; margin: 0; padding: 0.5rem; overflow-y: auto; flex: 1; }
.sidebar li { display: flex; align-items: center; gap: 0.55rem; padding: 0.6rem 0.65rem; border-radius: 0.5rem; cursor: pointer; font-size: 0.88rem; }
.sidebar li:hover { background: var(--panel-2); }
.sidebar li.active { background: color-mix(in srgb, var(--accent) 22%, transparent); }
.sidebar li.exited .title { color: var(--muted); }
@@ -207,6 +457,9 @@ function select(id: string): void {
.sidebar li .badge { font-size: 0.68rem; color: var(--red); }
.sidebar li.empty { color: var(--muted); cursor: default; justify-content: center; padding: 1.2rem 0; }
.sidebar li.empty:hover { background: none; }
.userbar { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.8rem; border-top: 1px solid var(--border); font-size: 0.8rem; }
.userbar .who { flex: 1; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; font: inherit; font-size: 0.8rem; padding: 0.1rem 0.2rem; }
.dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--muted); flex: none; }
.dot.running { background: var(--green); }
@@ -214,89 +467,45 @@ function select(id: string): void {
.stage { flex: 1; min-width: 0; position: relative; display: flex; }
.stage > .terminal-view { flex: 1; }
.toggle {
display: none;
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 5;
width: 2.2rem;
height: 2.2rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
}
.toggle { display: none; position: absolute; top: 0.5rem; left: 0.5rem; z-index: 5; width: 2.2rem; height: 2.2rem; border-radius: 0.5rem; border: 1px solid var(--border); background: var(--panel); color: var(--text); }
.placeholder { margin: auto; color: var(--muted); }
/* Godkännande-kort (BeforeTool-hook) */
.approvals {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(380px, calc(100vw - 2rem));
}
.approval-card {
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 0.7rem;
padding: 0.75rem 0.85rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
/* Modal (inställningar/admin) */
.modal { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); display: grid; place-items: center; z-index: 30; padding: 1rem; }
.sheet { width: min(560px, 100%); max-height: 90vh; overflow: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 0.9rem; padding: 1.2rem; }
.sheet header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.sheet h2 { margin: 0; font-size: 1.1rem; }
.sheet section { border-top: 1px solid var(--border); padding: 0.8rem 0; }
.sheet h3 { margin: 0 0 0.5rem; font-size: 0.9rem; }
.clients { list-style: none; margin: 0 0 0.6rem; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.clients li { display: flex; align-items: center; gap: 0.5rem; font-size: 0.82rem; }
.clients li.revoked { opacity: 0.5; }
.clients .title { flex: 1; }
.clients .meta { color: var(--muted); font-size: 0.72rem; }
.form { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.form.col { flex-direction: column; align-items: stretch; }
.form input[type="text"], .form input:not([type]), .form input[type="password"] { flex: 1; min-width: 8rem; padding: 0.45rem 0.55rem; border-radius: 0.45rem; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; }
.chk { display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--muted); }
.sm { padding: 0.45rem 0.7rem; margin: 0; font-size: 0.82rem; }
.badge { font-size: 0.68rem; color: var(--red); }
/* Godkännande-kort */
.approvals { position: fixed; right: 1rem; bottom: 1rem; z-index: 20; display: flex; flex-direction: column; gap: 0.6rem; width: min(380px, calc(100vw - 2rem)); }
.approval-card { background: var(--panel); border: 1px solid var(--accent); border-radius: 0.7rem; padding: 0.75rem 0.85rem; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45); }
.ac-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
.ac-badge {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent);
padding: 0.1rem 0.45rem;
border-radius: 999px;
}
.ac-badge { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; background: color-mix(in srgb, var(--accent) 25%, transparent); color: var(--accent); padding: 0.1rem 0.45rem; border-radius: 999px; }
.ac-session { font-size: 0.75rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ac-tool { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.35rem; word-break: break-word; }
.ac-input {
margin: 0 0 0.6rem;
max-height: 9rem;
overflow: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.45rem;
padding: 0.5rem;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, Menlo, Consolas, monospace;
color: var(--text);
}
.ac-input { margin: 0 0 0.6rem; max-height: 9rem; overflow: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 0.45rem; padding: 0.5rem; font-size: 0.78rem; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--text); }
.ac-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
.ac-actions button {
flex: 1;
padding: 0.55rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
font-weight: 700;
font-size: 0.9rem;
}
.ac-actions button { flex: 1; padding: 0.55rem; border-radius: 0.5rem; border: 1px solid var(--border); font-weight: 700; font-size: 0.9rem; }
.ac-actions .allow { background: var(--green); color: #06121f; border-color: var(--green); }
.ac-actions .deny { background: var(--panel-2); color: var(--red); }
.ac-actions button:active { opacity: 0.8; }
@media (max-width: 720px) {
.approvals { left: 0.6rem; right: 0.6rem; bottom: 0.6rem; width: auto; }
}
@media (max-width: 720px) {
.sidebar {
position: absolute;
z-index: 10;
height: 100%;
transform: translateX(-100%);
transition: transform 0.18s ease;
}
.approvals { left: 0.6rem; right: 0.6rem; bottom: 0.6rem; width: auto; }
.sidebar { position: absolute; z-index: 10; height: 100%; transform: translateX(-100%); transition: transform 0.18s ease; }
.sidebar.open { transform: translateX(0); }
.toggle { display: block; }
}

View File

@@ -0,0 +1,79 @@
/** Tunn fetch-wrapper mot serverns REST-API. Allt same-origin → cookien följer med. */
import type {
DeviceInfoResponse,
MeResponse,
PublicConfig,
} from "@agent-helm/shared";
async function jsonOrThrow<T>(r: Response): Promise<T> {
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
msg = (await r.json()).error || msg;
} catch {
/* tom */
}
throw new Error(msg);
}
return r.json() as Promise<T>;
}
const post = (path: string, body?: unknown) =>
fetch(path, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
export const getConfig = () => fetch("/api/config", { credentials: "include" }).then((r) => jsonOrThrow<PublicConfig>(r));
export async function getMe(): Promise<MeResponse | null> {
const r = await fetch("/api/me", { credentials: "include" });
return r.ok ? ((await r.json()) as MeResponse) : null;
}
export const login = (username: string, password: string) =>
post("/api/login", { username, password }).then((r) => jsonOrThrow<MeResponse>(r));
export const setup = (username: string, password: string, displayName?: string) =>
post("/api/setup", { username, password, displayName }).then((r) => jsonOrThrow<MeResponse>(r));
export const logout = () => post("/api/logout");
export const deviceInfo = (userCode: string) =>
fetch(`/api/device/info?user_code=${encodeURIComponent(userCode)}`, { credentials: "include" }).then((r) =>
jsonOrThrow<DeviceInfoResponse>(r),
);
export const deviceApprove = (userCode: string, decision: "allow" | "deny") =>
post("/api/device/approve", { userCode, decision }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export interface ClientCredDto {
id: string;
name: string;
createdAt: number;
lastSeenAt: number;
revoked: boolean;
}
export const listClients = () =>
fetch("/api/clients", { credentials: "include" }).then((r) => jsonOrThrow<{ clients: ClientCredDto[] }>(r));
export const revokeClient = (id: string) =>
fetch(`/api/clients/${id}`, { method: "DELETE", credentials: "include" }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
/* ---- admin ---- */
export const adminListUsers = () =>
fetch("/api/admin/users", { credentials: "include" }).then((r) => jsonOrThrow<{ users: MeResponse[] }>(r));
export const adminCreateUser = (username: string, password: string, displayName: string, isAdmin: boolean) =>
post("/api/admin/users", { username, password, displayName, isAdmin }).then((r) => jsonOrThrow<MeResponse>(r));
export const adminDeleteUser = (id: string) =>
fetch(`/api/admin/users/${id}`, { method: "DELETE", credentials: "include" }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export const adminSetOidc = (cfg: {
issuer: string;
clientId: string;
clientSecret: string;
label?: string;
allowedGroup?: string;
}) => post("/api/admin/oidc", cfg).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export const adminSetAuthMode = (mode: "local" | "oidc") =>
post("/api/admin/authmode", { mode }).then((r) => jsonOrThrow<{ ok: boolean }>(r));

View File

@@ -30,12 +30,13 @@ export function createHelmClient() {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(url: string, token: string): void {
function connect(url: string): void {
error.value = null;
connecting.value = true;
ws = new WebSocket(url);
ws.onopen = () => send({ type: "web:hello", token });
// Ingen token — vi autentiseras via inloggnings-cookien på WS-handskaket.
ws.onopen = () => send({ type: "web:hello" });
ws.onmessage = (ev) => {
let msg: ServerToWeb;