- 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
506 lines
15 KiB
TypeScript
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)",
|
|
};
|