# 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 `