fix(web): scope repository access operations
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/model.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/structure.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",
|
||||
|
||||
Generated
+33
-11
@@ -15,13 +15,13 @@
|
||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@testing-library/svelte@5.2.8": "5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@18.0.1_happy-dom@18.0.1",
|
||||
"npm:@testing-library/svelte@5.2.8": "5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@20.12.0_happy-dom@20.12.0",
|
||||
"npm:clsx@2.1.1": "2.1.1",
|
||||
"npm:cookie@0.6.0": "0.6.0",
|
||||
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||
"npm:devalue@5.6.4": "5.6.4",
|
||||
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
||||
"npm:happy-dom@18.0.1": "18.0.1",
|
||||
"npm:happy-dom@20.12.0": "20.12.0",
|
||||
"npm:marked@18.0.13": "18.0.13",
|
||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||
"npm:shiki@4.4.2": "4.4.2",
|
||||
@@ -30,7 +30,7 @@
|
||||
"npm:svelte@5.45.6": "5.45.6",
|
||||
"npm:typescript@5.9.3": "5.9.3",
|
||||
"npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
|
||||
"npm:vitest@3.2.4": "3.2.4_happy-dom@18.0.1",
|
||||
"npm:vitest@3.2.4": "3.2.4_happy-dom@20.12.0",
|
||||
"npm:yaml@2.9.0": "2.9.0"
|
||||
},
|
||||
"jsr": {
|
||||
@@ -556,7 +556,7 @@
|
||||
"pretty-format"
|
||||
]
|
||||
},
|
||||
"@testing-library/svelte@5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@18.0.1_happy-dom@18.0.1": {
|
||||
"@testing-library/svelte@5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@20.12.0_happy-dom@20.12.0": {
|
||||
"integrity": "sha512-ucQOtGsJhtawOEtUmbR4rRh53e6RbM1KUluJIXRmh6D4UzxR847iIqqjRtg9mHNFmGQ8Vkam9yVcR5d1mhIHKA==",
|
||||
"dependencies": [
|
||||
"@testing-library/dom",
|
||||
@@ -612,6 +612,12 @@
|
||||
"@types/whatwg-mimetype@3.0.2": {
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="
|
||||
},
|
||||
"@types/ws@8.18.1": {
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
]
|
||||
},
|
||||
"@typescript-eslint/types@8.57.2": {
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="
|
||||
},
|
||||
@@ -702,6 +708,12 @@
|
||||
"axobject-query@4.1.0": {
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
|
||||
},
|
||||
"buffer-image-size@0.6.4": {
|
||||
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
]
|
||||
},
|
||||
"cac@6.7.14": {
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
|
||||
},
|
||||
@@ -786,7 +798,7 @@
|
||||
"dependencies": [
|
||||
"domelementtype",
|
||||
"domhandler",
|
||||
"entities"
|
||||
"entities@8.1.0"
|
||||
]
|
||||
},
|
||||
"domelementtype@3.0.0": {
|
||||
@@ -806,6 +818,9 @@
|
||||
"domhandler"
|
||||
]
|
||||
},
|
||||
"entities@7.0.1": {
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="
|
||||
},
|
||||
"entities@8.1.0": {
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="
|
||||
},
|
||||
@@ -884,12 +899,16 @@
|
||||
"github-slugger@2.0.0": {
|
||||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="
|
||||
},
|
||||
"happy-dom@18.0.1": {
|
||||
"integrity": "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==",
|
||||
"happy-dom@20.12.0": {
|
||||
"integrity": "sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g==",
|
||||
"dependencies": [
|
||||
"@types/node",
|
||||
"@types/whatwg-mimetype",
|
||||
"whatwg-mimetype"
|
||||
"@types/ws",
|
||||
"buffer-image-size",
|
||||
"entities@7.0.1",
|
||||
"whatwg-mimetype",
|
||||
"ws"
|
||||
]
|
||||
},
|
||||
"hast-util-to-html@9.0.5": {
|
||||
@@ -923,7 +942,7 @@
|
||||
"domelementtype",
|
||||
"domhandler",
|
||||
"domutils",
|
||||
"entities"
|
||||
"entities@8.1.0"
|
||||
]
|
||||
},
|
||||
"is-reference@3.0.3": {
|
||||
@@ -1332,7 +1351,7 @@
|
||||
"vite"
|
||||
]
|
||||
},
|
||||
"vitest@3.2.4_happy-dom@18.0.1": {
|
||||
"vitest@3.2.4_happy-dom@20.12.0": {
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dependencies": [
|
||||
"@types/chai",
|
||||
@@ -1379,6 +1398,9 @@
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"ws@8.21.3": {
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="
|
||||
},
|
||||
"yaml@2.9.0": {
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"bin": true
|
||||
@@ -1419,7 +1441,7 @@
|
||||
"npm:@humanspeak/svelte-markdown@1.9.1",
|
||||
"npm:@testing-library/svelte@5.2.8",
|
||||
"npm:gen-interface-jp@0.8.0",
|
||||
"npm:happy-dom@18.0.1",
|
||||
"npm:happy-dom@20.12.0",
|
||||
"npm:shiki@4.4.2",
|
||||
"npm:vitest@3.2.4",
|
||||
"npm:yaml@2.9.0"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/svelte": "5.2.8",
|
||||
"happy-dom": "18.0.1",
|
||||
"happy-dom": "20.12.0",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,29 @@ function sectionErrorMessage(cause: unknown, label: string): string {
|
||||
return `${label} could not be loaded.`;
|
||||
}
|
||||
|
||||
function httpErrorStatus(cause: unknown): number | null {
|
||||
if (!cause || typeof cause !== "object" || !("status" in cause)) return null;
|
||||
const status = (cause as { status?: unknown }).status;
|
||||
return typeof status === "number" ? status : null;
|
||||
}
|
||||
|
||||
export async function loadRepositoryAccessPermissionGate<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) {
|
||||
if (httpErrorStatus(cause) === 403) throw cause;
|
||||
return { data: null, error: sectionErrorMessage(cause, label) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRepositoryAccessSection<T>(
|
||||
fetcher: typeof fetch,
|
||||
path: string,
|
||||
|
||||
@@ -1254,9 +1254,12 @@
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.repository-access-form-heading > code {
|
||||
.repository-access-form-heading > code,
|
||||
.repository-access-form-heading > div > code {
|
||||
display: block;
|
||||
max-width: 28rem;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
+239
-104
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import type {
|
||||
CreateRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshCredentialRequest,
|
||||
@@ -32,17 +32,25 @@
|
||||
type CredentialForm = 'generate' | 'import' | null;
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
|
||||
const workspaceDefaultCredentialId = 'workspace-default';
|
||||
|
||||
function sortCredentials(items: RepositorySshCredential[]): RepositorySshCredential[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (a.credential_id === workspaceDefaultCredentialId) return -1;
|
||||
if (b.credential_id === workspaceDefaultCredentialId) return 1;
|
||||
return a.credential_id.localeCompare(b.credential_id);
|
||||
});
|
||||
}
|
||||
|
||||
let credentials = $state<RepositorySshCredential[]>(sortCredentials(untrack(() => data.credentials)));
|
||||
let publicKeys = $state<Record<string, RepositorySshPublicKey>>(
|
||||
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
|
||||
);
|
||||
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
|
||||
const accessProjection = untrack(() => data.accessProjection);
|
||||
const workspaceDefaultCredentialId = 'workspace-default';
|
||||
let activeOperation = $state<string | null>(null);
|
||||
let pendingOperations = $state<string[]>([]);
|
||||
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
|
||||
);
|
||||
@@ -55,7 +63,11 @@
|
||||
|
||||
let copiedCredentialId = $state<string | null>(null);
|
||||
let credentialNotice = $state<Notice | null>(null);
|
||||
let credentialFormNotice = $state<Notice | null>(null);
|
||||
let credentialRowNotices = $state<Record<string, Notice>>({});
|
||||
let hostNotice = $state<Notice | null>(null);
|
||||
let hostFormNotice = $state<Notice | null>(null);
|
||||
let hostRowNotices = $state<Record<string, Notice>>({});
|
||||
let publicKeyNotices = $state<Record<string, Notice>>({});
|
||||
|
||||
let credentialForm = $state<CredentialForm>(null);
|
||||
@@ -70,6 +82,12 @@
|
||||
let rotatePrivateKey = $state('');
|
||||
let rotatePassphrase = $state('');
|
||||
let rotateErrors = $state<RepositoryAccessFieldErrors>({});
|
||||
let generateCredentialIdInput = $state<HTMLInputElement>();
|
||||
let importCredentialIdInput = $state<HTMLInputElement>();
|
||||
let rotatePrivateKeyInput = $state<HTMLTextAreaElement>();
|
||||
let credentialFormElement = $state<HTMLFormElement>();
|
||||
let rotationFormElement = $state<HTMLFormElement>();
|
||||
let credentialReturnFocus: HTMLElement | null = null;
|
||||
|
||||
let hostEditorOpen = $state(false);
|
||||
let hostTrustId = $state('');
|
||||
@@ -78,6 +96,13 @@
|
||||
let hostKey = $state('');
|
||||
let hostExpectedRevision = $state<number | null>(null);
|
||||
let hostErrors = $state<RepositoryAccessFieldErrors>({});
|
||||
let hostTrustIdInput = $state<HTMLInputElement>();
|
||||
let hostKeyInput = $state<HTMLTextAreaElement>();
|
||||
let hostFormElement = $state<HTMLFormElement>();
|
||||
let hostReturnFocus: HTMLElement | null = null;
|
||||
const editingHostTrust = $derived(
|
||||
hostExpectedRevision === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
|
||||
);
|
||||
|
||||
function operationId(prefix: string): string {
|
||||
return `${prefix}-${crypto.randomUUID()}`;
|
||||
@@ -87,6 +112,36 @@
|
||||
return { tone: 'error', text: error instanceof Error ? error.message : fallback };
|
||||
}
|
||||
|
||||
function isPending(operation: string): boolean {
|
||||
return pendingOperations.includes(operation);
|
||||
}
|
||||
|
||||
function startOperation(operation: string): boolean {
|
||||
if (isPending(operation)) return false;
|
||||
pendingOperations = [...pendingOperations, operation];
|
||||
return true;
|
||||
}
|
||||
|
||||
function finishOperation(operation: string): void {
|
||||
pendingOperations = pendingOperations.filter((entry) => entry !== operation);
|
||||
}
|
||||
|
||||
function currentFocus(): HTMLElement | null {
|
||||
return document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
}
|
||||
|
||||
async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise<void> {
|
||||
await tick();
|
||||
const fallback = fallbackId ? document.getElementById(fallbackId) : null;
|
||||
const destination = fallback ?? (target?.isConnected ? target : null);
|
||||
destination?.focus();
|
||||
}
|
||||
|
||||
async function focusFirstInvalid(form: HTMLFormElement | undefined): Promise<void> {
|
||||
await tick();
|
||||
form?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
|
||||
}
|
||||
|
||||
function request<T>(
|
||||
path: string,
|
||||
method: string,
|
||||
@@ -149,15 +204,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function openCredentialForm(form: Exclude<CredentialForm, null>) {
|
||||
async function openCredentialForm(form: Exclude<CredentialForm, null>) {
|
||||
credentialReturnFocus = currentFocus();
|
||||
credentialForm = form;
|
||||
rotateCredentialId = null;
|
||||
credentialNotice = null;
|
||||
credentialFormNotice = null;
|
||||
generateErrors = {};
|
||||
importErrors = {};
|
||||
await tick();
|
||||
(form === 'generate' ? generateCredentialIdInput : importCredentialIdInput)?.focus();
|
||||
}
|
||||
|
||||
function closeCredentialForms() {
|
||||
async function closeCredentialForms(restore = true) {
|
||||
const returnFocus = credentialReturnFocus;
|
||||
credentialForm = null;
|
||||
generateCredentialId = '';
|
||||
generateCredentialName = '';
|
||||
@@ -167,13 +227,20 @@
|
||||
passphrase = '';
|
||||
generateErrors = {};
|
||||
importErrors = {};
|
||||
credentialFormNotice = null;
|
||||
credentialReturnFocus = null;
|
||||
if (restore) await restoreFocus(returnFocus);
|
||||
}
|
||||
|
||||
async function generateCredential() {
|
||||
generateErrors = validateGeneratedCredential({ credentialId: generateCredentialId, name: generateCredentialName });
|
||||
if (Object.keys(generateErrors).length > 0 || busy) return;
|
||||
activeOperation = 'generate-credential';
|
||||
credentialNotice = null;
|
||||
if (Object.keys(generateErrors).length > 0) {
|
||||
await focusFirstInvalid(credentialFormElement);
|
||||
return;
|
||||
}
|
||||
const operation = 'generate-credential';
|
||||
if (!startOperation(operation)) return;
|
||||
credentialFormNotice = null;
|
||||
try {
|
||||
const body: GenerateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-generate'),
|
||||
@@ -181,21 +248,20 @@
|
||||
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));
|
||||
credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]);
|
||||
await refreshPublicKey(created.credential_id);
|
||||
closeCredentialForms();
|
||||
await 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.');
|
||||
credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.');
|
||||
} finally {
|
||||
activeOperation = null;
|
||||
finishOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPublicKey(credential: RepositorySshCredential) {
|
||||
if (busy) return;
|
||||
activeOperation = `copy-${credential.credential_id}`;
|
||||
const operation = `copy-${credential.credential_id}`;
|
||||
if (!startOperation(operation)) return;
|
||||
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } };
|
||||
try {
|
||||
const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id);
|
||||
@@ -209,15 +275,19 @@
|
||||
} catch (error) {
|
||||
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') };
|
||||
} finally {
|
||||
activeOperation = null;
|
||||
finishOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
async function createCredential() {
|
||||
importErrors = validateImportedCredential({ credentialId, name: credentialName, privateKey });
|
||||
if (Object.keys(importErrors).length > 0 || busy) return;
|
||||
activeOperation = 'import-credential';
|
||||
credentialNotice = null;
|
||||
if (Object.keys(importErrors).length > 0) {
|
||||
await focusFirstInvalid(credentialFormElement);
|
||||
return;
|
||||
}
|
||||
const operation = 'import-credential';
|
||||
if (!startOperation(operation)) return;
|
||||
credentialFormNotice = null;
|
||||
try {
|
||||
const body: CreateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-create'),
|
||||
@@ -227,40 +297,52 @@
|
||||
passphrase: passphrase || null
|
||||
};
|
||||
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));
|
||||
credentials = sortCredentials([...credentials, created]);
|
||||
await refreshPublicKey(created.credential_id);
|
||||
closeCredentialForms();
|
||||
await closeCredentialForms();
|
||||
credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` };
|
||||
} catch (error) {
|
||||
credentialNotice = operationError(error, 'Credential import failed.');
|
||||
credentialFormNotice = operationError(error, 'Credential import failed.');
|
||||
} finally {
|
||||
privateKey = '';
|
||||
passphrase = '';
|
||||
activeOperation = null;
|
||||
finishOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
function openCredentialRotation(credential: RepositorySshCredential) {
|
||||
async function openCredentialRotation(credential: RepositorySshCredential) {
|
||||
credentialReturnFocus = currentFocus();
|
||||
credentialForm = null;
|
||||
credentialNotice = null;
|
||||
credentialFormNotice = null;
|
||||
rotateCredentialId = credential.credential_id;
|
||||
rotatePrivateKey = '';
|
||||
rotatePassphrase = '';
|
||||
rotateErrors = {};
|
||||
await tick();
|
||||
rotatePrivateKeyInput?.focus();
|
||||
}
|
||||
|
||||
function closeCredentialRotation() {
|
||||
async function closeCredentialRotation(restore = true) {
|
||||
const returnFocus = credentialReturnFocus;
|
||||
const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined;
|
||||
rotateCredentialId = null;
|
||||
rotatePrivateKey = '';
|
||||
rotatePassphrase = '';
|
||||
rotateErrors = {};
|
||||
credentialFormNotice = null;
|
||||
credentialReturnFocus = null;
|
||||
if (restore) await restoreFocus(returnFocus, fallbackId);
|
||||
}
|
||||
|
||||
async function rotateCredential(credential: RepositorySshCredential) {
|
||||
rotateErrors = validateCredentialRotation(rotatePrivateKey);
|
||||
if (Object.keys(rotateErrors).length > 0 || busy) return;
|
||||
activeOperation = `rotate-${credential.credential_id}`;
|
||||
credentialNotice = null;
|
||||
if (Object.keys(rotateErrors).length > 0) {
|
||||
await focusFirstInvalid(rotationFormElement);
|
||||
return;
|
||||
}
|
||||
const operation = `rotate-${credential.credential_id}`;
|
||||
if (!startOperation(operation)) return;
|
||||
credentialFormNotice = null;
|
||||
try {
|
||||
const body: RotateRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-rotate'),
|
||||
@@ -277,22 +359,28 @@
|
||||
);
|
||||
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
|
||||
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.` };
|
||||
finishOperation(operation);
|
||||
await closeCredentialRotation();
|
||||
credentialRowNotices = {
|
||||
...credentialRowNotices,
|
||||
[rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }
|
||||
};
|
||||
} catch (error) {
|
||||
credentialNotice = operationError(error, 'Credential rotation failed.');
|
||||
credentialFormNotice = operationError(error, 'Credential rotation failed.');
|
||||
} finally {
|
||||
rotatePrivateKey = '';
|
||||
rotatePassphrase = '';
|
||||
activeOperation = null;
|
||||
finishOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCredential(credential: RepositorySshCredential) {
|
||||
if (credential.referenced_repositories.length > 0 || busy) return;
|
||||
if (credential.referenced_repositories.length > 0) return;
|
||||
const operation = `delete-${credential.credential_id}`;
|
||||
if (isPending(operation)) return;
|
||||
if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return;
|
||||
activeOperation = `delete-${credential.credential_id}`;
|
||||
credentialNotice = null;
|
||||
if (!startOperation(operation)) return;
|
||||
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } };
|
||||
try {
|
||||
const body: DeleteRepositorySshCredentialRequest = {
|
||||
operation_id: operationId('credential-delete'),
|
||||
@@ -305,13 +393,14 @@
|
||||
publicKeys = remainingPublicKeys;
|
||||
credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` };
|
||||
} catch (error) {
|
||||
credentialNotice = operationError(error, 'Credential deletion failed.');
|
||||
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') };
|
||||
} finally {
|
||||
activeOperation = null;
|
||||
finishOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
function openNewHostTrust() {
|
||||
async function openNewHostTrust() {
|
||||
hostReturnFocus = currentFocus();
|
||||
hostEditorOpen = true;
|
||||
hostTrustId = '';
|
||||
hostname = '';
|
||||
@@ -319,10 +408,13 @@
|
||||
hostKey = '';
|
||||
hostExpectedRevision = null;
|
||||
hostErrors = {};
|
||||
hostNotice = null;
|
||||
hostFormNotice = null;
|
||||
await tick();
|
||||
hostTrustIdInput?.focus();
|
||||
}
|
||||
|
||||
function editHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||
async function editHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||
hostReturnFocus = currentFocus();
|
||||
hostEditorOpen = true;
|
||||
hostTrustId = hostTrust.host_trust_id;
|
||||
hostname = hostTrust.hostname;
|
||||
@@ -330,10 +422,14 @@
|
||||
hostKey = hostTrust.host_key;
|
||||
hostExpectedRevision = hostTrust.current_revision;
|
||||
hostErrors = {};
|
||||
hostNotice = null;
|
||||
hostFormNotice = null;
|
||||
await tick();
|
||||
hostKeyInput?.focus();
|
||||
}
|
||||
|
||||
function closeHostEditor() {
|
||||
async function closeHostEditor(restore = true) {
|
||||
const returnFocus = hostReturnFocus;
|
||||
const fallbackId = hostExpectedRevision !== null ? `rotate-host-${hostTrustId}` : undefined;
|
||||
hostEditorOpen = false;
|
||||
hostTrustId = '';
|
||||
hostname = '';
|
||||
@@ -341,41 +437,58 @@
|
||||
hostKey = '';
|
||||
hostExpectedRevision = null;
|
||||
hostErrors = {};
|
||||
hostFormNotice = null;
|
||||
hostReturnFocus = null;
|
||||
if (restore) await restoreFocus(returnFocus, fallbackId);
|
||||
}
|
||||
|
||||
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;
|
||||
if (Object.keys(hostErrors).length > 0) {
|
||||
await focusFirstInvalid(hostFormElement);
|
||||
return;
|
||||
}
|
||||
const rotating = hostExpectedRevision !== null;
|
||||
const rotationTarget = editingHostTrust;
|
||||
if (rotating && !rotationTarget) {
|
||||
hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' };
|
||||
return;
|
||||
}
|
||||
const operation = rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust';
|
||||
if (!startOperation(operation)) return;
|
||||
hostFormNotice = null;
|
||||
try {
|
||||
const body: PutRepositorySshHostTrustRequest = {
|
||||
operation_id: operationId('host-trust-save'),
|
||||
host_trust_id: hostTrustId.trim(),
|
||||
hostname: hostname.trim(),
|
||||
port,
|
||||
hostname: rotationTarget?.hostname ?? hostname.trim(),
|
||||
port: rotationTarget?.port ?? 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.` };
|
||||
const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key');
|
||||
hostTrusts = rotating
|
||||
? hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry)
|
||||
: [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id));
|
||||
finishOperation(operation);
|
||||
await closeHostEditor();
|
||||
const notice: Notice = { tone: 'success', text: `${rotating ? 'Rotated' : 'Pinned'} the key for ${saved.hostname}:${saved.port}. This records host identity; it does not prove Git authentication succeeded.` };
|
||||
if (rotating) hostRowNotices = { ...hostRowNotices, [saved.host_trust_id]: notice };
|
||||
else hostNotice = notice;
|
||||
} catch (error) {
|
||||
hostNotice = operationError(error, 'Failed to save the pinned host key.');
|
||||
hostFormNotice = operationError(error, 'Failed to save the pinned host key.');
|
||||
} finally {
|
||||
activeOperation = null;
|
||||
finishOperation(operation);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
|
||||
if (hostTrust.referenced_repositories.length > 0 || busy) return;
|
||||
if (hostTrust.referenced_repositories.length > 0) return;
|
||||
const operation = `delete-host-${hostTrust.host_trust_id}`;
|
||||
if (isPending(operation)) 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;
|
||||
if (!startOperation(operation)) return;
|
||||
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } };
|
||||
try {
|
||||
const body: DeleteRepositorySshHostTrustRequest = {
|
||||
operation_id: operationId('host-trust-delete'),
|
||||
@@ -385,9 +498,9 @@
|
||||
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
|
||||
hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` };
|
||||
} catch (error) {
|
||||
hostNotice = operationError(error, 'Host key deletion failed.');
|
||||
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') };
|
||||
} finally {
|
||||
activeOperation = null;
|
||||
finishOperation(operation);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -402,12 +515,18 @@
|
||||
<p>Explicit overrides for Repository authentication and host identity.</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if accessProjection.bindings.length === 0}
|
||||
{#if data.accessProjectionError}
|
||||
<div class="repository-access-empty error" role="alert">
|
||||
<strong>Repository bindings unavailable</strong>
|
||||
<p>{data.accessProjectionError}</p>
|
||||
<button type="button" class="secondary" onclick={() => window.location.reload()}>Retry bindings</button>
|
||||
</div>
|
||||
{:else if accessProjection && 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>
|
||||
{:else}
|
||||
{:else if accessProjection}
|
||||
<!-- 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">
|
||||
@@ -425,6 +544,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{#if accessProjection}
|
||||
<details class="repository-access-technical">
|
||||
<summary>Technical details</summary>
|
||||
<dl>
|
||||
@@ -432,9 +552,10 @@
|
||||
<div><dt>Projection digest</dt><dd><code>{accessProjection.projection_digest}</code></dd></div>
|
||||
</dl>
|
||||
</details>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="repository-access-section" aria-labelledby="credentials-heading" aria-busy={busy}>
|
||||
<section class="repository-access-section" aria-labelledby="credentials-heading">
|
||||
<div class="repository-access-section-heading">
|
||||
<div>
|
||||
<h2 id="credentials-heading">SSH credentials</h2>
|
||||
@@ -442,8 +563,8 @@
|
||||
</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>
|
||||
<button type="button" class="secondary" onclick={() => void openCredentialForm('generate')}>Generate credential</button>
|
||||
<button type="button" class="secondary" onclick={() => void openCredentialForm('import')}>Import credential</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -467,34 +588,37 @@
|
||||
<!-- 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>
|
||||
<thead><tr><th>Credential</th><th>Role</th><th>Fingerprint</th><th>Repository application</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
{#each credentials as credential (credential.credential_id)}
|
||||
<tr>
|
||||
<tr aria-busy={isPending(`copy-${credential.credential_id}`) || isPending(`delete-${credential.credential_id}`) || isPending(`rotate-${credential.credential_id}`)}>
|
||||
<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
|
||||
Automatic for all SSH Repositories
|
||||
{:else if credential.referenced_repositories.length > 0}
|
||||
{credential.referenced_repositories.join(', ')}
|
||||
Explicit: {credential.referenced_repositories.join(', ')}
|
||||
{:else}
|
||||
Not referenced
|
||||
No explicit references
|
||||
{/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 type="button" class="secondary" disabled={isPending(`copy-${credential.credential_id}`)} onclick={() => void copyPublicKey(credential)}>
|
||||
{isPending(`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>
|
||||
<button id={`rotate-credential-${credential.credential_id}`} type="button" class="secondary" disabled={isPending(`rotate-${credential.credential_id}`)} onclick={() => void openCredentialRotation(credential)}>Rotate</button>
|
||||
{#if credential.referenced_repositories.length === 0}
|
||||
<button type="button" class="danger" disabled={busy} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||
<button type="button" class="danger" disabled={isPending(`delete-${credential.credential_id}`)} onclick={() => void deleteCredential(credential)}>Delete</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if credentialRowNotices[credential.credential_id]}
|
||||
<small class:field-error={credentialRowNotices[credential.credential_id].tone === 'error'} role={credentialRowNotices[credential.credential_id].tone === 'error' ? 'alert' : 'status'}>{credentialRowNotices[credential.credential_id].text}</small>
|
||||
{/if}
|
||||
{#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}
|
||||
@@ -511,38 +635,41 @@
|
||||
{/if}
|
||||
|
||||
{#if credentialForm === 'generate'}
|
||||
<form class="repository-access-form" novalidate onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
|
||||
<form bind:this={credentialFormElement} class="repository-access-form" aria-busy={isPending('generate-credential')} 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>
|
||||
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
|
||||
<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>Credential id</span><input bind:this={generateCredentialIdInput} 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>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={isPending('generate-credential')}>{isPending('generate-credential') ? 'Generating…' : 'Generate credential'}</button><button type="button" class="secondary" disabled={isPending('generate-credential')} onclick={() => void closeCredentialForms()}>Cancel</button></div>
|
||||
</form>
|
||||
{:else if credentialForm === 'import'}
|
||||
<form class="repository-access-form" novalidate onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
|
||||
<form bind:this={credentialFormElement} class="repository-access-form" aria-busy={isPending('import-credential')} 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>
|
||||
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
|
||||
<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>Credential id</span><input bind:this={importCredentialIdInput} 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>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={isPending('import-credential')}>{isPending('import-credential') ? 'Importing…' : 'Import credential'}</button><button type="button" class="secondary" disabled={isPending('import-credential')} onclick={() => void closeCredentialForms()}>Cancel</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if selectedCredential}
|
||||
<form class="repository-access-form" novalidate onsubmit={(event) => { event.preventDefault(); void rotateCredential(selectedCredential); }}>
|
||||
<form bind:this={rotationFormElement} class="repository-access-form" aria-busy={isPending(`rotate-${selectedCredential.credential_id}`)} 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>
|
||||
<div><h3>Rotate {selectedCredential.name}</h3><p>{selectedCredential.referenced_repositories.length > 0 ? `Explicitly referenced by ${selectedCredential.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this credential. '}Every external provider where the current public key is installed must be updated after rotation.</p></div>
|
||||
<code>{selectedCredential.public_key_fingerprint}</code>
|
||||
</div>
|
||||
{#if credentialFormNotice}<p class="repository-access-notice {credentialFormNotice.tone}" role={credentialFormNotice.tone === 'error' ? 'alert' : 'status'}>{credentialFormNotice.text}</p>{/if}
|
||||
<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>New OpenSSH private key (Ed25519)</span><textarea bind:this={rotatePrivateKeyInput} 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>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={isPending(`rotate-${selectedCredential.credential_id}`)}>{isPending(`rotate-${selectedCredential.credential_id}`) ? 'Rotating…' : 'Rotate credential'}</button><button type="button" class="secondary" disabled={isPending(`rotate-${selectedCredential.credential_id}`)} onclick={() => void closeCredentialRotation()}>Cancel</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -554,13 +681,13 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="repository-access-section" aria-labelledby="host-trust-heading" aria-busy={busy}>
|
||||
<section class="repository-access-section" aria-labelledby="host-trust-heading">
|
||||
<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}
|
||||
{#if !data.hostTrustsError}<button type="button" class="secondary" disabled={isPending('add-host-trust')} onclick={() => void 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}
|
||||
@@ -577,19 +704,23 @@
|
||||
<!-- 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>
|
||||
<thead><tr><th>Host and port</th><th>Fingerprint</th><th>Repository application</th><th><span class="visually-hidden">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
|
||||
<tr>
|
||||
<tr aria-busy={isPending(`rotate-host-${hostTrust.host_trust_id}`) || isPending(`delete-host-${hostTrust.host_trust_id}`)}>
|
||||
<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>{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'}</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}
|
||||
<button id={`rotate-host-${hostTrust.host_trust_id}`} type="button" class="secondary" disabled={isPending(`rotate-host-${hostTrust.host_trust_id}`)} onclick={() => void editHostTrust(hostTrust)}>Rotate key</button>
|
||||
{#if hostTrust.referenced_repositories.length === 0}<button type="button" class="danger" disabled={isPending(`delete-host-${hostTrust.host_trust_id}`)} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>{/if}
|
||||
</div>
|
||||
{#if hostTrust.referenced_repositories.length > 0}<small>Remove Repository references before deletion.</small>{/if}
|
||||
{#if hostRowNotices[hostTrust.host_trust_id]}
|
||||
<small class:field-error={hostRowNotices[hostTrust.host_trust_id].tone === 'error'} role={hostRowNotices[hostTrust.host_trust_id].tone === 'error' ? 'alert' : 'status'}>{hostRowNotices[hostTrust.host_trust_id].text}</small>
|
||||
{:else if hostTrust.referenced_repositories.length > 0}
|
||||
<small>Remove explicit Repository references before deletion.</small>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -602,18 +733,22 @@
|
||||
<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(); }}>
|
||||
<form bind:this={hostFormElement} class="repository-access-form" aria-busy={hostExpectedRevision === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} 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>
|
||||
<h3>{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate key for ${hostname}:${port}`}</h3>
|
||||
<p>{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : `This keeps the endpoint fixed and replaces its current trusted identity. ${editingHostTrust?.referenced_repositories.length ? `Explicitly referenced by ${editingHostTrust.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this pin. '}Repositories without an explicit host-key binding may also select it automatically when it is the unique pin matching this host and port.`}</p>
|
||||
</div>
|
||||
{#if hostExpectedRevision !== null}<div><small>Current fingerprint</small><code>{editingHostTrust?.fingerprint}</code></div>{/if}
|
||||
</div>
|
||||
{#if hostFormNotice}<p class="repository-access-notice {hostFormNotice.tone}" role={hostFormNotice.tone === 'error' ? 'alert' : 'status'}>{hostFormNotice.text}</p>{/if}
|
||||
<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>
|
||||
<label><span>Host trust id</span><input bind:this={hostTrustIdInput} 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} disabled={hostExpectedRevision !== null} 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} disabled={hostExpectedRevision !== null} 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:this={hostKeyInput} bind:value={hostKey} rows="4" aria-invalid={Boolean(hostErrors.hostKey)} aria-describedby={hostErrors.hostKey ? 'host-key-error host-key-help' : 'host-key-help'}></textarea><small id="host-key-help">The new fingerprint is derived from this key after save; compare the key with a trusted source first.</small>{#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>
|
||||
<div class="repository-access-form-actions"><button type="submit" class="primary" disabled={hostExpectedRevision === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)}>{hostExpectedRevision === null ? (isPending('add-host-trust') ? 'Saving…' : 'Pin host key') : (isPending(`rotate-host-${hostTrustId}`) ? 'Saving…' : 'Save new key')}</button><button type="button" class="secondary" disabled={hostExpectedRevision === null ? isPending('add-host-trust') : isPending(`rotate-host-${hostTrustId}`)} onclick={() => void closeHostEditor()}>Cancel</button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseRepositorySshPublicKey,
|
||||
} from "$lib/workspace/api/repository-access";
|
||||
import {
|
||||
loadRepositoryAccessJson,
|
||||
loadRepositoryAccessPermissionGate,
|
||||
loadRepositoryAccessSection,
|
||||
} from "$lib/workspace/api/repository-access-loader";
|
||||
import type { PageLoad } from "./$types";
|
||||
@@ -15,10 +15,11 @@ const WORKSPACE_DEFAULT_CREDENTIAL_ID = "workspace-default";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const accessProjection = await loadRepositoryAccessJson(
|
||||
const accessProjectionResult = await loadRepositoryAccessPermissionGate(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access"),
|
||||
parseRepositoryAccessProjection,
|
||||
"Repository bindings",
|
||||
);
|
||||
const [credentialsResult, hostTrustsResult] = await Promise.all([
|
||||
loadRepositoryAccessSection(
|
||||
@@ -64,6 +65,7 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
defaultPublicKeyError: defaultPublicKeyResult.error,
|
||||
hostTrusts: hostTrustsResult.data ?? [],
|
||||
hostTrustsError: hostTrustsResult.error,
|
||||
accessProjection,
|
||||
accessProjection: accessProjectionResult.data,
|
||||
accessProjectionError: accessProjectionResult.error,
|
||||
};
|
||||
};
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import RepositoryAccessPage from "./+page.svelte";
|
||||
import type { PageProps } from "./$types";
|
||||
|
||||
const defaultCredential = {
|
||||
credential_id: "workspace-default",
|
||||
workspace_id: "workspace-1",
|
||||
name: "Workspace default",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:default",
|
||||
current_revision: 3,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
referenced_repositories: [],
|
||||
};
|
||||
|
||||
const deployCredential = {
|
||||
credential_id: "deploy",
|
||||
workspace_id: "workspace-1",
|
||||
name: "Deploy key",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy",
|
||||
current_revision: 2,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
referenced_repositories: [],
|
||||
};
|
||||
|
||||
const hostTrust = {
|
||||
host_trust_id: "github-com",
|
||||
workspace_id: "workspace-1",
|
||||
hostname: "github.com",
|
||||
port: 22,
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA-current",
|
||||
fingerprint: "SHA256:host-current",
|
||||
current_revision: 4,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
referenced_repositories: [],
|
||||
};
|
||||
|
||||
function pageData() {
|
||||
return {
|
||||
workspaceId: "workspace-1",
|
||||
credentials: [defaultCredential, deployCredential],
|
||||
credentialsError: null,
|
||||
publicKeys: [{
|
||||
credential_id: "workspace-default",
|
||||
current_revision: 3,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:default",
|
||||
public_key: "ssh-ed25519 AAAA-default",
|
||||
}],
|
||||
defaultPublicKeyError: null,
|
||||
hostTrusts: [hostTrust],
|
||||
hostTrustsError: null,
|
||||
accessProjection: {
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 1,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [],
|
||||
},
|
||||
accessProjectionError: null,
|
||||
repositories: {
|
||||
items: [{
|
||||
repository_key: "platform",
|
||||
source: { kind: "ssh", uri: "git@github.com:team/platform.git" },
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
const props = {
|
||||
data: pageData(),
|
||||
params: { workspaceId: "workspace-1" },
|
||||
} as unknown as PageProps;
|
||||
return render(RepositoryAccessPage, props);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("opens disclosures with focus, focuses invalid fields, and restores trigger focus on cancel", async () => {
|
||||
renderPage();
|
||||
const trigger = screen.getByRole("button", { name: "Generate credential" });
|
||||
|
||||
expect(screen.queryByRole("heading", { name: "Generate SSH credential" }))
|
||||
.toBeNull();
|
||||
trigger.focus();
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
const idInput = screen.getByRole("textbox", { name: "Credential id" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(idInput));
|
||||
await fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Generate credential" })[1],
|
||||
);
|
||||
expect(await screen.findByText("Credential id is required.")).not.toBeNull();
|
||||
expect(document.activeElement).toBe(idInput);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "Generate SSH credential" }))
|
||||
.toBeNull()
|
||||
);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
test("fences a deferred submission without disabling unrelated controls and restores focus after success", async () => {
|
||||
const createResponse = deferred<Response>();
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/credentials/generate")) return createResponse.promise;
|
||||
if (url.endsWith("/credentials/generated/public-key")) {
|
||||
return Promise.resolve(Response.json({
|
||||
credential_id: "generated",
|
||||
current_revision: 1,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:generated",
|
||||
public_key: "ssh-ed25519 AAAA-generated",
|
||||
}));
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
renderPage();
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "Generate credential" });
|
||||
trigger.focus();
|
||||
await fireEvent.click(trigger);
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: "Credential id" }),
|
||||
{ target: { value: "generated" } },
|
||||
);
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: "Name" }), {
|
||||
target: { value: "Generated key" },
|
||||
});
|
||||
const submit =
|
||||
screen.getAllByRole("button", { name: "Generate credential" })[1];
|
||||
await fireEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Generating…" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Import credential",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Add pinned key",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Generating…" }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
createResponse.resolve(Response.json({
|
||||
...deployCredential,
|
||||
credential_id: "generated",
|
||||
name: "Generated key",
|
||||
public_key_fingerprint: "SHA256:generated",
|
||||
current_revision: 1,
|
||||
}));
|
||||
expect(await screen.findByText("Generated key")).not.toBeNull();
|
||||
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("keeps a deferred row failure on that row while unrelated actions remain enabled", async () => {
|
||||
const publicKeyResponse = deferred<Response>();
|
||||
const fetchMock = vi.fn(() => publicKeyResponse.promise);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
renderPage();
|
||||
|
||||
const row = screen.getByText("Deploy key").closest("tr");
|
||||
if (!row) throw new Error("missing credential row");
|
||||
const copy = within(row).getByRole("button", { name: "Copy public key" });
|
||||
await fireEvent.click(copy);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(within(row).getByRole("button", {
|
||||
name: "Loading…",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Generate credential",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Add pinned key",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
await fireEvent.click(within(row).getByRole("button", { name: "Loading…" }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
publicKeyResponse.resolve(new Response(null, { status: 500 }));
|
||||
const alert = await within(row).findByRole("alert");
|
||||
expect(alert.textContent).toContain("Unable to load this public key");
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => {
|
||||
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
Promise.resolve(Response.json({
|
||||
...hostTrust,
|
||||
host_key: "ssh-ed25519 AAAA-new",
|
||||
fingerprint: "SHA256:host-new",
|
||||
current_revision: 5,
|
||||
}))
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
renderPage();
|
||||
|
||||
const row = screen.getByText("github.com:22").closest("tr");
|
||||
if (!row) throw new Error("missing host trust row");
|
||||
const trigger = within(row).getByRole("button", { name: "Rotate key" });
|
||||
trigger.focus();
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
const hostname = screen.getByRole("textbox", { name: "Hostname" });
|
||||
const port = screen.getByRole("spinbutton", { name: "Port" });
|
||||
const hostKeyInput = screen.getByRole("textbox", {
|
||||
name: /^OpenSSH public host key/,
|
||||
});
|
||||
expect((hostname as HTMLInputElement).disabled).toBe(true);
|
||||
expect((port as HTMLInputElement).disabled).toBe(true);
|
||||
expect(screen.getByText(/keeps the endpoint fixed/).textContent).toContain(
|
||||
"may also select it automatically",
|
||||
);
|
||||
expect(screen.getByText("Current fingerprint").parentElement?.textContent)
|
||||
.toContain("SHA256:host-current");
|
||||
await waitFor(() => expect(document.activeElement).toBe(hostKeyInput));
|
||||
|
||||
await fireEvent.input(hostKeyInput, {
|
||||
target: { value: "ssh-ed25519 AAAA-new" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save new key" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
|
||||
expect(body.hostname).toBe("github.com");
|
||||
expect(body.port).toBe(22);
|
||||
expect(body.expected_revision).toBe(4);
|
||||
expect(await within(row).findByText(/Rotated the key for github.com:22/)).not
|
||||
.toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "Rotate key" }),
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,92 @@
|
||||
import {
|
||||
loadRepositoryAccessJson,
|
||||
loadRepositoryAccessPermissionGate,
|
||||
loadRepositoryAccessSection,
|
||||
} from "../../src/lib/workspace/api/repository-access-loader.ts";
|
||||
import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts";
|
||||
import { load as loadRepositoryAccessPage } from "../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts";
|
||||
|
||||
type HttpFailure = { status?: number; body?: { message?: string } };
|
||||
|
||||
type LoadedPage = {
|
||||
accessProjection: unknown;
|
||||
accessProjectionError: string | null;
|
||||
credentials: Array<{ credential_id: string }>;
|
||||
credentialsError: string | null;
|
||||
publicKeys: Array<{ credential_id: string }>;
|
||||
defaultPublicKeyError: string | null;
|
||||
hostTrusts: Array<{ host_trust_id: string }>;
|
||||
hostTrustsError: string | null;
|
||||
};
|
||||
|
||||
const projectionFixture = {
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 1,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [],
|
||||
};
|
||||
const credentialFixture = {
|
||||
credential_id: "workspace-default",
|
||||
workspace_id: "workspace-1",
|
||||
name: "Workspace default",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
current_revision: 1,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
referenced_repositories: [],
|
||||
};
|
||||
const publicKeyFixture = {
|
||||
credential_id: "workspace-default",
|
||||
current_revision: 1,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
public_key: "ssh-ed25519 AAAA-workspace-default",
|
||||
};
|
||||
const hostTrustFixture = {
|
||||
host_trust_id: "github-com",
|
||||
workspace_id: "workspace-1",
|
||||
hostname: "github.com",
|
||||
port: 22,
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA-host",
|
||||
fingerprint: "SHA256:host",
|
||||
current_revision: 1,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
referenced_repositories: [],
|
||||
};
|
||||
|
||||
async function loadPageWithFailure(failedSuffix: string): Promise<LoadedPage> {
|
||||
const fetcher = (input: RequestInfo | URL): Promise<Response> => {
|
||||
const path = String(input);
|
||||
if (path.endsWith(failedSuffix)) {
|
||||
return Promise.resolve(new Response(null, { status: 503 }));
|
||||
}
|
||||
if (path.endsWith("/settings/repository-access")) {
|
||||
return Promise.resolve(Response.json(projectionFixture));
|
||||
}
|
||||
if (path.endsWith("/credentials")) {
|
||||
return Promise.resolve(Response.json([credentialFixture]));
|
||||
}
|
||||
if (path.endsWith("/credentials/workspace-default/public-key")) {
|
||||
return Promise.resolve(Response.json(publicKeyFixture));
|
||||
}
|
||||
if (path.endsWith("/host-trusts")) {
|
||||
return Promise.resolve(Response.json([hostTrustFixture]));
|
||||
}
|
||||
throw new Error(`unexpected loader request: ${path}`);
|
||||
};
|
||||
return await (loadRepositoryAccessPage as unknown as (event: {
|
||||
fetch: typeof fetch;
|
||||
params: { workspaceId: string };
|
||||
}) => Promise<LoadedPage>)({
|
||||
fetch: fetcher as typeof fetch,
|
||||
params: { workspaceId: "workspace-1" },
|
||||
});
|
||||
}
|
||||
|
||||
async function captureHttpFailure(
|
||||
run: () => Promise<unknown>,
|
||||
expectedStatus: number,
|
||||
@@ -30,6 +111,66 @@ async function captureHttpFailure(
|
||||
throw new Error(`expected bounded ${expectedStatus} error`);
|
||||
}
|
||||
|
||||
Deno.test("Repository Access page loader preserves successful siblings for every section failure", async () => {
|
||||
const projectionFailure = await loadPageWithFailure(
|
||||
"/settings/repository-access",
|
||||
);
|
||||
if (
|
||||
projectionFailure.accessProjection !== null ||
|
||||
!projectionFailure.accessProjectionError
|
||||
) {
|
||||
throw new Error(
|
||||
"projection failure was not contained to Repository bindings",
|
||||
);
|
||||
}
|
||||
if (
|
||||
projectionFailure.credentials.length !== 1 ||
|
||||
projectionFailure.hostTrusts.length !== 1
|
||||
) {
|
||||
throw new Error("projection failure hid a successful sibling section");
|
||||
}
|
||||
|
||||
const credentialFailure = await loadPageWithFailure("/credentials");
|
||||
if (
|
||||
credentialFailure.credentials.length !== 0 ||
|
||||
!credentialFailure.credentialsError
|
||||
) {
|
||||
throw new Error("credential failure was not contained to SSH credentials");
|
||||
}
|
||||
if (
|
||||
!credentialFailure.accessProjection ||
|
||||
credentialFailure.hostTrusts.length !== 1
|
||||
) {
|
||||
throw new Error("credential failure hid a successful sibling section");
|
||||
}
|
||||
|
||||
const hostFailure = await loadPageWithFailure("/host-trusts");
|
||||
if (hostFailure.hostTrusts.length !== 0 || !hostFailure.hostTrustsError) {
|
||||
throw new Error("host-trust failure was not contained to pinned host keys");
|
||||
}
|
||||
if (!hostFailure.accessProjection || hostFailure.credentials.length !== 1) {
|
||||
throw new Error("host-trust failure hid a successful sibling section");
|
||||
}
|
||||
|
||||
const publicKeyFailure = await loadPageWithFailure(
|
||||
"/credentials/workspace-default/public-key",
|
||||
);
|
||||
if (
|
||||
publicKeyFailure.publicKeys.length !== 0 ||
|
||||
!publicKeyFailure.defaultPublicKeyError
|
||||
) {
|
||||
throw new Error(
|
||||
"public-key failure was not contained to its credential row",
|
||||
);
|
||||
}
|
||||
if (
|
||||
publicKeyFailure.credentials.length !== 1 ||
|
||||
publicKeyFailure.hostTrusts.length !== 1
|
||||
) {
|
||||
throw new Error("public-key failure hid successful sections");
|
||||
}
|
||||
});
|
||||
|
||||
for (const status of [401, 403]) {
|
||||
Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => {
|
||||
let requests = 0;
|
||||
@@ -93,6 +234,37 @@ Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository Access permission gate contains projection failure so sibling sections can load", async () => {
|
||||
const result = await loadRepositoryAccessPermissionGate(
|
||||
() => Promise.resolve(new Response(null, { status: 503 })),
|
||||
"/api/w/workspace-1/settings/repository-access",
|
||||
(value) => value,
|
||||
"Repository bindings",
|
||||
);
|
||||
if (result.data !== null) {
|
||||
throw new Error("failed permission projection must not expose data");
|
||||
}
|
||||
if (result.error !== "Repository Access request failed with status 503.") {
|
||||
throw new Error(
|
||||
`unexpected contained projection error: ${String(result.error)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository Access permission gate still aborts on forbidden access", async () => {
|
||||
await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessPermissionGate(
|
||||
() => Promise.resolve(new Response(null, { status: 403 })),
|
||||
"/api/w/workspace-1/settings/repository-access",
|
||||
(value) => value,
|
||||
"Repository bindings",
|
||||
),
|
||||
403,
|
||||
"Repository Access is unavailable for this account.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access section loader contains one failed sibling", async () => {
|
||||
const result = await loadRepositoryAccessSection(
|
||||
() => Promise.resolve(new Response(null, { status: 503 })),
|
||||
|
||||
+12
-7
@@ -67,6 +67,7 @@ test("Repository Access authorizes first and preserves independently available s
|
||||
"credentialsError",
|
||||
"hostTrustsError",
|
||||
"defaultPublicKeyError",
|
||||
"accessProjectionError",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
@@ -143,7 +144,7 @@ test("Repository Access explains automatic application without inventing a bindi
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access opens one scoped form with validation, cancellation, and pending fences", () => {
|
||||
test("Repository Access keeps mutation forms disclosed and operation state scoped", () => {
|
||||
for (
|
||||
const token of [
|
||||
"credentialForm === 'generate'",
|
||||
@@ -156,16 +157,20 @@ test("Repository Access opens one scoped form with validation, cancellation, and
|
||||
"validateHostTrust",
|
||||
'class="field-error"',
|
||||
"aria-invalid",
|
||||
"disabled={busy}",
|
||||
"pendingOperations",
|
||||
"credentialFormNotice",
|
||||
"credentialRowNotices",
|
||||
"hostFormNotice",
|
||||
"hostRowNotices",
|
||||
">Cancel</button>",
|
||||
"Generating…",
|
||||
"Importing…",
|
||||
"Rotating…",
|
||||
"Saving…",
|
||||
]
|
||||
) {
|
||||
assert(source.includes(token), `missing scoped form behavior ${token}`);
|
||||
assert(source.includes(token), `missing scoped form structure ${token}`);
|
||||
}
|
||||
assert(
|
||||
!source.includes("disabled={busy}") && !source.includes("activeOperation"),
|
||||
"unrelated controls must not share page-wide mutation state",
|
||||
);
|
||||
const permanentFormStart = source.indexOf("<form");
|
||||
const generateDisclosure = source.indexOf(
|
||||
"{#if credentialForm === 'generate'}",
|
||||
Reference in New Issue
Block a user