Add: multi token management

This commit is contained in:
DaZuo0122
2026-02-15 12:20:00 +08:00
parent f20ed1fd9d
commit 832fbda04d
8 changed files with 713 additions and 16 deletions

View File

@@ -25,6 +25,13 @@ type UiSpritePackOption = {
pack_id_or_path: string;
};
type UiApiToken = {
id: string;
label: string;
token: string;
created_at_ms: number;
};
const WINDOW_PADDING = 16;
const WINDOW_WORKAREA_MARGIN = 80;
const MIN_WINDOW_SIZE = 64;
@@ -62,6 +69,22 @@ async function invokeSetAnimationSlowdownFactor(factor: number): Promise<number>
return invoke<number>("set_tauri_animation_slowdown_factor", { factor });
}
async function invokeListApiTokens(): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("list_api_tokens");
}
async function invokeCreateApiToken(label?: string): Promise<UiApiToken> {
return invoke<UiApiToken>("create_api_token", { label });
}
async function invokeRenameApiToken(id: string, label: string): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("rename_api_token", { id, label });
}
async function invokeRevokeApiToken(id: string): Promise<UiApiToken[]> {
return invoke<UiApiToken[]>("revoke_api_token", { id });
}
function fittedWindowSize(
scale: number
): { width: number; height: number } {
@@ -396,6 +419,9 @@ function MainOverlayWindow(): JSX.Element {
function SettingsWindow(): JSX.Element {
const [settings, setSettings] = React.useState<UiSettingsSnapshot | null>(null);
const [packs, setPacks] = React.useState<UiSpritePackOption[]>([]);
const [tokens, setTokens] = React.useState<UiApiToken[]>([]);
const [tokenDrafts, setTokenDrafts] = React.useState<Record<string, string>>({});
const [newTokenLabel, setNewTokenLabel] = React.useState("");
const [activePack, setActivePack] = React.useState<UiSpritePack | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [pending, setPending] = React.useState(false);
@@ -406,15 +432,23 @@ function SettingsWindow(): JSX.Element {
Promise.all([
invoke<UiSettingsSnapshot>("settings_snapshot"),
invoke<UiSpritePackOption[]>("list_sprite_packs"),
invoke<UiSpritePack>("load_active_sprite_pack")
invoke<UiSpritePack>("load_active_sprite_pack"),
invokeListApiTokens()
])
.then(async ([snapshot, options, pack]) => {
.then(async ([snapshot, options, pack, authTokens]) => {
if (!mounted) {
return;
}
setSettings(snapshot);
setPacks(options);
setActivePack(pack);
setTokens(authTokens);
setTokenDrafts(
authTokens.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
unlisten = await listen<UiSnapshot>("runtime:snapshot", (event) => {
if (!mounted) {
return;
@@ -574,6 +608,85 @@ function SettingsWindow(): JSX.Element {
[withPending]
);
const onNewTokenLabelChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setNewTokenLabel(event.target.value);
},
[]
);
const onCreateToken = React.useCallback(async () => {
const created = await withPending(() => invokeCreateApiToken(newTokenLabel || undefined));
if (created === null) {
return;
}
const refreshed = await withPending(() => invokeListApiTokens());
if (refreshed === null) {
return;
}
setTokens(refreshed);
setTokenDrafts(
refreshed.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
setNewTokenLabel("");
}, [newTokenLabel, withPending]);
const onTokenDraftChange = React.useCallback((id: string, value: string) => {
setTokenDrafts((prev) => ({
...prev,
[id]: value
}));
}, []);
const onRenameToken = React.useCallback(
async (id: string) => {
const nextLabel = tokenDrafts[id] ?? "";
const refreshed = await withPending(() => invokeRenameApiToken(id, nextLabel));
if (refreshed === null) {
return;
}
setTokens(refreshed);
setTokenDrafts(
refreshed.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
},
[tokenDrafts, withPending]
);
const onCopyToken = React.useCallback(async (token: string) => {
try {
await navigator.clipboard.writeText(token);
} catch (err) {
setError(String(err));
}
}, []);
const onRevokeToken = React.useCallback(
async (id: string) => {
if (!window.confirm("Revoke this API token?")) {
return;
}
const refreshed = await withPending(() => invokeRevokeApiToken(id));
if (refreshed === null) {
return;
}
setTokens(refreshed);
setTokenDrafts(
refreshed.reduce<Record<string, string>>((acc, token) => {
acc[token.id] = token.label;
return acc;
}, {})
);
},
[withPending]
);
return (
<main className="settings-root">
<section className="settings-card">
@@ -642,6 +755,68 @@ function SettingsWindow(): JSX.Element {
/>
<span>Always on top</span>
</label>
<section className="token-section">
<h2>API Tokens</h2>
<p className="token-help">
Use any listed token as `Authorization: Bearer &lt;token&gt;`.
</p>
<div className="token-create">
<input
type="text"
value={newTokenLabel}
placeholder="New token label"
disabled={pending}
onChange={onNewTokenLabelChange}
/>
<button type="button" disabled={pending} onClick={onCreateToken}>
Create token
</button>
</div>
<div className="token-list">
{tokens.map((entry) => (
<article className="token-item" key={entry.id}>
<label className="field">
<span>Label</span>
<input
type="text"
value={tokenDrafts[entry.id] ?? ""}
disabled={pending}
onChange={(event) =>
onTokenDraftChange(entry.id, event.target.value)
}
/>
</label>
<label className="field">
<span>Token</span>
<input type="text" value={entry.token} readOnly />
</label>
<div className="token-actions">
<button
type="button"
disabled={pending}
onClick={() => onCopyToken(entry.token)}
>
Copy
</button>
<button
type="button"
disabled={pending}
onClick={() => onRenameToken(entry.id)}
>
Save label
</button>
<button
type="button"
disabled={pending || tokens.length <= 1}
onClick={() => onRevokeToken(entry.id)}
>
Revoke
</button>
</div>
</article>
))}
</div>
</section>
</>
)}
</section>

View File

@@ -85,6 +85,7 @@ dd {
}
.settings-root {
height: 100vh;
min-height: 100vh;
display: flex;
align-items: stretch;
@@ -92,6 +93,8 @@ dd {
background: linear-gradient(180deg, #f1f5f9 0%, #dbe4ee 100%);
color: #0f172a;
user-select: none;
overflow-y: auto;
overflow-x: hidden;
}
.settings-card {
@@ -102,6 +105,7 @@ dd {
display: grid;
gap: 14px;
align-content: start;
box-sizing: border-box;
}
.settings-card h1 {
@@ -137,7 +141,8 @@ dd {
}
.field select,
.field input[type="range"] {
.field input[type="range"],
.field input[type="text"] {
width: 100%;
}
@@ -149,6 +154,15 @@ dd {
color: #0f172a;
}
.field input[type="text"] {
border: 1px solid #94a3b8;
border-radius: 8px;
padding: 8px 10px;
background: #ffffff;
color: #0f172a;
box-sizing: border-box;
}
.toggle {
display: flex;
align-items: center;
@@ -156,3 +170,64 @@ dd {
font-size: 14px;
color: #1e293b;
}
.token-section {
margin-top: 8px;
border-top: 1px solid #cbd5e1;
padding-top: 12px;
display: grid;
gap: 10px;
}
.token-section h2 {
margin: 0;
font-size: 18px;
}
.token-help {
margin: 0;
font-size: 12px;
color: #475569;
}
.token-create {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.token-create button,
.token-actions button {
border: 1px solid #94a3b8;
border-radius: 8px;
padding: 7px 10px;
background: #ffffff;
color: #0f172a;
cursor: pointer;
}
.token-create button:disabled,
.token-actions button:disabled {
opacity: 0.5;
cursor: default;
}
.token-list {
display: grid;
gap: 10px;
}
.token-item {
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 10px;
display: grid;
gap: 8px;
background: #f8fafc;
}
.token-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}