The backend's ApiKeyDto.prefix is just the 8-char public head (e.g. "PKXPCPH3"); the actual token the user pastes into their CLI is "pic_PKXPCPH3…". Display the full visible identifier so operators can match a row against the token in their notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
761 lines
17 KiB
Svelte
761 lines
17 KiB
Svelte
<!--
|
||
/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">pic_{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>
|