web/src/routes/settings/+page.svelte
Ref: Size: 9.3 KiB History
<script lang="ts">
import { onMount } from 'svelte';
import {
fleet,
action,
copyText,
createToken,
listTokens,
revokeToken,
uploadUserCA,
deleteUserCA,
refreshUserCAs,
type APIToken,
type CreateAPITokenResponse
} from '$lib/fleet.svelte';
// PAT state ---------------------------------------------------------------
let tokens = $state<APIToken[]>([]);
// TTL presets: label → seconds; 0 mints a non-expiring token.
const ttlPresets: { label: string; seconds: number }[] = [
{ label: 'never', seconds: 0 },
{ label: '1 hour', seconds: 3600 },
{ label: '30 days', seconds: 2592000 },
{ label: '90 days', seconds: 7776000 }
];
let patForm = $state<{ name: string; ttl: number }>({ name: '', ttl: 0 });
let patBusy = $state(false);
// minted holds the freshly created token's secret—shown exactly once, then
// dismissed (it is never fetchable again).
let minted = $state<CreateAPITokenResponse | null>(null);
let copied = $state(false);
// confirmRevoke is the id of the token whose Revoke button is in its second
// (confirm) step—an inline two-step replaces a blocking confirm() dialog.
let confirmRevoke = $state('');
async function refreshTokens() {
await action(() => listTokens().then((t) => (tokens = t)));
}
onMount(() => {
refreshTokens();
refreshUserCAs();
});
async function submitCreateToken(e: Event) {
e.preventDefault();
patBusy = true;
const name = patForm.name.trim();
// ttl 0 = non-expiring: pass undefined so the request omits ttl_seconds.
const res = await action(async () => {
minted = await createToken(name, patForm.ttl || undefined);
});
if (res) {
copied = false;
patForm = { name: '', ttl: 0 };
await refreshTokens();
}
patBusy = false;
}
async function copySecret() {
if (!minted) return;
copied = await copyText(minted.token, document.getElementById('minted-token'), 'token');
}
async function doRevoke(id: string) {
confirmRevoke = '';
if (await action(() => revokeToken(id))) await refreshTokens();
}
// fmtTime renders an RFC3339 timestamp, or a fallback when the column is
// NULL (empty string): non-expiring tokens, never-used tokens.
function fmtTime(s: string, fallback: string): string {
if (!s) return fallback;
const d = new Date(s);
return isNaN(d.getTime()) ? s : d.toLocaleString();
}
// SSH user-CA state (moved from the fleet page—this is tenant settings) ---
let caForm = $state<{ public_key: string; label: string }>({ public_key: '', label: '' });
let caBusy = $state(false);
async function submitUploadCA(e: Event) {
e.preventDefault();
caBusy = true;
if (
await action(() =>
uploadUserCA({ public_key: caForm.public_key.trim(), label: caForm.label.trim() || undefined })
)
) {
caForm = { public_key: '', label: '' };
await refreshUserCAs();
}
caBusy = false;
}
async function removeCA(pubkey: string) {
if (!confirm('Remove this SSH CA? New connections signed by it will be rejected; existing VMs keep trusting it until recreated.'))
return;
if (await action(() => deleteUserCA(pubkey))) await refreshUserCAs();
}
</script>
<div class="row">
<h2>Settings</h2>
</div>
<section>
<div class="row">
<h2>Identity</h2>
</div>
{#if fleet.me}
<table class="kv">
<tbody>
<tr><th>Email</th><td>{fleet.me.email}</td></tr>
<tr><th>Tenant</th><td>{fleet.me.tenant}</td></tr>
</tbody>
</table>
{/if}
</section>
<!-- The two things a tenant registers are peers: a token authenticates you to
the API, a CA authenticates you to a guest. Given the width they stand side
by side; narrower, they stack in the order they are written. -->
<div class="columns">
<section>
<div class="row">
<h2>Personal access tokens ({tokens.length})</h2>
</div>
<p class="hint">
Use a PAT to authenticate the CLI and automation over the <code>EITRI_TOKEN</code> env var.
The secret is shown once at creation and stored only as a hash.
</p>
{#if minted}
<div class="secret-box">
<div class="secret-head">
<strong>Token “{minted.name}” created</strong>
<button type="button" class="ghost" onclick={() => (minted = null)}>Dismiss</button>
</div>
<p class="warn">Copy it now—you won't be able to see this token again.</p>
<div class="secret-value">
<code id="minted-token">{minted.token}</code>
<button type="button" onclick={copySecret}>{copied ? 'Copied' : 'Copy'}</button>
</div>
</div>
{/if}
<form class="pat-form" onsubmit={submitCreateToken}>
<label>Name<input bind:value={patForm.name} placeholder="laptop-cli" /></label>
<label
>Expires
<select bind:value={patForm.ttl}>
{#each ttlPresets as p (p.seconds)}
<option value={p.seconds}>{p.label}</option>
{/each}
</select>
</label>
<button type="submit" disabled={patBusy || !patForm.name.trim()}>{patBusy ? 'Creating…' : 'Create token'}</button>
</form>
{#if tokens.length > 0}
<div class="scroll">
<table>
<thead>
<tr><th>Name</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
</thead>
<tbody>
{#each tokens as t (t.id)}
<tr>
<td>{t.name}</td>
<td>{fmtTime(t.created_at, '—')}</td>
<td>{fmtTime(t.expires_at, 'never')}</td>
<td>{fmtTime(t.last_used_at, 'never')}</td>
<td class="actions">
{#if t.revoked_at}
<span class="hint">revoked</span>
{:else if confirmRevoke === t.id}
<button class="danger" onclick={() => doRevoke(t.id)}>Confirm</button>
<button class="ghost" onclick={() => (confirmRevoke = '')}>Cancel</button>
{:else}
<button class="danger" onclick={() => (confirmRevoke = t.id)}>Revoke</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section>
<div class="row">
<h2>SSH Access ({fleet.userCAs.length})</h2>
</div>
{#if fleet.userCAsLoaded && fleet.userCAs.length === 0}
<p class="hint">No SSH CA registered—a VM cannot be created until the tenant has one.</p>
<div class="enroll">
Generate a CA, paste its public key below, then connect:
<code>ssh-keygen -t ed25519 -f ~/.ssh/eitri_user_ca</code>
<code># paste ~/.ssh/eitri_user_ca.pub in the field below, then:</code>
<code>EITRI_CA=~/.ssh/eitri_user_ca eitri ssh <vm-name></code>
</div>
{:else if fleet.userCAs.length > 0}
<div class="scroll">
<table>
<thead>
<tr><th>Fingerprint</th><th>Label</th><th></th></tr>
</thead>
<tbody>
{#each fleet.userCAs as ca (ca.pubkey)}
<tr>
<td>{ca.fingerprint}</td>
<td>{ca.label || '—'}</td>
<td><button class="danger" onclick={() => removeCA(ca.pubkey)}>Remove</button></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<form class="ca-add" onsubmit={submitUploadCA}>
<label>SSH CA public key<input bind:value={caForm.public_key} placeholder="ssh-ed25519 AAAA… (contents of your ~/.ssh/eitri_user_ca.pub)" /></label>
<label>Label (optional)<input bind:value={caForm.label} placeholder="laptop" /></label>
<button type="submit" disabled={caBusy || !caForm.public_key.trim()}>{caBusy ? 'Adding…' : 'Add CA'}</button>
</form>
</section>
</div>
<style>
.row {
display: flex;
align-items: center;
gap: 0.75em;
}
h2 {
font-size: inherit;
margin: 1.5em 0 0.5em;
}
/* Given the width, the two registers stand side by side instead of a screen
apart—the same breakpoint, fractions and gap the VM and host pages use, so
the console reads as one layout rather than three. Tokens take the wider
share: their table carries three timestamps and a name. Who you are sits
above both, because it qualifies either one. The .columns grid and the
.scroll boxes it holds are the shared two-column vocabulary (:global in
+layout.svelte); this page adds nothing page-local on top of it. The
:global .scroll overflow-x deliberately lives inside the ≥1100px query—an
always-on one makes each box a formatting context, which stops the margins
collapsing and changes the stacked spacing. */
.actions {
display: flex;
gap: 0.5em;
}
.enroll {
border: 1px solid var(--hairline);
padding: 1em;
margin: 0.5em 0;
overflow-x: auto;
}
.enroll code {
display: block;
word-break: break-all;
}
.pat-form {
display: flex;
gap: 0.5em;
align-items: flex-end;
flex-wrap: wrap;
margin-top: 0.5em;
}
.pat-form label {
display: flex;
flex-direction: column;
gap: 0.1em;
color: var(--faint);
}
/* The one secret the console ever shows, and it shows it once: ink border,
like the modal, so it does not read as another panel. */
.secret-box {
border: 1px solid var(--ink);
padding: 0.8em;
margin: 0.6em 0;
max-width: 72ch;
}
.secret-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75em;
}
.secret-box .warn {
color: var(--bad);
margin: 0.4em 0;
}
.secret-value {
display: flex;
align-items: center;
gap: 0.5em;
}
.secret-value code {
flex: 1;
border: 1px solid var(--hairline);
padding: 0.05em 0.6em;
word-break: break-all;
}
.ca-add {
display: flex;
gap: 0.5em;
align-items: flex-end;
flex-wrap: wrap;
margin-top: 0.5em;
}
.ca-add label {
flex: 1;
min-width: 25ch;
display: flex;
flex-direction: column;
gap: 0.1em;
color: var(--faint);
}
.ca-add input {
width: 100%;
}
</style>