All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-runners (pull_request) Has been skipped
CI/CD / e2e-smoke (pull_request) Has been skipped
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m42s
CI/CD / test-sqlite (pull_request) Successful in 3m5s
CI/CD / build-and-deploy (pull_request) Successful in 1m10s
The frontend SDK doesn't expose sw.api.fetch — use sw.api.get(), sw.api.post(), sw.api.del() with full /api/v1/ paths instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
257 lines
11 KiB
JavaScript
257 lines
11 KiB
JavaScript
/**
|
|
* TokensSection — personal access token CRUD
|
|
*
|
|
* Create, list, and revoke PATs for programmatic API access.
|
|
* Token value is shown once on creation; stored as SHA-256 hash server-side.
|
|
*/
|
|
const { html } = window;
|
|
const { useState, useEffect, useCallback } = hooks;
|
|
|
|
function esc(s) { return s == null ? '' : String(s); }
|
|
|
|
export function TokensSection() {
|
|
const [tokens, setTokens] = useState(null);
|
|
const [availablePerms, setAvailablePerms] = useState([]);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [createdToken, setCreatedToken] = useState(null);
|
|
const [form, setForm] = useState({ name: '', permissions: [], expires_at: '' });
|
|
const [saving, setSaving] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const resp = await sw.api.get('/api/v1/auth/tokens');
|
|
setTokens(resp || []);
|
|
} catch (e) {
|
|
setTokens([]);
|
|
}
|
|
}, []);
|
|
|
|
const loadPerms = useCallback(async () => {
|
|
try {
|
|
const resp = await sw.api.get('/api/v1/profile/permissions');
|
|
setAvailablePerms(resp?.permissions || []);
|
|
} catch (_) {}
|
|
}, []);
|
|
|
|
useEffect(() => { load(); loadPerms(); }, [load, loadPerms]);
|
|
|
|
const openCreate = useCallback(() => {
|
|
setCreatedToken(null);
|
|
setForm({ name: '', permissions: [], expires_at: '' });
|
|
setShowForm(true);
|
|
}, []);
|
|
|
|
const cancel = useCallback(() => {
|
|
setShowForm(false);
|
|
setCreatedToken(null);
|
|
}, []);
|
|
|
|
const togglePerm = useCallback((perm) => {
|
|
setForm(f => {
|
|
const perms = f.permissions.includes(perm)
|
|
? f.permissions.filter(p => p !== perm)
|
|
: [...f.permissions, perm];
|
|
return { ...f, permissions: perms };
|
|
});
|
|
}, []);
|
|
|
|
const create = useCallback(async () => {
|
|
if (!form.name) {
|
|
sw.emit('toast', { message: 'Token name is required', variant: 'error' });
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const body = { name: form.name, permissions: form.permissions };
|
|
if (form.expires_at) body.expires_at = new Date(form.expires_at).toISOString();
|
|
const resp = await sw.api.post('/api/v1/auth/tokens', body);
|
|
setCreatedToken(resp.token);
|
|
setShowForm(false);
|
|
load();
|
|
sw.emit('toast', { message: 'Token created', variant: 'success' });
|
|
} catch (e) {
|
|
sw.emit('toast', { message: e.message || 'Failed to create token', variant: 'error' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [form, load]);
|
|
|
|
const revoke = useCallback(async (id, name) => {
|
|
if (!await sw.confirm(`Revoke token "${esc(name)}"? This action cannot be undone.`)) return;
|
|
try {
|
|
await sw.api.del(`/api/v1/auth/tokens/${id}`);
|
|
sw.emit('toast', { message: 'Token revoked', variant: 'success' });
|
|
load();
|
|
} catch (e) {
|
|
sw.emit('toast', { message: e.message || 'Failed to revoke', variant: 'error' });
|
|
}
|
|
}, [load]);
|
|
|
|
const copyToken = useCallback(async () => {
|
|
if (!createdToken) return;
|
|
try {
|
|
await navigator.clipboard.writeText(createdToken);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch (_) {
|
|
sw.emit('toast', { message: 'Copy failed — select and copy manually', variant: 'error' });
|
|
}
|
|
}, [createdToken]);
|
|
|
|
const dismissToken = useCallback(() => { setCreatedToken(null); }, []);
|
|
|
|
// ── Render ──────────────────────────────
|
|
|
|
if (tokens === null) return html`<p>Loading…</p>`;
|
|
|
|
return html`
|
|
<div class="ext-settings-tokens">
|
|
${createdToken && html`
|
|
<div class="ext-settings-tokens__created">
|
|
<strong>Token created — copy it now. It won't be shown again.</strong>
|
|
<div class="ext-settings-tokens__token-display">
|
|
<code class="ext-settings-tokens__token-value">${createdToken}</code>
|
|
<button class="sw-btn sw-btn--sm" onClick=${copyToken}>
|
|
${copied ? 'Copied!' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
<button class="sw-btn sw-btn--text sw-btn--sm" onClick=${dismissToken}>Dismiss</button>
|
|
</div>
|
|
`}
|
|
|
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-4);">
|
|
<p style="color:var(--text-2);margin:0;">
|
|
Personal access tokens provide programmatic API access.
|
|
Treat tokens like passwords.
|
|
</p>
|
|
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${openCreate}>
|
|
New Token
|
|
</button>
|
|
</div>
|
|
|
|
${showForm && html`
|
|
<div class="ext-settings-tokens__form" style="border:1px solid var(--border);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-4);">
|
|
<div style="margin-bottom:var(--sp-3);">
|
|
<label style="display:block;font-weight:600;margin-bottom:var(--sp-1);">Name</label>
|
|
<input type="text" class="sw-input" placeholder="e.g. CI Pipeline"
|
|
value=${form.name}
|
|
onInput=${e => setForm(f => ({ ...f, name: e.target.value }))} />
|
|
</div>
|
|
|
|
<div style="margin-bottom:var(--sp-3);">
|
|
<label style="display:block;font-weight:600;margin-bottom:var(--sp-1);">Expiration (optional)</label>
|
|
<input type="date" class="sw-input"
|
|
value=${form.expires_at}
|
|
onInput=${e => setForm(f => ({ ...f, expires_at: e.target.value }))} />
|
|
</div>
|
|
|
|
<div style="margin-bottom:var(--sp-3);">
|
|
<label style="display:block;font-weight:600;margin-bottom:var(--sp-1);">Permissions</label>
|
|
<p style="color:var(--text-3);font-size:0.85rem;margin:0 0 var(--sp-2) 0;">
|
|
Select the permissions this token should have. Leave empty for read-only access.
|
|
</p>
|
|
<div style="display:flex;flex-wrap:wrap;gap:var(--sp-2);">
|
|
${availablePerms.map(p => html`
|
|
<label key=${p} style="display:flex;align-items:center;gap:var(--sp-1);cursor:pointer;">
|
|
<input type="checkbox"
|
|
checked=${form.permissions.includes(p)}
|
|
onChange=${() => togglePerm(p)} />
|
|
<code style="font-size:0.85rem;">${p}</code>
|
|
</label>
|
|
`)}
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex;gap:var(--sp-2);">
|
|
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${create} disabled=${saving}>
|
|
${saving ? 'Creating…' : 'Create Token'}
|
|
</button>
|
|
<button class="sw-btn sw-btn--sm" onClick=${cancel}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
`}
|
|
|
|
${tokens.length === 0 && !showForm && html`
|
|
<div class="sw-inline-error" style="text-align:center;padding:var(--sp-8) 0;">
|
|
<p>No tokens yet</p>
|
|
<p style="color:var(--text-3);">Create a personal access token for CI, scripts, or API integration.</p>
|
|
</div>
|
|
`}
|
|
|
|
${tokens.length > 0 && html`
|
|
<table class="sw-table" style="width:100%;">
|
|
<thead>
|
|
<tr>
|
|
<th>Name</th>
|
|
<th>Token</th>
|
|
<th>Permissions</th>
|
|
<th>Expires</th>
|
|
<th>Last Used</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${tokens.map(t => html`
|
|
<tr key=${t.id}>
|
|
<td><strong>${esc(t.name)}</strong></td>
|
|
<td><code style="font-size:0.85rem;color:var(--text-3);">arm_pat_${esc(t.prefix)}…</code></td>
|
|
<td>
|
|
${(t.permissions || []).length === 0
|
|
? html`<span style="color:var(--text-3);">none</span>`
|
|
: (t.permissions || []).map(p => html`
|
|
<code key=${p} class="ext-settings-tokens__perm-badge">${p}</code>
|
|
`)
|
|
}
|
|
</td>
|
|
<td>${t.expires_at ? new Date(t.expires_at).toLocaleDateString() : '—'}</td>
|
|
<td>${t.last_used_at ? new Date(t.last_used_at).toLocaleDateString() : 'Never'}</td>
|
|
<td>
|
|
<button class="sw-btn sw-btn--danger sw-btn--sm"
|
|
onClick=${() => revoke(t.id, t.name)}>
|
|
Revoke
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`)}
|
|
</tbody>
|
|
</table>
|
|
`}
|
|
</div>
|
|
|
|
<style>
|
|
.ext-settings-tokens__created {
|
|
background: var(--bg-success, #e8f5e9);
|
|
border: 1px solid var(--success, #4caf50);
|
|
border-radius: var(--radius);
|
|
padding: var(--sp-4);
|
|
margin-bottom: var(--sp-4);
|
|
}
|
|
.ext-settings-tokens__token-display {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--sp-2);
|
|
margin: var(--sp-2) 0;
|
|
}
|
|
.ext-settings-tokens__token-value {
|
|
font-size: 0.85rem;
|
|
word-break: break-all;
|
|
background: var(--bg-secondary);
|
|
padding: var(--sp-2) var(--sp-3);
|
|
border-radius: var(--radius-sm);
|
|
flex: 1;
|
|
}
|
|
.ext-settings-tokens__perm-badge {
|
|
display: inline-block;
|
|
font-size: 0.75rem;
|
|
background: var(--bg-secondary);
|
|
padding: 2px 6px;
|
|
border-radius: var(--radius-sm);
|
|
margin: 1px 2px;
|
|
}
|
|
</style>
|
|
`;
|
|
}
|
|
|
|
export default TokensSection;
|