Feat v0.7.7 API tokens + extension permissions (#61)
Personal access tokens (PATs) for programmatic API access with SHA-256 hashing, permission scoping (git model), and Settings/Admin UI. Extension-declared user permissions with dynamic registry, gate_permission manifest field, permissions Starlark module, and grouped admin UI. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
256
src/js/sw/surfaces/settings/tokens.js
Normal file
256
src/js/sw/surfaces/settings/tokens.js
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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.fetch('GET', '/auth/tokens');
|
||||
setTokens(resp?.data || []);
|
||||
} catch (e) {
|
||||
setTokens([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPerms = useCallback(async () => {
|
||||
try {
|
||||
const resp = await sw.api.fetch('GET', '/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.fetch('POST', '/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.fetch('DELETE', `/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;
|
||||
Reference in New Issue
Block a user