Add API tokens, CLI server API and shared core crate
- Personal API tokens (full/read scope, expiry, revoke) and CLI password login - Cargo workspace: codecraft-core (types, providers, permission rules) shared by server and the upcoming terminal client; server re-exports the old paths - CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools), /llm/stream model gateway with budgets and usage per user - Theme/language preferences mirrored to the server; personal memory endpoint - Statistics: provider and mode tables stacked; CLI usage labelled - cli/: work in progress (terminal client is being rewritten as a local TUI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fca1c41087
commit
aa1f8cf016
40 files changed
+2454
-814
No files matched your search
+5
-1
@@ -14,6 +14,7 @@ import { Images } from "./components/Images";
|
||||
import { Jobs } from "./components/Jobs";
|
||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||
import { DialogHost } from "./dialogs";
|
||||
import { startPrefsSync } from "./prefsSync";
|
||||
|
||||
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
||||
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
||||
@@ -31,7 +32,10 @@ export function App() {
|
||||
try {
|
||||
const i = await get<Info>("/info");
|
||||
setState(() => ({ me: i.user }));
|
||||
if (i.user) await loadSessions();
|
||||
if (i.user) {
|
||||
await loadSessions();
|
||||
void startPrefsSync();
|
||||
}
|
||||
setInfo(i);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
|
||||
@@ -253,15 +253,13 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<h3>{t("stats.byModel")}</h3>
|
||||
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
|
||||
</div>
|
||||
<div className="two-col">
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byProvider")}</h3>
|
||||
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byAgent")}</h3>
|
||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byProvider")}</h3>
|
||||
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byAgent")}</h3>
|
||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||
</div>
|
||||
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
||||
<div className="panel">
|
||||
@@ -271,7 +269,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
)}
|
||||
<div className="panel">
|
||||
<h3>{t("stats.topSessions")}</h3>
|
||||
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => navigate(`/s/${r.session_id}`)} />
|
||||
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
import { GitAccount } from "./GitAccount";
|
||||
import { ApiTokens } from "./ApiTokens";
|
||||
|
||||
export function Account() {
|
||||
const t = useT();
|
||||
@@ -46,6 +47,7 @@ export function Account() {
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
||||
</div>
|
||||
<GitAccount />
|
||||
<ApiTokens />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { del, get, post } from "../../api";
|
||||
import { fmtRelative, useT } from "../../i18n";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface TokenInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
scope: "full" | "read";
|
||||
created_at: number;
|
||||
last_used?: number | null;
|
||||
expires_at?: number | null;
|
||||
}
|
||||
|
||||
/** Personal API tokens for the CLI and scripts. */
|
||||
export function ApiTokens() {
|
||||
const t = useT();
|
||||
const [list, setList] = useState<TokenInfo[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [scope, setScope] = useState<"full" | "read">("full");
|
||||
const [days, setDays] = useState("");
|
||||
const [created, setCreated] = useState<string>();
|
||||
|
||||
const reload = () => get<TokenInfo[]>("/account/tokens").then(setList).catch((e) => toast(e.message));
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
const create = async () => {
|
||||
try {
|
||||
const r = await post<{ token: string }>("/account/tokens", { name: name.trim(), scope, days: days ? Number(days) : undefined });
|
||||
setCreated(r.token);
|
||||
setName("");
|
||||
void reload();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="lock" /> {t("tokens.title")}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
{t("tokens.hint")}
|
||||
</div>
|
||||
{created && (
|
||||
<div className="notice ok token-created">
|
||||
<div>{t("tokens.copyNow")}</div>
|
||||
<div className="row" style={{ gap: 6, marginTop: 6 }}>
|
||||
<code className="token-value">{created}</code>
|
||||
<button
|
||||
className="small"
|
||||
onClick={() => navigator.clipboard.writeText(created).then(() => toast(t("tokens.copied"), "success"))}
|
||||
>
|
||||
<Icon name="copy" /> {t("tokens.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mono token-example">{`codecraft login ${location.origin}`}</pre>
|
||||
</div>
|
||||
)}
|
||||
{list.length > 0 && (
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("tokens.name")}</th>
|
||||
<th>{t("tokens.scope")}</th>
|
||||
<th>{t("tokens.lastUsed")}</th>
|
||||
<th>{t("tokens.expires")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((k) => (
|
||||
<tr key={k.id}>
|
||||
<td>
|
||||
{k.name} <code className="muted">{k.prefix}…</code>
|
||||
</td>
|
||||
<td>{t(k.scope === "read" ? "tokens.scopeRead" : "tokens.scopeFull")}</td>
|
||||
<td>{k.last_used ? fmtRelative(k.last_used) : "–"}</td>
|
||||
<td>{k.expires_at ? new Date(k.expires_at).toLocaleDateString() : t("tokens.never")}</td>
|
||||
<td>
|
||||
<button
|
||||
className="small ghost danger"
|
||||
aria-label={t("tokens.revoke")}
|
||||
title={t("tokens.revoke")}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ message: t("tokens.revokeConfirm", { name: k.name }), danger: true })) {
|
||||
await del(`/account/tokens/${k.id}`).catch((e) => toast(e.message));
|
||||
void reload();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
|
||||
<input style={{ flex: 1, minWidth: 160 }} placeholder={t("tokens.namePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<select value={scope} onChange={(e) => setScope(e.target.value as "full" | "read")}>
|
||||
<option value="full">{t("tokens.scopeFull")}</option>
|
||||
<option value="read">{t("tokens.scopeRead")}</option>
|
||||
</select>
|
||||
<select value={days} onChange={(e) => setDays(e.target.value)}>
|
||||
<option value="">{t("tokens.noExpiry")}</option>
|
||||
<option value="30">30 {t("tokens.days")}</option>
|
||||
<option value="90">90 {t("tokens.days")}</option>
|
||||
<option value="365">365 {t("tokens.days")}</option>
|
||||
</select>
|
||||
<button className="primary" disabled={!name.trim()} onClick={create}>
|
||||
<Icon name="plus" /> {t("tokens.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -370,6 +370,24 @@ export const de: Messages = {
|
||||
"voice.stop": "Diktat beenden",
|
||||
"voice.denied": "Mikrofonzugriff verweigert – in den Browser-Einstellungen erlauben.",
|
||||
"voice.error": "Spracherkennung fehlgeschlagen",
|
||||
"tokens.title": "API-Tokens",
|
||||
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
||||
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
||||
"tokens.copy": "Kopieren",
|
||||
"tokens.copied": "Kopiert",
|
||||
"tokens.name": "Name",
|
||||
"tokens.scope": "Rechte",
|
||||
"tokens.lastUsed": "Zuletzt benutzt",
|
||||
"tokens.expires": "Läuft ab",
|
||||
"tokens.never": "nie",
|
||||
"tokens.revoke": "Widerrufen",
|
||||
"tokens.revokeConfirm": "Token „{name}“ widerrufen? Programme, die ihn nutzen, verlieren sofort den Zugriff.",
|
||||
"tokens.namePlaceholder": "Name, z. B. Laptop-CLI",
|
||||
"tokens.scopeFull": "Voller Zugriff",
|
||||
"tokens.scopeRead": "Nur lesen",
|
||||
"tokens.noExpiry": "Kein Ablauf",
|
||||
"tokens.days": "Tage",
|
||||
"tokens.create": "Token erstellen",
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.up": "Hoch",
|
||||
|
||||
@@ -369,6 +369,24 @@ export const en = {
|
||||
"voice.stop": "Stop dictation",
|
||||
"voice.denied": "Microphone access was denied – allow it in the browser settings.",
|
||||
"voice.error": "Speech recognition failed",
|
||||
"tokens.title": "API tokens",
|
||||
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
||||
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
||||
"tokens.copy": "Copy",
|
||||
"tokens.copied": "Copied",
|
||||
"tokens.name": "Name",
|
||||
"tokens.scope": "Rights",
|
||||
"tokens.lastUsed": "Last used",
|
||||
"tokens.expires": "Expires",
|
||||
"tokens.never": "never",
|
||||
"tokens.revoke": "Revoke",
|
||||
"tokens.revokeConfirm": "Revoke the token “{name}”? Programs using it lose access immediately.",
|
||||
"tokens.namePlaceholder": "Name, e.g. laptop CLI",
|
||||
"tokens.scopeFull": "Full access",
|
||||
"tokens.scopeRead": "Read only",
|
||||
"tokens.noExpiry": "No expiry",
|
||||
"tokens.days": "days",
|
||||
"tokens.create": "Create token",
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.up": "Up",
|
||||
|
||||
@@ -25,6 +25,11 @@ let current = detect();
|
||||
document.documentElement.lang = current;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function onLangChange(fn: () => void) {
|
||||
listeners.add(fn);
|
||||
return () => void listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function getLang() {
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { get, put } from "./api";
|
||||
import { getLang, onLangChange, setLang } from "./i18n";
|
||||
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEMES, type Theme, type ThemeMode } from "./theme";
|
||||
|
||||
/**
|
||||
* Theme and language are stored per device (localStorage) and mirrored to the
|
||||
* server, so the command line client uses the same colours and language and a
|
||||
* new device starts with your settings.
|
||||
*/
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let started = false;
|
||||
|
||||
function push() {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
void put("/account/prefs", { theme: getTheme(), theme_mode: getThemeMode(), lang: getLang() }).catch(() => undefined);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
export async function startPrefsSync() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
// A device without own settings takes the server's.
|
||||
const fresh = !localStorage.getItem("codecraft.theme") && !localStorage.getItem("codecraft.themeMode");
|
||||
try {
|
||||
const p = await get<{ theme?: string; theme_mode?: string; lang?: string }>("/account/prefs");
|
||||
if (fresh && p.theme && THEMES.some((t) => t.id === p.theme)) {
|
||||
applyTheme(p.theme as Theme, (p.theme_mode as ThemeMode) ?? "auto");
|
||||
}
|
||||
if (fresh && p.lang) setLang(p.lang);
|
||||
if (!p.theme) push();
|
||||
} catch {
|
||||
/* offline or old server */
|
||||
}
|
||||
onThemeChange(push);
|
||||
onLangChange(push);
|
||||
}
|
||||
@@ -3040,3 +3040,17 @@ button.danger-solid {
|
||||
color: var(--red);
|
||||
animation: glow 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
word-break: break-all;
|
||||
background: var(--bg0);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
.token-example {
|
||||
margin: 8px 0 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12px;
|
||||
}
|
||||
Reference in new issue
Block a user