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:
@@ -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 -->
|
||||
|
||||
187
frontend/src/lib/components/security/WizardContent.svelte
Normal file
187
frontend/src/lib/components/security/WizardContent.svelte
Normal 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 -->
|
||||
Reference in New Issue
Block a user