diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 2340d1e..b0cb4bc 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -1003,7 +1003,7 @@ dependencies = [ [[package]] name = "foxschema-desktop" -version = "0.1.2" +version = "0.1.12" dependencies = [ "keyring", "log", diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index da0ad30..ef3ca12 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -450,6 +450,48 @@ async fn update_db_config( Ok(build_setup_state(state.inner(), &api_base)) } +/// Rebind the encryption key to a new email — from Settings, any time after +/// setup (which defaults to a placeholder email so first-run needs no input). +/// Re-stores the SAME key material under the new email's keychain account — +/// the DEK itself never changes, so already-encrypted data keeps decrypting — +/// then best-effort removes the old account. No sidecar respawn: the running +/// process already holds the DEK in memory and nothing about the metadata DB +/// changes. +#[tauri::command] +async fn update_email(state: State<'_, AppState>, email: String) -> Result { + let email_norm = email.trim().to_lowercase(); + if !email_norm.contains('@') || email_norm.contains(char::is_whitespace) { + return Err("A valid email is required.".into()); + } + + let old_email = state.setup.lock().unwrap().email.clone(); + if old_email.is_empty() { + return Err("Setup is not complete.".into()); + } + if email_norm == old_email { + let api_base = state.api_base.lock().unwrap().clone(); + return Ok(build_setup_state(state.inner(), &api_base)); + } + + let dek = keychain_get(&old_email).ok_or("Encryption key not found in keychain.")?; + keychain_set(&email_norm, &dek)?; + + let info = { + let mut s = state.setup.lock().unwrap(); + s.email = email_norm.clone(); + s.key_scheme = "v2".to_string(); + s.clone() + }; + write_setup(&state.data_dir, &info)?; + + if let Ok(entry) = keyring::Entry::new(KEY_SERVICE, &old_email) { + let _ = entry.delete_credential(); + } + + let api_base = state.api_base.lock().unwrap().clone(); + Ok(build_setup_state(state.inner(), &api_base)) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -505,6 +547,7 @@ pub fn run() { get_setup_state, complete_setup, update_db_config, + update_email, pick_db_location ]) .build(tauri::generate_context!()) diff --git a/apps/web/src/frontend/api/apiBase.ts b/apps/web/src/frontend/api/apiBase.ts index 40b506e..d6adc6b 100644 --- a/apps/web/src/frontend/api/apiBase.ts +++ b/apps/web/src/frontend/api/apiBase.ts @@ -23,10 +23,19 @@ export function isTauri(): boolean { } /** Invoke a Tauri command. Rejects if not running under Tauri. */ -export function invokeTauri(cmd: string, args?: unknown): Promise { +export async function invokeTauri(cmd: string, args?: unknown): Promise { const invoke = tauri()?.core?.invoke; - if (!invoke) return Promise.reject(new Error('Not running under Tauri')); - return invoke(cmd, args); + if (!invoke) throw new Error('Not running under Tauri'); + try { + return await invoke(cmd, args); + } catch (err) { + // Rust commands return Result, so a failed command rejects + // with a plain string here, not an Error — every call site's + // `err instanceof Error ? err.message : fallback` would otherwise + // always take the fallback and hide the real reason. Normalize once, + // at the IPC boundary, instead of fixing every catch site individually. + throw err instanceof Error ? err : new Error(typeof err === 'string' ? err : JSON.stringify(err)); + } } /** Set the cached API base (e.g. after the sidecar is spawned at setup). */ diff --git a/apps/web/src/frontend/api/setupApi.ts b/apps/web/src/frontend/api/setupApi.ts index 3dc03bf..db86b43 100644 --- a/apps/web/src/frontend/api/setupApi.ts +++ b/apps/web/src/frontend/api/setupApi.ts @@ -92,3 +92,12 @@ export async function updateDbConfig(input: { dbUrl: input.dbUrl || null, }); } + +/** + * Rebind the encryption key to a new email (desktop only, Settings → Security). + * The key material is unchanged — only which keychain account holds it, and + * setup.json's record of it, move to the new email. + */ +export async function updateEmail(email: string): Promise { + return invokeTauri('update_email', { email }); +} diff --git a/apps/web/src/frontend/components/EmailSettings.tsx b/apps/web/src/frontend/components/EmailSettings.tsx new file mode 100644 index 0000000..12007bd --- /dev/null +++ b/apps/web/src/frontend/components/EmailSettings.tsx @@ -0,0 +1,116 @@ +import React, { useState } from 'react'; +import { ShieldCheck, Loader2, AlertCircle, CheckCircle2, Pencil } from 'lucide-react'; +import { isTauri } from '../api/apiBase'; +import { updateEmail, type AppInfo } from '../api/setupApi'; + +/** + * Rebind the encryption key's email in Settings → Security. Setup itself + * defaults to a placeholder email so first-run needs no input — this is + * where a user sets (or changes) the real one whenever they want. + * Desktop-only; the web edition's key isn't email-bound the same way. + */ +export const EmailSettings: React.FC<{ info: AppInfo; onUpdated: (info: AppInfo) => void }> = ({ info, onUpdated }) => { + const [editing, setEditing] = useState(false); + const [email, setEmail] = useState(info.security.boundEmail); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + if (!isTauri() || !info.security.emailBound) { + return ( +

+ {info.security.emailBound ? ( + <> + Encryption key bound to {info.security.boundEmail} and + held in the OS keychain — a copied database can't be decrypted elsewhere. + + ) : ( + <>Encryption key stored on this install (legacy key scheme). + )} +

+ ); + } + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + setError(null); + try { + const state = await updateEmail(email.trim()); + onUpdated({ ...info, security: { ...info.security, boundEmail: state.email } }); + setEditing(false); + setSaved(true); + setTimeout(() => setSaved(false), 2500); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update email.'); + } finally { + setSaving(false); + } + }; + + if (!editing) { + return ( +
+ +

+ Encryption key bound to {info.security.boundEmail} and + held in the OS keychain — a copied database can't be decrypted elsewhere. +

+ + {saved && ( + + Saved + + )} +
+ ); + } + + return ( +
+
+ setEmail(e.target.value)} + placeholder="your@email.com" + className="flex-1 min-w-0 bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-2.5 py-1.5 text-xs outline-none" + /> + + +
+ {error && ( +
+ + {error} +
+ )} +
+ ); +}; diff --git a/apps/web/src/frontend/components/SettingsPanel.tsx b/apps/web/src/frontend/components/SettingsPanel.tsx index 7c6bb1a..2e45192 100644 --- a/apps/web/src/frontend/components/SettingsPanel.tsx +++ b/apps/web/src/frontend/components/SettingsPanel.tsx @@ -4,6 +4,7 @@ import { X, Sun, Moon, Monitor, Palette, Type, RotateCcw, ShieldCheck, Database, import { useUiStore, ACCENTS, TONES, FONT_SIZES, THEME_PRESETS, type ThemeMode, type AccentId, type ThemePreset } from '../store/uiStore'; import { fetchAppInfo, type AppInfo } from '../api/setupApi'; import { DatabaseSettings } from './DatabaseSettings'; +import { EmailSettings } from './EmailSettings'; import { UpdatesSettings } from './UpdatesSettings'; interface Props { @@ -192,21 +193,8 @@ export const SettingsPanel: React.FC = ({ open, onClose }) => { {info && (
} title="Security"> -
- -

- {info.security.emailBound ? ( - <> - Encryption key bound to{' '} - {info.security.boundEmail} and held in the OS - keychain — a copied database can't be decrypted elsewhere. - - ) : ( - <>Encryption key stored on this install (legacy key scheme). - )} -

+
+
)} diff --git a/apps/web/src/frontend/components/SetupScreen.tsx b/apps/web/src/frontend/components/SetupScreen.tsx index 279a5aa..eec3383 100644 --- a/apps/web/src/frontend/components/SetupScreen.tsx +++ b/apps/web/src/frontend/components/SetupScreen.tsx @@ -1,123 +1,66 @@ -import React, { useState } from 'react'; -import { Loader2, AlertCircle, ShieldCheck, HardDrive, FolderOpen } from 'lucide-react'; -import { completeSetup, pickDbLocation, type SetupState } from '../api/setupApi'; -import { isTauri } from '../api/apiBase'; +import React, { useEffect, useState } from 'react'; +import { Loader2, AlertCircle, ShieldCheck } from 'lucide-react'; +import { completeSetup, type SetupState } from '../api/setupApi'; import { Brand } from './Brand'; +// Matches authStore.ts's LOCAL_USER — the same placeholder identity the web +// edition's single-user mode already uses. Real per-install key binding (a +// distinct email per machine) is opt-in via Settings → Security → Change, +// rather than required to get through first run. +const DEFAULT_EMAIL = 'local@foxschema.app'; + /** - * One-time desktop first-run screen. Collects the user's email — the per-install - * encryption key is bound to it and stored in the OS keychain, so a copied - * database can't be decrypted on another machine — and where the bundled SQLite - * database lives. New installs always start on SQLite; switching to Postgres / - * MySQL is done later in Settings → Database. + * One-time desktop first-run screen. Silently binds the encryption key (kept + * in the OS keychain) and starts the app on the default SQLite location — no + * input required. Both the bound email and the database engine/location can + * be changed afterward in Settings → Security / Settings → Database. */ export const SetupScreen: React.FC<{ initial: SetupState; onDone: (s: SetupState) => void }> = ({ initial, onDone, }) => { - const [email, setEmail] = useState(initial.email); - const [dbPath, setDbPath] = useState(initial.db_path || initial.default_db_path); - const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); - const browse = async () => { - try { - const picked = await pickDbLocation(dbPath || initial.default_db_path); - if (picked) setDbPath(picked); - } catch { - /* dialog cancelled / unavailable — keep the typed value */ - } - }; - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); + useEffect(() => { + let alive = true; setError(null); - setBusy(true); - try { - const state = await completeSetup({ - email: email.trim(), - engine: 'sqlite', - dbPath: dbPath.trim() || undefined, - }); - onDone(state); - } catch (err) { - setError(err instanceof Error ? err.message : 'Setup failed.'); - setBusy(false); - } - }; + completeSetup({ + email: initial.email || DEFAULT_EMAIL, + engine: 'sqlite', + }) + .then((state) => alive && onDone(state)) + .catch((err) => alive && setError(err instanceof Error ? err.message : 'Setup failed.')); + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- retried by bumping `attempt`, not by re-deriving inputs + }, [attempt]); return (
-
-
- -

Welcome — let's secure this install. Takes a few seconds.

-
+
+ -
-
- - setEmail(e.target.value)} - placeholder="your@email.com" - className="bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-3 py-2 text-sm outline-none" - /> -

- Your encryption key is generated on this machine, bound to this email, and stored in the OS keychain — - never written to disk. Copying the database to another computer won't decrypt it. -

-
- -
- -
- setDbPath(e.target.value)} - spellCheck={false} - className="flex-1 min-w-0 bg-slate-950 border border-slate-800 focus:border-cyan-500 rounded-md px-3 py-2 text-xs font-mono outline-none" - /> - {isTauri() && ( - - )} -
-

- Fox stores its data in a local SQLite database here. You can switch to your own Postgres or MySQL - server later in Settings → Database. -

-
- - {error && ( + {error ? ( +
{error}
- )} - - - + +
+ ) : ( +

+ Securing this install… +

+ )}
);