a73x

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 &lt;vm-name&gt;</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>