Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 43 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetupState, String> {
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()
Expand Down Expand Up @@ -505,6 +547,7 @@ pub fn run() {
get_setup_state,
complete_setup,
update_db_config,
update_email,
pick_db_location
])
.build(tauri::generate_context!())
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/frontend/api/apiBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ export function isTauri(): boolean {
}

/** Invoke a Tauri command. Rejects if not running under Tauri. */
export function invokeTauri<T>(cmd: string, args?: unknown): Promise<T> {
export async function invokeTauri<T>(cmd: string, args?: unknown): Promise<T> {
const invoke = tauri()?.core?.invoke;
if (!invoke) return Promise.reject(new Error('Not running under Tauri'));
return invoke<T>(cmd, args);
if (!invoke) throw new Error('Not running under Tauri');
try {
return await invoke<T>(cmd, args);
} catch (err) {
// Rust commands return Result<T, String>, 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). */
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/frontend/api/setupApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetupState> {
return invokeTauri<SetupState>('update_email', { email });
}
116 changes: 116 additions & 0 deletions apps/web/src/frontend/components/EmailSettings.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [saved, setSaved] = useState(false);

if (!isTauri() || !info.security.emailBound) {
return (
<p className="text-slate-300">
{info.security.emailBound ? (
<>
Encryption key bound to <span className="font-mono text-slate-200">{info.security.boundEmail}</span> and
held in the OS keychain — a copied database can't be decrypted elsewhere.
</>
) : (
<>Encryption key stored on this install (legacy key scheme).</>
)}
</p>
);
}

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 (
<div className="flex items-start gap-2">
<ShieldCheck className="w-3.5 h-3.5 mt-0.5 shrink-0 text-emerald-400" />
<p className="text-slate-300 flex-1">
Encryption key bound to <span className="font-mono text-slate-200">{info.security.boundEmail}</span> and
held in the OS keychain — a copied database can't be decrypted elsewhere.
</p>
<button
type="button"
onClick={() => {
setEmail(info.security.boundEmail);
setEditing(true);
setSaved(false);
}}
className="shrink-0 flex items-center gap-1 text-[11px] font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer"
>
<Pencil className="w-3 h-3" /> Change
</button>
{saved && (
<span className="shrink-0 flex items-center gap-1 text-[11px] text-emerald-400">
<CheckCircle2 className="w-3 h-3" /> Saved
</span>
)}
</div>
);
}

return (
<form onSubmit={submit} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
type="email"
required
autoFocus
value={email}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={saving}
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold accent-grad on-accent-fg rounded-md transition disabled:opacity-50"
>
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <ShieldCheck className="w-3.5 h-3.5" />}
Save
</button>
<button
type="button"
onClick={() => setEditing(false)}
disabled={saving}
className="shrink-0 px-2.5 py-1.5 text-xs font-semibold text-slate-400 hover:text-slate-200 transition cursor-pointer disabled:opacity-50"
>
Cancel
</button>
</div>
{error && (
<div className="flex items-start gap-2 text-xs text-rose-400 bg-rose-950/40 border border-rose-500/20 rounded-md px-3 py-2">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{error}</span>
</div>
)}
</form>
);
};
18 changes: 3 additions & 15 deletions apps/web/src/frontend/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -192,21 +193,8 @@ export const SettingsPanel: React.FC<Props> = ({ open, onClose }) => {

{info && (
<Section icon={<ShieldCheck className="w-3 h-3" />} title="Security">
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg border border-slate-800 bg-slate-950/40 text-xs">
<ShieldCheck
className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${info.security.emailBound ? 'text-emerald-400' : 'text-amber-400'}`}
/>
<p className="text-slate-300">
{info.security.emailBound ? (
<>
Encryption key bound to{' '}
<span className="font-mono text-slate-200">{info.security.boundEmail}</span> and held in the OS
keychain — a copied database can't be decrypted elsewhere.
</>
) : (
<>Encryption key stored on this install (legacy key scheme).</>
)}
</p>
<div className="px-3 py-2.5 rounded-lg border border-slate-800 bg-slate-950/40 text-xs">
<EmailSettings info={info} onUpdated={setInfo} />
</div>
</Section>
)}
Expand Down
Loading
Loading