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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 23:18:50 +01:00
1 parent fca1c41087
commit aa1f8cf016
40 files changed
+2454 -814

No files matched your search

+8 -10
View File
@@ -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>
</>
)}
+2
View File
@@ -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 />
</>
);
}
+123
View File
@@ -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>
);
}