auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
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:
@@ -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; }
|
||||
}
|
||||
|
||||
79
packages/web/src/lib/api.ts
Normal file
79
packages/web/src/lib/api.ts
Normal 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));
|
||||
@@ -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;
|
||||
|
||||
@@ -18,5 +18,12 @@ export default defineConfig({
|
||||
server: {
|
||||
host: true,
|
||||
fs: { allow: [repoRoot] },
|
||||
// Proxa API + WS till control-plane så dev körs same-origin (cookie-auth funkar).
|
||||
// Webben ansluter WS till `${origin}/ws`; servern accepterar WS på valfri path.
|
||||
proxy: {
|
||||
"/api": { target: "http://localhost:8787", changeOrigin: true },
|
||||
"/auth": { target: "http://localhost:8787", changeOrigin: true },
|
||||
"/ws": { target: "ws://localhost:8787", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user