From 5da5fee942cac3a02a6a6aef759c0ee5011e64fb Mon Sep 17 00:00:00 2001 From: Simon Bernier St-Pierre Date: Sat, 1 Aug 2026 15:46:38 -0400 Subject: [PATCH] simplify list ownership & invitations --- src/db.rs | 211 +++++++++++++++++---------------------------------- src/main.rs | 59 +++++++------- src/views.rs | 91 ++++++++++------------ 3 files changed, 138 insertions(+), 223 deletions(-) diff --git a/src/db.rs b/src/db.rs index ad24cf0..9403574 100644 --- a/src/db.rs +++ b/src/db.rs @@ -46,22 +46,9 @@ pub struct SessionUser { pub struct GroceryList { pub id: String, pub name: String, - pub owner_id: String, pub revision: i64, } -#[derive(Clone, Debug)] -pub struct ListAccess { - pub list: GroceryList, - pub role: String, -} - -#[derive(Clone, Debug)] -pub struct ListSummary { - pub list: GroceryList, - pub role: String, -} - #[derive(Clone, Debug)] pub struct Item { pub id: String, @@ -80,11 +67,6 @@ pub struct Category { pub name: String, } -#[derive(Clone, Debug)] -pub struct InvitationInfo { - pub list_name: String, -} - impl Database { pub fn open(path: impl AsRef) -> DbResult { let connection = Connection::open(path).map_err(sql_error)?; @@ -240,27 +222,21 @@ impl Database { .await } - pub async fn list_summaries(&self, user_id: String) -> DbResult> { + pub async fn list_summaries(&self) -> DbResult> { self.call(move |connection| { let mut statement = connection .prepare( - "SELECT l.id, l.name, l.owner_id, l.revision, m.role + "SELECT l.id, l.name, l.revision FROM lists l - JOIN list_members m ON m.list_id = l.id - WHERE m.user_id = ?1 ORDER BY l.created_at DESC", ) .map_err(sql_error)?; let rows = statement - .query_map(params![user_id], |row| { - Ok(ListSummary { - list: GroceryList { - id: row.get(0)?, - name: row.get(1)?, - owner_id: row.get(2)?, - revision: row.get(3)?, - }, - role: row.get(4)?, + .query_map([], |row| { + Ok(GroceryList { + id: row.get(0)?, + name: row.get(1)?, + revision: row.get(2)?, }) }) .map_err(sql_error)?; @@ -270,27 +246,19 @@ impl Database { .await } - pub async fn create_list(&self, owner_id: String, name: String) -> DbResult { + pub async fn create_list(&self, name: String) -> DbResult { self.call(move |connection| { let list = GroceryList { id: Uuid::new_v4().to_string(), name, - owner_id: owner_id.clone(), revision: 0, }; let transaction = connection.transaction().map_err(sql_error)?; transaction .execute( - "INSERT INTO lists (id, name, owner_id, revision, created_at) - VALUES (?1, ?2, ?3, 0, ?4)", - params![list.id, list.name, owner_id, now()], - ) - .map_err(sql_error)?; - transaction - .execute( - "INSERT INTO list_members (list_id, user_id, role) - VALUES (?1, ?2, 'owner')", - params![list.id, list.owner_id], + "INSERT INTO lists (id, name, revision, created_at) + VALUES (?1, ?2, 0, ?3)", + params![list.id, list.name, now()], ) .map_err(sql_error)?; for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() { @@ -314,28 +282,19 @@ impl Database { .await } - pub async fn list_access( - &self, - list_id: String, - user_id: String, - ) -> DbResult> { + pub async fn list_access(&self, list_id: String) -> DbResult> { self.call(move |connection| { connection .query_row( - "SELECT l.id, l.name, l.owner_id, l.revision, m.role + "SELECT l.id, l.name, l.revision FROM lists l - JOIN list_members m ON m.list_id = l.id - WHERE l.id = ?1 AND m.user_id = ?2", - params![list_id, user_id], + WHERE l.id = ?1", + params![list_id], |row| { - Ok(ListAccess { - list: GroceryList { - id: row.get(0)?, - name: row.get(1)?, - owner_id: row.get(2)?, - revision: row.get(3)?, - }, - role: row.get(4)?, + Ok(GroceryList { + id: row.get(0)?, + name: row.get(1)?, + revision: row.get(2)?, }) }, ) @@ -547,19 +506,14 @@ impl Database { .await } - pub async fn create_invitation( - &self, - list_id: String, - created_by: String, - token: String, - ) -> DbResult { + pub async fn create_invitation(&self, created_by: String, token: String) -> DbResult { self.call(move |connection| { let expires_at = now() + 60 * 60 * 24 * 7; connection .execute( - "INSERT INTO invitations (token_hash, list_id, created_by, expires_at) - VALUES (?1, ?2, ?3, ?4)", - params![hash_secret(&token), list_id, created_by, expires_at], + "INSERT INTO invitations (token_hash, created_by, expires_at) + VALUES (?1, ?2, ?3)", + params![hash_secret(&token), created_by, expires_at], ) .map_err(sql_error)?; Ok(expires_at) @@ -567,47 +521,39 @@ impl Database { .await } - pub async fn invitation(&self, token: String) -> DbResult> { + pub async fn invitation(&self, token: String) -> DbResult { self.call(move |connection| { - connection + let valid = connection .query_row( - "SELECT l.name - FROM invitations i - JOIN lists l ON l.id = i.list_id - WHERE i.token_hash = ?1 AND i.expires_at > ?2", + "SELECT 1 FROM invitations + WHERE token_hash = ?1 AND expires_at > ?2", params![hash_secret(&token), now()], - |row| { - Ok(InvitationInfo { - list_name: row.get(0)?, - }) - }, + |_| Ok(()), ) .optional() - .map_err(sql_error) + .map_err(sql_error)? + .is_some(); + Ok(valid) }) .await } - pub async fn accept_invitation(&self, token: String, user_id: String) -> DbResult { + pub async fn accept_invitation(&self, token: String) -> DbResult<()> { self.call(move |connection| { let transaction = connection.transaction().map_err(sql_error)?; - let invitation = transaction + let valid = transaction .query_row( - "SELECT list_id FROM invitations + "SELECT 1 FROM invitations WHERE token_hash = ?1 AND expires_at > ?2", params![hash_secret(&token), now()], - |row| row.get::<_, String>(0), + |_| Ok(()), ) .optional() .map_err(sql_error)? - .ok_or(DbError::NotFound)?; - transaction - .execute( - "INSERT OR IGNORE INTO list_members (list_id, user_id, role) - VALUES (?1, ?2, 'member')", - params![invitation, user_id], - ) - .map_err(sql_error)?; + .is_some(); + if !valid { + return Err(DbError::NotFound); + } transaction .execute( "DELETE FROM invitations WHERE token_hash = ?1", @@ -615,7 +561,7 @@ impl Database { ) .map_err(sql_error)?; transaction.commit().map_err(sql_error)?; - Ok(invitation) + Ok(()) }) .await } @@ -653,16 +599,9 @@ fn migrate(connection: &Connection) -> DbResult<()> { CREATE TABLE IF NOT EXISTS lists ( id TEXT PRIMARY KEY, name TEXT NOT NULL, - owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, revision INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); - CREATE TABLE IF NOT EXISTS list_members ( - list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK (role IN ('owner', 'member')), - PRIMARY KEY (list_id, user_id) - ); CREATE TABLE IF NOT EXISTS categories ( id TEXT PRIMARY KEY, list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, @@ -673,7 +612,6 @@ fn migrate(connection: &Connection) -> DbResult<()> { ); CREATE TABLE IF NOT EXISTS invitations ( token_hash TEXT PRIMARY KEY, - list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL ); @@ -692,8 +630,7 @@ fn migrate(connection: &Connection) -> DbResult<()> { ); CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id); CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id); - CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id); - CREATE INDEX IF NOT EXISTS list_members_user_idx ON list_members(user_id);", + CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);", ) .map_err(sql_error)?; @@ -798,12 +735,8 @@ mod tests { #[tokio::test] async fn creates_a_list_and_item() { let database = Database::open_in_memory().unwrap(); - let user = database - .create_user("test@example.com".into(), "Test User".into(), "hash".into()) - .await - .unwrap(); let list = database - .create_list(user.id.clone(), "Weekly shop".into()) + .create_list("Weekly shop".into()) .await .unwrap(); database @@ -829,12 +762,8 @@ mod tests { .create_user("owner@example.com".into(), "Owner".into(), "hash".into()) .await .unwrap(); - let member = database - .create_user("member@example.com".into(), "Member".into(), "hash".into()) - .await - .unwrap(); let list = database - .create_list(owner.id.clone(), "Household".into()) + .create_list("Household".into()) .await .unwrap(); assert_eq!(database.categories(list.id.clone()).await.unwrap().len(), 6); @@ -848,52 +777,60 @@ mod tests { assert_eq!(session.user.id, owner.id); assert_eq!(session.csrf_token, csrf_token); + // Any registered account can access every list. + assert_eq!( + database + .list_access(list.id.clone()) + .await + .unwrap() + .unwrap() + .name, + "Household" + ); + let invitation_token = "test-invitation".to_owned(); database - .create_invitation(list.id.clone(), owner.id, invitation_token.clone()) + .create_invitation(owner.id.clone(), invitation_token.clone()) .await .unwrap(); - assert_eq!( + assert!( database .invitation(invitation_token.clone()) .await .unwrap() - .unwrap() - .list_name, - "Household" ); - let accepted_list = database - .accept_invitation(invitation_token.clone(), member.id.clone()) + database + .accept_invitation(invitation_token.clone()) .await .unwrap(); - assert_eq!(accepted_list, list.id); assert!( - database + !database .invitation(invitation_token) .await .unwrap() - .is_none() ); + + // A list created later is also accessible to every account. + let future_list = database + .create_list("Future shop".into()) + .await + .unwrap(); assert_eq!( database - .list_access(list.id, member.id) + .list_access(future_list.id) .await .unwrap() .unwrap() - .role, - "member" + .name, + "Future shop" ); } #[tokio::test] async fn checked_state_is_set_not_toggled() { let database = Database::open_in_memory().unwrap(); - let user = database - .create_user("check@example.com".into(), "Checker".into(), "hash".into()) - .await - .unwrap(); - let list = database.create_list(user.id, "List".into()).await.unwrap(); + let list = database.create_list("List".into()).await.unwrap(); database .add_item( list.id.clone(), @@ -923,15 +860,7 @@ mod tests { #[tokio::test] async fn checking_an_item_does_not_change_list_order() { let database = Database::open_in_memory().unwrap(); - let user = database - .create_user( - "order@example.com".into(), - "Order Tester".into(), - "hash".into(), - ) - .await - .unwrap(); - let list = database.create_list(user.id, "List".into()).await.unwrap(); + let list = database.create_list("List".into()).await.unwrap(); database .add_item( list.id.clone(), diff --git a/src/main.rs b/src/main.rs index 52d5110..276999f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -267,7 +267,7 @@ async fn main() -> Result<(), Box> { .route("/lists/{list_id}/items/{item_id}/edit", post(edit_item)) .route("/lists/{list_id}/items/{item_id}/delete", post(delete_item)) .route("/lists/{list_id}/categories", post(create_category)) - .route("/lists/{list_id}/invitations", post(create_invitation)) + .route("/invitations", post(create_invitation)) .route("/lists/{list_id}/stream", get(list_stream)) .route("/invite/{token}", get(invitation_page)) .route("/invite/{token}/accept", post(accept_invitation)) @@ -435,7 +435,7 @@ async fn lists_page( ) -> Result { let lists = state .db - .list_summaries(user.session.user.id.clone()) + .list_summaries() .await?; Ok(html_response(views::lists_page( &user.session.user, @@ -456,7 +456,7 @@ async fn create_list( "List names must be between 1 and 80 characters.".into(), )); } - let list = state.db.create_list(user.session.user.id, name).await?; + let list = state.db.create_list(name).await?; Ok(Redirect::to(&format!("/lists/{}", list.id)).into_response()) } @@ -465,7 +465,7 @@ async fn list_page( user: CurrentUser, Path(list_id): Path, ) -> Result { - let access = require_access(&state, &user, &list_id).await?; + let access = require_access(&state, &list_id).await?; let items = state.db.items(list_id.clone()).await?; let categories = state.db.categories(list_id.clone()).await?; let presence = state.hub.presence(&list_id).await; @@ -486,7 +486,7 @@ async fn add_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_access(&state, &user, &list_id).await?; + require_access(&state, &list_id).await?; let name = form.name.trim().to_owned(); let quantity = form.quantity.trim().to_owned(); let note = form.note.trim().to_owned(); @@ -514,7 +514,7 @@ async fn check_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_access(&state, &user, &list_id).await?; + require_access(&state, &list_id).await?; let checked = match form.checked.as_str() { "1" | "true" => true, "0" | "false" => false, @@ -538,7 +538,7 @@ async fn edit_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_access(&state, &user, &list_id).await?; + require_access(&state, &list_id).await?; let name = form.name.trim().to_owned(); if name.is_empty() || name.chars().count() > 120 { return Err(AppError::BadRequest( @@ -570,7 +570,7 @@ async fn delete_item( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_access(&state, &user, &list_id).await?; + require_access(&state, &list_id).await?; let revision = state.db.delete_item(list_id.clone(), item_id).await?; state .hub @@ -586,7 +586,7 @@ async fn create_category( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - require_access(&state, &user, &list_id).await?; + require_access(&state, &list_id).await?; let name = form.name.trim().to_owned(); if name.is_empty() || name.chars().count() > 60 { return Err(AppError::BadRequest( @@ -599,7 +599,7 @@ async fn create_category( .publish_list_changed(list_id.clone(), revision) .await; - let access = require_access(&state, &user, &list_id).await?; + let access = require_access(&state, &list_id).await?; let items = state.db.items(list_id.clone()).await?; let categories = state.db.categories(list_id).await?; Ok(html_response(views::category_created( @@ -613,20 +613,13 @@ async fn create_category( async fn create_invitation( State(state): State, user: CurrentUser, - Path(list_id): Path, LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - let access = require_access(&state, &user, &list_id).await?; - if access.role != "owner" { - return Err(AppError::BadRequest( - "Only the list owner can create invitations.".into(), - )); - } let token = db::new_secret(); state .db - .create_invitation(list_id, user.session.user.id, token.clone()) + .create_invitation(user.session.user.id, token.clone()) .await?; let url = format!( "{}/invite/{token}", @@ -643,11 +636,12 @@ async fn invitation_page( let info = state .db .invitation(token.clone()) - .await? - .ok_or(AppError::NotFound)?; + .await?; + if !info { + return Err(AppError::NotFound); + } let user = optional_user(&state, &headers).await?; Ok(html_response(views::invite_page( - &info, user.as_ref().map(|current| ¤t.session.user), &token, None, @@ -663,11 +657,11 @@ async fn accept_invitation( LoggedForm(form): LoggedForm, ) -> Result { verify_csrf(&user, &form.csrf)?; - let list_id = state + state .db - .accept_invitation(token, user.session.user.id) + .accept_invitation(token) .await?; - Ok(Redirect::to(&format!("/lists/{list_id}")).into_response()) + Ok(Redirect::to("/lists").into_response()) } async fn list_stream( @@ -676,7 +670,7 @@ async fn list_stream( Path(list_id): Path, websocket: WebSocketUpgrade, ) -> Result { - require_access(&state, &user, &list_id).await?; + require_access(&state, &list_id).await?; let state_for_socket = state.clone(); let user_for_socket = user.clone(); Ok(websocket @@ -784,7 +778,7 @@ async fn websocket_snapshot( list_id: &str, presence: &[hub::PresenceUser], ) -> Result { - let access = require_access(state, user, list_id).await?; + let access = require_access(state, list_id).await?; let items = state.db.items(list_id.to_owned()).await?; let categories = state.db.categories(list_id.to_owned()).await?; Ok( @@ -799,7 +793,7 @@ async fn websocket_list_update( user: &CurrentUser, list_id: &str, ) -> Result { - let access = require_access(state, user, list_id).await?; + let access = require_access(state, list_id).await?; let items = state.db.items(list_id.to_owned()).await?; let categories = state.db.categories(list_id.to_owned()).await?; Ok( @@ -813,11 +807,11 @@ async fn list_fragment_response( user: &CurrentUser, list_id: &str, ) -> Result { - let access = require_access(state, user, list_id).await?; + let access = require_access(state, list_id).await?; let items = state.db.items(list_id.to_owned()).await?; let categories = state.db.categories(list_id.to_owned()).await?; Ok(html_response(views::list_items_fragment( - &access.list, + &access, &items, &categories, &user.session.csrf_token, @@ -827,12 +821,11 @@ async fn list_fragment_response( async fn require_access( state: &AppState, - user: &CurrentUser, list_id: &str, -) -> Result { +) -> Result { state .db - .list_access(list_id.to_owned(), user.session.user.id.clone()) + .list_access(list_id.to_owned()) .await? .ok_or(AppError::NotFound) } @@ -877,7 +870,7 @@ async fn can_register(state: &AppState, invite: Option<&str>) -> Result Result { diff --git a/src/views.rs b/src/views.rs index 8f0bdbd..09baa55 100644 --- a/src/views.rs +++ b/src/views.rs @@ -1,7 +1,7 @@ use maud::{DOCTYPE, Markup, html}; use crate::{ - db::{Category, GroceryList, InvitationInfo, Item, ListAccess, ListSummary, User}, + db::{Category, GroceryList, Item, User}, hub::PresenceUser, }; @@ -13,7 +13,7 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup { div class="auth-card" { p class="eyebrow" { "SUSTENANCE" } h1 { "Welcome back" } - p class="lede" { "Keep the household running, one item at a time." } + p class="lede" { "Keep the shopping list in sync." } @if let Some(error) = error { div class="alert alert-error" role="alert" { (error) } } @@ -69,23 +69,23 @@ pub fn registration_closed_page() -> Markup { None, html! { div class="auth-card" { - p class="eyebrow" { "PRIVATE HOUSEHOLD" } + p class="eyebrow" { "PRIVATE LISTS" } h1 { "Registration is invite-only" } - p class="lede" { "Ask someone who owns a list to send you an invitation link." } + p class="lede" { "Ask someone who uses the app to send you an invitation link." } a class="button button-primary" href="/login" { "Back to sign in" } } }, ) } -pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Markup { +pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Markup { page( "Your lists", Some(user), html! { div class="page-heading" { div { - p class="eyebrow" { "YOUR HOUSEHOLD" } + p class="eyebrow" { "SHARED LISTS" } h1 { "Grocery lists" } p class="lede" { "Everything you need, in one place." } } @@ -104,12 +104,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku } } @else { div class="list-cards" { - @for summary in lists { - a class="list-card" href=(format!("/lists/{}", summary.list.id)) { + @for list in lists { + a class="list-card" href=(format!("/lists/{}", list.id)) { span class="list-card-icon" { "✓" } span class="list-card-copy" { - strong { (summary.list.name) } - small { @if summary.role == "owner" { "Owner" } @else { "Member" } } + strong { (list.name) } } span class="list-card-arrow" { "→" } } @@ -126,6 +125,20 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku button class="button button-primary" type="submit" { "Create list" } } } + section id="sharing" class="panel sharing-panel" { + div class="panel-heading" { h2 { "Invite someone" } } + p { "Create a one-time invite link so a new person can join." } + form + hx-post="/invitations" + hx-target="#invite-result" + hx-swap="innerHTML" + class="stack" + { + input type="hidden" name="csrf" value=(csrf_token); + button class="button button-secondary" type="submit" { "Create invite link" } + } + div id="invite-result" class="invite-result" {} + } } }, ) @@ -133,24 +146,20 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku pub fn list_page( user: &User, - access: &ListAccess, + list: &GroceryList, items: &[Item], categories: &[Category], presence: &[PresenceUser], csrf_token: &str, ) -> Markup { - let is_owner = access.role == "owner"; page( - &access.list.name, + &list.name, Some(user), html! { div class="list-topbar" { a class="back-link" href="/lists" { "← All lists" } div class="list-topbar-actions" { span class="live-pill" { span class="live-dot" {} "Live" } - @if is_owner { - a class="button button-small button-quiet" href="#sharing" { "Share list" } - } } } div class="list-layout" { @@ -158,38 +167,22 @@ pub fn list_page( div class="list-heading" { div { p class="eyebrow" { "SHARED LIST" } - h1 { (access.list.name) } + h1 { (list.name) } p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" } } } - (list_content_fragment(&access.list, items, categories, csrf_token, false)) + (list_content_fragment(list, items, categories, csrf_token, false)) } aside class="side-column" { (presence_panel(presence, false)) - (categories_panel(&access.list, categories, csrf_token, false)) - @if is_owner { - section id="sharing" class="panel sharing-panel" { - div class="panel-heading" { h2 { "Share this list" } } - p { "Create a one-time invite link for someone you shop with." } - form - hx-post=(format!("/lists/{}/invitations", access.list.id)) - hx-target="#invite-result" - hx-swap="innerHTML" - class="stack" - { - input type="hidden" name="csrf" value=(csrf_token); - button class="button button-secondary" type="submit" { "Create invite link" } - } - div id="invite-result" class="invite-result" {} - } - } + (categories_panel(list, categories, csrf_token, false)) section class="panel tip-panel" { span class="tip-label" { "TIP" } p { "Check items off as you go. Everyone viewing this list will see it instantly." } } } } - div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", access.list.id)) {} + div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", list.id)) {} }, ) } @@ -448,26 +441,26 @@ pub fn categories_panel( } pub fn live_list_fragments( - access: &ListAccess, + list: &GroceryList, items: &[Item], categories: &[Category], csrf_token: &str, ) -> Markup { html! { - (list_content_fragment(&access.list, items, categories, csrf_token, true)) - (categories_panel(&access.list, categories, csrf_token, true)) + (list_content_fragment(list, items, categories, csrf_token, true)) + (categories_panel(list, categories, csrf_token, true)) } } pub fn category_created( - access: &ListAccess, + list: &GroceryList, items: &[Item], categories: &[Category], csrf_token: &str, ) -> Markup { html! { p class="category-success" { "Category added." } - (live_list_fragments(access, items, categories, csrf_token)) + (live_list_fragments(list, items, categories, csrf_token)) } } @@ -509,7 +502,6 @@ fn presence_content(presence: &[PresenceUser]) -> Markup { } pub fn invite_page( - info: &InvitationInfo, user: Option<&User>, token: &str, error: Option<&str>, @@ -521,8 +513,8 @@ pub fn invite_page( html! { div class="auth-card invite-card" { p class="eyebrow" { "YOU'RE INVITED" } - h1 { "Join " (info.list_name) } - p class="lede" { "Shop together and keep the list in sync." } + h1 { "Join the shared lists" } + p class="lede" { "Shop together and keep all the lists in sync." } @if let Some(error) = error { div class="alert alert-error" role="alert" { (error) } } @@ -532,14 +524,15 @@ pub fn invite_page( @if let Some(csrf_token) = csrf_token { input type="hidden" name="csrf" value=(csrf_token); } - button class="button button-primary" type="submit" { "Join list" } + button class="button button-primary" type="submit" { "Join lists" } } + p class="muted" { "Want to join as a different person? Create a new account below." } } @else { p { "Sign in or create an account to accept this invite." } - div class="invite-actions" { - a class="button button-primary" href=(format!("/login?invite={}", token)) { "Sign in" } - a class="button button-secondary" href=(format!("/register?invite={}", token)) { "Create account" } - } + } + div class="invite-actions" { + a class="button button-primary" href=(format!("/register?invite={}", token)) { "Create account" } + a class="button button-secondary" href=(format!("/login?invite={}", token)) { "Sign in" } } } },