feat(web): redesign repository access settings
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -5,6 +5,38 @@ import {
|
||||
RepositoryAccessSchemaError,
|
||||
} from "./repository-access.ts";
|
||||
|
||||
export type RepositoryAccessSectionResult<T> = {
|
||||
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<T>(
|
||||
fetcher: typeof fetch,
|
||||
path: string,
|
||||
parse: (value: unknown) => T,
|
||||
label: string,
|
||||
): Promise<RepositoryAccessSectionResult<T>> {
|
||||
try {
|
||||
return {
|
||||
data: await loadRepositoryAccessJson(fetcher, path, parse),
|
||||
error: null,
|
||||
};
|
||||
} catch (cause) {
|
||||
return { data: null, error: sectionErrorMessage(cause, label) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRepositoryAccessJson<T>(
|
||||
fetcher: typeof fetch,
|
||||
path: string,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export type RepositoryAccessFieldErrors = Record<string, string>;
|
||||
|
||||
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}.`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+445
-212
@@ -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<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||
let publicKeys = $state<Record<string, RepositorySshPublicKey>>(
|
||||
@@ -27,45 +38,69 @@
|
||||
);
|
||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||
const accessProjection = untrack(() => data.accessProjection);
|
||||
let message = $state<string | null>(null);
|
||||
let pending = $state(false);
|
||||
let copiedCredentialId = $state<string | null>(null);
|
||||
const workspaceDefaultCredentialId = 'workspace-default';
|
||||
let activeOperation = $state<string | null>(null);
|
||||
let rotateCredentialId = $state<string | null>(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<string | null>(null);
|
||||
let credentialNotice = $state<Notice | null>(null);
|
||||
let hostNotice = $state<Notice | null>(null);
|
||||
let publicKeyNotices = $state<Record<string, Notice>>({});
|
||||
|
||||
let credentialForm = $state<CredentialForm>(null);
|
||||
let generateCredentialId = $state('');
|
||||
let generateCredentialName = $state('');
|
||||
let generateErrors = $state<RepositoryAccessFieldErrors>({});
|
||||
let credentialId = $state('');
|
||||
let credentialName = $state('');
|
||||
let privateKey = $state('');
|
||||
let passphrase = $state('');
|
||||
let rotateCredentialId = $state<string | null>(null);
|
||||
let importErrors = $state<RepositoryAccessFieldErrors>({});
|
||||
let rotatePrivateKey = $state('');
|
||||
let rotatePassphrase = $state('');
|
||||
let rotateErrors = $state<RepositoryAccessFieldErrors>({});
|
||||
|
||||
let hostEditorOpen = $state(false);
|
||||
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`);
|
||||
const workspaceDefaultCredentialId = 'workspace-default';
|
||||
let hostErrors = $state<RepositoryAccessFieldErrors>({});
|
||||
|
||||
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<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
parse: (value: unknown) => T
|
||||
parse: (value: unknown) => T,
|
||||
operation: string
|
||||
): Promise<T>;
|
||||
function request(path: string, method: string, body: unknown, parse: null): Promise<void>;
|
||||
function request(path: string, method: string, body: unknown, parse: null, operation: string): Promise<void>;
|
||||
async function request<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
body: unknown,
|
||||
parse: ((value: unknown) => T) | null
|
||||
parse: ((value: unknown) => T) | null,
|
||||
operation: string
|
||||
): Promise<T | undefined> {
|
||||
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<void> {
|
||||
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, null>) {
|
||||
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<RepositorySshCredential>(
|
||||
'/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<RepositorySshCredential>(
|
||||
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<RepositorySshHostTrust>(
|
||||
'/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;
|
||||
}
|
||||
}
|
||||
</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>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.</p>
|
||||
{#if message}<p class="status-message">{message}</p>{/if}
|
||||
|
||||
<div class="settings-runtime-list">
|
||||
<h3>Active access projection</h3>
|
||||
<p>Config revision {accessProjection.config_revision} · <code>{accessProjection.projection_digest}</code></p>
|
||||
{#if accessProjection.bindings.length === 0}<p>No repository access bindings are active.</p>{/if}
|
||||
{#each accessProjection.bindings as binding (binding.repository_key)}
|
||||
<div class="card">
|
||||
<strong>{binding.repository_key}</strong>
|
||||
<p>{binding.access} · additional credential <code>{binding.credential_id}</code> · always includes <code>{workspaceDefaultCredentialId}</code> · host trust <code>{binding.host_trust_id}</code></p>
|
||||
<div class="repository-access-page" data-testid="repository-access-page">
|
||||
<section class="repository-access-section" aria-labelledby="repository-bindings-heading">
|
||||
<div class="repository-access-section-heading">
|
||||
<div>
|
||||
<h2 id="repository-bindings-heading">Repository bindings</h2>
|
||||
<p>Explicit overrides for Repository authentication and host identity.</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{#if credential.credential_id === workspaceDefaultCredentialId}<span class="badge success">Workspace default</span>{/if}
|
||||
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
|
||||
{#if publicKeys[credential.credential_id]}
|
||||
<label><span>Public key</span><textarea readonly rows="3" value={publicKeys[credential.credential_id].public_key}></textarea></label>
|
||||
<button type="button" onclick={() => void copyPublicKey(credential.credential_id)}>{copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}</button>
|
||||
{/if}
|
||||
<p>References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}</p>
|
||||
{#if credential.credential_id !== workspaceDefaultCredentialId}
|
||||
<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}
|
||||
{#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>
|
||||
{#if accessProjection.bindings.length === 0}
|
||||
<div class="repository-access-empty">
|
||||
<strong>No explicit bindings</strong>
|
||||
<p>SSH Repositories can still use the Workspace default credential and a unique pinned key that matches their host and port.</p>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="Explicit Repository access bindings">
|
||||
<table class="repository-access-table repository-access-binding-table">
|
||||
<thead><tr><th>Repository</th><th>Access</th><th>Authentication</th><th>Host identity</th></tr></thead>
|
||||
<tbody>
|
||||
{#each accessProjection.bindings as binding (binding.repository_key)}
|
||||
<tr>
|
||||
<td><strong>{binding.repository_key}</strong><small>Explicit binding</small></td>
|
||||
<td>{binding.access === 'read_write' ? 'Read and write' : 'Read only'}</td>
|
||||
<td><code>{workspaceDefaultCredentialId}</code><small>plus <code>{binding.credential_id}</code></small></td>
|
||||
<td><code>{binding.host_trust_id}</code></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
<details class="repository-access-technical">
|
||||
<summary>Technical details</summary>
|
||||
<dl>
|
||||
<div><dt>Config revision</dt><dd>{accessProjection.config_revision}</dd></div>
|
||||
<div><dt>Projection digest</dt><dd><code>{accessProjection.projection_digest}</code></dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
|
||||
<h3>Generate Repository SSH credential</h3>
|
||||
<p>Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.</p>
|
||||
<label><span>Credential id</span><input bind:value={generateCredentialId} placeholder="repository-deploy" required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
|
||||
<label><span>Name</span><input bind:value={generateCredentialName} placeholder="Repository deploy key" required maxlength="200" /></label>
|
||||
<button type="submit" disabled={pending}>Generate credential</button>
|
||||
</form>
|
||||
|
||||
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
||||
<h3>Import existing 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>
|
||||
<section class="repository-access-section" aria-labelledby="credentials-heading" aria-busy={busy}>
|
||||
<div class="repository-access-section-heading">
|
||||
<div>
|
||||
<h2 id="credentials-heading">SSH credentials</h2>
|
||||
<p>Keys this Workspace can offer when it authenticates to an SSH Repository.</p>
|
||||
</div>
|
||||
{#if !data.credentialsError}
|
||||
<div class="repository-access-actions" aria-label="Add SSH credential">
|
||||
<button type="button" class="secondary" disabled={busy} onclick={() => openCredentialForm('generate')}>Generate credential</button>
|
||||
<button type="button" class="secondary" disabled={busy} onclick={() => openCredentialForm('import')}>Import credential</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{#if credentialNotice}
|
||||
<p class="repository-access-notice {credentialNotice.tone}" role={credentialNotice.tone === 'error' ? 'alert' : 'status'}>{credentialNotice.text}</p>
|
||||
{/if}
|
||||
|
||||
{#if data.credentialsError}
|
||||
<div class="repository-access-empty error" role="alert">
|
||||
<strong>SSH credentials unavailable</strong>
|
||||
<p>{data.credentialsError}</p>
|
||||
<button type="button" class="secondary" onclick={() => window.location.reload()}>Retry credentials</button>
|
||||
</div>
|
||||
{:else if credentials.length === 0}
|
||||
<div class="repository-access-empty">
|
||||
<strong>No SSH credentials are available</strong>
|
||||
<p>Generate a credential to get a new public key, or import an existing Ed25519 private key.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="SSH credentials">
|
||||
<table class="repository-access-table repository-access-credential-table">
|
||||
<thead><tr><th>Credential</th><th>Role</th><th>Fingerprint</th><th>Used by</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
{#each credentials as credential (credential.credential_id)}
|
||||
<tr>
|
||||
<td><strong>{credential.name}</strong><code>{credential.credential_id}</code></td>
|
||||
<td>{credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'}</td>
|
||||
<td><code>{credential.public_key_algorithm}</code><code>{credential.public_key_fingerprint}</code></td>
|
||||
<td>
|
||||
{#if credential.credential_id === workspaceDefaultCredentialId}
|
||||
All SSH Repositories
|
||||
{:else if credential.referenced_repositories.length > 0}
|
||||
{credential.referenced_repositories.join(', ')}
|
||||
{:else}
|
||||
Not referenced
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="repository-access-row-actions">
|
||||
<button type="button" class="secondary" disabled={busy} onclick={() => void copyPublicKey(credential)}>
|
||||
{activeOperation === `copy-${credential.credential_id}` ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}
|
||||
</button>
|
||||
{#if credential.credential_id !== workspaceDefaultCredentialId}
|
||||
<button type="button" class="secondary" disabled={busy} onclick={() => openCredentialRotation(credential)}>Rotate</button>
|
||||
{#if credential.referenced_repositories.length === 0}
|
||||
<button type="button" class="danger" disabled={busy} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if publicKeyNotices[credential.credential_id]}
|
||||
<small class:field-error={publicKeyNotices[credential.credential_id].tone === 'error'} role={publicKeyNotices[credential.credential_id].tone === 'error' ? 'alert' : 'status'}>{publicKeyNotices[credential.credential_id].text}</small>
|
||||
{:else if credential.credential_id === workspaceDefaultCredentialId && data.defaultPublicKeyError}
|
||||
<small class="field-error" role="alert">{data.defaultPublicKeyError} Use Copy public key to retry.</small>
|
||||
{:else if credential.referenced_repositories.length > 0 && credential.credential_id !== workspaceDefaultCredentialId}
|
||||
<small>Remove Repository references before deletion.</small>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if credentialForm === 'generate'}
|
||||
<form class="repository-access-form" novalidate onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
|
||||
<div class="repository-access-form-heading"><div><h3>Generate SSH credential</h3><p>Creates an Ed25519 key. Copy the public key to the Repository provider after saving.</p></div></div>
|
||||
<div class="repository-access-form-grid">
|
||||
<label><span>Credential id</span><input bind:value={generateCredentialId} aria-invalid={Boolean(generateErrors.credentialId)} aria-describedby={generateErrors.credentialId ? 'generate-id-error' : undefined} autocomplete="off" />{#if generateErrors.credentialId}<small id="generate-id-error" class="field-error">{generateErrors.credentialId}</small>{/if}</label>
|
||||
<label><span>Name</span><input bind:value={generateCredentialName} aria-invalid={Boolean(generateErrors.name)} aria-describedby={generateErrors.name ? 'generate-name-error' : undefined} autocomplete="off" />{#if generateErrors.name}<small id="generate-name-error" class="field-error">{generateErrors.name}</small>{/if}</label>
|
||||
</div>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={busy}>{activeOperation === 'generate-credential' ? 'Generating…' : 'Generate credential'}</button><button type="button" class="secondary" disabled={busy} onclick={closeCredentialForms}>Cancel</button></div>
|
||||
</form>
|
||||
{:else if credentialForm === 'import'}
|
||||
<form class="repository-access-form" novalidate onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
||||
<div class="repository-access-form-heading"><div><h3>Import SSH credential</h3><p>The private key and optional passphrase are write-only and cleared after every save attempt.</p></div></div>
|
||||
<div class="repository-access-form-grid">
|
||||
<label><span>Credential id</span><input bind:value={credentialId} aria-invalid={Boolean(importErrors.credentialId)} aria-describedby={importErrors.credentialId ? 'import-id-error' : undefined} autocomplete="off" />{#if importErrors.credentialId}<small id="import-id-error" class="field-error">{importErrors.credentialId}</small>{/if}</label>
|
||||
<label><span>Name</span><input bind:value={credentialName} aria-invalid={Boolean(importErrors.name)} aria-describedby={importErrors.name ? 'import-name-error' : undefined} autocomplete="off" />{#if importErrors.name}<small id="import-name-error" class="field-error">{importErrors.name}</small>{/if}</label>
|
||||
<label class="wide"><span>OpenSSH private key (Ed25519)</span><textarea bind:value={privateKey} rows="8" autocomplete="off" data-web-ux-redact aria-invalid={Boolean(importErrors.privateKey)} aria-describedby={importErrors.privateKey ? 'import-key-error' : 'import-key-help'}></textarea><small id="import-key-help">Secret value; it cannot be read back after import.</small>{#if importErrors.privateKey}<small id="import-key-error" class="field-error">{importErrors.privateKey}</small>{/if}</label>
|
||||
<label class="wide"><span>Passphrase (optional)</span><input type="password" bind:value={passphrase} autocomplete="new-password" data-web-ux-redact /><small>Enter only when the private key is encrypted.</small></label>
|
||||
</div>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={busy}>{activeOperation === 'import-credential' ? 'Importing…' : 'Import credential'}</button><button type="button" class="secondary" disabled={busy} onclick={closeCredentialForms}>Cancel</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if selectedCredential}
|
||||
<form class="repository-access-form" novalidate onsubmit={(event) => { event.preventDefault(); void rotateCredential(selectedCredential); }}>
|
||||
<div class="repository-access-form-heading">
|
||||
<div><h3>Rotate {selectedCredential.name}</h3><p>Used by {selectedCredential.referenced_repositories.join(', ') || 'no Repositories'}. Existing provider keys must be replaced with the new public key after rotation.</p></div>
|
||||
<code>{selectedCredential.public_key_fingerprint}</code>
|
||||
</div>
|
||||
<div class="repository-access-form-grid">
|
||||
<label class="wide"><span>New OpenSSH private key (Ed25519)</span><textarea bind:value={rotatePrivateKey} rows="8" autocomplete="off" data-web-ux-redact aria-invalid={Boolean(rotateErrors.privateKey)} aria-describedby={rotateErrors.privateKey ? 'rotate-key-error' : 'rotate-key-help'}></textarea><small id="rotate-key-help">Secret value; it cannot be read back after rotation.</small>{#if rotateErrors.privateKey}<small id="rotate-key-error" class="field-error">{rotateErrors.privateKey}</small>{/if}</label>
|
||||
<label class="wide"><span>Passphrase (optional)</span><input type="password" bind:value={rotatePassphrase} autocomplete="new-password" data-web-ux-redact /></label>
|
||||
</div>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={busy}>{activeOperation === `rotate-${selectedCredential.credential_id}` ? 'Rotating…' : 'Rotate credential'}</button><button type="button" class="secondary" disabled={busy} onclick={closeCredentialRotation}>Cancel</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if credentials.length > 0 && !data.credentialsError}
|
||||
<details class="repository-access-technical">
|
||||
<summary>Credential revisions</summary>
|
||||
<dl>{#each credentials as credential (credential.credential_id)}<div><dt>{credential.credential_id}</dt><dd>Revision {credential.current_revision}</dd></div>{/each}</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="repository-access-section" aria-labelledby="host-trust-heading" aria-busy={busy}>
|
||||
<div class="repository-access-section-heading">
|
||||
<div>
|
||||
<h2 id="host-trust-heading">Pinned SSH host keys</h2>
|
||||
<p>Keys used to verify the identity of a specific SSH host and port.</p>
|
||||
</div>
|
||||
{#if !data.hostTrustsError}<button type="button" class="secondary" disabled={busy} onclick={openNewHostTrust}>Add pinned key</button>{/if}
|
||||
</div>
|
||||
|
||||
{#if hostNotice}<p class="repository-access-notice {hostNotice.tone}" role={hostNotice.tone === 'error' ? 'alert' : 'status'}>{hostNotice.text}</p>{/if}
|
||||
|
||||
{#if data.hostTrustsError}
|
||||
<div class="repository-access-empty error" role="alert"><strong>Pinned host keys unavailable</strong><p>{data.hostTrustsError}</p><button type="button" class="secondary" onclick={() => window.location.reload()}>Retry pinned keys</button></div>
|
||||
{:else if hostTrusts.length === 0}
|
||||
<div class="repository-access-empty">
|
||||
<strong>No SSH host keys are pinned</strong>
|
||||
<p>Observe a host key from the Runtime that will clone the Repository, then compare its fingerprint before trusting it.</p>
|
||||
<a class="inline-link" href={connectionTestHref}>{sshRepository ? `Open ${sshRepository.repository_key} SSH connection test` : 'Choose an SSH Repository'}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="repository-access-table-wrap" tabindex="0" role="region" aria-label="Pinned SSH host keys">
|
||||
<table class="repository-access-table repository-access-host-table">
|
||||
<thead><tr><th>Host and port</th><th>Fingerprint</th><th>Used by</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
|
||||
<tr>
|
||||
<td><strong>{hostTrust.hostname}:{hostTrust.port}</strong><code>{hostTrust.host_trust_id}</code></td>
|
||||
<td><code>{hostTrust.key_algorithm}</code><code>{hostTrust.fingerprint}</code></td>
|
||||
<td>{hostTrust.referenced_repositories.join(', ') || 'Not referenced'}</td>
|
||||
<td>
|
||||
<div class="repository-access-row-actions">
|
||||
<button type="button" class="secondary" disabled={busy} onclick={() => editHostTrust(hostTrust)}>Rotate key</button>
|
||||
{#if hostTrust.referenced_repositories.length === 0}<button type="button" class="danger" disabled={busy} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>{/if}
|
||||
</div>
|
||||
{#if hostTrust.referenced_repositories.length > 0}<small>Remove Repository references before deletion.</small>{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="repository-access-guidance">A successful probe only observes a key. Saving a pin verifies future host identity; Git authentication succeeds only when a later Repository connection completes.</p>
|
||||
<a class="inline-link" href={connectionTestHref}>{sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'}</a>
|
||||
|
||||
{#if hostEditorOpen}
|
||||
<form class="repository-access-form" novalidate onsubmit={(event) => { event.preventDefault(); void saveHostTrust(); }}>
|
||||
<div class="repository-access-form-heading">
|
||||
<div><h3>{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate ${hostname}:${port}`}</h3><p>{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.'}</p></div>
|
||||
{#if hostExpectedRevision !== null}<code>{hostTrusts.find((entry) => entry.host_trust_id === hostTrustId)?.fingerprint}</code>{/if}
|
||||
</div>
|
||||
<div class="repository-access-form-grid">
|
||||
<label><span>Host trust id</span><input bind:value={hostTrustId} disabled={hostExpectedRevision !== null} aria-invalid={Boolean(hostErrors.hostTrustId)} aria-describedby={hostErrors.hostTrustId ? 'host-id-error' : undefined} />{#if hostErrors.hostTrustId}<small id="host-id-error" class="field-error">{hostErrors.hostTrustId}</small>{/if}</label>
|
||||
<label><span>Hostname</span><input bind:value={hostname} aria-invalid={Boolean(hostErrors.hostname)} aria-describedby={hostErrors.hostname ? 'hostname-error' : undefined} />{#if hostErrors.hostname}<small id="hostname-error" class="field-error">{hostErrors.hostname}</small>{/if}</label>
|
||||
<label><span>Port</span><input type="number" bind:value={port} aria-invalid={Boolean(hostErrors.port)} aria-describedby={hostErrors.port ? 'host-port-error' : undefined} />{#if hostErrors.port}<small id="host-port-error" class="field-error">{hostErrors.port}</small>{/if}</label>
|
||||
<label class="wide"><span>OpenSSH public host key (Ed25519)</span><textarea bind:value={hostKey} rows="4" aria-invalid={Boolean(hostErrors.hostKey)} aria-describedby={hostErrors.hostKey ? 'host-key-error' : undefined}></textarea>{#if hostErrors.hostKey}<small id="host-key-error" class="field-error">{hostErrors.hostKey}</small>{/if}</label>
|
||||
</div>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={busy}>{activeOperation === 'add-host-trust' || activeOperation?.startsWith('rotate-host-') ? 'Saving…' : hostExpectedRevision === null ? 'Pin host key' : 'Save new key'}</button><button type="button" class="secondary" disabled={busy} onclick={closeHostEditor}>Cancel</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if hostTrusts.length > 0 && !data.hostTrustsError}
|
||||
<details class="repository-access-technical">
|
||||
<summary>Host key revisions</summary>
|
||||
<dl>{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}<div><dt>{hostTrust.host_trust_id}</dt><dd>Revision {hostTrust.current_revision}</dd></div>{/each}</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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("<h2>Repository Access</h2>"),
|
||||
"the route title should remain owned by the Header",
|
||||
);
|
||||
assert(
|
||||
!source.includes("<textarea readonly"),
|
||||
"public keys should not occupy permanent textareas",
|
||||
);
|
||||
assert(
|
||||
settingsCss.includes(".repository-access-table-wrap") &&
|
||||
settingsCss.includes("overflow-x: auto") &&
|
||||
settingsCss.includes(".repository-access-form-grid"),
|
||||
"Settings CSS should own bounded table overflow and responsive forms",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access generates and copies selectable public keys", () => {
|
||||
test("Repository Access explains automatic application without inventing a binding", () => {
|
||||
for (
|
||||
const token of [
|
||||
"No explicit bindings",
|
||||
"Workspace default credential",
|
||||
"unique pinned key that matches their host and port",
|
||||
"Explicit binding",
|
||||
"plus <code>{binding.credential_id}",
|
||||
]
|
||||
) {
|
||||
assert(source.includes(token), `missing access authority copy ${token}`);
|
||||
}
|
||||
assert(
|
||||
source.includes("A successful probe only observes a key") &&
|
||||
source.includes("Git authentication succeeds only"),
|
||||
"probe, trust persistence, and Git authentication must remain distinct",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access opens one scoped form with validation, cancellation, and pending fences", () => {
|
||||
for (
|
||||
const token of [
|
||||
"credentialForm === 'generate'",
|
||||
"credentialForm === 'import'",
|
||||
"hostEditorOpen",
|
||||
"selectedCredential",
|
||||
"validateGeneratedCredential",
|
||||
"validateImportedCredential",
|
||||
"validateCredentialRotation",
|
||||
"validateHostTrust",
|
||||
'class="field-error"',
|
||||
"aria-invalid",
|
||||
"disabled={busy}",
|
||||
">Cancel</button>",
|
||||
"Generating…",
|
||||
"Importing…",
|
||||
"Rotating…",
|
||||
"Saving…",
|
||||
]
|
||||
) {
|
||||
assert(source.includes(token), `missing scoped form behavior ${token}`);
|
||||
}
|
||||
const permanentFormStart = source.indexOf("<form");
|
||||
const generateDisclosure = source.indexOf(
|
||||
"{#if credentialForm === 'generate'}",
|
||||
);
|
||||
assert(
|
||||
permanentFormStart > generateDisclosure,
|
||||
"forms should render only after an explicit disclosure action",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access keeps public key copy easy without exposing key material", () => {
|
||||
for (
|
||||
const token of [
|
||||
"/credentials/generate",
|
||||
"/public-key",
|
||||
"Generate Repository SSH credential",
|
||||
"navigator.clipboard.writeText",
|
||||
"publicKeys[credential.credential_id]",
|
||||
"Copy public key",
|
||||
"workspace-default",
|
||||
"always offered during SSH clone",
|
||||
"publicKeys[credential.credential_id]",
|
||||
"data-web-ux-redact",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
source.includes(token),
|
||||
`missing generated public key flow ${token}`,
|
||||
`missing safe public key or secret handling ${token}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
source.includes("binding.credential_id"),
|
||||
"Repository bindings should identify the selected credential",
|
||||
source.includes("publicKeys = { ...publicKeys") &&
|
||||
source.includes("loadPublicKey(credential.credential_id)"),
|
||||
"non-default public keys should load only on demand",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access hides Rotate for the Workspace default credential", () => {
|
||||
const credentialsStart = source.indexOf("<h3>SSH credentials</h3>");
|
||||
const generateStart = source.indexOf(
|
||||
"<h3>Generate Repository SSH credential</h3>",
|
||||
);
|
||||
assert(
|
||||
credentialsStart >= 0 && generateStart > credentialsStart,
|
||||
"credential list should appear before the generation form",
|
||||
);
|
||||
|
||||
const credentialList = source.slice(credentialsStart, generateStart);
|
||||
const additionalCredentialGuard = credentialList.indexOf(
|
||||
test("Repository Access protects default and referenced resources from destructive controls", () => {
|
||||
const defaultGuard = source.indexOf(
|
||||
"{#if credential.credential_id !== workspaceDefaultCredentialId}",
|
||||
);
|
||||
const rotateAction = credentialList.indexOf(
|
||||
"rotateCredentialId = rotateCredentialId === credential.credential_id",
|
||||
additionalCredentialGuard,
|
||||
const rotateAction = source.indexOf(
|
||||
"openCredentialRotation(credential)",
|
||||
defaultGuard,
|
||||
);
|
||||
const deleteAction = credentialList.indexOf(
|
||||
"onclick={() => void deleteCredential(credential)}",
|
||||
const unreferencedGuard = source.indexOf(
|
||||
"{#if credential.referenced_repositories.length === 0}",
|
||||
rotateAction,
|
||||
);
|
||||
const guardEnd = credentialList.indexOf("{/if}", deleteAction);
|
||||
|
||||
assert(
|
||||
additionalCredentialGuard >= 0 &&
|
||||
rotateAction > additionalCredentialGuard &&
|
||||
deleteAction > rotateAction &&
|
||||
guardEnd > deleteAction,
|
||||
"Rotate and Delete should render only for additional credentials",
|
||||
const deleteAction = source.indexOf(
|
||||
"deleteCredential(credential)",
|
||||
unreferencedGuard,
|
||||
);
|
||||
assert(
|
||||
credentialList.indexOf(
|
||||
"{#if rotateCredentialId === credential.credential_id}",
|
||||
guardEnd,
|
||||
) > guardEnd,
|
||||
"the existing rotation form should remain available after selecting an additional credential",
|
||||
defaultGuard >= 0 && rotateAction > defaultGuard &&
|
||||
unreferencedGuard > rotateAction && deleteAction > unreferencedGuard,
|
||||
"default and referenced credentials should not render destructive actions",
|
||||
);
|
||||
assert(
|
||||
source.includes("Remove Repository references before deletion") &&
|
||||
source.includes("hostTrust.referenced_repositories.length === 0"),
|
||||
"reference constraints should be visible beside affected rows",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||
test("Repository credential submissions clear write-only fields after attempts", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
const rotateStart = source.indexOf("async function rotateCredential(");
|
||||
const deleteStart = source.indexOf("async function deleteCredential(");
|
||||
assert(
|
||||
createStart >= 0 && rotateStart > createStart && deleteStart > rotateStart,
|
||||
"credential handlers should appear in source order",
|
||||
);
|
||||
|
||||
const createBody = source.slice(createStart, rotateStart);
|
||||
const rotateBody = source.slice(rotateStart, deleteStart);
|
||||
for (const token of ["finally", "privateKey = ''", "passphrase = ''"]) {
|
||||
|
||||
Reference in New Issue
Block a user