Add: multi token management
This commit is contained in:
@@ -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 <token>`.
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user