Files
silo/web/src/pages/SettingsPage.tsx
Forbes da65d4bc1a feat(web): favicon, narrow settings, scrollable token list
- Add kindred-logo.svg as site favicon (#115)
- Narrow settings page to 66% max-width, centered (#116)
- Add max-height and scroll to API token table (#118)

Closes #115, closes #116, closes #118
2026-02-15 12:38:20 -06:00

506 lines
15 KiB
TypeScript

import { useEffect, useState, type FormEvent } from "react";
import { get, post, del } from "../api/client";
import { useAuth } from "../hooks/useAuth";
import type { ApiToken, ApiTokenCreated } from "../api/types";
import { AdminModules } from "../components/settings/AdminModules";
export function SettingsPage() {
const { user } = useAuth();
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [tokensError, setTokensError] = useState<string | null>(null);
// Create token form
const [tokenName, setTokenName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
// Newly created token (show once)
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Revoke confirmation
const [revoking, setRevoking] = useState<string | null>(null);
const loadTokens = async () => {
try {
const list = await get<ApiToken[]>("/api/auth/tokens");
setTokens(list);
setTokensError(null);
} catch (e) {
setTokensError(e instanceof Error ? e.message : "Failed to load tokens");
} finally {
setTokensLoading(false);
}
};
useEffect(() => {
loadTokens();
}, []);
const handleCreateToken = async (e: FormEvent) => {
e.preventDefault();
if (!tokenName.trim()) return;
setCreateError("");
setCreating(true);
try {
const result = await post<ApiTokenCreated>("/api/auth/tokens", {
name: tokenName.trim(),
});
setNewToken(result.token);
setCopied(false);
setTokenName("");
await loadTokens();
} catch (e) {
setCreateError(e instanceof Error ? e.message : "Failed to create token");
} finally {
setCreating(false);
}
};
const handleRevoke = async (id: string) => {
try {
await del(`/api/auth/tokens/${id}`);
setRevoking(null);
await loadTokens();
} catch {
// silently fail — token may already be revoked
}
};
const copyToken = async () => {
if (!newToken) return;
try {
await navigator.clipboard.writeText(newToken);
setCopied(true);
} catch {
// fallback: select the text
}
};
const formatDate = (s?: string) => {
if (!s) return "Never";
const d = new Date(s);
return (
d.toLocaleDateString() +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
};
return (
<div style={{ maxWidth: "66%", margin: "0 auto" }}>
<h2 style={{ marginBottom: "1rem" }}>Settings</h2>
{/* Account Card */}
<div style={cardStyle}>
<h3 style={cardTitleStyle}>Account</h3>
{user ? (
<dl style={dlStyle}>
<dt style={dtStyle}>Username</dt>
<dd style={ddStyle}>{user.username}</dd>
<dt style={dtStyle}>Display Name</dt>
<dd style={ddStyle}>
{user.display_name || <span style={mutedStyle}>Not set</span>}
</dd>
<dt style={dtStyle}>Email</dt>
<dd style={ddStyle}>
{user.email || <span style={mutedStyle}>Not set</span>}
</dd>
<dt style={dtStyle}>Auth Source</dt>
<dd style={ddStyle}>{user.auth_source}</dd>
<dt style={dtStyle}>Role</dt>
<dd style={ddStyle}>
<span
style={{
display: "inline-block",
padding: "0.25rem 0.5rem",
borderRadius: "1rem",
fontSize: "var(--font-table)",
fontWeight: 600,
...roleBadgeStyles[user.role],
}}
>
{user.role}
</span>
</dd>
</dl>
) : (
<p style={mutedStyle}>Not logged in</p>
)}
</div>
{/* API Tokens Card */}
<div style={cardStyle}>
<h3 style={cardTitleStyle}>API Tokens</h3>
<p
style={{
color: "var(--ctp-subtext0)",
marginBottom: "1.25rem",
fontSize: "var(--font-body)",
}}
>
API tokens allow the FreeCAD plugin and scripts to authenticate with
Silo. Tokens inherit your role permissions.
</p>
{/* New token banner */}
{newToken && (
<div style={newTokenBannerStyle}>
<p
style={{
color: "var(--ctp-green)",
fontWeight: 600,
marginBottom: "0.5rem",
}}
>
Your new API token (copy it now it won't be shown again):
</p>
<code style={tokenDisplayStyle}>{newToken}</code>
<div
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginTop: "0.5rem",
}}
>
<button onClick={copyToken} style={btnCopyStyle}>
{copied ? "Copied!" : "Copy to clipboard"}
</button>
<button onClick={() => setNewToken(null)} style={btnDismissStyle}>
Dismiss
</button>
</div>
<p
style={{
color: "var(--ctp-subtext0)",
fontSize: "var(--font-body)",
marginTop: "0.5rem",
}}
>
Store this token securely. You will not be able to see it again.
</p>
</div>
)}
{/* Create token form */}
<form onSubmit={handleCreateToken} style={createFormStyle}>
<div style={{ flex: 1, minWidth: 200 }}>
<label style={labelStyle}>Token Name</label>
<input
type="text"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="e.g., FreeCAD workstation"
required
className="silo-input"
style={inputStyle}
/>
</div>
<button
type="submit"
disabled={creating}
style={{ ...btnPrimaryStyle, alignSelf: "flex-end" }}
>
{creating ? "Creating..." : "Create Token"}
</button>
</form>
{createError && <div style={errorStyle}>{createError}</div>}
{/* Token list */}
{tokensLoading ? (
<p style={mutedStyle}>Loading tokens...</p>
) : tokensError ? (
<p style={{ color: "var(--ctp-red)", fontSize: "var(--font-body)" }}>
{tokensError}
</p>
) : (
<div
style={{
overflowX: "auto",
overflowY: "auto",
maxHeight: "28rem",
marginTop: "1rem",
}}
>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Name</th>
<th style={thStyle}>Prefix</th>
<th style={thStyle}>Created</th>
<th style={thStyle}>Last Used</th>
<th style={thStyle}>Expires</th>
<th style={thStyle}>Actions</th>
</tr>
</thead>
<tbody>
{tokens.length === 0 ? (
<tr>
<td
colSpan={6}
style={{
...tdStyle,
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
}}
>
No API tokens yet. Create one to get started.
</td>
</tr>
) : (
tokens.map((t) => (
<tr key={t.id}>
<td style={tdStyle}>{t.name}</td>
<td style={tdStyle}>
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{t.token_prefix}...
</span>
</td>
<td style={tdStyle}>{formatDate(t.created_at)}</td>
<td style={tdStyle}>
{t.last_used_at ? (
formatDate(t.last_used_at)
) : (
<span style={mutedStyle}>Never</span>
)}
</td>
<td style={tdStyle}>
{t.expires_at ? (
formatDate(t.expires_at)
) : (
<span style={mutedStyle}>Never</span>
)}
</td>
<td style={tdStyle}>
{revoking === t.id ? (
<div
style={{
display: "flex",
gap: "0.25rem",
alignItems: "center",
}}
>
<button
onClick={() => handleRevoke(t.id)}
style={btnRevokeConfirmStyle}
>
Confirm
</button>
<button
onClick={() => setRevoking(null)}
style={btnTinyStyle}
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setRevoking(t.id)}
style={btnDangerStyle}
>
Revoke
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
{/* Admin: Module Configuration */}
{user?.role === "admin" && <AdminModules />}
</div>
);
}
// --- Styles ---
const roleBadgeStyles: Record<string, React.CSSProperties> = {
admin: { background: "rgba(203, 166, 247, 0.2)", color: "var(--ctp-mauve)" },
editor: { background: "rgba(137, 180, 250, 0.2)", color: "var(--ctp-blue)" },
viewer: { background: "rgba(148, 226, 213, 0.2)", color: "var(--ctp-teal)" },
};
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.75rem",
padding: "1.5rem",
marginBottom: "1.5rem",
};
const cardTitleStyle: React.CSSProperties = {
marginBottom: "1rem",
fontSize: "var(--font-title)",
};
const dlStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: "auto 1fr",
gap: "0.5rem 1.5rem",
};
const dtStyle: React.CSSProperties = {
color: "var(--ctp-subtext0)",
fontWeight: 500,
fontSize: "var(--font-body)",
};
const ddStyle: React.CSSProperties = {
margin: 0,
fontSize: "var(--font-body)",
};
const mutedStyle: React.CSSProperties = {
color: "var(--ctp-overlay0)",
};
const newTokenBannerStyle: React.CSSProperties = {
background: "rgba(166, 227, 161, 0.1)",
border: "1px solid rgba(166, 227, 161, 0.3)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1.5rem",
};
const tokenDisplayStyle: React.CSSProperties = {
display: "block",
padding: "0.75rem 1rem",
background: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "var(--font-body)",
color: "var(--ctp-peach)",
wordBreak: "break-all",
};
const createFormStyle: React.CSSProperties = {
display: "flex",
gap: "0.75rem",
alignItems: "flex-end",
flexWrap: "wrap",
marginBottom: "0.5rem",
};
const labelStyle: React.CSSProperties = {
display: "block",
marginBottom: "0.25rem",
fontWeight: 500,
color: "var(--ctp-subtext1)",
fontSize: "var(--font-body)",
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: "0.5rem 0.75rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
color: "var(--ctp-text)",
fontSize: "var(--font-body)",
boxSizing: "border-box",
};
const btnPrimaryStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
fontWeight: 500,
fontSize: "0.75rem",
cursor: "pointer",
whiteSpace: "nowrap",
};
const btnCopyStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
background: "var(--ctp-surface1)",
border: "none",
borderRadius: "0.25rem",
color: "var(--ctp-text)",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
};
const btnDismissStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
background: "none",
border: "none",
borderRadius: "0.25rem",
color: "var(--ctp-subtext0)",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
};
const btnDangerStyle: React.CSSProperties = {
background: "rgba(243, 139, 168, 0.15)",
color: "var(--ctp-red)",
border: "none",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
};
const btnRevokeConfirmStyle: React.CSSProperties = {
background: "var(--ctp-red)",
color: "var(--ctp-crust)",
border: "none",
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
cursor: "pointer",
fontSize: "0.75rem",
fontWeight: 500,
};
const btnTinyStyle: React.CSSProperties = {
padding: "0.25rem 0.5rem",
borderRadius: "0.25rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
fontSize: "0.75rem",
fontWeight: 500,
cursor: "pointer",
};
const errorStyle: React.CSSProperties = {
color: "var(--ctp-red)",
fontSize: "var(--font-body)",
marginTop: "0.25rem",
};
const thStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-overlay1)",
fontWeight: 600,
fontSize: "var(--font-table)",
textTransform: "uppercase",
letterSpacing: "0.05em",
};
const tdStyle: React.CSSProperties = {
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
fontSize: "var(--font-body)",
};