Feat v0.7.7 api tokens ext permissions (#61)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #61.
This commit is contained in:
2026-04-02 19:11:47 +00:00
committed by xcaliber
parent e02b13dc12
commit e4f0bdbd36
34 changed files with 1769 additions and 58 deletions

View File

@@ -10,6 +10,7 @@ export default function GroupsSection() {
const [detail, setDetail] = useState(null);
const [members, setMembers] = useState([]);
const [allPerms, setAllPerms] = useState([]);
const [groupedPerms, setGroupedPerms] = useState({});
const [allUsers, setAllUsers] = useState([]);
const [groupData, setGroupData] = useState({});
const [showCreate, setShowCreate] = useState(false);
@@ -41,6 +42,7 @@ export default function GroupsSection() {
]);
setMembers(m || []);
setAllPerms(p.permissions || []);
setGroupedPerms(p.grouped || {});
setAllUsers(u || []);
} catch (e) { sw.toast(e.message, 'error'); }
}
@@ -131,16 +133,37 @@ export default function GroupsSection() {
<div class="settings-section" style="margin-bottom:16px;">
<h5 style="margin:0 0 8px;">Permissions</h5>
<div class="admin-permissions-grid">
${allPerms.map(p => html`
<label class="toggle-label" key=${p}>
<input type="checkbox" checked=${groupData.permissions.includes(p)}
onChange=${() => togglePerm(p)} />
<span class="toggle-track"></span>
<span>${p}</span>
</label>
`)}
</div>
${Object.keys(groupedPerms).length > 0
? Object.entries(groupedPerms).map(([source, perms]) => html`
<div key=${source} style="margin-bottom:var(--sp-3,12px);">
<h6 style="margin:0 0 4px;font-size:0.85rem;color:var(--text-2);">
${source === 'kernel' ? 'Platform' : source}
</h6>
<div class="admin-permissions-grid">
${perms.map(p => html`
<label class="toggle-label" key=${p}>
<input type="checkbox" checked=${groupData.permissions.includes(p)}
onChange=${() => togglePerm(p)} />
<span class="toggle-track"></span>
<span>${p}</span>
</label>
`)}
</div>
</div>
`)
: html`
<div class="admin-permissions-grid">
${allPerms.map(p => html`
<label class="toggle-label" key=${p}>
<input type="checkbox" checked=${groupData.permissions.includes(p)}
onChange=${() => togglePerm(p)} />
<span class="toggle-track"></span>
<span>${p}</span>
</label>
`)}
</div>
`
}
</div>
<div style="margin-bottom:16px;">

View File

@@ -87,6 +87,17 @@ export default function UsersSection() {
} catch (e) { sw.toast(e.message, 'error'); }
}
async function createTokenForUser(userId, username) {
const name = await sw.prompt(`Token name for "${username}":`, '');
if (!name) return;
try {
const resp = await sw.api.post('/api/v1/admin/tokens', {
user_id: userId, name, permissions: [],
});
await sw.alert(`Token created for ${username}. Copy it now — it won't be shown again:\n\n${resp.token}`);
} catch (e) { sw.toast(e.message || 'Failed to create token', 'error'); }
}
function statusBadge(u) {
if (u.status === 'pending') return html`<span class="badge badge-pending">pending</span>`;
if (!u.is_active) return html`<span class="badge badge-inactive">inactive</span>`;
@@ -142,6 +153,7 @@ export default function UsersSection() {
: html`<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => toggleActive(u.id, true)}>Enable</button>`
}
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => resetPassword(u.id, u.username)}>Reset PW</button>
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => createTokenForUser(u.id, u.username)}>PAT</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteUser(u.id, u.username)}>Delete</button>
</div>
</div>

View File

@@ -25,6 +25,7 @@ const sectionModules = {
teams: () => import('./teams.js'),
connections: () => import('./connections.js'),
notifications: () => import('./notifications.js'),
tokens: () => import('./tokens.js'),
};
// ── Nav structure ───────────────────────────
@@ -35,6 +36,7 @@ const NAV_ITEMS = [
{ key: 'teams', label: 'Teams' },
{ key: 'connections', label: 'Connections' },
{ key: 'notifications', label: 'Notifications' },
{ key: 'tokens', label: 'API Tokens' },
];
// ── Section title map ───────────────────────
@@ -42,6 +44,7 @@ const SECTION_TITLES = {
general: 'General', appearance: 'Appearance',
profile: 'Profile', teams: 'Teams',
connections: 'Connections', notifications: 'Notifications',
tokens: 'API Tokens',
};
// ── Extension config sections ──────

View 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.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;