feat: add Repository access settings UI
This commit is contained in:
@@ -38,14 +38,36 @@ Deno.test("settings section navigation stays under the settings route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("settings shell advertises no fake browser admin model", () => {
|
||||
Deno.test("settings shell advertises scoped account authority", () => {
|
||||
assert(
|
||||
SETTINGS_PERMISSION_NOTICE.includes("no browser user, role, permission"),
|
||||
"notice should explicitly deny a browser permission model",
|
||||
SETTINGS_PERMISSION_NOTICE.includes("authenticated account authority"),
|
||||
"notice should identify authenticated account authority",
|
||||
);
|
||||
assert(
|
||||
SETTINGS_PERMISSION_NOTICE.includes("does not create an admin role"),
|
||||
"notice should not imply an admin role exists",
|
||||
SETTINGS_PERMISSION_NOTICE.includes("current Workspace owner"),
|
||||
"notice should state the Repository secret permission boundary",
|
||||
);
|
||||
assert(
|
||||
SETTINGS_PERMISSION_NOTICE.includes("does not expose secret material"),
|
||||
"notice should not imply that stored secret material is readable",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository access settings are editable and canonically routed", () => {
|
||||
const section = SETTINGS_SECTIONS.find((entry) =>
|
||||
entry.id === "repository-access"
|
||||
);
|
||||
assert(
|
||||
section?.status === "editable",
|
||||
"Repository Access should be editable",
|
||||
);
|
||||
assert(
|
||||
settingsSectionHref("repository-access") === "/settings/repository-access",
|
||||
"Repository Access should have a dedicated settings route",
|
||||
);
|
||||
assert(
|
||||
section?.bullets.join("\n").includes("write-only"),
|
||||
"Repository Access copy should preserve write-only secret semantics",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsSectionId =
|
||||
| "runtime-connections"
|
||||
| "runtime-inventory"
|
||||
| "configuration-sources"
|
||||
| "repository-access"
|
||||
| "profile-sources"
|
||||
| "backend-config"
|
||||
| "workspace-identity";
|
||||
@@ -72,7 +73,7 @@ export type RemoteRuntimeTestResponse = {
|
||||
export const SETTINGS_ROUTE = "/settings";
|
||||
|
||||
export const SETTINGS_PERMISSION_NOTICE =
|
||||
"Yoi currently has no browser user, role, permission, or multi-user authorization model. This local settings surface uses typed Backend APIs only; it does not create an admin role or grant broad mutation authority.";
|
||||
"Workspace settings use authenticated account authority and Workspace-scoped typed Backend APIs. Repository secret management requires the current Workspace owner; this surface does not expose secret material or grant Runtime execution authority.";
|
||||
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{
|
||||
@@ -111,6 +112,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
"Profile launch data is projected from this active revision; remaining Skill, Prompt, and Plugin consumers migrate in their follow-up cutovers.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "repository-access",
|
||||
label: "Repository Access",
|
||||
status: "editable",
|
||||
summary:
|
||||
"Manage Workspace-scoped SSH credentials and pinned host keys without exposing stored secret material.",
|
||||
bullets: [
|
||||
"Private keys and passphrases are write-only; list and detail responses contain public metadata only.",
|
||||
"Host trust requires an explicitly pinned key and never uses accept-new or TOFU.",
|
||||
"Repository bindings are committed through the shared Workspace configuration editor and validated against these records.",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "profile-sources",
|
||||
label: "Profile Sources",
|
||||
@@ -175,6 +188,8 @@ export function settingsSectionHref(id: SettingsSectionId): string {
|
||||
return `${SETTINGS_ROUTE}/runtimes`;
|
||||
case "configuration-sources":
|
||||
return `${SETTINGS_ROUTE}/configuration`;
|
||||
case "repository-access":
|
||||
return `${SETTINGS_ROUTE}/repository-access`;
|
||||
case "profile-sources":
|
||||
return `${SETTINGS_ROUTE}/profiles`;
|
||||
case "workspace-identity":
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
<dt>Source</dt>
|
||||
<dd>{data.repository.item.source.kind} · {data.repository.item.source.uri}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Repository access</dt>
|
||||
<dd><a href={`/w/${encodeURIComponent(data.workspace.workspace_id)}/settings/repository-access`}>Manage SSH credentials and pinned host keys</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source revision</dt>
|
||||
<dd>{data.repository.item.source_revision} · {data.repository.item.source_fingerprint}</dd>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageProps } from './$types';
|
||||
import type { RepositorySshCredential, RepositorySshHostTrust } from './+page';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||
let message = $state<string | null>(null);
|
||||
let pending = $state(false);
|
||||
|
||||
let credentialId = $state('');
|
||||
let credentialName = $state('');
|
||||
let privateKey = $state('');
|
||||
let passphrase = $state('');
|
||||
let rotateCredentialId = $state<string | null>(null);
|
||||
let rotatePrivateKey = $state('');
|
||||
let rotatePassphrase = $state('');
|
||||
|
||||
let hostTrustId = $state('');
|
||||
let hostname = $state('');
|
||||
let port = $state(22);
|
||||
let hostKey = $state('');
|
||||
let hostExpectedRevision = $state<number | null>(null);
|
||||
|
||||
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
|
||||
|
||||
function operationId(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, method: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = `request failed (${response.status})`;
|
||||
try {
|
||||
const payload = (await response.json()) as { error?: string; message?: string };
|
||||
detail = payload.message ?? payload.error ?? detail;
|
||||
} catch {
|
||||
// Do not surface submitted secret values from response bodies.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function createCredential() {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshCredential>('/credentials', 'POST', {
|
||||
operation_id: operationId('credential-create'),
|
||||
credential_id: credentialId,
|
||||
name: credentialName,
|
||||
private_key: privateKey,
|
||||
passphrase: passphrase || null
|
||||
});
|
||||
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
|
||||
credentialId = '';
|
||||
credentialName = '';
|
||||
privateKey = '';
|
||||
passphrase = '';
|
||||
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Credential creation failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateCredential(credential: RepositorySshCredential) {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const rotated = await request<RepositorySshCredential>(
|
||||
`/credentials/${encodeURIComponent(credential.credential_id)}/rotate`,
|
||||
'POST',
|
||||
{
|
||||
operation_id: operationId('credential-rotate'),
|
||||
expected_revision: credential.current_revision,
|
||||
private_key: rotatePrivateKey,
|
||||
passphrase: rotatePassphrase || null
|
||||
}
|
||||
);
|
||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||
rotatePrivateKey = '';
|
||||
rotatePassphrase = '';
|
||||
rotateCredentialId = null;
|
||||
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Credential rotation failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCredential(credential: RepositorySshCredential) {
|
||||
if (!confirm(`Delete credential ${credential.credential_id}?`)) return;
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', {
|
||||
operation_id: operationId('credential-delete'),
|
||||
expected_revision: credential.current_revision
|
||||
});
|
||||
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
|
||||
message = `Credential ${credential.credential_id} deleted.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Credential deletion failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createHostTrust() {
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
const created = await request<RepositorySshHostTrust>('/host-trusts', 'POST', {
|
||||
operation_id: operationId('host-trust-create'),
|
||||
host_trust_id: hostTrustId,
|
||||
hostname,
|
||||
port,
|
||||
host_key: hostKey,
|
||||
expected_revision: hostExpectedRevision
|
||||
});
|
||||
hostTrusts = hostExpectedRevision === null
|
||||
? [...hostTrusts, created].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id))
|
||||
: hostTrusts.map((entry) => entry.host_trust_id === created.host_trust_id ? created : entry);
|
||||
hostTrustId = '';
|
||||
hostname = '';
|
||||
port = 22;
|
||||
hostKey = '';
|
||||
hostExpectedRevision = null;
|
||||
message = `Host trust ${created.host_trust_id} saved at revision ${created.current_revision}.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Host trust creation failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||
hostTrustId = hostTrust.host_trust_id;
|
||||
hostname = hostTrust.hostname;
|
||||
port = hostTrust.port;
|
||||
hostKey = hostTrust.host_key;
|
||||
hostExpectedRevision = hostTrust.current_revision;
|
||||
}
|
||||
|
||||
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||
if (!confirm(`Delete host trust ${hostTrust.host_trust_id}?`)) return;
|
||||
pending = true;
|
||||
message = null;
|
||||
try {
|
||||
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', {
|
||||
operation_id: operationId('host-trust-delete'),
|
||||
expected_revision: hostTrust.current_revision
|
||||
});
|
||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||
message = `Host trust ${hostTrust.host_trust_id} deleted.`;
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Host trust deletion failed';
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Repository Access · Yoi Workspace</title></svelte:head>
|
||||
|
||||
<section class="card settings-section">
|
||||
<header class="settings-section-header">
|
||||
<div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
|
||||
<span class="badge success">encrypted</span>
|
||||
</header>
|
||||
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
|
||||
{#if message}<p class="status-message">{message}</p>{/if}
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
<h3>SSH credentials</h3>
|
||||
{#if credentials.length === 0}<p>No credentials configured.</p>{/if}
|
||||
{#each credentials as credential (credential.credential_id)}
|
||||
<div class="card">
|
||||
<strong>{credential.name}</strong> <code>{credential.credential_id}</code>
|
||||
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
|
||||
<p>References: {credential.referenced_repositories.join(', ') || 'none'}</p>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
|
||||
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||
</div>
|
||||
{#if rotateCredentialId === credential.credential_id}
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}>
|
||||
<label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
|
||||
<label><span>Passphrase (only for an encrypted key)</span><input type="password" bind:value={rotatePassphrase} autocomplete="new-password" /></label>
|
||||
<button type="submit" disabled={pending}>Rotate credential</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
||||
<h3>Add SSH credential</h3>
|
||||
<label><span>Credential id</span><input bind:value={credentialId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||
<label><span>Name</span><input bind:value={credentialName} required maxlength="200" /></label>
|
||||
<label><span>OpenSSH private key (ssh-ed25519)</span><textarea bind:value={privateKey} required rows="10" autocomplete="off"></textarea></label>
|
||||
<label><span>Passphrase (only for an encrypted key)</span><input type="password" bind:value={passphrase} autocomplete="new-password" /></label>
|
||||
<button type="submit" disabled={pending}>Add credential</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
<h3>Pinned SSH host keys</h3>
|
||||
{#if hostTrusts.length === 0}<p>No host trust records configured.</p>{/if}
|
||||
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
|
||||
<div class="card">
|
||||
<strong>{hostTrust.hostname}:{hostTrust.port}</strong> <code>{hostTrust.host_trust_id}</code>
|
||||
<p>{hostTrust.key_algorithm} · {hostTrust.fingerprint} · revision {hostTrust.current_revision}</p>
|
||||
<p>References: {hostTrust.referenced_repositories.join(', ') || 'none'}</p>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" onclick={() => editHostTrust(hostTrust)}>Rotate key</button>
|
||||
<button type="button" class="danger" disabled={pending || hostTrust.referenced_repositories.length > 0} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createHostTrust(); }}>
|
||||
<h3>{hostExpectedRevision === null ? 'Add pinned host key' : 'Rotate pinned host key'}</h3>
|
||||
<label><span>Host trust id</span><input bind:value={hostTrustId} disabled={hostExpectedRevision !== null} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||
<label><span>Hostname</span><input bind:value={hostname} required /></label>
|
||||
<label><span>Port</span><input type="number" bind:value={port} min="1" max="65535" required /></label>
|
||||
<label><span>OpenSSH public host key (ssh-ed25519)</span><textarea bind:value={hostKey} required rows="4"></textarea></label>
|
||||
<button type="submit" disabled={pending}>{hostExpectedRevision === null ? 'Add host key' : 'Save new revision'}</button>
|
||||
{#if hostExpectedRevision !== null}<button type="button" onclick={() => { hostTrustId = ''; hostname = ''; port = 22; hostKey = ''; hostExpectedRevision = null; }}>Cancel</button>{/if}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import { loadJson } from "$lib/workspace/api/http";
|
||||
|
||||
export interface RepositorySshCredential {
|
||||
credential_id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
public_key_algorithm: string;
|
||||
public_key_fingerprint: string;
|
||||
current_revision: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export interface RepositorySshHostTrust {
|
||||
host_trust_id: string;
|
||||
workspace_id: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
key_algorithm: string;
|
||||
host_key: string;
|
||||
fingerprint: string;
|
||||
current_revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
referenced_repositories: string[];
|
||||
}
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const base = `/api/w/${
|
||||
encodeURIComponent(params.workspaceId)
|
||||
}/settings/repository-access`;
|
||||
const [credentialResult, hostTrustResult] = await Promise.all([
|
||||
loadJson<RepositorySshCredential[]>(fetch, `${base}/credentials`),
|
||||
loadJson<RepositorySshHostTrust[]>(fetch, `${base}/host-trusts`),
|
||||
]);
|
||||
if (!credentialResult.data || !hostTrustResult.data) {
|
||||
throw new Error(
|
||||
credentialResult.error ?? hostTrustResult.error ??
|
||||
"Repository access settings unavailable",
|
||||
);
|
||||
}
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
credentials: credentialResult.data,
|
||||
hostTrusts: hostTrustResult.data,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user