fix(ui): render KeyRecoveryWizard into body portal to avoid layout margin

Modal now mounts at document.body level via svelte mount/unmount.
Extracted WizardContent.svelte for portal rendering.
Eliminates 32px margin from page layout wrapper
(max-w-7xl/space-y-6/px-4 container).

Added KeyRecoveryWizard as portal wrapper, WizardContent as
the actual modal component.
This commit is contained in:
2026-07-06 19:00:24 +03:00
parent 6db49e45a6
commit e7dbee563f
3 changed files with 222 additions and 232 deletions

View File

@@ -2,9 +2,11 @@
<!-- @BRIEF Key recovery wizard — guides admin through fixing encrypted secrets after ENCRYPTION_KEY change. -->
<!-- @RELATION BINDS_TO -> [KeyRecoveryModel] -->
<script lang="ts">
import { mount, unmount } from "svelte";
import { t } from "$lib/i18n/index.svelte.js";
import { KeyRecoveryModel } from "$lib/models/KeyRecoveryModel.svelte";
import type { EncryptionHealthItem, RecoveryTab } from "../../../types/encryptionRecovery";
import WizardContent from "./WizardContent.svelte";
let {
show = false,
@@ -13,245 +15,46 @@
const model = new KeyRecoveryModel();
let selectedTab: RecoveryTab = $state("llm_provider");
let portalEl: HTMLDivElement | null = $state(null);
$effect(() => {
if (show && model.state === "idle") {
model.scan();
if (show && !portalEl) {
portalEl = document.createElement("div");
document.body.appendChild(portalEl);
mount(WizardContent, {
target: portalEl,
props: {
model,
onclose: () => {
handleClose();
},
},
});
if (model.state === "idle") {
model.scan();
}
}
if (!show && portalEl) {
unmount(portalEl, { outro: false });
portalEl.remove();
portalEl = null;
}
return () => {
if (portalEl) {
unmount(portalEl, { outro: false });
portalEl.remove();
portalEl = null;
}
};
});
function handleClose() {
model.dismiss();
onclose();
}
function itemTypeLabel(type: string): string {
const k = `encryption_recovery_${type === "llm_provider" ? "llm" : type === "database_connection" ? "db" : "git"}_tab`;
return t.settings?.[k] || type;
}
function itemLocation(item: EncryptionHealthItem): string {
switch (item.type) {
case "llm_provider":
return `${item.metadata?.provider_type ?? "?"}${item.metadata?.base_url ?? "?"}`;
case "database_connection":
return `${item.metadata?.host ?? "?"}/${item.metadata?.database ?? "?"} (${item.metadata?.username ?? "?"})`;
default:
return "";
}
}
function itemIdType(item: EncryptionHealthItem): string {
return `${item.type}#${item.id}`;
}
function brokenByType(type: string): EncryptionHealthItem[] {
return model.brokenItems.filter(i => i.type === type);
}
const tabFilters: { key: RecoveryTab; brokenCount: number }[] = $derived([
{ key: "llm_provider" as RecoveryTab, brokenCount: brokenByType("llm_provider").length },
{ key: "database_connection" as RecoveryTab, brokenCount: brokenByType("database_connection").length },
{ key: "profile_git_token" as RecoveryTab, brokenCount: brokenByType("profile_git_token").length },
]);
const brokenFiltered: EncryptionHealthItem[] = $derived(
brokenByType(selectedTab)
);
const recoverableTypes: Set<string> = new Set(["llm_provider", "database_connection"]);
</script>
{#if show && model.state !== "idle"}
<div
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
>
<div class="bg-surface-card rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 class="text-lg font-semibold text-text">
{model.state === "scanning"
? t.settings?.encryption_recovery_scanning || "Scanning..."
: model.state === "healthy"
? t.settings?.encryption_recovery_healthy || "All credentials healthy"
: model.state === "complete"
? t.settings?.encryption_recovery_complete || "Recovery complete"
: t.settings?.encryption_recovery_title || "Encrypted credentials need re-entry"}
</h2>
<button
class="text-text-muted hover:text-text text-xl leading-none"
onclick={handleClose}
aria-label={t.settings?.encryption_recovery_close || "Close"}
>×</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
<!-- Scanning -->
{#if model.state === "scanning"}
<div class="flex items-center gap-3 py-8 justify-center">
<span class="inline-block w-5 h-5 border-2 border-border-strong border-t-transparent rounded-full animate-spin"></span>
<span class="text-text-muted">{t.settings?.encryption_recovery_scanning || "Checking all stored secrets..."}</span>
</div>
{/if}
<!-- Error -->
{#if model.state === "error"}
<div class="bg-destructive-light border border-destructive-ring text-destructive px-4 py-3 rounded">
{model.error || "Scan failed"}
</div>
<button
class="rounded bg-primary px-4 py-2 text-sm text-white"
onclick={() => model.scan()}
>{t.settings?.encryption_recovery_retry || "Retry scan"}</button>
{/if}
<!-- Healthy -->
{#if model.state === "healthy"}
<div class="flex items-center gap-3 py-4">
<span class="text-success text-2xl"></span>
<div>
<p class="text-text font-medium">{t.settings?.encryption_recovery_healthy || "All known encrypted secrets can be decrypted."}</p>
<p class="text-text-muted text-sm">
{t.settings?.encryption_recovery_key_fingerprint || "Key fingerprint"}: <code class="font-mono">{model.health?.key_fingerprint ?? "?"}</code>
</p>
</div>
</div>
{/if}
<!-- Needs Recovery / Editing / Partial / Saving / Complete -->
{#if ["needs_recovery", "editing", "partial_success", "saving", "complete"].includes(model.state)}
<!-- Summary -->
<div class="text-sm text-text-muted space-y-1">
<p>{t.settings?.encryption_recovery_key_fingerprint || "Key fingerprint"}: <code class="font-mono text-xs">{model.health?.key_fingerprint ?? "?"}</code></p>
<p>{t.settings?.encryption_recovery_auth_vs_encryption || "Changing AUTH_SECRET_KEY logs users out. Changing ENCRYPTION_KEY requires re-entering stored secrets or running re-encryption."}</p>
</div>
<!-- Tabs -->
<div class="flex gap-1 border-b border-border">
{#each tabFilters as tab}
<button
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors
{selectedTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text'}"
onclick={() => { selectedTab = tab.key; }}
>
{itemTypeLabel(tab.key)}
{#if tab.brokenCount > 0}
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-destructive-light text-destructive">{tab.brokenCount}</span>
{/if}
</button>
{/each}
</div>
<!-- Item list per tab -->
<div class="space-y-3">
{#each brokenFiltered as item (itemIdType(item))}
<div class="rounded-lg border border-border bg-surface-muted p-3" class:border-destructive-ring={model.failedIds.has(item.id)}>
<div class="flex items-center justify-between mb-1">
<div>
<span class="font-medium text-text">{item.label}</span>
<span class="ml-2 text-xs text-text-muted">{itemTypeLabel(item.type)}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-destructive-light text-destructive">
{t.settings?.encryption_recovery_cannot_decrypt || "⚠ cannot decrypt"}
</span>
</div>
<div class="text-xs text-text-muted mb-2">{itemLocation(item)}</div>
{#if model.state === "editing" && recoverableTypes.has(item.type)}
<div class="flex gap-2 mt-2">
<input
type="password"
class="flex-1 rounded border border-border-strong px-2 py-1 text-sm"
placeholder={item.requires[0] === "api_key"
? t.settings?.encryption_recovery_reenter_key || "Enter new API key..."
: t.settings?.encryption_recovery_reenter_password || "Enter new password..."}
value={model.secretValues[item.id] || ""}
oninput={(e: Event) => {
const t = e.target as HTMLInputElement;
model.setSecretValue(item.id, t.value);
}}
/>
{#if model.savingIds.has(item.id)}
<span class="text-sm text-text-muted self-center">{t.settings?.encryption_recovery_saving || "Saving..."}</span>
{:else if model.savedIds.has(item.id)}
<span class="text-sm text-success self-center">{t.common?.saved || "Saved"}</span>
{:else if model.failedIds.has(item.id)}
<span class="text-sm text-destructive self-center">{t.common?.failed || "Failed"}</span>
{/if}
</div>
{:else if item.type === "profile_git_token"}
<p class="text-xs text-text-subtle mt-1">
{t.settings?.encryption_recovery_git_token_hint || "Git tokens are personal. Each user must open Profile → Git token and enter a new PAT."}
</p>
{/if}
</div>
{/each}
</div>
{#if brokenFiltered.length === 0}
<p class="text-text-muted text-sm">{t.settings?.encryption_recovery_empty_category || "No broken items in this category."}</p>
{/if}
{/if}
<!-- Recovery info panel -->
{#if model.state === "needs_recovery"}
<div class="rounded-lg bg-surface-muted border border-border p-3 text-sm">
<p class="font-medium text-text mb-1">{t.settings?.encryption_recovery_recommended || "Recommended if old key is available:"}</p>
<code class="block text-xs font-mono text-text-muted bg-surface-card p-2 rounded">
{t.settings?.encryption_recovery_reencrypt_cmd || "OLD_ENCRYPTION_KEY=<old> NEW_ENCRYPTION_KEY=<new> \\\n python -m src.scripts.reencrypt --dry-run"}
</code>
</div>
{/if}
{#if model.state === "partial_success"}
<div class="rounded-lg bg-warning-light border border-warning-DEFAULT p-3 text-sm text-warning">
{t.settings?.encryption_recovery_partial_success || "Some secrets saved, some failed. Check failed items above and re-enter."}
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-3 border-t border-border bg-surface-muted">
<div class="flex gap-2">
<button
class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition"
onclick={() => model.rescan()}
disabled={model.state === "scanning" || model.state === "saving"}
>{t.settings?.encryption_recovery_rescan || "Rescan"}</button>
</div>
<div class="flex gap-2">
{#if model.state === "needs_recovery"}
<button
class="rounded bg-primary px-4 py-1.5 text-sm text-white hover:bg-primary-hover transition"
onclick={() => model.startEditing()}
>{t.settings?.encryption_recovery_enter_values || "Enter replacement values"}</button>
{:else if model.state === "editing"}
<button
class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition"
onclick={() => model.cancelEditing()}
>{t.settings?.encryption_recovery_cancel || "Cancel"}</button>
<button
class="rounded bg-primary px-4 py-1.5 text-sm text-white hover:bg-primary-hover transition disabled:opacity-50"
onclick={() => model.saveSecrets()}
disabled={Object.values(model.secretValues).every(v => !v?.trim())}
>{t.settings?.encryption_recovery_save_secrets || "Save entered secrets"}</button>
{:else if model.state === "saving"}
<button disabled class="rounded bg-primary px-4 py-1.5 text-sm text-white opacity-50">
{t.settings?.encryption_recovery_saving || "Saving..."}
</button>
{/if}
<button
class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition"
onclick={handleClose}
>{t.settings?.encryption_recovery_close || "Close"}</button>
</div>
</div>
</div>
</div>
{/if}
<!-- #endregion KeyRecoveryWizard -->

View File

@@ -0,0 +1,187 @@
<!-- #region WizardContent [C:3] [TYPE Component] [SEMANTICS security,encryption,recovery,modal] -->
<!-- @BRIEF Modal content for Key Recovery Wizard — mounted into body portal. -->
<!-- @RELATION BINDS_TO -> [KeyRecoveryModel] -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
import type { KeyRecoveryModel } from "$lib/models/KeyRecoveryModel.svelte";
import type { EncryptionHealthItem, RecoveryTab } from "../../../types/encryptionRecovery";
let {
model,
onclose = () => {},
}: { model: KeyRecoveryModel; onclose?: () => void } = $props();
let selectedTab: RecoveryTab = $state("llm_provider");
function itemTypeLabel(type: string): string {
const k = `encryption_recovery_${type === "llm_provider" ? "llm" : type === "database_connection" ? "db" : "git"}_tab`;
return t.settings?.[k] || type;
}
function itemLocation(item: EncryptionHealthItem): string {
switch (item.type) {
case "llm_provider":
return `${item.metadata?.provider_type ?? "?"}${item.metadata?.base_url ?? "?"}`;
case "database_connection":
return `${item.metadata?.host ?? "?"}/${item.metadata?.database ?? "?"} (${item.metadata?.username ?? "?"})`;
default:
return "";
}
}
function itemIdType(item: EncryptionHealthItem): string {
return `${item.type}#${item.id}`;
}
function brokenByType(type: string): EncryptionHealthItem[] {
return model.brokenItems.filter(i => i.type === type);
}
const tabFilters: { key: RecoveryTab; brokenCount: number }[] = $derived([
{ key: "llm_provider" as RecoveryTab, brokenCount: brokenByType("llm_provider").length },
{ key: "database_connection" as RecoveryTab, brokenCount: brokenByType("database_connection").length },
{ key: "profile_git_token" as RecoveryTab, brokenCount: brokenByType("profile_git_token").length },
]);
const brokenFiltered: EncryptionHealthItem[] = $derived(brokenByType(selectedTab));
const recoverableTypes: Set<string> = new Set(["llm_provider", "database_connection"]);
</script>
<div class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center">
<div class="bg-surface-card rounded-lg shadow-xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 class="text-lg font-semibold text-text">
{model.state === "scanning"
? t.settings?.encryption_recovery_scanning || "Scanning..."
: model.state === "healthy"
? t.settings?.encryption_recovery_healthy || "All credentials healthy"
: model.state === "complete"
? t.settings?.encryption_recovery_complete || "Recovery complete"
: t.settings?.encryption_recovery_title || "Encrypted credentials need re-entry"}
</h2>
<button
class="text-text-muted hover:text-text text-xl leading-none"
onclick={() => onclose()}
aria-label={t.settings?.encryption_recovery_close || "Close"}
>×</button>
</div>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{#if model.state === "scanning"}
<div class="flex items-center gap-3 py-8 justify-center">
<span class="inline-block w-5 h-5 border-2 border-border-strong border-t-transparent rounded-full animate-spin"></span>
<span class="text-text-muted">{t.settings?.encryption_recovery_scanning || "Checking..."}</span>
</div>
{/if}
{#if model.state === "error"}
<div class="bg-destructive-light border border-destructive-ring text-destructive px-4 py-3 rounded">{model.error || "Scan failed"}</div>
<button class="rounded bg-primary px-4 py-2 text-sm text-white" onclick={() => model.scan()}>{t.settings?.encryption_recovery_retry || "Retry"}</button>
{/if}
{#if model.state === "healthy"}
<div class="flex items-center gap-3 py-4">
<span class="text-success text-2xl"></span>
<div>
<p class="text-text font-medium">{t.settings?.encryption_recovery_healthy || "All known encrypted secrets can be decrypted."}</p>
<p class="text-text-muted text-sm">{t.settings?.encryption_recovery_key_fingerprint || "Key fingerprint"}: <code class="font-mono">{model.health?.key_fingerprint ?? "?"}</code></p>
</div>
</div>
{/if}
{#if ["needs_recovery", "editing", "partial_success", "saving", "complete"].includes(model.state as string)}
<div class="text-sm text-text-muted space-y-1">
<p>{t.settings?.encryption_recovery_key_fingerprint || "Key fingerprint"}: <code class="font-mono text-xs">{model.health?.key_fingerprint ?? "?"}</code></p>
<p>{t.settings?.encryption_recovery_auth_vs_encryption || "Changing AUTH_SECRET_KEY..."}</p>
</div>
<div class="flex gap-1 border-b border-border">
{#each tabFilters as tab}
<button
class="px-3 py-2 text-sm font-medium border-b-2 transition-colors {selectedTab === tab.key ? 'border-primary text-primary' : 'border-transparent text-text-muted hover:text-text'}"
onclick={() => { selectedTab = tab.key; }}
>
{itemTypeLabel(tab.key)}
{#if tab.brokenCount > 0}
<span class="ml-1 px-1.5 py-0.5 text-xs rounded-full bg-destructive-light text-destructive">{tab.brokenCount}</span>
{/if}
</button>
{/each}
</div>
<div class="space-y-3">
{#each brokenFiltered as item (itemIdType(item))}
<div class="rounded-lg border border-border bg-surface-muted p-3" class:border-destructive-ring={model.failedIds.has(item.id)}>
<div class="flex items-center justify-between mb-1">
<div>
<span class="font-medium text-text">{item.label}</span>
<span class="ml-2 text-xs text-text-muted">{itemTypeLabel(item.type)}</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-full bg-destructive-light text-destructive">{t.settings?.encryption_recovery_cannot_decrypt || "⚠ cannot decrypt"}</span>
</div>
<div class="text-xs text-text-muted mb-2">{itemLocation(item)}</div>
{#if model.state === "editing" && recoverableTypes.has(item.type)}
<div class="flex gap-2 mt-2">
<input
type="password"
class="flex-1 rounded border border-border-strong px-2 py-1 text-sm"
placeholder={item.requires[0] === "api_key" ? t.settings?.encryption_recovery_reenter_key || "Enter API key..." : t.settings?.encryption_recovery_reenter_password || "Enter password..."}
value={model.secretValues[item.id] || ""}
oninput={(e: Event) => { const el = e.target as HTMLInputElement; model.setSecretValue(item.id, el.value); }}
/>
{#if model.savingIds.has(item.id)}
<span class="text-sm text-text-muted self-center">{t.settings?.encryption_recovery_saving || "Saving..."}</span>
{:else if model.savedIds.has(item.id)}
<span class="text-sm text-success self-center"></span>
{:else if model.failedIds.has(item.id)}
<span class="text-sm text-destructive self-center"></span>
{/if}
</div>
{:else if item.type === "profile_git_token"}
<p class="text-xs text-text-subtle mt-1">{t.settings?.encryption_recovery_git_token_hint || "Git tokens are personal..."}</p>
{/if}
</div>
{/each}
</div>
{#if brokenFiltered.length === 0}
<p class="text-text-muted text-sm">{t.settings?.encryption_recovery_empty_category || "No broken items in this category."}</p>
{/if}
{/if}
{#if model.state === "needs_recovery"}
<div class="rounded-lg bg-surface-muted border border-border p-3 text-sm">
<p class="font-medium text-text mb-1">{t.settings?.encryption_recovery_recommended || "Recommended if old key is available:"}</p>
<code class="block text-xs font-mono text-text-muted bg-surface-card p-2 rounded">{t.settings?.encryption_recovery_reencrypt_cmd || "..."}</code>
</div>
{/if}
{#if model.state === "partial_success"}
<div class="rounded-lg bg-warning-light border border-warning-DEFAULT p-3 text-sm text-warning">{t.settings?.encryption_recovery_partial_success || "Some saved, some failed."}</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-3 border-t border-border bg-surface-muted">
<div class="flex gap-2">
<button class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition" onclick={() => model.rescan()} disabled={model.state === "scanning" || model.state === "saving"}>{t.settings?.encryption_recovery_rescan || "Rescan"}</button>
</div>
<div class="flex gap-2">
{#if model.state === "needs_recovery"}
<button class="rounded bg-primary px-4 py-1.5 text-sm text-white hover:bg-primary-hover transition" onclick={() => model.startEditing()}>{t.settings?.encryption_recovery_enter_values || "Enter values"}</button>
{:else if model.state === "editing"}
<button class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition" onclick={() => model.cancelEditing()}>{t.settings?.encryption_recovery_cancel || "Cancel"}</button>
<button class="rounded bg-primary px-4 py-1.5 text-sm text-white hover:bg-primary-hover transition disabled:opacity-50" onclick={() => model.saveSecrets()} disabled={Object.values(model.secretValues).every(v => !v?.trim())}>{t.settings?.encryption_recovery_save_secrets || "Save"}</button>
{:else if model.state === "saving"}
<button disabled class="rounded bg-primary px-4 py-1.5 text-sm text-white opacity-50">{t.settings?.encryption_recovery_saving || "Saving..."}</button>
{/if}
<button class="rounded px-3 py-1.5 text-sm border border-border hover:bg-surface-card transition" onclick={() => onclose()}>{t.settings?.encryption_recovery_close || "Close"}</button>
</div>
</div>
</div>
</div>
<!-- #endregion WizardContent -->