diff --git a/docs/superpowers/plans/2026-04-17-persistent-login-unused-commands.md b/docs/superpowers/plans/2026-04-17-persistent-login-unused-commands.md new file mode 100644 index 0000000..6d4e1fb --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-persistent-login-unused-commands.md @@ -0,0 +1,998 @@ +# Persistent Login + Unused Commands Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Persist the JWT session token across app launches so users sign in once per machine, and wire the three unused backend commands (`delete_message`, `update_profile`, `add_contact`) into the frontend UI. + +**Architecture:** The Rust backend gains `tauri-plugin-store` for token persistence; `signin`/`signup`/`signout` commands save/clear the token, and a new `restore_session` command re-authenticates on startup. Frontend UI adds a message delete option to the context menu, an inline profile-edit form in the sidebar footer, and an inline add-contact form in the sidebar contacts section. + +**Tech Stack:** Rust/Tauri 2, tauri-plugin-store 2, SurrealDB 3, Svelte 5, TypeScript + +--- + +## File Map + +| File | Change | +|------|--------| +| `src-tauri/Cargo.toml` | Add `tauri-plugin-store = "2"` | +| `src-tauri/src/lib.rs` | Register store plugin; add `restore_session` to invoke_handler | +| `src-tauri/src/commands/user.rs` | Add `AppHandle` to `signin`/`signup`/`signout`; save/clear token in store; add `restore_session` command | +| `src/routes/+page.svelte` | Replace `get_me` with `restore_session` in `init()`; add `deleteMessage`, `updateProfile`, `addContact` handlers; thread new props to components | +| `src/lib/components/ChatMain.svelte` | Add `user` prop and `onDeleteMessage` prop; add delete item to message context menu | +| `src/lib/components/Sidebar.svelte` | Add `onUpdateProfile` and `onAddContact` props; add inline profile-edit form; add inline add-contact form | + +--- + +## Task 1: Add tauri-plugin-store dependency + +**Files:** +- Modify: `src-tauri/Cargo.toml` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Add the crate to Cargo.toml** + +Open `src-tauri/Cargo.toml`. In the `[dependencies]` section, add after `tauri-plugin-opener`: + +```toml +tauri-plugin-store = "2" +``` + +- [ ] **Step 2: Register the plugin in lib.rs** + +Open `src-tauri/src/lib.rs`. The current builder chain is: +```rust +tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .setup(|app| { +``` + +Change it to: +```rust +tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_store::Builder::default().build()) + .setup(|app| { +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +cd src-tauri && cargo check 2>&1 +``` + +Expected: no errors. You will see it downloading and compiling tauri-plugin-store. If you see "use of undeclared crate or module `tauri_plugin_store`", double-check the Cargo.toml spelling. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/src/lib.rs +git commit -m "feat: add tauri-plugin-store for session persistence" +``` + +--- + +## Task 2: Update user commands for token persistence + +**Files:** +- Modify: `src-tauri/src/commands/user.rs` +- Modify: `src-tauri/src/lib.rs` + +This task updates `signin`, `signup`, `signout` to save/clear the token in the store, and adds the new `restore_session` command. + +- [ ] **Step 1: Replace the contents of commands/user.rs** + +Replace the entire file with: + +```rust +use tauri::{AppHandle, State}; +use tauri_plugin_store::StoreExt; + +use crate::db::{AppState, SURREAL_ACCESS, SURREAL_DB, SURREAL_NS}; +use crate::error::{into_err, AppError}; +use crate::models::{Contact, User}; + +const SESSION_STORE: &str = "session.json"; +const TOKEN_KEY: &str = "token"; + +/// Create a new user account via SurrealDB Record Auth SIGNUP. +/// Returns the created User record. Persists the JWT token to disk. +#[tauri::command] +pub async fn signup( + state: State<'_, AppState>, + app_handle: AppHandle, + email: String, + username: String, + password: String, +) -> Result { + let credentials = surrealdb::opt::auth::Record { + access: SURREAL_ACCESS.to_string(), + namespace: SURREAL_NS.to_string(), + database: SURREAL_DB.to_string(), + params: serde_json::json!({ + "email": email, + "username": username, + "password": password, + }), + }; + let token = state.db.signup(credentials).await.map_err(into_err)?; + let token_str = token.access.into_insecure_token(); + *state.token.lock().unwrap() = Some(token_str.clone()); + save_token(&app_handle, &token_str)?; + + let mut result: Vec = state + .db + .query("SELECT * FROM $auth") + .await + .map_err(into_err)? + .take(0) + .map_err(into_err)?; + + result.pop().ok_or_else(|| into_err(AppError::Auth("signup succeeded but $auth not set".into()))) +} + +/// Authenticate an existing user via SurrealDB Record Auth SIGNIN. +/// Returns the JWT token string. Persists the token to disk. +#[tauri::command] +pub async fn signin( + state: State<'_, AppState>, + app_handle: AppHandle, + email: String, + password: String, +) -> Result { + let credentials = surrealdb::opt::auth::Record { + access: SURREAL_ACCESS.to_string(), + namespace: SURREAL_NS.to_string(), + database: SURREAL_DB.to_string(), + params: serde_json::json!({ + "email": email, + "password": password, + }), + }; + let token_str = state.db.signin(credentials).await.map_err(into_err)?.access.into_insecure_token(); + *state.token.lock().unwrap() = Some(token_str.clone()); + save_token(&app_handle, &token_str)?; + Ok(token_str) +} + +/// Clear the current session. Invalidates the token in state and removes it from disk. +#[tauri::command] +pub async fn signout( + state: State<'_, AppState>, + app_handle: AppHandle, +) -> Result<(), String> { + state.db.invalidate().await.map_err(into_err)?; + *state.token.lock().unwrap() = None; + clear_token(&app_handle)?; + Ok(()) +} + +/// Attempt to restore a previous session from the persisted token on disk. +/// Authenticates the DB connection with the stored JWT. +/// Returns the authenticated User on success, or an error if no token exists +/// or the token is expired/invalid (in which case the stored token is also cleared). +#[tauri::command] +pub async fn restore_session( + state: State<'_, AppState>, + app_handle: AppHandle, +) -> Result { + let token_str = load_token(&app_handle)?.ok_or_else(|| { + AppError::Auth("no saved session".into()).to_string() + })?; + + match state.db.authenticate(surrealdb::opt::auth::Jwt::from(token_str.clone())).await { + Ok(_) => { + *state.token.lock().unwrap() = Some(token_str); + + let mut result: Vec = state + .db + .query("SELECT * FROM $auth") + .await + .map_err(into_err)? + .take(0) + .map_err(into_err)?; + + result.pop().ok_or_else(|| into_err(AppError::Auth("session restored but $auth not set".into()))) + } + Err(_) => { + // Token expired or invalid — purge it so next launch shows auth screen. + let _ = clear_token(&app_handle); + *state.token.lock().unwrap() = None; + Err(AppError::Auth("session expired, please sign in again".into()).to_string()) + } + } +} + +/// Fetch the currently authenticated user record. +#[tauri::command] +pub async fn get_me(state: State<'_, AppState>) -> Result { + let mut result: Vec = state + .db + .query("SELECT * FROM $auth") + .await + .map_err(into_err)? + .take(0) + .map_err(into_err)?; + + result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into()))) +} + +/// Update mutable profile fields. Only provided fields are changed. +#[tauri::command] +pub async fn update_profile( + state: State<'_, AppState>, + username: Option, + avatar: Option, +) -> Result { + let mut result: Vec = state + .db + .query( + "UPDATE $auth SET + username = $username ?? username, + avatar = $avatar ?? avatar + RETURN AFTER", + ) + .bind(("username", username)) + .bind(("avatar", avatar)) + .await + .map_err(into_err)? + .take(0) + .map_err(into_err)?; + + result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into()))) +} + +/// Return the contacts list for the current user. +#[tauri::command] +pub async fn get_contacts(state: State<'_, AppState>) -> Result, String> { + let result: Vec = state + .db + .query("SELECT target.* FROM contact WHERE owner = $auth") + .await + .map_err(into_err)? + .take(0) + .map_err(into_err)?; + + Ok(result) +} + +/// Add a user to the current user's contact list. +#[tauri::command] +pub async fn add_contact( + state: State<'_, AppState>, + user_id: String, +) -> Result { + let mut result: Vec = state + .db + .query("CREATE contact SET owner = $auth, target = type::record('user', $uid)") + .bind(("uid", user_id)) + .await + .map_err(into_err)? + .take(0) + .map_err(into_err)?; + + result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into()))) +} + +// ── Private helpers ─────────────────────────────────────────────────────────── + +fn save_token(app: &AppHandle, token: &str) -> Result<(), String> { + let store = app.store(SESSION_STORE).map_err(|e| e.to_string())?; + store.set(TOKEN_KEY, serde_json::json!(token)); + store.save().map_err(|e| e.to_string()) +} + +fn load_token(app: &AppHandle) -> Result, String> { + let store = app.store(SESSION_STORE).map_err(|e| e.to_string())?; + Ok(store.get(TOKEN_KEY).and_then(|v| v.as_str().map(String::from))) +} + +fn clear_token(app: &AppHandle) -> Result<(), String> { + let store = app.store(SESSION_STORE).map_err(|e| e.to_string())?; + store.delete(TOKEN_KEY); + store.save().map_err(|e| e.to_string()) +} +``` + +- [ ] **Step 2: Add restore_session to the invoke_handler in lib.rs** + +Open `src-tauri/src/lib.rs`. The invoke_handler currently lists the commands. Add `commands::user::restore_session` so the handler looks like: + +```rust +.invoke_handler(tauri::generate_handler![ + commands::user::signup, + commands::user::signin, + commands::user::signout, + commands::user::get_me, + commands::user::restore_session, + commands::user::update_profile, + commands::user::get_contacts, + commands::user::add_contact, + commands::chat::create_room, + commands::chat::get_rooms, + commands::chat::send_message, + commands::chat::get_messages, + commands::chat::delete_message, + commands::chat::subscribe_room, + commands::chat::unsubscribe_room, +]) +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +cd src-tauri && cargo check 2>&1 +``` + +Expected: no errors. Common failure: +- `"method not found in surrealdb::Surreal"` for `authenticate` — check that `surrealdb::opt::auth::Jwt` is the right type for your surrealdb 3.x version. If `Jwt::from(string)` fails, try `token_str.as_str().parse().unwrap_or_default()` or consult `cargo doc --open` for the `authenticate` signature. +- `"cannot find value TOKEN_KEY"` — make sure the constant is defined at module level, not inside a function. + +- [ ] **Step 4: Commit** + +```bash +git add src-tauri/src/commands/user.rs src-tauri/src/lib.rs +git commit -m "feat: persist session token across app restarts" +``` + +--- + +## Task 3: Update frontend init() to use restore_session + +**Files:** +- Modify: `src/routes/+page.svelte` + +- [ ] **Step 1: Replace the init() function** + +In `src/routes/+page.svelte`, find the `init()` function (lines 37–46): + +```typescript + async function init() { + try { + user = await cmd('get_me'); + view = 'app'; + await loadRooms(); + contacts = await cmd('get_contacts').catch(() => []); + } catch { + view = 'auth'; + } + } +``` + +Replace with: + +```typescript + async function init() { + try { + user = await cmd('restore_session'); + view = 'app'; + await loadRooms(); + contacts = await cmd('get_contacts').catch(() => []); + } catch { + view = 'auth'; + } + } +``` + +Only the `cmd` call changes — `'get_me'` → `'restore_session'`. Everything else stays the same. + +- [ ] **Step 2: Verify TypeScript types** + +```bash +pnpm check 2>&1 +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/+page.svelte +git commit -m "feat: restore session on app launch instead of failing to login screen" +``` + +--- + +## Task 4: Wire delete_message into the UI + +**Files:** +- Modify: `src/routes/+page.svelte` +- Modify: `src/lib/components/ChatMain.svelte` + +- [ ] **Step 1: Add deleteMessage handler in +page.svelte** + +In `src/routes/+page.svelte`, find the `sendMessage()` function (around line 111). Add the new function directly after it, before `onMount`: + +```typescript + async function deleteMessage(msgId: string) { + err = ''; + try { + await cmd('delete_message', { messageId: msgId }); + messages = messages.filter(m => full(m.id) !== msgId); + } catch (e) { err = String(e); } + } +``` + +- [ ] **Step 2: Pass onDeleteMessage and user to ChatMain** + +In the same file, find the `` block in the template (around line 156): + +```svelte + +``` + +Replace with: + +```svelte + +``` + +- [ ] **Step 3: Update ChatMain.svelte to accept user and onDeleteMessage props** + +Open `src/lib/components/ChatMain.svelte`. Find the `interface Props` block (lines 6–13): + +```typescript + interface Props { + activeRoom: Room | null; + messages: Message[]; + err: string; + fMsg: string; + onSendMessage: () => void; + onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void; + } +``` + +Replace with: + +```typescript + interface Props { + activeRoom: Room | null; + messages: Message[]; + user: User | null; + err: string; + fMsg: string; + onSendMessage: () => void; + onDeleteMessage: (msgId: string) => void; + onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void; + } +``` + +- [ ] **Step 4: Update destructuring in ChatMain.svelte** + +Find the destructuring block (lines 16–23): + +```typescript + let { + activeRoom, + messages, + err, + fMsg = $bindable(), + onSendMessage, + onShowMenu, + }: Props = $props(); +``` + +Replace with: + +```typescript + let { + activeRoom, + messages, + user, + err, + fMsg = $bindable(), + onSendMessage, + onDeleteMessage, + onShowMenu, + }: Props = $props(); +``` + +- [ ] **Step 5: Add User import to ChatMain.svelte** + +Find the import line at the top of the script block: + +```typescript + import type { Room, Message, ContextMenuItem } from '$lib/types'; +``` + +Replace with: + +```typescript + import type { User, Room, Message, ContextMenuItem } from '$lib/types'; +``` + +- [ ] **Step 6: Add delete option to message context menu** + +Find the `oncontextmenu` handler on the message `
` (around line 82 of ChatMain.svelte): + +```svelte + oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) }])} +``` + +Replace with: + +```svelte + oncontextmenu={(e) => { + const items: ContextMenuItem[] = [ + { label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) }, + ]; + if (user && full(msg.author) === full(user.id)) { + items.push({ label: 'Delete message', action: () => onDeleteMessage(full(msg.id)) }); + } + onShowMenu(e, items); + }} +``` + +- [ ] **Step 7: Verify TypeScript types** + +```bash +pnpm check 2>&1 +``` + +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/routes/+page.svelte src/lib/components/ChatMain.svelte +git commit -m "feat: add delete message to context menu" +``` + +--- + +## Task 5: Wire update_profile into the sidebar + +**Files:** +- Modify: `src/routes/+page.svelte` +- Modify: `src/lib/components/Sidebar.svelte` + +- [ ] **Step 1: Add updateProfile handler in +page.svelte** + +In `src/routes/+page.svelte`, add after `deleteMessage()`: + +```typescript + async function updateProfile(fields: { username?: string; avatar?: string }) { + user = await cmd('update_profile', fields); + } +``` + +- [ ] **Step 2: Pass onUpdateProfile to Sidebar** + +Find the `` block in the template: + +```svelte + +``` + +Replace with: + +```svelte + +``` + +- [ ] **Step 3: Add onUpdateProfile prop and profile edit form to Sidebar.svelte** + +Open `src/lib/components/Sidebar.svelte`. Make the following changes: + +**3a — Add `onUpdateProfile` to the `interface Props` block:** + +Find: +```typescript + interface Props { + user: User | null; + rooms: Room[]; + contacts: User[]; + activeRoom: Room | null; + showNewRoom: boolean; + fRoom: string; + onSelectRoom: (room: Room) => void; + onCreateRoom: () => void; + onSignout: () => void; + onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void; + } +``` + +Replace with: +```typescript + interface Props { + user: User | null; + rooms: Room[]; + contacts: User[]; + activeRoom: Room | null; + showNewRoom: boolean; + fRoom: string; + onSelectRoom: (room: Room) => void; + onCreateRoom: () => void; + onSignout: () => void; + onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void; + onUpdateProfile: (fields: { username?: string; avatar?: string }) => Promise; + } +``` + +**3b — Add `onUpdateProfile` to the destructuring block:** + +Find: +```typescript + let { + user, + rooms, + contacts, + activeRoom, + showNewRoom = $bindable(), + fRoom = $bindable(), + onSelectRoom, + onCreateRoom, + onSignout, + onShowMenu, + }: Props = $props(); +``` + +Replace with: +```typescript + let { + user, + rooms, + contacts, + activeRoom, + showNewRoom = $bindable(), + fRoom = $bindable(), + onSelectRoom, + onCreateRoom, + onSignout, + onShowMenu, + onUpdateProfile, + }: Props = $props(); +``` + +**3c — Add local state and submit handler for the profile form. Add after the destructuring block:** + +```typescript + let showEditProfile = $state(false); + let fProfileUsername = $state(''); + let fProfileAvatar = $state(''); + let profileErr = $state(''); + + function openEditProfile() { + fProfileUsername = user?.username ?? ''; + fProfileAvatar = user?.avatar ?? ''; + profileErr = ''; + showEditProfile = true; + } + + async function submitProfile() { + profileErr = ''; + try { + await onUpdateProfile({ + username: fProfileUsername.trim() || undefined, + avatar: fProfileAvatar.trim() || undefined, + }); + showEditProfile = false; + } catch (e) { profileErr = String(e); } + } +``` + +**3d — Update the user footer in the template.** Find the `` section: + +```svelte + + +``` + +Replace with: + +```svelte + + {#if showEditProfile} +
+ e.key === 'Enter' && submitProfile()} + onkeyup={(e) => e.key === 'Escape' && (showEditProfile = false)} /> + e.key === 'Enter' && submitProfile()} + onkeyup={(e) => e.key === 'Escape' && (showEditProfile = false)} /> + {#if profileErr}

{profileErr}

{/if} +
+ + +
+
+ {/if} + +``` + +**3e — Add styles for the new elements.** In the ``: + +```css + .edit-profile-form { + display: flex; flex-direction: column; gap: 6px; + padding: 10px 12px; + border-top: 1px solid var(--border-subtle); + animation: rise 0.15s ease; + } + .form-row { + display: flex; gap: 6px; + } + .btn-ghost { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + } + .btn-ghost:hover { opacity: 0.8; border-color: var(--muted); } + .form-err { + font-size: 10px; color: var(--danger); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .user-pill { + display: flex; align-items: center; gap: 8px; min-width: 0; + background: none; border: none; cursor: pointer; padding: 0; + font-family: inherit; text-align: left; + } + .user-pill:hover .user-name { color: var(--text); } +``` + +Note: the existing `.user-pill` in the style block is a `div` style with no cursor. We are replacing the `
` with a ` +
+ {#if showAddContact} +
+ e.key === 'Enter' && submitContact()} /> + +
+ {#if contactErr}

{contactErr}

{/if} + {/if} + {#if contacts.length > 0} +
+``` + +The original code has `{#if contacts.length > 0}` wrapping both the label and list. We are moving the label outside that condition so the "+" button is always visible. The list itself stays inside `{#if contacts.length > 0}`. + +- [ ] **Step 4: Add section-label-row style to Sidebar.svelte** + +In the `