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
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:
@@ -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;">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ──────
|
||||
|
||||
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.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;
|
||||
Reference in New Issue
Block a user