From 4c458cbf74ad51b00be1360d50233d86312c29e0 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 23 Sep 2026 23:30:28 +0900 Subject: [PATCH] feat(web): redesign repository access settings --- web/workspace/deno.json | 2 +- .../workspace/api/repository-access-loader.ts | 32 + .../lib/workspace/repository-access/model.ts | 107 +++ .../src/lib/workspace/styles/settings.css | 318 +++++++++ .../settings/repository-access/+page.svelte | 657 ++++++++++++------ .../settings/repository-access/+page.ts | 54 +- .../test/repository-access/loader.test.ts | 20 +- .../test/repository-access/model.test.ts | 91 +++ .../test/repository-access/ui.test.ts | 212 ++++-- 9 files changed, 1200 insertions(+), 293 deletions(-) create mode 100644 web/workspace/src/lib/workspace/repository-access/model.ts create mode 100644 web/workspace/test/repository-access/model.test.ts diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 293b4ae3b..32b0e4f91 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview", diff --git a/web/workspace/src/lib/workspace/api/repository-access-loader.ts b/web/workspace/src/lib/workspace/api/repository-access-loader.ts index 26c5384e5..b93424a76 100644 --- a/web/workspace/src/lib/workspace/api/repository-access-loader.ts +++ b/web/workspace/src/lib/workspace/api/repository-access-loader.ts @@ -5,6 +5,38 @@ import { RepositoryAccessSchemaError, } from "./repository-access.ts"; +export type RepositoryAccessSectionResult = { + data: T | null; + error: string | null; +}; + +function sectionErrorMessage(cause: unknown, label: string): string { + if (cause && typeof cause === "object" && "body" in cause) { + const body = (cause as { body?: unknown }).body; + if (body && typeof body === "object" && "message" in body) { + const message = (body as { message?: unknown }).message; + if (typeof message === "string") return message; + } + } + return `${label} could not be loaded.`; +} + +export async function loadRepositoryAccessSection( + fetcher: typeof fetch, + path: string, + parse: (value: unknown) => T, + label: string, +): Promise> { + try { + return { + data: await loadRepositoryAccessJson(fetcher, path, parse), + error: null, + }; + } catch (cause) { + return { data: null, error: sectionErrorMessage(cause, label) }; + } +} + export async function loadRepositoryAccessJson( fetcher: typeof fetch, path: string, diff --git a/web/workspace/src/lib/workspace/repository-access/model.ts b/web/workspace/src/lib/workspace/repository-access/model.ts new file mode 100644 index 000000000..9c28f290b --- /dev/null +++ b/web/workspace/src/lib/workspace/repository-access/model.ts @@ -0,0 +1,107 @@ +export type RepositoryAccessFieldErrors = Record; + +const RESOURCE_ID = /^[A-Za-z0-9_.-]+$/; + +function required(value: string, label: string): string | null { + return value.trim() ? null : `${label} is required.`; +} + +function resourceId(value: string, label: string): string | null { + const missing = required(value, label); + if (missing) return missing; + if (value.length > 128) return `${label} must be 128 characters or fewer.`; + if (!RESOURCE_ID.test(value)) { + return `${label} may contain only letters, numbers, period, underscore, and hyphen.`; + } + return null; +} + +function assign( + errors: RepositoryAccessFieldErrors, + field: string, + message: string | null, +): void { + if (message) errors[field] = message; +} + +export function validateGeneratedCredential(input: { + credentialId: string; + name: string; +}): RepositoryAccessFieldErrors { + const errors: RepositoryAccessFieldErrors = {}; + assign( + errors, + "credentialId", + resourceId(input.credentialId, "Credential id"), + ); + assign(errors, "name", required(input.name, "Name")); + if (input.name.length > 200) { + errors.name = "Name must be 200 characters or fewer."; + } + return errors; +} + +export function validateImportedCredential(input: { + credentialId: string; + name: string; + privateKey: string; +}): RepositoryAccessFieldErrors { + const errors = validateGeneratedCredential(input); + assign( + errors, + "privateKey", + required(input.privateKey, "OpenSSH private key"), + ); + return errors; +} + +export function validateCredentialRotation( + privateKey: string, +): RepositoryAccessFieldErrors { + const errors: RepositoryAccessFieldErrors = {}; + assign(errors, "privateKey", required(privateKey, "New private key")); + return errors; +} + +export function validateHostTrust(input: { + hostTrustId: string; + hostname: string; + port: number; + hostKey: string; +}): RepositoryAccessFieldErrors { + const errors: RepositoryAccessFieldErrors = {}; + assign(errors, "hostTrustId", resourceId(input.hostTrustId, "Host trust id")); + assign(errors, "hostname", required(input.hostname, "Hostname")); + if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65_535) { + errors.port = "Port must be an integer from 1 to 65535."; + } + assign(errors, "hostKey", required(input.hostKey, "OpenSSH public host key")); + return errors; +} + +export function repositoryAccessRequestError( + status: number, + operation: string, +): Error { + if (status === 401 || status === 403) { + return new Error(`You do not have permission to ${operation}.`); + } + if (status === 409) { + return new Error( + `This item changed before it could be saved. Reload the page, review the current value, and try again.`, + ); + } + if (status === 400 || status === 422) { + return new Error( + `The server rejected the fields for ${operation}. Review the values and try again.`, + ); + } + if (status === 404) { + return new Error( + `The item for ${operation} no longer exists. Reload the page to see the current state.`, + ); + } + return new Error( + `Unable to ${operation}. Repository Access returned status ${status}.`, + ); +} diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index d81fbb496..b0bb6b92e 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -616,6 +616,7 @@ .settings-page { display: grid; + min-width: 0; gap: var(--space-5); } .settings-section-card, @@ -1053,4 +1054,321 @@ .status-message.error { color: var(--danger); } + + .repository-access-page { + display: grid; + min-width: 0; + gap: var(--space-6); + } + + .repository-access-section { + display: grid; + gap: var(--space-4); + min-width: 0; + } + + .repository-access-section + .repository-access-section { + padding-top: var(--space-5); + border-top: 1px solid var(--line); + } + + .repository-access-section h2, + .repository-access-section h3, + .repository-access-section p { + margin: 0; + } + + .repository-access-section-heading, + .repository-access-form-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + } + + .repository-access-section-heading > div, + .repository-access-form-heading > div { + display: grid; + gap: var(--space-1); + min-width: 0; + } + + .repository-access-section-heading p, + .repository-access-form-heading p, + .repository-access-guidance, + .repository-access-form small, + .repository-access-row-actions + small { + color: var(--text-muted); + } + + .repository-access-actions, + .repository-access-row-actions, + .repository-access-form-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + + .repository-access-section button { + min-height: 2.25rem; + border-radius: var(--radius-soft); + padding: var(--space-2) var(--space-3); + font: inherit; + font-weight: 700; + cursor: pointer; + } + + .repository-access-section button.primary { + border: 1px solid transparent; + background: var(--accent); + color: var(--bg); + } + + .repository-access-section button.secondary { + border: 1px solid var(--line); + background: var(--bg-raised); + color: var(--text-strong); + } + + .repository-access-section button.danger { + border: 1px solid var(--danger); + background: transparent; + color: var(--danger); + } + + .repository-access-section button:hover:not(:disabled), + .repository-access-section button:focus-visible:not(:disabled) { + background: var(--interactive-hover); + color: var(--text-strong); + } + + .repository-access-section button:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .repository-access-table-wrap { + position: relative; + width: 100%; + min-width: 0; + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--line); + background: var(--bg-raised); + } + + .repository-access-table { + width: 100%; + min-width: 48rem; + border-collapse: collapse; + font-size: var(--font-size-compact); + line-height: var(--line-height-compact); + } + + .repository-access-binding-table { + min-width: 40rem; + } + + .repository-access-credential-table { + min-width: 62rem; + } + + .repository-access-host-table { + min-width: 56rem; + } + + .repository-access-table th, + .repository-access-table td { + padding: var(--space-3); + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; + } + + .repository-access-table th { + color: var(--text-muted); + font-weight: 700; + } + + .repository-access-table tbody tr:last-child td { + border-bottom: 0; + } + + .repository-access-table td { + overflow-wrap: anywhere; + } + + .repository-access-table td > strong, + .repository-access-table td > code, + .repository-access-table td > small { + display: block; + } + + .repository-access-table td > strong { + color: var(--text-strong); + } + + .repository-access-table td > code + code, + .repository-access-table td > strong + code, + .repository-access-table td > strong + small, + .repository-access-table td > code + small, + .repository-access-row-actions + small { + margin-top: var(--space-1); + } + + .repository-access-row-actions { + min-width: max-content; + } + + .repository-access-empty { + display: grid; + gap: var(--space-2); + padding: var(--space-4); + background: var(--bg-subtle); + } + + .repository-access-empty.error { + border-left: 3px solid var(--danger); + } + + .repository-access-notice { + padding-left: var(--space-3); + border-left: 3px solid var(--line-strong); + } + + .repository-access-notice.success { + border-color: var(--success); + } + + .repository-access-notice.error, + .repository-access-section .field-error { + border-color: var(--danger); + color: var(--danger); + } + + .repository-access-form { + display: grid; + gap: var(--space-4); + padding: var(--space-4); + border-left: 3px solid var(--line-strong); + background: var(--bg-subtle); + } + + .repository-access-form-heading > code { + max-width: 28rem; + overflow-wrap: anywhere; + color: var(--text-muted); + font-size: var(--font-size-compact); + } + + .repository-access-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + } + + .repository-access-form label { + display: grid; + align-content: start; + gap: var(--space-1); + min-width: 0; + color: var(--text-muted); + font-weight: 700; + } + + .repository-access-form label.wide { + grid-column: 1 / -1; + } + + .repository-access-form input, + .repository-access-form textarea { + width: 100%; + min-width: 0; + border: 1px solid var(--line); + border-radius: var(--radius-soft); + background: var(--bg-raised); + color: var(--text-strong); + padding: var(--space-2) var(--space-3); + font: inherit; + } + + .repository-access-form textarea { + resize: vertical; + font-family: var(--font-mono); + font-size: var(--font-size-compact); + } + + .repository-access-form input[aria-invalid="true"], + .repository-access-form textarea[aria-invalid="true"] { + border-color: var(--danger); + } + + .repository-access-technical { + color: var(--text-muted); + font-size: var(--font-size-compact); + } + + .repository-access-technical summary { + width: fit-content; + cursor: pointer; + color: var(--text-strong); + font-weight: 700; + } + + .repository-access-technical dl { + display: grid; + gap: var(--space-2); + margin: var(--space-3) 0 0; + } + + .repository-access-technical dl div { + display: grid; + grid-template-columns: minmax(8rem, 0.25fr) minmax(0, 1fr); + gap: var(--space-3); + } + + .repository-access-technical dt { + font-weight: 700; + } + + .repository-access-technical dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + } + + .repository-access-guidance { + max-width: 64rem; + } + + .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + @media (max-width: 48rem) { + .repository-access-section-heading, + .repository-access-form-heading { + display: grid; + } + + .repository-access-form-grid { + grid-template-columns: 1fr; + } + + .repository-access-form label.wide { + grid-column: auto; + } + + .repository-access-technical dl div { + grid-template-columns: 1fr; + gap: var(--space-1); + } + } } diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte index bac097bc9..979b67110 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -18,8 +18,19 @@ parseRepositorySshPublicKey, } from '$lib/workspace/api/repository-access'; import { readBoundedJson } from '$lib/workspace/api/http'; + import { + repositoryAccessRequestError, + validateCredentialRotation, + validateGeneratedCredential, + validateHostTrust, + validateImportedCredential, + type RepositoryAccessFieldErrors, + } from '$lib/workspace/repository-access/model'; import type { PageProps } from './$types'; + type Notice = { tone: 'success' | 'error'; text: string }; + type CredentialForm = 'generate' | 'import' | null; + let { data }: PageProps = $props(); let credentials = $state(untrack(() => data.credentials)); let publicKeys = $state>( @@ -27,45 +38,69 @@ ); let hostTrusts = $state(untrack(() => data.hostTrusts)); const accessProjection = untrack(() => data.accessProjection); - let message = $state(null); - let pending = $state(false); - let copiedCredentialId = $state(null); + const workspaceDefaultCredentialId = 'workspace-default'; + let activeOperation = $state(null); + let rotateCredentialId = $state(null); + const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); + const busy = $derived(activeOperation !== null); + const selectedCredential = $derived( + rotateCredentialId ? credentials.find((credential) => credential.credential_id === rotateCredentialId) ?? null : null + ); + const sshRepository = $derived(data.repositories?.items.find((repository) => repository.source.kind === 'ssh') ?? null); + const connectionTestHref = $derived( + sshRepository + ? `/w/${encodeURIComponent(data.workspaceId)}/repositories/${encodeURIComponent(sshRepository.repository_key)}` + : `/w/${encodeURIComponent(data.workspaceId)}/settings/repositories` + ); + let copiedCredentialId = $state(null); + let credentialNotice = $state(null); + let hostNotice = $state(null); + let publicKeyNotices = $state>({}); + + let credentialForm = $state(null); let generateCredentialId = $state(''); let generateCredentialName = $state(''); + let generateErrors = $state({}); let credentialId = $state(''); let credentialName = $state(''); let privateKey = $state(''); let passphrase = $state(''); - let rotateCredentialId = $state(null); + let importErrors = $state({}); let rotatePrivateKey = $state(''); let rotatePassphrase = $state(''); + let rotateErrors = $state({}); + let hostEditorOpen = $state(false); let hostTrustId = $state(''); let hostname = $state(''); let port = $state(22); let hostKey = $state(''); let hostExpectedRevision = $state(null); - - const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); - const workspaceDefaultCredentialId = 'workspace-default'; + let hostErrors = $state({}); function operationId(prefix: string): string { return `${prefix}-${crypto.randomUUID()}`; } + function operationError(error: unknown, fallback: string): Notice { + return { tone: 'error', text: error instanceof Error ? error.message : fallback }; + } + function request( path: string, method: string, body: unknown, - parse: (value: unknown) => T + parse: (value: unknown) => T, + operation: string ): Promise; - function request(path: string, method: string, body: unknown, parse: null): Promise; + function request(path: string, method: string, body: unknown, parse: null, operation: string): Promise; async function request( path: string, method: string, body: unknown, - parse: ((value: unknown) => T) | null + parse: ((value: unknown) => T) | null, + operation: string ): Promise { const response = await fetch(`${base}${path}`, { method, @@ -74,7 +109,7 @@ }); if (!response.ok) { await response.body?.cancel(); - throw new Error(`Repository Access request failed with status ${response.status}.`); + throw repositoryAccessRequestError(response.status, operation); } if (parse === null) { if (response.status !== 204) { @@ -84,9 +119,7 @@ await response.body?.cancel(); return undefined; } - if (response.status === 204) { - throw new Error('Repository Access returned an unexpected empty response.'); - } + if (response.status === 204) throw new Error('Repository Access returned an unexpected empty response.'); const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES); return parse(payload); } @@ -98,84 +131,136 @@ ); if (!response.ok) { await response.body?.cancel(); - throw new Error(`Repository Access request failed with status ${response.status}.`); + throw repositoryAccessRequestError(response.status, 'load this public key'); } const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES); return parseRepositorySshPublicKey(body); } - async function generateCredential() { - pending = true; - message = null; + async function refreshPublicKey(credentialId: string): Promise { try { - const body: GenerateRepositorySshCredentialRequest = { - operation_id: operationId('credential-generate'), - credential_id: generateCredentialId, - name: generateCredentialName - }; - const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential); - const publicKey = await loadPublicKey(created.credential_id); - credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created]; - publicKeys = { ...publicKeys, [created.credential_id]: publicKey }; - generateCredentialId = ''; - generateCredentialName = ''; - message = `Generated SSH credential ${created.credential_id}`; + const publicKey = await loadPublicKey(credentialId); + publicKeys = { ...publicKeys, [credentialId]: publicKey }; } catch (error) { - message = error instanceof Error ? error.message : 'Failed to generate SSH credential'; - } finally { - pending = false; + publicKeyNotices = { + ...publicKeyNotices, + [credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.') + }; } } - async function copyPublicKey(credentialId: string) { - const publicKey = publicKeys[credentialId]?.public_key; - if (!publicKey) return; + function openCredentialForm(form: Exclude) { + credentialForm = form; + rotateCredentialId = null; + credentialNotice = null; + generateErrors = {}; + importErrors = {}; + } + + function closeCredentialForms() { + credentialForm = null; + generateCredentialId = ''; + generateCredentialName = ''; + credentialId = ''; + credentialName = ''; + privateKey = ''; + passphrase = ''; + generateErrors = {}; + importErrors = {}; + } + + async function generateCredential() { + generateErrors = validateGeneratedCredential({ credentialId: generateCredentialId, name: generateCredentialName }); + if (Object.keys(generateErrors).length > 0 || busy) return; + activeOperation = 'generate-credential'; + credentialNotice = null; try { - await navigator.clipboard.writeText(publicKey); - copiedCredentialId = credentialId; + const body: GenerateRepositorySshCredentialRequest = { + operation_id: operationId('credential-generate'), + credential_id: generateCredentialId.trim(), + name: generateCredentialName.trim() + }; + const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential'); + credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created] + .sort((a, b) => a.credential_id.localeCompare(b.credential_id)); + await refreshPublicKey(created.credential_id); + closeCredentialForms(); + credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` }; + } catch (error) { + credentialNotice = operationError(error, 'Failed to generate the SSH credential.'); + } finally { + activeOperation = null; + } + } + + async function copyPublicKey(credential: RepositorySshCredential) { + if (busy) return; + activeOperation = `copy-${credential.credential_id}`; + publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } }; + try { + const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id); + publicKeys = { ...publicKeys, [credential.credential_id]: publicKey }; + await navigator.clipboard.writeText(publicKey.public_key); + copiedCredentialId = credential.credential_id; + publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } }; window.setTimeout(() => { - if (copiedCredentialId === credentialId) copiedCredentialId = null; + if (copiedCredentialId === credential.credential_id) copiedCredentialId = null; }, 1500); - } catch { - message = 'Failed to copy the public key'; + } catch (error) { + publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') }; + } finally { + activeOperation = null; } } async function createCredential() { - pending = true; - message = null; + importErrors = validateImportedCredential({ credentialId, name: credentialName, privateKey }); + if (Object.keys(importErrors).length > 0 || busy) return; + activeOperation = 'import-credential'; + credentialNotice = null; try { const body: CreateRepositorySshCredentialRequest = { operation_id: operationId('credential-create'), - credential_id: credentialId, - name: credentialName, + credential_id: credentialId.trim(), + name: credentialName.trim(), private_key: privateKey, passphrase: passphrase || null }; - const created = await request( - '/credentials', - 'POST', - body, - parseRepositorySshCredential - ); - const publicKey = await loadPublicKey(created.credential_id); + const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential'); credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id)); - publicKeys = { ...publicKeys, [created.credential_id]: publicKey }; - credentialId = ''; - credentialName = ''; - message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`; + await refreshPublicKey(created.credential_id); + closeCredentialForms(); + credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Credential creation failed'; + credentialNotice = operationError(error, 'Credential import failed.'); } finally { privateKey = ''; passphrase = ''; - pending = false; + activeOperation = null; } } + function openCredentialRotation(credential: RepositorySshCredential) { + credentialForm = null; + credentialNotice = null; + rotateCredentialId = credential.credential_id; + rotatePrivateKey = ''; + rotatePassphrase = ''; + rotateErrors = {}; + } + + function closeCredentialRotation() { + rotateCredentialId = null; + rotatePrivateKey = ''; + rotatePassphrase = ''; + rotateErrors = {}; + } + async function rotateCredential(credential: RepositorySshCredential) { - pending = true; - message = null; + rotateErrors = validateCredentialRotation(rotatePrivateKey); + if (Object.keys(rotateErrors).length > 0 || busy) return; + activeOperation = `rotate-${credential.credential_id}`; + credentialNotice = null; try { const body: RotateRepositorySshCredentialRequest = { operation_id: operationId('credential-rotate'), @@ -183,212 +268,360 @@ private_key: rotatePrivateKey, passphrase: rotatePassphrase || null }; - const rotated = await request( + const rotated = await request( `/credentials/${encodeURIComponent(credential.credential_id)}/rotate`, 'POST', body, - parseRepositorySshCredential + parseRepositorySshCredential, + `rotate ${credential.name}` ); - const publicKey = await loadPublicKey(rotated.credential_id); credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry); - publicKeys = { ...publicKeys, [rotated.credential_id]: publicKey }; - rotateCredentialId = null; - message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`; + await refreshPublicKey(rotated.credential_id); + closeCredentialRotation(); + credentialNotice = { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Credential rotation failed'; + credentialNotice = operationError(error, 'Credential rotation failed.'); } finally { rotatePrivateKey = ''; rotatePassphrase = ''; - pending = false; + activeOperation = null; } } async function deleteCredential(credential: RepositorySshCredential) { - if (!confirm(`Delete credential ${credential.credential_id}?`)) return; - pending = true; - message = null; + if (credential.referenced_repositories.length > 0 || busy) return; + if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return; + activeOperation = `delete-${credential.credential_id}`; + credentialNotice = null; try { const body: DeleteRepositorySshCredentialRequest = { operation_id: operationId('credential-delete'), expected_revision: credential.current_revision }; - await request( - `/credentials/${encodeURIComponent(credential.credential_id)}`, - 'DELETE', - body, - null - ); + await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`); credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id); const remainingPublicKeys = { ...publicKeys }; delete remainingPublicKeys[credential.credential_id]; publicKeys = remainingPublicKeys; - message = `Credential ${credential.credential_id} deleted.`; + credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Credential deletion failed'; + credentialNotice = operationError(error, 'Credential deletion failed.'); } finally { - pending = false; + activeOperation = null; } } - async function createHostTrust() { - pending = true; - message = null; - try { - const body: PutRepositorySshHostTrustRequest = { - operation_id: operationId('host-trust-create'), - host_trust_id: hostTrustId, - hostname, - port, - host_key: hostKey, - expected_revision: hostExpectedRevision - }; - const created = await request( - '/host-trusts', - 'POST', - body, - parseRepositorySshHostTrust - ); - 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 openNewHostTrust() { + hostEditorOpen = true; + hostTrustId = ''; + hostname = ''; + port = 22; + hostKey = ''; + hostExpectedRevision = null; + hostErrors = {}; + hostNotice = null; } function editHostTrust(hostTrust: RepositorySshHostTrust) { + hostEditorOpen = true; hostTrustId = hostTrust.host_trust_id; hostname = hostTrust.hostname; port = hostTrust.port; hostKey = hostTrust.host_key; hostExpectedRevision = hostTrust.current_revision; + hostErrors = {}; + hostNotice = null; + } + + function closeHostEditor() { + hostEditorOpen = false; + hostTrustId = ''; + hostname = ''; + port = 22; + hostKey = ''; + hostExpectedRevision = null; + hostErrors = {}; + } + + async function saveHostTrust() { + hostErrors = validateHostTrust({ hostTrustId, hostname, port, hostKey }); + if (Object.keys(hostErrors).length > 0 || busy) return; + activeOperation = hostExpectedRevision === null ? 'add-host-trust' : `rotate-host-${hostTrustId}`; + hostNotice = null; + try { + const body: PutRepositorySshHostTrustRequest = { + operation_id: operationId('host-trust-save'), + host_trust_id: hostTrustId.trim(), + hostname: hostname.trim(), + port, + host_key: hostKey.trim(), + expected_revision: hostExpectedRevision + }; + const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, hostExpectedRevision === null ? 'pin this SSH host key' : 'rotate this pinned SSH host key'); + hostTrusts = hostExpectedRevision === null + ? [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id)) + : hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry); + const action = hostExpectedRevision === null ? 'Pinned' : 'Rotated'; + closeHostEditor(); + hostNotice = { tone: 'success', text: `${action} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` }; + } catch (error) { + hostNotice = operationError(error, 'Failed to save the pinned host key.'); + } finally { + activeOperation = null; + } } async function deleteHostTrust(hostTrust: RepositorySshHostTrust) { - if (!confirm(`Delete host trust ${hostTrust.host_trust_id}?`)) return; - pending = true; - message = null; + if (hostTrust.referenced_repositories.length > 0 || busy) return; + if (!confirm(`Delete the pinned host key for ${hostTrust.hostname}:${hostTrust.port}? Future SSH connections cannot verify this host until another key is pinned.`)) return; + activeOperation = `delete-host-${hostTrust.host_trust_id}`; + hostNotice = null; try { const body: DeleteRepositorySshHostTrustRequest = { operation_id: operationId('host-trust-delete'), expected_revision: hostTrust.current_revision }; - await request( - `/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, - 'DELETE', - body, - null - ); + await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`); hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id); - message = `Host trust ${hostTrust.host_trust_id} deleted.`; + hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` }; } catch (error) { - message = error instanceof Error ? error.message : 'Host trust deletion failed'; + hostNotice = operationError(error, 'Host key deletion failed.'); } finally { - pending = false; + activeOperation = null; } } Repository Access · Yoi Workspace -
-
-

owner only

Repository Access

- encrypted -
-

The Workspace default SSH key is generated separately from the Runtime authentication identity and is always offered during SSH clone. Without an explicit Repository binding, a unique pinned host trust matching the Repository URI is used with this default key. A binding can add one dedicated credential; OpenSSH receives both candidates and tries them through one operation-scoped agent. Private keys and passphrases remain write-only.

- {#if message}

{message}

{/if} - -
-

Active access projection

-

Config revision {accessProjection.config_revision} · {accessProjection.projection_digest}

- {#if accessProjection.bindings.length === 0}

No repository access bindings are active.

{/if} - {#each accessProjection.bindings as binding (binding.repository_key)} -
- {binding.repository_key} -

{binding.access} · additional credential {binding.credential_id} · always includes {workspaceDefaultCredentialId} · host trust {binding.host_trust_id}

+
+
+
+
+

Repository bindings

+

Explicit overrides for Repository authentication and host identity.

- {/each} -
- -
-

SSH credentials

- {#if credentials.length === 0}

No credentials configured.

{/if} - {#each credentials as credential (credential.credential_id)} -
- {credential.name} {credential.credential_id} - {#if credential.credential_id === workspaceDefaultCredentialId}Workspace default{/if} -

{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}

- {#if publicKeys[credential.credential_id]} - - - {/if} -

References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}

- {#if credential.credential_id !== workspaceDefaultCredentialId} -
- - -
- {/if} - {#if rotateCredentialId === credential.credential_id} -
{ event.preventDefault(); void rotateCredential(credential); }}> - - - -
- {/if} +
+ {#if accessProjection.bindings.length === 0} +
+ No explicit bindings +

SSH Repositories can still use the Workspace default credential and a unique pinned key that matches their host and port.

- {/each} + {:else} + +
+ + + + {#each accessProjection.bindings as binding (binding.repository_key)} + + + + + + + {/each} + +
RepositoryAccessAuthenticationHost identity
{binding.repository_key}Explicit binding{binding.access === 'read_write' ? 'Read and write' : 'Read only'}{workspaceDefaultCredentialId}plus {binding.credential_id}{binding.host_trust_id}
+
+ {/if} +
+ Technical details +
+
Config revision
{accessProjection.config_revision}
+
Projection digest
{accessProjection.projection_digest}
+
+
+
-
{ event.preventDefault(); void generateCredential(); }}> -

Generate Repository SSH credential

-

Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.

- - - -
- -
{ event.preventDefault(); void createCredential(); }}> -

Import existing SSH credential

- - - - - -
-
- -
-

Pinned SSH host keys

- {#if hostTrusts.length === 0}

No host trust records configured.

{/if} - {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} -
- {hostTrust.hostname}:{hostTrust.port} {hostTrust.host_trust_id} -

{hostTrust.key_algorithm} · {hostTrust.fingerprint} · revision {hostTrust.current_revision}

-

References: {hostTrust.referenced_repositories.join(', ') || 'none'}

-
- - +
+
+
+

SSH credentials

+

Keys this Workspace can offer when it authenticates to an SSH Repository.

+
+ {#if !data.credentialsError} +
+ +
-
- {/each} + {/if} +
-
{ event.preventDefault(); void createHostTrust(); }}> -

{hostExpectedRevision === null ? 'Add pinned host key' : 'Rotate pinned host key'}

- - - - - - {#if hostExpectedRevision !== null}{/if} -
-
-
+ {#if credentialNotice} +

{credentialNotice.text}

+ {/if} + + {#if data.credentialsError} + + {:else if credentials.length === 0} +
+ No SSH credentials are available +

Generate a credential to get a new public key, or import an existing Ed25519 private key.

+
+ {:else} + +
+ + + + {#each credentials as credential (credential.credential_id)} + + + + + + + + {/each} + +
CredentialRoleFingerprintUsed byActions
{credential.name}{credential.credential_id}{credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'}{credential.public_key_algorithm}{credential.public_key_fingerprint} + {#if credential.credential_id === workspaceDefaultCredentialId} + All SSH Repositories + {:else if credential.referenced_repositories.length > 0} + {credential.referenced_repositories.join(', ')} + {:else} + Not referenced + {/if} + +
+ + {#if credential.credential_id !== workspaceDefaultCredentialId} + + {#if credential.referenced_repositories.length === 0} + + {/if} + {/if} +
+ {#if publicKeyNotices[credential.credential_id]} + {publicKeyNotices[credential.credential_id].text} + {:else if credential.credential_id === workspaceDefaultCredentialId && data.defaultPublicKeyError} + {data.defaultPublicKeyError} Use Copy public key to retry. + {:else if credential.referenced_repositories.length > 0 && credential.credential_id !== workspaceDefaultCredentialId} + Remove Repository references before deletion. + {/if} +
+
+ {/if} + + {#if credentialForm === 'generate'} +
{ event.preventDefault(); void generateCredential(); }}> +

Generate SSH credential

Creates an Ed25519 key. Copy the public key to the Repository provider after saving.

+
+ + +
+
+
+ {:else if credentialForm === 'import'} +
{ event.preventDefault(); void createCredential(); }}> +

Import SSH credential

The private key and optional passphrase are write-only and cleared after every save attempt.

+
+ + + + +
+
+
+ {/if} + + {#if selectedCredential} +
{ event.preventDefault(); void rotateCredential(selectedCredential); }}> +
+

Rotate {selectedCredential.name}

Used by {selectedCredential.referenced_repositories.join(', ') || 'no Repositories'}. Existing provider keys must be replaced with the new public key after rotation.

+ {selectedCredential.public_key_fingerprint} +
+
+ + +
+
+
+ {/if} + + {#if credentials.length > 0 && !data.credentialsError} +
+ Credential revisions +
{#each credentials as credential (credential.credential_id)}
{credential.credential_id}
Revision {credential.current_revision}
{/each}
+
+ {/if} + + +
+
+
+

Pinned SSH host keys

+

Keys used to verify the identity of a specific SSH host and port.

+
+ {#if !data.hostTrustsError}{/if} +
+ + {#if hostNotice}

{hostNotice.text}

{/if} + + {#if data.hostTrustsError} + + {:else if hostTrusts.length === 0} +
+ No SSH host keys are pinned +

Observe a host key from the Runtime that will clone the Repository, then compare its fingerprint before trusting it.

+ {sshRepository ? `Open ${sshRepository.repository_key} SSH connection test` : 'Choose an SSH Repository'} +
+ {:else} + +
+ + + + {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} + + + + + + + {/each} + +
Host and portFingerprintUsed byActions
{hostTrust.hostname}:{hostTrust.port}{hostTrust.host_trust_id}{hostTrust.key_algorithm}{hostTrust.fingerprint}{hostTrust.referenced_repositories.join(', ') || 'Not referenced'} +
+ + {#if hostTrust.referenced_repositories.length === 0}{/if} +
+ {#if hostTrust.referenced_repositories.length > 0}Remove Repository references before deletion.{/if} +
+
+ {/if} + +

A successful probe only observes a key. Saving a pin verifies future host identity; Git authentication succeeds only when a later Repository connection completes.

+ {sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'} + + {#if hostEditorOpen} +
{ event.preventDefault(); void saveHostTrust(); }}> +
+

{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate ${hostname}:${port}`}

{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : 'The new key replaces the current trusted identity for this host and port.'}

+ {#if hostExpectedRevision !== null}{hostTrusts.find((entry) => entry.host_trust_id === hostTrustId)?.fingerprint}{/if} +
+
+ + + + +
+
+
+ {/if} + + {#if hostTrusts.length > 0 && !data.hostTrustsError} +
+ Host key revisions +
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
{hostTrust.host_trust_id}
Revision {hostTrust.current_revision}
{/each}
+
+ {/if} +
+ diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts index dc1905d2f..a17e8bb5f 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts @@ -5,9 +5,14 @@ import { parseRepositorySshHostTrusts, parseRepositorySshPublicKey, } from "$lib/workspace/api/repository-access"; -import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader"; +import { + loadRepositoryAccessJson, + loadRepositoryAccessSection, +} from "$lib/workspace/api/repository-access-loader"; import type { PageLoad } from "./$types"; +const WORKSPACE_DEFAULT_CREDENTIAL_ID = "workspace-default"; + export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const accessProjection = await loadRepositoryAccessJson( @@ -15,39 +20,50 @@ export const load: PageLoad = async ({ fetch, params }) => { workspaceApiPath(workspaceId, "/settings/repository-access"), parseRepositoryAccessProjection, ); - const [credentials, hostTrusts] = await Promise.all([ - loadRepositoryAccessJson( + const [credentialsResult, hostTrustsResult] = await Promise.all([ + loadRepositoryAccessSection( fetch, workspaceApiPath(workspaceId, "/settings/repository-access/credentials"), parseRepositorySshCredentials, + "SSH credentials", ), - loadRepositoryAccessJson( + loadRepositoryAccessSection( fetch, workspaceApiPath(workspaceId, "/settings/repository-access/host-trusts"), parseRepositorySshHostTrusts, + "Pinned SSH host keys", ), ]); - const publicKeys = await Promise.all( - credentials.map((credential) => - loadRepositoryAccessJson( - fetch, - workspaceApiPath( - workspaceId, - `/settings/repository-access/credentials/${ - encodeURIComponent(credential.credential_id) - }/public-key`, - ), - parseRepositorySshPublicKey, - ) - ), + const credentials = credentialsResult.data ?? []; + const defaultCredential = credentials.find( + (credential) => + credential.credential_id === WORKSPACE_DEFAULT_CREDENTIAL_ID, ); + const defaultPublicKeyResult = defaultCredential + ? await loadRepositoryAccessSection( + fetch, + workspaceApiPath( + workspaceId, + `/settings/repository-access/credentials/${ + encodeURIComponent(defaultCredential.credential_id) + }/public-key`, + ), + parseRepositorySshPublicKey, + "The Workspace default public key", + ) + : { data: null, error: null }; return { workspaceId, credentials, - publicKeys, - hostTrusts, + credentialsError: credentialsResult.error, + publicKeys: defaultPublicKeyResult.data + ? [defaultPublicKeyResult.data] + : [], + defaultPublicKeyError: defaultPublicKeyResult.error, + hostTrusts: hostTrustsResult.data ?? [], + hostTrustsError: hostTrustsResult.error, accessProjection, }; }; diff --git a/web/workspace/test/repository-access/loader.test.ts b/web/workspace/test/repository-access/loader.test.ts index 21b46be31..1df716f69 100644 --- a/web/workspace/test/repository-access/loader.test.ts +++ b/web/workspace/test/repository-access/loader.test.ts @@ -1,4 +1,7 @@ -import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts"; +import { + loadRepositoryAccessJson, + loadRepositoryAccessSection, +} from "../../src/lib/workspace/api/repository-access-loader.ts"; import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts"; type HttpFailure = { status?: number; body?: { message?: string } }; @@ -90,6 +93,21 @@ Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502 } }); +Deno.test("Repository Access section loader contains one failed sibling", async () => { + const result = await loadRepositoryAccessSection( + () => Promise.resolve(new Response(null, { status: 503 })), + "/api/w/workspace-1/settings/repository-access/credentials", + (value) => value, + "SSH credentials", + ); + if (result.data !== null) { + throw new Error("failed section must not expose data"); + } + if (result.error !== "Repository Access request failed with status 503.") { + throw new Error(`unexpected scoped error: ${String(result.error)}`); + } +}); + Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => { const upstreamSecret = "secret-ref-must-not-leak"; const failure = await captureHttpFailure( diff --git a/web/workspace/test/repository-access/model.test.ts b/web/workspace/test/repository-access/model.test.ts new file mode 100644 index 000000000..46c2a88b3 --- /dev/null +++ b/web/workspace/test/repository-access/model.test.ts @@ -0,0 +1,91 @@ +import { + repositoryAccessRequestError, + validateCredentialRotation, + validateGeneratedCredential, + validateHostTrust, + validateImportedCredential, +} from "../../src/lib/workspace/repository-access/model.ts"; + +function assert(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("Repository Access validates disclosed credential forms before mutation", () => { + const generated = validateGeneratedCredential({ + credentialId: "invalid id", + name: "", + }); + assert( + generated.credentialId?.includes("letters"), + "invalid credential id should have a field error", + ); + assert( + generated.name === "Name is required.", + "missing name should have a field error", + ); + + const imported = validateImportedCredential({ + credentialId: "deploy-key", + name: "Deploy key", + privateKey: "", + }); + assert( + imported.privateKey?.includes("required"), + "missing private key should have a field error", + ); + assert( + Object.keys(validateCredentialRotation(" ")).length === 1, + "rotation should reject an empty private key", + ); +}); + +Deno.test("Repository Access validates host identity fields in the editor scope", () => { + const errors = validateHostTrust({ + hostTrustId: "host trust", + hostname: "", + port: 70_000, + hostKey: "", + }); + for (const field of ["hostTrustId", "hostname", "port", "hostKey"]) { + assert( + Boolean(errors[field]), + `missing host trust field error for ${field}`, + ); + } + assert( + Object.keys(validateHostTrust({ + hostTrustId: "github-com", + hostname: "github.com", + port: 22, + hostKey: "ssh-ed25519 fixture-public-host-key", + })).length === 0, + "valid host trust fields should pass", + ); +}); + +Deno.test("Repository Access distinguishes permission, conflict, field, and missing-item failures", () => { + assert( + repositoryAccessRequestError(403, "rotate the key").message.includes( + "permission", + ), + "403 should explain permission", + ); + assert( + repositoryAccessRequestError(409, "rotate the key").message.includes( + "Reload", + ), + "409 should explain revision recovery", + ); + assert( + repositoryAccessRequestError(422, "rotate the key").message.includes( + "fields", + ), + "422 should point to fields", + ); + assert( + repositoryAccessRequestError(404, "rotate the key").message.includes( + "no longer exists", + ), + "404 should explain stale target", + ); +}); diff --git a/web/workspace/test/repository-access/ui.test.ts b/web/workspace/test/repository-access/ui.test.ts index c9b785062..f484b9536 100644 --- a/web/workspace/test/repository-access/ui.test.ts +++ b/web/workspace/test/repository-access/ui.test.ts @@ -19,24 +19,25 @@ const loaderSource = await Deno.readTextFile( import.meta.url, ), ); +const settingsCss = await Deno.readTextFile( + new URL("../../src/lib/workspace/styles/settings.css", import.meta.url), +); -test("Repository Access Web code consumes server-api legacy generated DTOs", () => { +test("Repository Access consumes generated contracts and validates every response boundary", () => { assert( source.includes("$lib/generated/repository-access-api"), "mutation code should import generated request and response contracts", ); - assert( - loaderSource.includes("parseRepositorySshCredentials") && - loaderSource.includes("parseRepositorySshHostTrusts") && - loaderSource.includes("parseRepositorySshPublicKey") && - loaderSource.includes("parseRepositoryAccessProjection"), - "loader should validate unknown JSON before exposing generated DTOs to Svelte", - ); - assert( - loaderSource.indexOf('"/settings/repository-access"') < - loaderSource.indexOf("Promise.all"), - "loader should check Repository Access permission before starting list preloads", - ); + for ( + const parser of [ + "parseRepositorySshCredentials", + "parseRepositorySshHostTrusts", + "parseRepositorySshPublicKey", + "parseRepositoryAccessProjection", + ] + ) { + assert(loaderSource.includes(parser), `loader should use ${parser}`); + } for ( const duplicate of [ "interface RepositorySshCredential", @@ -51,94 +52,185 @@ test("Repository Access Web code consumes server-api legacy generated DTOs", () } }); -test("Repository Access renders the shared access projection fields", () => { +test("Repository Access authorizes first and preserves independently available sections", () => { + assert( + loaderSource.indexOf('"/settings/repository-access"') < + loaderSource.indexOf("Promise.all"), + "loader should check Repository Access permission before section preloads", + ); + assert( + loaderSource.match(/loadRepositoryAccessSection/g)?.length === 4, + "credentials, host trusts, and the default public key should have independent failures", + ); for ( const field of [ + "credentialsError", + "hostTrustsError", + "defaultPublicKeyError", + ] + ) { + assert( + source.includes(`data.${field}`), + `page should render scoped ${field}`, + ); + } + assert( + loaderSource.includes("WORKSPACE_DEFAULT_CREDENTIAL_ID") && + !loaderSource.includes("credentials.map("), + "loader should preload only the default public key instead of every public key", + ); +}); + +test("Repository Access presents current state as tables with technical disclosure", () => { + for ( + const token of [ + "Repository bindings", + "SSH credentials", + "Pinned SSH host keys", + "repository-access-table-wrap", + "Technical details", + "Credential revisions", + "Host key revisions", "accessProjection.config_revision", "accessProjection.projection_digest", - "accessProjection.bindings", "binding.repository_key", "binding.credential_id", "binding.host_trust_id", "binding.access", ] ) { - assert(source.includes(field), `missing access projection field ${field}`); + assert( + source.includes(token), + `missing information structure token ${token}`, + ); } + assert( + !source.includes('class="card'), + "Repository Access should not nest generic cards", + ); + assert( + !source.includes("

Repository Access

"), + "the route title should remain owned by the Header", + ); + assert( + !source.includes("