diff --git a/DEPLOY.md b/DEPLOY.md index bf8e2d89..d646dd73 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -26,6 +26,7 @@ routing. - Multi-user tracking, upstream repo discovery, monitor-all mode - Repo pinning/reordering, themes, ignore system - IndexedDB caching + ETag optimization +- Plaintext settings export and import (client-side only) - MCP server (separate Node.js process, independent of Worker) **What does NOT work without a backend:** @@ -41,6 +42,10 @@ routing. 404 console errors on static hosts. Optionally remove the `report-uri` and `report-to` directives if the noise is unwanted. - **Jira token sealing** (planned) — requires server-side encryption +- **Encrypted-credentials export/import** — the credential seal/unseal endpoints + (`/api/proxy/seal`, `/api/proxy/unseal`) run only on the Worker. Plaintext settings + export and import still work on static-only deploys; only the optional + encrypted-credentials bundle needs the backend. **Security note:** The `public/_headers` file sets Content-Security-Policy and other security headers. Ensure your static host serves these headers — Cloudflare Pages, @@ -64,6 +69,7 @@ PAT instead), and Turnstile is only used by the planned Jira integration. 2. **Update `wrangler.toml`** — Change `pattern = "gh.gordoncode.dev"` to your domain 3. **Set GitHub Actions secrets and variables** — See sections below 4. **Set Cloudflare Worker secrets** — See "Cloudflare Worker Secrets" section below. **Critical:** `ALLOWED_ORIGIN` must exactly match your deployment URL (e.g., `https://your-domain.example.com`). An incorrect value causes all API requests to fail with CORS errors. +5. **Provision the credential-nonce KV namespace** — See "Cloudflare KV Namespaces" section below. Required for the encrypted-credential import feature; skipping it makes every encrypted-credential import fail with a 503 (plaintext-config import still works). **Verify configuration:** Run `pnpm validate:deploy` locally to check that all required Cloudflare Worker secrets are set. In CI, the deploy workflow runs @@ -150,13 +156,44 @@ wrangler secret put ALLOWED_ORIGIN - `GITHUB_CLIENT_SECRET`: the Client Secret from your GitHub OAuth App - `ALLOWED_ORIGIN`: `https://YOUR-DOMAIN` (e.g. `https://gh.gordoncode.dev`) +## Cloudflare KV Namespaces + +The encrypted-credential import feature (`/api/proxy/unseal`) requires a KV namespace +bound as `CREDENTIAL_NONCE_KV`. It backs single-use consumption of credential-bundle +nonces, blocking an unseal-then-reseal renewal of a bundle's 30-day expiry. It holds only +opaque nonce values (never plaintext, ciphertext, or tokens), with a TTL bounded by each +bundle's own expiry window. + +Create the production and preview namespaces: + +```sh +wrangler kv namespace create CREDENTIAL_NONCE_KV +wrangler kv namespace create CREDENTIAL_NONCE_KV --preview +``` + +Each command prints an ID. Add the returned `id` and `preview_id` to the +`[[kv_namespaces]]` block in `wrangler.toml`, replacing the upstream placeholder values: + +```toml +[[kv_namespaces]] +binding = "CREDENTIAL_NONCE_KV" +id = "" +preview_id = "" +``` + +`pnpm validate:deploy` does **not** check KV bindings — it validates Worker secrets and +build-time env vars only — so provision this manually. If the binding is missing or +misconfigured, every encrypted-credential import fails closed with a **503**; the +plaintext-configuration import path is unaffected and still works. + ## Worker API Endpoints | Endpoint | Method | Purpose | |----------|--------|---------| | `/api/oauth/token` | POST | Exchange OAuth authorization code for permanent access token. | | `/api/health` | GET | Health check. Returns `OK`. | -| `/api/proxy/seal` | POST | Encrypt an API token for client-side storage. Requires Turnstile + session. | +| `/api/proxy/seal` | POST | Encrypt (seal) an API token or credential-export bundle for client-side storage. Requires Turnstile + session. | +| `/api/proxy/unseal` | POST | Decrypt (unseal) an encrypted credential-export bundle during import. Reachable pre-authentication (Login-page import). Requires Turnstile + the `CREDENTIAL_NONCE_KV` binding (single-use nonce enforcement). | ### Token Storage Security diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 390ea186..404dedcb 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -52,6 +52,16 @@ GitHub Tracker is a dashboard that aggregates open issues, pull requests, and Gi - [Bookmarking Jira Issues](#bookmarking-jira-issues) - [Disconnecting](#disconnecting-jira) - [Settings Reference](#settings-reference) +- [Exporting and Importing Settings](#exporting-and-importing-settings) + - [Exporting](#exporting) + - [View Preferences Are Included Too](#view-preferences-are-included-too) + - [Including Encrypted Credentials](#including-encrypted-credentials) + - [Importing on the Settings Page](#importing-on-the-settings-page) + - [Importing on the Login Page](#importing-on-the-login-page) + - [The One-Time Code Is Single-Use](#the-one-time-code-is-single-use) + - [Keep the Export File Safe](#keep-the-export-file-safe) + - [Jira Credentials and Staleness](#jira-credentials-and-staleness) + - [Same-Deployment Only](#same-deployment-only) - [Troubleshooting](#troubleshooting) --- @@ -693,7 +703,7 @@ Settings are saved automatically to `localStorage` and persist across sessions. ### View State Settings -These are UI preferences that persist across sessions but are not included in the exported config file. +These are UI preferences that persist across sessions in your browser. Most of them also travel with an exported settings file — see [View Preferences Are Included Too](#view-preferences-are-included-too) for exactly what's included and what isn't. | Setting | Default | Description | |---------|---------|-------------| @@ -707,6 +717,92 @@ These are UI preferences that persist across sessions but are not included in th --- +## Exporting and Importing Settings + +You can export your configuration to a JSON file and import it back later — on the same machine, a new machine, or an incognito window for testing. Import is available both from the Settings page (when you are already signed in) and from the Login page (before you sign in, so a single file can restore your settings *and* sign you in). + +### Exporting + +Go to **Settings > Data > Export** and click **Export**. A dialog opens with two choices: + +- **Export config only** — downloads `github-tracker-settings.json` immediately: your full configuration (repositories, organizations, tracked users, tabs, refresh interval, notification preferences, Jira display settings, and everything else on the Settings page) plus your view preferences (below), with no credentials. +- **Export with encrypted credentials** — see [Including Encrypted Credentials](#including-encrypted-credentials). + +A plaintext export (config only) contains **no credentials**. Your Atlassian email is also omitted (it is personally identifiable and not needed to restore the configuration). Importing a plaintext export restores your settings but does not sign you in — you still authenticate normally. + +### View Preferences Are Included Too + +Every export — plaintext or with credentials — also carries a curated set of view preferences. On import, these are restored automatically alongside your configuration, for whichever GitHub identity that import establishes: + +- Jira custom sort order +- Expand/collapse state and pinned/locked repos, per tab +- Tab filters (including per-tab username filters) and custom tab filter values +- Dependency-group expansion state +- The Show PR Runs and Hide Dependency Dashboard toggles +- Your Ignored Items and Tracked Items lists + +A few things are deliberately left out: + +- Issue/PR **titles and URLs**, and a tracked Jira issue's **status** (e.g. "In Progress"), are never included. On the target machine, tracked GitHub issues/PRs pick up their real title again automatically once the app has fetched their repo's data; tracked Jira issues show a blank title and status, and Ignored items show a blank title, until you interact with them again (re-track or re-ignore) — tracking and ignoring still work either way, since both are keyed by a stable identifier (item ID or Jira key), not the title. +- What DOES travel for those lists — repo names, issue/PR numbers, Jira keys — is plaintext in the export file, the same as the rest of your configuration. So are your saved filter values (per-tab username filters, custom tab filter text). +- The **org/repo filter** at the top of the dashboard is not included — it's transient and resets on a new browser or session anyway. +- View preferences travel in the **plaintext** part of the export, never inside the encrypted credentials section — there's nothing secret in them. + +### Including Encrypted Credentials + +To migrate a full session (or set up an incognito test session) without re-authenticating, choose **Export with encrypted credentials** in the export dialog. Right above that choice, the dialog shows a warning: *"This is a one-time transfer, not a durable backup — the encrypted credentials can be imported once and expire in 30 days."* The export then also bundles your GitHub token and — if connected — your Jira credentials, encrypted so they can travel in the file safely. + +When you export with credentials: + +1. A **one-time code** is generated and shown in a dialog — 26 characters, shown in dashed groups for readability (`XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XX`). **This code is displayed only once.** Copy it and save it **separately from the export file** (for example, in a password manager) — you need *both* the file and the code to restore credentials, and it cannot be shown again or recovered later. When retyping it, case doesn't matter, and the letters I, L, and O are read as 1, 1, and 0 respectively, so a common misreading of the code still works. +2. The file downloads only *after* you acknowledge the code dialog. If you dismiss the dialog without acknowledging, nothing is downloaded. + +The code never leaves your browser and is never written into the export file. The credentials inside the file are encrypted with it, so the file alone cannot reveal them. + +The encrypted-credentials portion of an export **expires 30 days after it is created**. After that, the credentials can no longer be imported and you must re-export. The plaintext configuration in the same file always imports regardless of age. + +### Importing on the Settings Page + +Go to **Settings > Data > Import** and choose an export file. + +- **Plaintext file (no credentials):** you are asked to confirm, then your current settings are replaced. +- **File with encrypted credentials:** you are prompted for the one-time code. Enter it (use the show/hide toggle to check a manually typed code), then confirm. Because you are already signed in, the Settings page **always** shows an identity confirmation — "This will sign you in as @username and replace your current settings" — before finishing. You can also choose **Continue without credentials** to import just the plaintext configuration. + +### Importing on the Login Page + +Before signing in, click **Import from backup** on the Login page and choose an export file. + +- A file **without** encrypted credentials cannot sign you in. You are told to sign in normally first, then use Import on the Settings page to restore your configuration. +- A file **with** encrypted credentials prompts for the one-time code and then signs you in automatically. If this browser already has settings from prior use (you have completed onboarding or selected any repos/orgs), an identity confirmation is shown first. A genuinely fresh browser or incognito window skips the confirmation and goes straight to the dashboard. + +If you have this app open in other browser tabs, importing credentials for a different GitHub identity reloads those tabs automatically so they reflect the new identity — any unsaved in-tab state there (such as a partially typed filter) is lost. + +### The One-Time Code Is Single-Use + +The encrypted-credentials portion of an export can be **decrypted only once — the moment you first submit the one-time code**, not once per successful import. That first submission is irreversible for that file. This means any of the following, *after* the first code submission, permanently consumes the credentials portion: + +- declining the identity confirmation, +- the GitHub token turning out to be revoked or expired, +- reloading the page or closing the tab. + +If any of these happen, you'll see a message like *"Couldn't restore credentials — this file may already have been used (single-use), or the code/file don't match. Re-export to try again."* The plaintext configuration still imports fine, but the credentials can no longer be restored from that file — **re-export to get a fresh, single-use file** for another migration, test session, or retry. + +Entering a wrong code is **not** one of these. Your one-time code is never sent to the server — it's checked locally against the result of that first submission — so a typo is safely retryable in the same session: just retype it and submit again, as long as you have not already declined the confirmation, reloaded the page, or closed the tab. + +### Keep the Export File Safe + +Anyone who obtains the export file — **even without the one-time code** — can trigger that single, irreversible decryption step and thereby invalidate the file's credentials portion for the rest of its lifetime. This is an availability/griefing consideration, **not** a confidentiality break: the one-time code still protects the credentials' secrecy, and recovery is simply re-exporting. Treat the export file as sensitive, and re-export if you suspect it was exposed. + +### Jira Credentials and Staleness + +Jira **OAuth** credentials in an export often go stale quickly. Atlassian rotates refresh tokens on every refresh, and a signed-in machine refreshes roughly once an hour — so an exported Jira-OAuth credential is typically invalidated within about an hour of export. For a reliable Jira-OAuth migration, **re-export immediately before importing**, or simply **reconnect Jira on the target machine** after importing (the import degrades gracefully with a reconnect prompt rather than failing the whole import). Jira **API-token** mode and GitHub tokens do not rotate and are unaffected. + +### Same-Deployment Only + +Encrypted credentials are sealed by this deployment's server and can only be imported back into **the same deployment**. Credentials exported from `gh.gordoncode.dev` cannot be imported into a different deployment (a self-hosted fork or a different domain). Plaintext configuration is portable across deployments; credentials are not. + +--- + ## Troubleshooting **Items I expect to see are not showing up.** diff --git a/src/app/components/settings/SettingsPage.tsx b/src/app/components/settings/SettingsPage.tsx index 3febd677..b6a4bd2a 100644 --- a/src/app/components/settings/SettingsPage.tsx +++ b/src/app/components/settings/SettingsPage.tsx @@ -1,11 +1,18 @@ import { createSignal, createMemo, Show, For, onCleanup, onMount } from "solid-js"; import { Select } from "@kobalte/core/select"; +import { Dialog } from "@kobalte/core/dialog"; import * as Sentry from "@sentry/solid"; import { getRelayStatus } from "../../lib/mcp-relay"; import { useNavigate } from "@solidjs/router"; -import { config, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo, isActionsBasedTab } from "../../stores/config"; -import type { JiraCustomField } from "../../../shared/schemas"; -import { viewState, updateViewState, setTabFilter } from "../../stores/view"; +import { config, setConfig, updateConfig, updateJiraConfig, updateJiraCustomFields, updateJiraCustomScopes, setMonitoredRepo, isActionsBasedTab } from "../../stores/config"; +import type { Config, JiraCustomField } from "../../../shared/schemas"; +import { + buildExportPayload, + buildEncryptedCredentialsSection, + commitImportedSettings, +} from "../../lib/settings-transfer"; +import { createCredentialImport } from "../../lib/use-credential-import"; +import { viewState, updateViewState, setTabFilter, applyImportedViewState } from "../../stores/view"; import { clearAuth, jiraAuth, setJiraAuth, clearJiraConfigFull, isJiraAuthenticated, token, setAuthFromPat } from "../../stores/auth"; import type { GitHubUser } from "../../stores/auth"; import { isValidPatFormat } from "../../lib/pat"; @@ -139,6 +146,18 @@ export default function SettingsPage() { const [localRepos, setLocalRepos] = createSignal(config.selectedRepos); const [localUpstream, setLocalUpstream] = createSignal(config.upstreamRepos); + // These local editor copies are captured once at mount. An in-place import + // (handleConfirmImport / handleConfirmCredImport / handleContinueWithoutCredentials) + // replaces config wholesale via setConfig without a reload, so they must be + // resynced afterward — otherwise the next org/repo edit would save the stale, + // pre-import selection and silently discard the imported repos/orgs. + // setConfig is synchronous, so config already reflects the import here. + function resyncLocalEditors() { + setLocalOrgs(config.selectedOrgs); + setLocalRepos(config.selectedRepos); + setLocalUpstream(config.upstreamRepos); + } + const monitoredRepoNames = createMemo(() => config.monitoredRepos.map(r => r.fullName).join(", ") ); @@ -240,44 +259,20 @@ export default function SettingsPage() { } } - function handleExportSettings() { - const data = JSON.stringify( - { - selectedOrgs: config.selectedOrgs, - selectedRepos: config.selectedRepos, - upstreamRepos: config.upstreamRepos, - monitoredRepos: config.monitoredRepos, - trackedUsers: config.trackedUsers, - refreshInterval: config.refreshInterval, - hotPollInterval: config.hotPollInterval, - maxWorkflowsPerRepo: config.maxWorkflowsPerRepo, - maxRunsPerWorkflow: config.maxRunsPerWorkflow, - notifications: config.notifications, - theme: config.theme, - viewDensity: config.viewDensity, - itemsPerPage: config.itemsPerPage, - defaultTab: config.defaultTab, - rememberLastTab: config.rememberLastTab, - enableTracking: config.enableTracking, - enableActions: config.enableActions, - customTabs: config.customTabs, - // Non-secret jira config fields only — no tokens, sealed blobs, or email - jira: { - enabled: config.jira?.enabled ?? false, - authMethod: config.jira?.authMethod ?? "oauth", - issueKeyDetection: config.jira?.issueKeyDetection ?? true, - cloudId: config.jira?.cloudId, - siteName: config.jira?.siteName, - siteUrl: config.jira?.siteUrl, - customFields: config.jira?.customFields ?? [], - customScopes: config.jira?.customScopes ?? [], - }, - dependencies: config.dependencies, - }, - null, - 2 - ); - const blob = new Blob([data], { type: "application/json" }); + // ── Export settings ──────────────────────────────────────────────────────── + // The Export button opens a choice dialog (config only vs. with encrypted + // credentials). Choosing "with credentials" runs the seal flow and, on + // success, the one-time code is shown in a SEPARATE modal — the file + // download is DEFERRED until the user acknowledges that modal, since the + // code is shown only once. + const [showExportChoice, setShowExportChoice] = createSignal(false); + const [exporting, setExporting] = createSignal(false); + const [exportCode, setExportCode] = createSignal(null); + const [codeCopied, setCodeCopied] = createSignal(false); + let pendingExportJson: string | null = null; + + function triggerDownload(jsonText: string) { + const blob = new Blob([jsonText], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -286,6 +281,167 @@ export default function SettingsPage() { URL.revokeObjectURL(url); } + function handleOpenExportChoice() { + // Re-entry guard: while the one-time-code modal is open, reopening the + // export-choice dialog would let a second export mint a NEW code while + // the old one is still displayed. The Kobalte Dialog traps + restores + // focus (so the Export button can't be re-fired behind the modal), and + // this guard closes the gap for any other trigger path. + if (exportCode() !== null) return; + setShowExportChoice(true); + } + + function handleExportConfigOnly() { + const payload = buildExportPayload(config, viewState); + triggerDownload(JSON.stringify(payload, null, 2)); + setShowExportChoice(false); + } + + async function handleExportWithCredentials() { + if (exporting()) return; // re-entry guard: exactly one seal in flight + setExporting(true); + try { + const payload = buildExportPayload(config, viewState); + // buildEncryptedCredentialsSection generates the code, encrypts the bundle + // with it, THEN seals the ciphertext (encrypt-then-seal). No secret is + // logged here. + const { sealed, salt, oneTimeCode } = await buildEncryptedCredentialsSection(); + // Defense-in-depth: the choice dialog's onOpenChange/buttons already block + // dismissal while exporting() is true, so this should be unreachable — but + // if the dialog was somehow closed out from under this await, discard the + // freshly-minted single-use code instead of popping it after the user + // backed out. + if (!showExportChoice()) return; + pendingExportJson = JSON.stringify({ ...payload, _credentials: { sealed, salt } }, null, 2); + setCodeCopied(false); + // Close the choice dialog, then open the code modal — only one of the + // two export dialogs is ever visible at a time. + setShowExportChoice(false); + setExportCode(oneTimeCode); // opens the modal; download deferred to ack + } catch { + // Turnstile rejection / SealError / oversized pre-check — leave the + // export-choice dialog open so the user can retry; do NOT download + // anything. + pushNotification( + "settings-export", + "Couldn't prepare encrypted credentials for export — please try again.", + "warning" + ); + } finally { + setExporting(false); + } + } + + async function handleCopyExportCode() { + const code = exportCode(); + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setCodeCopied(true); + } catch { + // clipboard unavailable — user can still select/copy the shown code + } + } + + function handleAcknowledgeExportCode() { + if (pendingExportJson) { + triggerDownload(pendingExportJson); + pendingExportJson = null; + } + setExportCode(null); + setCodeCopied(false); + } + + function handleDismissExportCode() { + // Dismiss without downloading. The sealed blob held in memory is inert + // ciphertext without the code; nothing needs cleanup. + pendingExportJson = null; + setExportCode(null); + setCodeCopied(false); + } + + // ── Import settings (plaintext) ──────────────────────────────────────────── + // pendingImport holds the parsed-and-validated Config (+ the file's raw, + // as-yet-unvalidated _viewPreferences section, if any) awaiting confirmation; + // non-null doubles as the two-click confirm state (mirrors confirmReset). + const [pendingImport, setPendingImport] = createSignal<{ config: Config; viewPreferences: unknown } | null>(null); + let importInputRef: HTMLInputElement | undefined; + + // ── Import settings (encrypted credentials) ─────────────────────────────── + // credentialImport.credImport() is set when the selected file has a valid + // _credentials section. On success it always shows the identity-confirm + // step below (unlike the Login page, which can skip it on a fresh session). + const [committing, setCommitting] = createSignal(false); + const credentialImport = createCredentialImport({ + expiredMessage: + "This export's credentials have expired — re-export from a machine where you're still signed in, or import the settings without credentials.", + onReadError: (message) => pushNotification("settings-import", message, "warning"), + onParseError: (message) => pushNotification("settings-import", message, "warning"), + onNoCredentials: (importedConfig, viewPreferences) => setPendingImport({ config: importedConfig, viewPreferences }), + onResolved: (bundle, identity) => credentialImport.setResolvedCred({ bundle, identity }), + }); + + async function handleImportFileSelected(e: Event) { + const input = e.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + // Allow re-selecting the same file (change won't fire otherwise). + try { input.value = ""; } catch { /* ignore if unsupported */ } + if (!file) return; + await credentialImport.handleFileSelected(file); + } + + function handleConfirmImport() { + const pending = pendingImport(); + if (!pending) return; + // Wholesale replace — parseImportFile returns a fully-parsed Config (every + // top-level key present), so setConfig is safe (NOT updateConfig's partial merge). + setConfig(pending.config); + // Identity is already established on this page (Settings is post-auth) — + // applyImportedViewState is total/no-throw, so this is safe even when the + // file carries no _viewPreferences section at all. + applyImportedViewState(pending.viewPreferences); + resyncLocalEditors(); + setPendingImport(null); + pushNotification("settings-import", "Settings imported", "info"); + } + + function handleCancelImport() { + setPendingImport(null); + } + + async function handleConfirmCredImport() { + if (committing()) return; // re-entry guard: exactly one commit + const cred = credentialImport.credImport(); + const rc = credentialImport.resolvedCred(); + if (!cred || !rc) return; + setCommitting(true); + try { + await commitImportedSettings(rc, cred.config, cred.viewPreferences); + resyncLocalEditors(); + credentialImport.reset(); + pushNotification("settings-import", "Settings and credentials imported", "info"); + } catch { + pushNotification("settings-import", "Something went wrong finishing the import — please try again.", "warning"); + } finally { + setCommitting(false); + } + } + + function handleContinueWithoutCredentials() { + const cred = credentialImport.credImport(); + if (!cred) return; + const { config: cfg, viewPreferences } = cred; + credentialImport.reset(); + // Route the plaintext-only path through the SAME two-click confirm the + // plaintext import uses (pendingImport), so both wholesale-replace paths + // confirm consistently. handleConfirmImport applies it + resyncs the editors. + setPendingImport({ config: cfg, viewPreferences }); + } + + function handleCancelCredImport() { + credentialImport.reset(); + } + function handleResetAll() { if (!confirmReset()) { setConfirmReset(true); @@ -1514,13 +1670,60 @@ export default function SettingsPage() { > + {/* Import settings */} + + + void handleImportFileSelected(e)} + /> + + + } + > +
+ This will replace your current settings — continue? + + +
+ +
+ {/* Reset all */} + {/* Export-choice dialog: config only vs. with encrypted credentials. + On success the "with credentials" path closes this dialog and opens + the one-time-code modal below — only one of the two is ever open. + While a seal is in flight (exporting()), dismissal is blocked — both + via onOpenChange (Escape/overlay click) and by disabling the other + buttons — so a cancel-during-seal can't pop the one-time-code modal + after the user has already backed out. */} + { if (!isOpen && !exporting()) setShowExportChoice(false); }} + modal + > + + + + Export settings + + Choose what to include in the exported file. + + +
+

+ This is a one-time transfer, not a durable backup — the encrypted + credentials can be imported once and expire in 30 days. +

+ +
+
+ +
+
+
+
+ + {/* One-time-code modal (encrypted export). Download is deferred until ack. + Kobalte Dialog provides the focus trap + focus restoration that keeps + the Export button from being re-fired behind the open modal. */} + { if (!isOpen) handleDismissExportCode(); }} + modal + > + + + + Save your one-time code + + Save this code separately from the export file — you'll need both to restore + credentials, and it's shown only once. + + + {(code) => ( +
+ + {code()} + + +
+ )} +
+
+ + +
+
+
+
+ + {/* Encrypted-credentials import dialog: one-time-code entry → identity confirm. + Kobalte Dialog adds focus trap / Escape / scroll-lock / focus restore. + While a network unseal is in flight the close controls are disabled so + the single-use nonce can't be abandoned mid-flight. */} + { if (!isOpen && !credentialImport.unsealInFlight() && !committing()) handleCancelCredImport(); }} + modal + > + + + + + Credentials unavailable + +
+ + +
+ + } + > + Restore encrypted credentials +

+ Enter the one-time code shown when this file was exported. +

+
{ e.preventDefault(); void credentialImport.handleCodeSubmit(); }}> +
+ credentialImport.setCodeInput(e.currentTarget.value)} + /> + +
+ + + +
+ + + +
+
+
+ } + > + {(rc) => ( + <> + Confirm identity +
+ +

+ This will sign you in as @{rc().identity.login} and replace + your current settings — continue? +

+
+
+ + + +
+ + )} + +
+
+
+