feat(dashboard): profile page with API-key list, mint, and revoke
/admin/profile is the per-principal page available to every authenticated user (owner, admin, member). Shows the caller's identity (username, role chip, email, id) plus a full API-key list/mint/revoke surface. Minting reveals the raw token exactly once in a yellow-bordered panel with a Copy button and an "I've saved it" acknowledgement gate before the Done button enables, matching the spec's one-shot secret-display pattern. Live mirrors the backend bound-key guard: picking an app from the binding dropdown drops any instance:* scopes from the selection and greys out their checkboxes with a tooltip, so submit never hits a 422 on that case. Also surfaces a one-shot info banner when /admin/users redirects a member here with ?denied=users. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
760
dashboard/src/routes/profile/+page.svelte
Normal file
760
dashboard/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
<!--
|
||||||
|
/admin/profile — every authenticated principal lands here for their
|
||||||
|
own identity + API-key management. No role gating: a member can mint
|
||||||
|
keys for the apps they belong to just like an admin can. Users-admin
|
||||||
|
actions live under /admin/users.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
ALL_SCOPES,
|
||||||
|
isInstanceScope,
|
||||||
|
type ApiKeyDto,
|
||||||
|
type App,
|
||||||
|
type MintApiKeyResponse,
|
||||||
|
type Scope
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
|
let keys = $state<ApiKeyDto[]>([]);
|
||||||
|
let apps = $state<App[]>([]);
|
||||||
|
let appBySlug = $derived(new Map(apps.map((a) => [a.id, a])));
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Surface the cross-page "access denied" notice when /users bounces
|
||||||
|
// a member back here. One-shot — clears as soon as the user
|
||||||
|
// navigates away or dismisses.
|
||||||
|
const deniedFromUsers = $derived(page.url.searchParams.get('denied') === 'users');
|
||||||
|
|
||||||
|
let mintOpen = $state(false);
|
||||||
|
let mintForm = $state<{
|
||||||
|
name: string;
|
||||||
|
scopes: Set<Scope>;
|
||||||
|
app_id: string | '';
|
||||||
|
expires_at: string;
|
||||||
|
}>({ name: '', scopes: new Set(), app_id: '', expires_at: '' });
|
||||||
|
let mintPending = $state(false);
|
||||||
|
let mintError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let reveal = $state<MintApiKeyResponse | null>(null);
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
let revokeTarget = $state<ApiKeyDto | null>(null);
|
||||||
|
let revokePending = $state(false);
|
||||||
|
|
||||||
|
const NAME_MAX = 64;
|
||||||
|
const scopeIsInstance = (s: Scope) => isInstanceScope(s);
|
||||||
|
const boundToApp = $derived(mintForm.app_id !== '');
|
||||||
|
|
||||||
|
const canSubmit = $derived(
|
||||||
|
mintForm.name.trim().length > 0 &&
|
||||||
|
mintForm.name.trim().length <= NAME_MAX &&
|
||||||
|
mintForm.scopes.size > 0 &&
|
||||||
|
!mintPending
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([refreshKeys(), loadApps()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshKeys() {
|
||||||
|
try {
|
||||||
|
keys = await api.apiKeys.list();
|
||||||
|
loadError = null;
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load API keys';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
try {
|
||||||
|
apps = await api.apps.list();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: the form falls back to "no app options" and the
|
||||||
|
// list shows the bare UUID in the binding column.
|
||||||
|
apps = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMint() {
|
||||||
|
mintForm = { name: '', scopes: new Set(), app_id: '', expires_at: '' };
|
||||||
|
mintError = null;
|
||||||
|
mintOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelMint() {
|
||||||
|
mintOpen = false;
|
||||||
|
mintError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScope(s: Scope) {
|
||||||
|
const next = new Set(mintForm.scopes);
|
||||||
|
if (next.has(s)) next.delete(s);
|
||||||
|
else next.add(s);
|
||||||
|
mintForm = { ...mintForm, scopes: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the user binds the key to an app, instance:* scopes are
|
||||||
|
// mutually exclusive — drop them from the selection so submit
|
||||||
|
// doesn't 422.
|
||||||
|
$effect(() => {
|
||||||
|
if (!boundToApp) return;
|
||||||
|
const filtered = new Set<Scope>();
|
||||||
|
let dropped = false;
|
||||||
|
for (const s of mintForm.scopes) {
|
||||||
|
if (scopeIsInstance(s)) dropped = true;
|
||||||
|
else filtered.add(s);
|
||||||
|
}
|
||||||
|
if (dropped) {
|
||||||
|
mintForm = { ...mintForm, scopes: filtered };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitMint(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
mintPending = true;
|
||||||
|
mintError = null;
|
||||||
|
try {
|
||||||
|
const r = await api.apiKeys.mint({
|
||||||
|
name: mintForm.name.trim(),
|
||||||
|
scopes: Array.from(mintForm.scopes),
|
||||||
|
app_id: mintForm.app_id === '' ? null : mintForm.app_id,
|
||||||
|
expires_at: mintForm.expires_at === ''
|
||||||
|
? null
|
||||||
|
: new Date(mintForm.expires_at + 'T23:59:59Z').toISOString()
|
||||||
|
});
|
||||||
|
reveal = r;
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
mintOpen = false;
|
||||||
|
await refreshKeys();
|
||||||
|
} catch (e) {
|
||||||
|
mintError = e instanceof ApiError ? e.message : 'failed to mint API key';
|
||||||
|
} finally {
|
||||||
|
mintPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!reveal) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reveal.raw_token);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
reveal = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRevoke(key: ApiKeyDto) {
|
||||||
|
revokeTarget = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRevoke() {
|
||||||
|
if (!revokeTarget) return;
|
||||||
|
revokePending = true;
|
||||||
|
const target = revokeTarget;
|
||||||
|
try {
|
||||||
|
await api.apiKeys.revoke(target.id);
|
||||||
|
revokeTarget = null;
|
||||||
|
keys = keys.filter((k) => k.id !== target.id);
|
||||||
|
flash('info', `Revoked "${target.name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to revoke key');
|
||||||
|
} finally {
|
||||||
|
revokePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appLabel(app_id: string | null): string {
|
||||||
|
if (!app_id) return 'Instance-wide';
|
||||||
|
const a = appBySlug.get(app_id);
|
||||||
|
return a ? a.slug : app_id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const sec = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return shortDate(iso);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if me}
|
||||||
|
<section class="identity">
|
||||||
|
<div class="identity-head">
|
||||||
|
<h1>{me.username}</h1>
|
||||||
|
<RoleChip role={me.instance_role} />
|
||||||
|
</div>
|
||||||
|
<dl class="identity-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{me.email ?? 'No email set'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>User ID</dt>
|
||||||
|
<dd class="mono">{me.id}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if deniedFromUsers}
|
||||||
|
<div class="banner banner-info">
|
||||||
|
You don't have access to the Users page. Ask an admin if you need to manage users.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="keys-section">
|
||||||
|
<header class="section-head">
|
||||||
|
<h2>API keys</h2>
|
||||||
|
{#if !mintOpen && !reveal}
|
||||||
|
<button type="button" class="primary" onclick={openMint}>+ Mint API key</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if reveal}
|
||||||
|
<div class="reveal">
|
||||||
|
<h3>Save this token now — it will never be shown again.</h3>
|
||||||
|
<p class="reveal-sub">
|
||||||
|
Paste it into your CLI config or external integration. PiCloud only ever stores a hash; if
|
||||||
|
you lose it, mint a new one.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{reveal.raw_token}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyToken}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've saved this token somewhere safe.</span>
|
||||||
|
</label>
|
||||||
|
<div class="reveal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mintOpen}
|
||||||
|
<form class="mint" onsubmit={submitMint}>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={mintForm.name}
|
||||||
|
maxlength={NAME_MAX}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="e.g. ci-deploy"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>1–{NAME_MAX} chars. Only you see it.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Binding</span>
|
||||||
|
<select bind:value={mintForm.app_id}>
|
||||||
|
<option value="">Instance-wide</option>
|
||||||
|
{#each apps as a (a.id)}
|
||||||
|
<option value={a.id}>{a.slug} ({a.name})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>Pick an app to scope this key, or leave instance-wide.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Expires</span>
|
||||||
|
<input type="date" bind:value={mintForm.expires_at} />
|
||||||
|
<small>Leave blank for no expiry.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="scopes">
|
||||||
|
<legend>Scopes</legend>
|
||||||
|
<div class="scope-grid">
|
||||||
|
{#each ALL_SCOPES as scope (scope)}
|
||||||
|
{@const instanceScope = scopeIsInstance(scope)}
|
||||||
|
{@const disabled = boundToApp && instanceScope}
|
||||||
|
<label
|
||||||
|
class="scope-chip"
|
||||||
|
class:disabled
|
||||||
|
title={disabled ? "Bound keys can't carry instance scopes" : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mintForm.scopes.has(scope)}
|
||||||
|
disabled={disabled || mintPending}
|
||||||
|
onchange={() => toggleScope(scope)}
|
||||||
|
/>
|
||||||
|
<span class="scope-name">{scope}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<small class="scope-hint">
|
||||||
|
{mintForm.scopes.size === 0
|
||||||
|
? 'Pick at least one scope.'
|
||||||
|
: `${mintForm.scopes.size} scope${mintForm.scopes.size === 1 ? '' : 's'} selected.`}
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if mintError}
|
||||||
|
<div class="error">{mintError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="ghost" onclick={cancelMint}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmit}>
|
||||||
|
{mintPending ? 'Minting…' : 'Mint key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refreshKeys}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if keys.length === 0 && !reveal && !mintOpen}
|
||||||
|
<p class="empty">
|
||||||
|
No API keys yet. Mint one to authenticate the CLI or external integrations.
|
||||||
|
</p>
|
||||||
|
{:else if keys.length > 0}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Name</div>
|
||||||
|
<div>Prefix</div>
|
||||||
|
<div>Scopes</div>
|
||||||
|
<div>Binding</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last used</div>
|
||||||
|
<div>Expires</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each keys as key (key.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">{key.name}</div>
|
||||||
|
<div class="mono prefix">{key.prefix}</div>
|
||||||
|
<div class="scopes-cell">
|
||||||
|
{#each key.scopes as s (s)}
|
||||||
|
<span class="scope-pill">{s}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>{appLabel(key.app_id)}</div>
|
||||||
|
<div>{shortDate(key.created_at)}</div>
|
||||||
|
<div title={key.last_used_at ?? ''}>{relative(key.last_used_at)}</div>
|
||||||
|
<div>{key.expires_at ? shortDate(key.expires_at) : 'Never'}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger-link"
|
||||||
|
onclick={() => openRevoke(key)}
|
||||||
|
aria-label="Revoke {key.name}"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if revokeTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Revoke API key?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
busy={revokePending}
|
||||||
|
busyLabel="Revoking…"
|
||||||
|
onConfirm={confirmRevoke}
|
||||||
|
onCancel={() => (revokeTarget = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Revoking <strong>{revokeTarget.name}</strong> (<code>{revokeTarget.prefix}</code>) takes
|
||||||
|
effect immediately. Any CLI or integration using it will start returning <code>401</code>
|
||||||
|
on the next request.
|
||||||
|
</p>
|
||||||
|
<p class="muted">This can't be undone — mint a new key if you need one again.</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.identity {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.identity-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.identity h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.identity-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.identity-meta div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.identity-meta dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.identity-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.reveal h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.reveal-sub {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reveal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mint {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopes {
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.scopes legend {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
.scope-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||||
|
gap: 0.4rem 0.75rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.scope-chip.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.scope-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.9fr 2fr 1fr 0.8fr 0.8fr 0.8fr 0.7fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.prefix {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.scopes-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-pill {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.danger-link {
|
||||||
|
background: transparent;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.danger-link:hover {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user