fix(web): fence repository access route state

This commit is contained in:
2026-09-24 19:13:43 +09:00
parent bec3ed9515
commit 0537894768
2 changed files with 304 additions and 63 deletions
@@ -47,7 +47,8 @@
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key])) Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
); );
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts)); let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
const accessProjection = untrack(() => data.accessProjection); let accessProjection = $state(untrack(() => data.accessProjection));
let pageEpoch = 0;
let pendingOperations = $state<string[]>([]); let pendingOperations = $state<string[]>([]);
let rotateCredentialId = $state<string | null>(null); let rotateCredentialId = $state<string | null>(null);
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
@@ -104,6 +105,52 @@
hostExpectedRevision === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null hostExpectedRevision === null ? null : hostTrusts.find((entry) => entry.host_trust_id === hostTrustId) ?? null
); );
function resetPageState(next: PageProps['data']): void {
pageEpoch += 1;
credentials = sortCredentials(next.credentials);
publicKeys = Object.fromEntries(next.publicKeys.map((key) => [key.credential_id, key]));
hostTrusts = next.hostTrusts;
accessProjection = next.accessProjection;
pendingOperations = [];
rotateCredentialId = null;
copiedCredentialId = null;
credentialNotice = null;
credentialFormNotice = null;
credentialRowNotices = {};
hostNotice = null;
hostFormNotice = null;
hostRowNotices = {};
publicKeyNotices = {};
credentialForm = null;
generateCredentialId = '';
generateCredentialName = '';
generateErrors = {};
credentialId = '';
credentialName = '';
privateKey = '';
passphrase = '';
importErrors = {};
rotatePrivateKey = '';
rotatePassphrase = '';
rotateErrors = {};
credentialReturnFocus = null;
hostEditorOpen = false;
hostTrustId = '';
hostname = '';
port = 22;
hostKey = '';
hostExpectedRevision = null;
hostErrors = {};
hostReturnFocus = null;
}
$effect(() => {
const next = data;
untrack(() => resetPageState(next));
});
class StaleRepositoryAccessRequestError extends Error {}
function operationId(prefix: string): string { function operationId(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`; return `${prefix}-${crypto.randomUUID()}`;
} }
@@ -112,24 +159,51 @@
return { tone: 'error', text: error instanceof Error ? error.message : fallback }; return { tone: 'error', text: error instanceof Error ? error.message : fallback };
} }
function pendingOperationKey(operation: string): string {
return `${pageEpoch}:${operation}`;
}
function isPending(operation: string): boolean { function isPending(operation: string): boolean {
return pendingOperations.includes(operation); return pendingOperations.includes(pendingOperationKey(operation));
} }
function startOperation(operation: string): boolean { function startOperation(operation: string): string | null {
if (isPending(operation)) return false; const key = pendingOperationKey(operation);
pendingOperations = [...pendingOperations, operation]; if (pendingOperations.includes(key)) return null;
return true; pendingOperations = [...pendingOperations, key];
return key;
} }
function finishOperation(operation: string): void { function finishOperation(operationKey: string): void {
pendingOperations = pendingOperations.filter((entry) => entry !== operation); pendingOperations = pendingOperations.filter((entry) => entry !== operationKey);
}
function isCurrentOperation(operationKey: string): boolean {
return operationKey.startsWith(`${pageEpoch}:`);
}
function requireCurrentOperation(operationKey: string): void {
if (!isCurrentOperation(operationKey)) throw new StaleRepositoryAccessRequestError();
}
function credentialEditorPending(): boolean {
const credentialRotationPrefix = `${pageEpoch}:rotate-`;
const hostRotationPrefix = `${pageEpoch}:rotate-host-`;
return isPending('generate-credential') || isPending('import-credential') || pendingOperations.some((entry) => entry.startsWith(credentialRotationPrefix) && !entry.startsWith(hostRotationPrefix));
}
function hostEditorPending(): boolean {
return isPending('add-host-trust') || pendingOperations.some((entry) => entry.startsWith(`${pageEpoch}:rotate-host-`));
} }
function currentFocus(): HTMLElement | null { function currentFocus(): HTMLElement | null {
return document.activeElement instanceof HTMLElement ? document.activeElement : null; return document.activeElement instanceof HTMLElement ? document.activeElement : null;
} }
function focusIsInside(element: HTMLElement | undefined): boolean {
return element?.contains(document.activeElement) ?? false;
}
async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise<void> { async function restoreFocus(target: HTMLElement | null, fallbackId?: string): Promise<void> {
await tick(); await tick();
const fallback = fallbackId ? document.getElementById(fallbackId) : null; const fallback = fallbackId ? document.getElementById(fallbackId) : null;
@@ -157,11 +231,16 @@
parse: ((value: unknown) => T) | null, parse: ((value: unknown) => T) | null,
operation: string operation: string
): Promise<T | undefined> { ): Promise<T | undefined> {
const epoch = pageEpoch;
const response = await fetch(`${base}${path}`, { const response = await fetch(`${base}${path}`, {
method, method,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
if (epoch !== pageEpoch) {
await response.body?.cancel();
throw new StaleRepositoryAccessRequestError();
}
if (!response.ok) { if (!response.ok) {
await response.body?.cancel(); await response.body?.cancel();
throw repositoryAccessRequestError(response.status, operation); throw repositoryAccessRequestError(response.status, operation);
@@ -176,27 +255,37 @@
} }
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); const payload = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
return parse(payload); return parse(payload);
} }
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> { async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
const epoch = pageEpoch;
const response = await fetch( const response = await fetch(
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`, `${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
{ headers: { accept: 'application/json' } } { headers: { accept: 'application/json' } }
); );
if (epoch !== pageEpoch) {
await response.body?.cancel();
throw new StaleRepositoryAccessRequestError();
}
if (!response.ok) { if (!response.ok) {
await response.body?.cancel(); await response.body?.cancel();
throw repositoryAccessRequestError(response.status, 'load this public key'); throw repositoryAccessRequestError(response.status, 'load this public key');
} }
const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES); const body = await readBoundedJson(response, REPOSITORY_ACCESS_MAX_RESPONSE_BYTES);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
return parseRepositorySshPublicKey(body); return parseRepositorySshPublicKey(body);
} }
async function refreshPublicKey(credentialId: string): Promise<void> { async function refreshPublicKey(credentialId: string): Promise<void> {
const epoch = pageEpoch;
try { try {
const publicKey = await loadPublicKey(credentialId); const publicKey = await loadPublicKey(credentialId);
if (epoch !== pageEpoch) throw new StaleRepositoryAccessRequestError();
publicKeys = { ...publicKeys, [credentialId]: publicKey }; publicKeys = { ...publicKeys, [credentialId]: publicKey };
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) throw error;
publicKeyNotices = { publicKeyNotices = {
...publicKeyNotices, ...publicKeyNotices,
[credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.') [credentialId]: operationError(error, 'The credential was saved, but its public key could not be loaded. Use Copy public key to retry.')
@@ -218,6 +307,7 @@
async function closeCredentialForms(restore = true) { async function closeCredentialForms(restore = true) {
const returnFocus = credentialReturnFocus; const returnFocus = credentialReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(credentialFormElement);
credentialForm = null; credentialForm = null;
generateCredentialId = ''; generateCredentialId = '';
generateCredentialName = ''; generateCredentialName = '';
@@ -229,7 +319,7 @@
importErrors = {}; importErrors = {};
credentialFormNotice = null; credentialFormNotice = null;
credentialReturnFocus = null; credentialReturnFocus = null;
if (restore) await restoreFocus(returnFocus); if (shouldRestoreFocus) await restoreFocus(returnFocus);
} }
async function generateCredential() { async function generateCredential() {
@@ -238,8 +328,8 @@
await focusFirstInvalid(credentialFormElement); await focusFirstInvalid(credentialFormElement);
return; return;
} }
const operation = 'generate-credential'; const operation = startOperation('generate-credential');
if (!startOperation(operation)) return; if (!operation) return;
credentialFormNotice = null; credentialFormNotice = null;
try { try {
const body: GenerateRepositorySshCredentialRequest = { const body: GenerateRepositorySshCredentialRequest = {
@@ -248,11 +338,15 @@
name: generateCredentialName.trim() name: generateCredentialName.trim()
}; };
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential'); const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential, 'generate this SSH credential');
requireCurrentOperation(operation);
credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]); credentials = sortCredentials([...credentials.filter((item) => item.credential_id !== created.credential_id), created]);
await refreshPublicKey(created.credential_id); await refreshPublicKey(created.credential_id);
requireCurrentOperation(operation);
await closeCredentialForms(); await closeCredentialForms();
requireCurrentOperation(operation);
credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` }; credentialNotice = { tone: 'success', text: `Generated ${created.name}. Copy its public key before configuring the Repository provider.` };
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.'); credentialFormNotice = operationError(error, 'Failed to generate the SSH credential.');
} finally { } finally {
finishOperation(operation); finishOperation(operation);
@@ -260,19 +354,22 @@
} }
async function copyPublicKey(credential: RepositorySshCredential) { async function copyPublicKey(credential: RepositorySshCredential) {
const operation = `copy-${credential.credential_id}`; const operation = startOperation(`copy-${credential.credential_id}`);
if (!startOperation(operation)) return; if (!operation) return;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } }; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Loading public key…' } };
try { try {
const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id); const publicKey = publicKeys[credential.credential_id] ?? await loadPublicKey(credential.credential_id);
requireCurrentOperation(operation);
publicKeys = { ...publicKeys, [credential.credential_id]: publicKey }; publicKeys = { ...publicKeys, [credential.credential_id]: publicKey };
await navigator.clipboard.writeText(publicKey.public_key); await navigator.clipboard.writeText(publicKey.public_key);
requireCurrentOperation(operation);
copiedCredentialId = credential.credential_id; copiedCredentialId = credential.credential_id;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } }; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: { tone: 'success', text: 'Public key copied.' } };
window.setTimeout(() => { window.setTimeout(() => {
if (copiedCredentialId === credential.credential_id) copiedCredentialId = null; if (isCurrentOperation(operation) && copiedCredentialId === credential.credential_id) copiedCredentialId = null;
}, 1500); }, 1500);
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') }; publicKeyNotices = { ...publicKeyNotices, [credential.credential_id]: operationError(error, 'Failed to copy the public key.') };
} finally { } finally {
finishOperation(operation); finishOperation(operation);
@@ -285,8 +382,8 @@
await focusFirstInvalid(credentialFormElement); await focusFirstInvalid(credentialFormElement);
return; return;
} }
const operation = 'import-credential'; const operation = startOperation('import-credential');
if (!startOperation(operation)) return; if (!operation) return;
credentialFormNotice = null; credentialFormNotice = null;
try { try {
const body: CreateRepositorySshCredentialRequest = { const body: CreateRepositorySshCredentialRequest = {
@@ -297,15 +394,21 @@
passphrase: passphrase || null passphrase: passphrase || null
}; };
const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential'); const created = await request('/credentials', 'POST', body, parseRepositorySshCredential, 'import this SSH credential');
requireCurrentOperation(operation);
credentials = sortCredentials([...credentials, created]); credentials = sortCredentials([...credentials, created]);
await refreshPublicKey(created.credential_id); await refreshPublicKey(created.credential_id);
requireCurrentOperation(operation);
await closeCredentialForms(); await closeCredentialForms();
requireCurrentOperation(operation);
credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` }; credentialNotice = { tone: 'success', text: `Imported ${created.name}. The private key and passphrase fields were cleared.` };
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Credential import failed.'); credentialFormNotice = operationError(error, 'Credential import failed.');
} finally { } finally {
privateKey = ''; if (isCurrentOperation(operation)) {
passphrase = ''; privateKey = '';
passphrase = '';
}
finishOperation(operation); finishOperation(operation);
} }
} }
@@ -324,6 +427,7 @@
async function closeCredentialRotation(restore = true) { async function closeCredentialRotation(restore = true) {
const returnFocus = credentialReturnFocus; const returnFocus = credentialReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(rotationFormElement);
const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined; const fallbackId = rotateCredentialId ? `rotate-credential-${rotateCredentialId}` : undefined;
rotateCredentialId = null; rotateCredentialId = null;
rotatePrivateKey = ''; rotatePrivateKey = '';
@@ -331,7 +435,7 @@
rotateErrors = {}; rotateErrors = {};
credentialFormNotice = null; credentialFormNotice = null;
credentialReturnFocus = null; credentialReturnFocus = null;
if (restore) await restoreFocus(returnFocus, fallbackId); if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
} }
async function rotateCredential(credential: RepositorySshCredential) { async function rotateCredential(credential: RepositorySshCredential) {
@@ -340,8 +444,8 @@
await focusFirstInvalid(rotationFormElement); await focusFirstInvalid(rotationFormElement);
return; return;
} }
const operation = `rotate-${credential.credential_id}`; const operation = startOperation(`rotate-${credential.credential_id}`);
if (!startOperation(operation)) return; if (!operation) return;
credentialFormNotice = null; credentialFormNotice = null;
try { try {
const body: RotateRepositorySshCredentialRequest = { const body: RotateRepositorySshCredentialRequest = {
@@ -357,29 +461,36 @@
parseRepositorySshCredential, parseRepositorySshCredential,
`rotate ${credential.name}` `rotate ${credential.name}`
); );
requireCurrentOperation(operation);
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry); credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
await refreshPublicKey(rotated.credential_id); await refreshPublicKey(rotated.credential_id);
requireCurrentOperation(operation);
finishOperation(operation); finishOperation(operation);
await closeCredentialRotation(); await closeCredentialRotation();
requireCurrentOperation(operation);
credentialRowNotices = { credentialRowNotices = {
...credentialRowNotices, ...credentialRowNotices,
[rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` } [rotated.credential_id]: { tone: 'success', text: `Rotated ${rotated.name}. Copy the new public key and update every external provider that uses it.` }
}; };
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialFormNotice = operationError(error, 'Credential rotation failed.'); credentialFormNotice = operationError(error, 'Credential rotation failed.');
} finally { } finally {
rotatePrivateKey = ''; if (isCurrentOperation(operation)) {
rotatePassphrase = ''; rotatePrivateKey = '';
rotatePassphrase = '';
}
finishOperation(operation); finishOperation(operation);
} }
} }
async function deleteCredential(credential: RepositorySshCredential) { async function deleteCredential(credential: RepositorySshCredential) {
if (credential.referenced_repositories.length > 0) return; if (credential.referenced_repositories.length > 0) return;
const operation = `delete-${credential.credential_id}`; const operationName = `delete-${credential.credential_id}`;
if (isPending(operation)) return; if (isPending(operationName)) return;
if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return; if (!confirm(`Delete SSH credential ${credential.name} (${credential.credential_id})? This cannot be undone.`)) return;
if (!startOperation(operation)) return; const operation = startOperation(operationName);
if (!operation) return;
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } }; credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: { tone: 'success', text: 'Deleting credential…' } };
try { try {
const body: DeleteRepositorySshCredentialRequest = { const body: DeleteRepositorySshCredentialRequest = {
@@ -387,12 +498,14 @@
expected_revision: credential.current_revision expected_revision: credential.current_revision
}; };
await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`); await request(`/credentials/${encodeURIComponent(credential.credential_id)}`, 'DELETE', body, null, `delete ${credential.name}`);
requireCurrentOperation(operation);
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id); credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
const remainingPublicKeys = { ...publicKeys }; const remainingPublicKeys = { ...publicKeys };
delete remainingPublicKeys[credential.credential_id]; delete remainingPublicKeys[credential.credential_id];
publicKeys = remainingPublicKeys; publicKeys = remainingPublicKeys;
credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` }; credentialNotice = { tone: 'success', text: `Deleted ${credential.name}.` };
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') }; credentialRowNotices = { ...credentialRowNotices, [credential.credential_id]: operationError(error, 'Credential deletion failed.') };
} finally { } finally {
finishOperation(operation); finishOperation(operation);
@@ -429,6 +542,7 @@
async function closeHostEditor(restore = true) { async function closeHostEditor(restore = true) {
const returnFocus = hostReturnFocus; const returnFocus = hostReturnFocus;
const shouldRestoreFocus = restore && focusIsInside(hostFormElement);
const fallbackId = hostExpectedRevision !== null ? `rotate-host-${hostTrustId}` : undefined; const fallbackId = hostExpectedRevision !== null ? `rotate-host-${hostTrustId}` : undefined;
hostEditorOpen = false; hostEditorOpen = false;
hostTrustId = ''; hostTrustId = '';
@@ -439,7 +553,7 @@
hostErrors = {}; hostErrors = {};
hostFormNotice = null; hostFormNotice = null;
hostReturnFocus = null; hostReturnFocus = null;
if (restore) await restoreFocus(returnFocus, fallbackId); if (shouldRestoreFocus) await restoreFocus(returnFocus, fallbackId);
} }
async function saveHostTrust() { async function saveHostTrust() {
@@ -454,8 +568,8 @@
hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' }; hostFormNotice = { tone: 'error', text: 'This pinned host key changed or was removed. Close the editor and try again.' };
return; return;
} }
const operation = rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust'; const operation = startOperation(rotating ? `rotate-host-${hostTrustId}` : 'add-host-trust');
if (!startOperation(operation)) return; if (!operation) return;
hostFormNotice = null; hostFormNotice = null;
try { try {
const body: PutRepositorySshHostTrustRequest = { const body: PutRepositorySshHostTrustRequest = {
@@ -467,15 +581,18 @@
expected_revision: hostExpectedRevision expected_revision: hostExpectedRevision
}; };
const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key'); const saved = await request('/host-trusts', 'POST', body, parseRepositorySshHostTrust, rotating ? 'rotate this pinned SSH host key' : 'pin this SSH host key');
requireCurrentOperation(operation);
hostTrusts = rotating hostTrusts = rotating
? hostTrusts.map((entry) => entry.host_trust_id === saved.host_trust_id ? saved : entry) ? 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)); : [...hostTrusts, saved].sort((a, b) => a.host_trust_id.localeCompare(b.host_trust_id));
finishOperation(operation); finishOperation(operation);
await closeHostEditor(); await closeHostEditor();
requireCurrentOperation(operation);
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.` }; 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 }; if (rotating) hostRowNotices = { ...hostRowNotices, [saved.host_trust_id]: notice };
else hostNotice = notice; else hostNotice = notice;
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
hostFormNotice = operationError(error, 'Failed to save the pinned host key.'); hostFormNotice = operationError(error, 'Failed to save the pinned host key.');
} finally { } finally {
finishOperation(operation); finishOperation(operation);
@@ -484,10 +601,11 @@
async function deleteHostTrust(hostTrust: RepositorySshHostTrust) { async function deleteHostTrust(hostTrust: RepositorySshHostTrust) {
if (hostTrust.referenced_repositories.length > 0) return; if (hostTrust.referenced_repositories.length > 0) return;
const operation = `delete-host-${hostTrust.host_trust_id}`; const operationName = `delete-host-${hostTrust.host_trust_id}`;
if (isPending(operation)) return; if (isPending(operationName)) 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; 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;
if (!startOperation(operation)) return; const operation = startOperation(operationName);
if (!operation) return;
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } }; hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: { tone: 'success', text: 'Deleting pinned key…' } };
try { try {
const body: DeleteRepositorySshHostTrustRequest = { const body: DeleteRepositorySshHostTrustRequest = {
@@ -495,9 +613,11 @@
expected_revision: hostTrust.current_revision expected_revision: hostTrust.current_revision
}; };
await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`); await request(`/host-trusts/${encodeURIComponent(hostTrust.host_trust_id)}`, 'DELETE', body, null, `delete the pin for ${hostTrust.hostname}:${hostTrust.port}`);
requireCurrentOperation(operation);
hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id); hostTrusts = hostTrusts.filter((entry) => entry.host_trust_id !== hostTrust.host_trust_id);
hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` }; hostNotice = { tone: 'success', text: `Deleted the pin for ${hostTrust.hostname}:${hostTrust.port}.` };
} catch (error) { } catch (error) {
if (error instanceof StaleRepositoryAccessRequestError) return;
hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') }; hostRowNotices = { ...hostRowNotices, [hostTrust.host_trust_id]: operationError(error, 'Host key deletion failed.') };
} finally { } finally {
finishOperation(operation); finishOperation(operation);
@@ -563,8 +683,8 @@
</div> </div>
{#if !data.credentialsError} {#if !data.credentialsError}
<div class="repository-access-actions" aria-label="Add SSH credential"> <div class="repository-access-actions" aria-label="Add SSH credential">
<button type="button" class="secondary" onclick={() => void openCredentialForm('generate')}>Generate credential</button> <button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('generate')}>Generate credential</button>
<button type="button" class="secondary" onclick={() => void openCredentialForm('import')}>Import credential</button> <button type="button" class="secondary" disabled={credentialEditorPending()} onclick={() => void openCredentialForm('import')}>Import credential</button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -606,13 +726,13 @@
</td> </td>
<td> <td>
<div class="repository-access-row-actions"> <div class="repository-access-row-actions">
<button type="button" class="secondary" disabled={isPending(`copy-${credential.credential_id}`)} onclick={() => void copyPublicKey(credential)}> <button type="button" class="secondary" disabled={isPending(`copy-${credential.credential_id}`) || isPending(`rotate-${credential.credential_id}`) || isPending(`delete-${credential.credential_id}`)} onclick={() => void copyPublicKey(credential)}>
{isPending(`copy-${credential.credential_id}`) ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'} {isPending(`copy-${credential.credential_id}`) ? 'Loading…' : copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}
</button> </button>
{#if credential.credential_id !== workspaceDefaultCredentialId} {#if credential.credential_id !== workspaceDefaultCredentialId}
<button id={`rotate-credential-${credential.credential_id}`} type="button" class="secondary" disabled={isPending(`rotate-${credential.credential_id}`)} onclick={() => void openCredentialRotation(credential)}>Rotate</button> <button id={`rotate-credential-${credential.credential_id}`} type="button" class="secondary" disabled={credentialEditorPending() || isPending(`delete-${credential.credential_id}`)} onclick={() => void openCredentialRotation(credential)}>Rotate</button>
{#if credential.referenced_repositories.length === 0} {#if credential.referenced_repositories.length === 0}
<button type="button" class="danger" disabled={isPending(`delete-${credential.credential_id}`)} onclick={() => void deleteCredential(credential)}>Delete</button> <button type="button" class="danger" disabled={isPending(`delete-${credential.credential_id}`) || isPending(`rotate-${credential.credential_id}`)} onclick={() => void deleteCredential(credential)}>Delete</button>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -687,7 +807,7 @@
<h2 id="host-trust-heading">Pinned SSH host keys</h2> <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> <p>Keys used to verify the identity of a specific SSH host and port.</p>
</div> </div>
{#if !data.hostTrustsError}<button type="button" class="secondary" disabled={isPending('add-host-trust')} onclick={() => void openNewHostTrust()}>Add pinned key</button>{/if} {#if !data.hostTrustsError}<button type="button" class="secondary" disabled={hostEditorPending()} onclick={() => void openNewHostTrust()}>Add pinned key</button>{/if}
</div> </div>
{#if hostNotice}<p class="repository-access-notice {hostNotice.tone}" role={hostNotice.tone === 'error' ? 'alert' : 'status'}>{hostNotice.text}</p>{/if} {#if hostNotice}<p class="repository-access-notice {hostNotice.tone}" role={hostNotice.tone === 'error' ? 'alert' : 'status'}>{hostNotice.text}</p>{/if}
@@ -713,8 +833,8 @@
<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>{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> <td>
<div class="repository-access-row-actions"> <div class="repository-access-row-actions">
<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> <button id={`rotate-host-${hostTrust.host_trust_id}`} type="button" class="secondary" disabled={hostEditorPending() || isPending(`delete-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} {#if hostTrust.referenced_repositories.length === 0}<button type="button" class="danger" disabled={isPending(`delete-host-${hostTrust.host_trust_id}`) || isPending(`rotate-host-${hostTrust.host_trust_id}`)} onclick={() => void deleteHostTrust(hostTrust)}>Delete</button>{/if}
</div> </div>
{#if hostRowNotices[hostTrust.host_trust_id]} {#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> <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>
@@ -52,10 +52,13 @@ const hostTrust = {
referenced_repositories: [], referenced_repositories: [],
}; };
function pageData() { function pageData(workspaceId = "workspace-1") {
return { return {
workspaceId: "workspace-1", workspaceId,
credentials: [defaultCredential, deployCredential], credentials: [
{ ...defaultCredential, workspace_id: workspaceId },
{ ...deployCredential, workspace_id: workspaceId },
],
credentialsError: null, credentialsError: null,
publicKeys: [{ publicKeys: [{
credential_id: "workspace-default", credential_id: "workspace-default",
@@ -65,10 +68,10 @@ function pageData() {
public_key: "ssh-ed25519 AAAA-default", public_key: "ssh-ed25519 AAAA-default",
}], }],
defaultPublicKeyError: null, defaultPublicKeyError: null,
hostTrusts: [hostTrust], hostTrusts: [{ ...hostTrust, workspace_id: workspaceId }],
hostTrustsError: null, hostTrustsError: null,
accessProjection: { accessProjection: {
workspace_id: "workspace-1", workspace_id: workspaceId,
config_revision: 1, config_revision: 1,
projection_digest: "sha256:projection", projection_digest: "sha256:projection",
bindings: [], bindings: [],
@@ -91,12 +94,15 @@ function deferred<T>() {
return { promise, resolve }; return { promise, resolve };
} }
function renderPage() { function pageProps(data = pageData()): PageProps {
const props = { return {
data: pageData(), data,
params: { workspaceId: "workspace-1" }, params: { workspaceId: data.workspaceId },
} as unknown as PageProps; } as unknown as PageProps;
return render(RepositoryAccessPage, props); }
function renderPage(data = pageData()) {
return render(RepositoryAccessPage, pageProps(data));
} }
afterEach(() => { afterEach(() => {
@@ -130,7 +136,7 @@ test("opens disclosures with focus, focuses invalid fields, and restores trigger
expect(document.activeElement).toBe(trigger); expect(document.activeElement).toBe(trigger);
}); });
test("fences a deferred submission without disabling unrelated controls and restores focus after success", async () => { test("fences a deferred editor while an unrelated host draft remains active after success", async () => {
const createResponse = deferred<Response>(); const createResponse = deferred<Response>();
const fetchMock = vi.fn((input: RequestInfo | URL) => { const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
@@ -173,15 +179,24 @@ test("fences a deferred submission without disabling unrelated controls and rest
(screen.getByRole("button", { (screen.getByRole("button", {
name: "Import credential", name: "Import credential",
}) as HTMLButtonElement).disabled, }) as HTMLButtonElement).disabled,
).toBe(false); ).toBe(true);
const deployRow = screen.getByText("Deploy key").closest("tr");
if (!deployRow) throw new Error("missing deploy credential row");
expect( expect(
(screen.getByRole("button", { (within(deployRow).getByRole("button", {
name: "Add pinned key", name: "Rotate",
}) as HTMLButtonElement).disabled, }) as HTMLButtonElement).disabled,
).toBe(false); ).toBe(true);
const addHost = screen.getByRole("button", { name: "Add pinned key" });
expect((addHost as HTMLButtonElement).disabled).toBe(false);
await fireEvent.click(screen.getByRole("button", { name: "Generating…" })); await fireEvent.click(screen.getByRole("button", { name: "Generating…" }));
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
await fireEvent.click(addHost);
const hostDraft = screen.getByRole("textbox", { name: "Host trust id" });
await fireEvent.input(hostDraft, { target: { value: "draft-host" } });
hostDraft.focus();
createResponse.resolve(Response.json({ createResponse.resolve(Response.json({
...deployCredential, ...deployCredential,
credential_id: "generated", credential_id: "generated",
@@ -190,7 +205,10 @@ test("fences a deferred submission without disabling unrelated controls and rest
current_revision: 1, current_revision: 1,
})); }));
expect(await screen.findByText("Generated key")).not.toBeNull(); expect(await screen.findByText("Generated key")).not.toBeNull();
await waitFor(() => expect(document.activeElement).toBe(trigger)); expect(screen.getByRole("heading", { name: "Add pinned host key" })).not
.toBeNull();
expect((hostDraft as HTMLInputElement).value).toBe("draft-host");
expect(document.activeElement).toBe(hostDraft);
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
}); });
@@ -231,14 +249,96 @@ test("keeps a deferred row failure on that row while unrelated actions remain en
expect(screen.getAllByRole("alert")).toHaveLength(1); expect(screen.getAllByRole("alert")).toHaveLength(1);
}); });
test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => { test("rerendering for another Workspace resets local state and fences stale responses", async () => {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => const stalePublicKey = deferred<Response>();
Promise.resolve(Response.json({ const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/w/workspace-1/")) return stalePublicKey.promise;
if (url.includes("/api/w/workspace-2/")) {
return Promise.resolve(new Response(null, { status: 500 }));
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { rerender } = renderPage();
await fireEvent.click(
screen.getByRole("button", { name: "Generate credential" }),
);
await fireEvent.input(
screen.getByRole("textbox", { name: "Credential id" }),
{
target: { value: "workspace-a-draft" },
},
);
const workspaceARow = screen.getByText("Deploy key").closest("tr");
if (!workspaceARow) throw new Error("missing Workspace A credential row");
await fireEvent.click(
within(workspaceARow).getByRole("button", { name: "Copy public key" }),
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const workspaceBData = {
...pageData("workspace-2"),
credentials: [
{
...defaultCredential,
workspace_id: "workspace-2",
name: "Workspace B default",
},
{
...deployCredential,
workspace_id: "workspace-2",
credential_id: "workspace-b-deploy",
name: "Workspace B deploy",
},
],
publicKeys: [],
hostTrusts: [{
...hostTrust, ...hostTrust,
host_key: "ssh-ed25519 AAAA-new", workspace_id: "workspace-2",
fingerprint: "SHA256:host-new", host_trust_id: "workspace-b-host",
current_revision: 5, hostname: "git.example.test",
})) }],
accessProjection: {
workspace_id: "workspace-2",
config_revision: 7,
projection_digest: "sha256:workspace-b",
bindings: [],
},
};
await rerender(pageProps(workspaceBData));
expect(await screen.findByText("Workspace B deploy")).not.toBeNull();
expect(screen.queryByText("Deploy key")).toBeNull();
expect(screen.queryByDisplayValue("workspace-a-draft")).toBeNull();
expect(screen.getByText("git.example.test:22")).not.toBeNull();
stalePublicKey.resolve(Response.json({
credential_id: "deploy",
current_revision: 2,
public_key_algorithm: "ssh-ed25519",
public_key_fingerprint: "SHA256:deploy",
public_key: "ssh-ed25519 AAAA-stale",
}));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.queryByText("Public key copied.")).toBeNull();
const workspaceBRow = screen.getByText("Workspace B deploy").closest("tr");
if (!workspaceBRow) throw new Error("missing Workspace B credential row");
await fireEvent.click(
within(workspaceBRow).getByRole("button", { name: "Copy public key" }),
);
expect(await within(workspaceBRow).findByRole("alert")).not.toBeNull();
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
"/api/w/workspace-2/settings/repository-access/credentials/workspace-b-deploy/public-key",
);
});
test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => {
const rotateResponse = deferred<Response>();
const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
rotateResponse.promise
); );
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
renderPage(); renderPage();
@@ -272,6 +372,27 @@ test("locks a rotation to its host and port and discloses explicit and automatic
expect(body.hostname).toBe("github.com"); expect(body.hostname).toBe("github.com");
expect(body.port).toBe(22); expect(body.port).toBe(22);
expect(body.expected_revision).toBe(4); expect(body.expected_revision).toBe(4);
expect(
(screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Add pinned key",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Generate credential",
}) as HTMLButtonElement).disabled,
).toBe(false);
rotateResponse.resolve(Response.json({
...hostTrust,
host_key: "ssh-ed25519 AAAA-new",
fingerprint: "SHA256:host-new",
current_revision: 5,
}));
expect(await within(row).findByText(/Rotated the key for github.com:22/)).not expect(await within(row).findByText(/Rotated the key for github.com:22/)).not
.toBeNull(); .toBeNull();
await waitFor(() => await waitFor(() =>