simplify list ownership & invitations

This commit is contained in:
2026-08-01 15:46:38 -04:00
parent 65886903cd
commit 5da5fee942
3 changed files with 138 additions and 223 deletions
+70 -141
View File
@@ -46,22 +46,9 @@ pub struct SessionUser {
pub struct GroceryList { pub struct GroceryList {
pub id: String, pub id: String,
pub name: String, pub name: String,
pub owner_id: String,
pub revision: i64, 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)] #[derive(Clone, Debug)]
pub struct Item { pub struct Item {
pub id: String, pub id: String,
@@ -80,11 +67,6 @@ pub struct Category {
pub name: String, pub name: String,
} }
#[derive(Clone, Debug)]
pub struct InvitationInfo {
pub list_name: String,
}
impl Database { impl Database {
pub fn open(path: impl AsRef<Path>) -> DbResult<Self> { pub fn open(path: impl AsRef<Path>) -> DbResult<Self> {
let connection = Connection::open(path).map_err(sql_error)?; let connection = Connection::open(path).map_err(sql_error)?;
@@ -240,27 +222,21 @@ impl Database {
.await .await
} }
pub async fn list_summaries(&self, user_id: String) -> DbResult<Vec<ListSummary>> { pub async fn list_summaries(&self) -> DbResult<Vec<GroceryList>> {
self.call(move |connection| { self.call(move |connection| {
let mut statement = connection let mut statement = connection
.prepare( .prepare(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role "SELECT l.id, l.name, l.revision
FROM lists l FROM lists l
JOIN list_members m ON m.list_id = l.id
WHERE m.user_id = ?1
ORDER BY l.created_at DESC", ORDER BY l.created_at DESC",
) )
.map_err(sql_error)?; .map_err(sql_error)?;
let rows = statement let rows = statement
.query_map(params![user_id], |row| { .query_map([], |row| {
Ok(ListSummary { Ok(GroceryList {
list: GroceryList { id: row.get(0)?,
id: row.get(0)?, name: row.get(1)?,
name: row.get(1)?, revision: row.get(2)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
}) })
}) })
.map_err(sql_error)?; .map_err(sql_error)?;
@@ -270,27 +246,19 @@ impl Database {
.await .await
} }
pub async fn create_list(&self, owner_id: String, name: String) -> DbResult<GroceryList> { pub async fn create_list(&self, name: String) -> DbResult<GroceryList> {
self.call(move |connection| { self.call(move |connection| {
let list = GroceryList { let list = GroceryList {
id: Uuid::new_v4().to_string(), id: Uuid::new_v4().to_string(),
name, name,
owner_id: owner_id.clone(),
revision: 0, revision: 0,
}; };
let transaction = connection.transaction().map_err(sql_error)?; let transaction = connection.transaction().map_err(sql_error)?;
transaction transaction
.execute( .execute(
"INSERT INTO lists (id, name, owner_id, revision, created_at) "INSERT INTO lists (id, name, revision, created_at)
VALUES (?1, ?2, ?3, 0, ?4)", VALUES (?1, ?2, 0, ?3)",
params![list.id, list.name, owner_id, now()], params![list.id, list.name, 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],
) )
.map_err(sql_error)?; .map_err(sql_error)?;
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() { for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
@@ -314,28 +282,19 @@ impl Database {
.await .await
} }
pub async fn list_access( pub async fn list_access(&self, list_id: String) -> DbResult<Option<GroceryList>> {
&self,
list_id: String,
user_id: String,
) -> DbResult<Option<ListAccess>> {
self.call(move |connection| { self.call(move |connection| {
connection connection
.query_row( .query_row(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role "SELECT l.id, l.name, l.revision
FROM lists l FROM lists l
JOIN list_members m ON m.list_id = l.id WHERE l.id = ?1",
WHERE l.id = ?1 AND m.user_id = ?2", params![list_id],
params![list_id, user_id],
|row| { |row| {
Ok(ListAccess { Ok(GroceryList {
list: GroceryList { id: row.get(0)?,
id: row.get(0)?, name: row.get(1)?,
name: row.get(1)?, revision: row.get(2)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
}) })
}, },
) )
@@ -547,19 +506,14 @@ impl Database {
.await .await
} }
pub async fn create_invitation( pub async fn create_invitation(&self, created_by: String, token: String) -> DbResult<i64> {
&self,
list_id: String,
created_by: String,
token: String,
) -> DbResult<i64> {
self.call(move |connection| { self.call(move |connection| {
let expires_at = now() + 60 * 60 * 24 * 7; let expires_at = now() + 60 * 60 * 24 * 7;
connection connection
.execute( .execute(
"INSERT INTO invitations (token_hash, list_id, created_by, expires_at) "INSERT INTO invitations (token_hash, created_by, expires_at)
VALUES (?1, ?2, ?3, ?4)", VALUES (?1, ?2, ?3)",
params![hash_secret(&token), list_id, created_by, expires_at], params![hash_secret(&token), created_by, expires_at],
) )
.map_err(sql_error)?; .map_err(sql_error)?;
Ok(expires_at) Ok(expires_at)
@@ -567,47 +521,39 @@ impl Database {
.await .await
} }
pub async fn invitation(&self, token: String) -> DbResult<Option<InvitationInfo>> { pub async fn invitation(&self, token: String) -> DbResult<bool> {
self.call(move |connection| { self.call(move |connection| {
connection let valid = connection
.query_row( .query_row(
"SELECT l.name "SELECT 1 FROM invitations
FROM invitations i WHERE token_hash = ?1 AND expires_at > ?2",
JOIN lists l ON l.id = i.list_id
WHERE i.token_hash = ?1 AND i.expires_at > ?2",
params![hash_secret(&token), now()], params![hash_secret(&token), now()],
|row| { |_| Ok(()),
Ok(InvitationInfo {
list_name: row.get(0)?,
})
},
) )
.optional() .optional()
.map_err(sql_error) .map_err(sql_error)?
.is_some();
Ok(valid)
}) })
.await .await
} }
pub async fn accept_invitation(&self, token: String, user_id: String) -> DbResult<String> { pub async fn accept_invitation(&self, token: String) -> DbResult<()> {
self.call(move |connection| { self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?; let transaction = connection.transaction().map_err(sql_error)?;
let invitation = transaction let valid = transaction
.query_row( .query_row(
"SELECT list_id FROM invitations "SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2", WHERE token_hash = ?1 AND expires_at > ?2",
params![hash_secret(&token), now()], params![hash_secret(&token), now()],
|row| row.get::<_, String>(0), |_| Ok(()),
) )
.optional() .optional()
.map_err(sql_error)? .map_err(sql_error)?
.ok_or(DbError::NotFound)?; .is_some();
transaction if !valid {
.execute( return Err(DbError::NotFound);
"INSERT OR IGNORE INTO list_members (list_id, user_id, role) }
VALUES (?1, ?2, 'member')",
params![invitation, user_id],
)
.map_err(sql_error)?;
transaction transaction
.execute( .execute(
"DELETE FROM invitations WHERE token_hash = ?1", "DELETE FROM invitations WHERE token_hash = ?1",
@@ -615,7 +561,7 @@ impl Database {
) )
.map_err(sql_error)?; .map_err(sql_error)?;
transaction.commit().map_err(sql_error)?; transaction.commit().map_err(sql_error)?;
Ok(invitation) Ok(())
}) })
.await .await
} }
@@ -653,16 +599,9 @@ fn migrate(connection: &Connection) -> DbResult<()> {
CREATE TABLE IF NOT EXISTS lists ( CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL 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 ( CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE, 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 ( CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY, 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, created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL 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 items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(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 sessions_user_idx ON sessions(user_id);",
CREATE INDEX IF NOT EXISTS list_members_user_idx ON list_members(user_id);",
) )
.map_err(sql_error)?; .map_err(sql_error)?;
@@ -798,12 +735,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn creates_a_list_and_item() { async fn creates_a_list_and_item() {
let database = Database::open_in_memory().unwrap(); 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 let list = database
.create_list(user.id.clone(), "Weekly shop".into()) .create_list("Weekly shop".into())
.await .await
.unwrap(); .unwrap();
database database
@@ -829,12 +762,8 @@ mod tests {
.create_user("owner@example.com".into(), "Owner".into(), "hash".into()) .create_user("owner@example.com".into(), "Owner".into(), "hash".into())
.await .await
.unwrap(); .unwrap();
let member = database
.create_user("member@example.com".into(), "Member".into(), "hash".into())
.await
.unwrap();
let list = database let list = database
.create_list(owner.id.clone(), "Household".into()) .create_list("Household".into())
.await .await
.unwrap(); .unwrap();
assert_eq!(database.categories(list.id.clone()).await.unwrap().len(), 6); 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.user.id, owner.id);
assert_eq!(session.csrf_token, csrf_token); 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(); let invitation_token = "test-invitation".to_owned();
database database
.create_invitation(list.id.clone(), owner.id, invitation_token.clone()) .create_invitation(owner.id.clone(), invitation_token.clone())
.await .await
.unwrap(); .unwrap();
assert_eq!( assert!(
database database
.invitation(invitation_token.clone()) .invitation(invitation_token.clone())
.await .await
.unwrap() .unwrap()
.unwrap()
.list_name,
"Household"
); );
let accepted_list = database database
.accept_invitation(invitation_token.clone(), member.id.clone()) .accept_invitation(invitation_token.clone())
.await .await
.unwrap(); .unwrap();
assert_eq!(accepted_list, list.id);
assert!( assert!(
database !database
.invitation(invitation_token) .invitation(invitation_token)
.await .await
.unwrap() .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!( assert_eq!(
database database
.list_access(list.id, member.id) .list_access(future_list.id)
.await .await
.unwrap() .unwrap()
.unwrap() .unwrap()
.role, .name,
"member" "Future shop"
); );
} }
#[tokio::test] #[tokio::test]
async fn checked_state_is_set_not_toggled() { async fn checked_state_is_set_not_toggled() {
let database = Database::open_in_memory().unwrap(); let database = Database::open_in_memory().unwrap();
let user = database let list = database.create_list("List".into()).await.unwrap();
.create_user("check@example.com".into(), "Checker".into(), "hash".into())
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
database database
.add_item( .add_item(
list.id.clone(), list.id.clone(),
@@ -923,15 +860,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn checking_an_item_does_not_change_list_order() { async fn checking_an_item_does_not_change_list_order() {
let database = Database::open_in_memory().unwrap(); let database = Database::open_in_memory().unwrap();
let user = database let list = database.create_list("List".into()).await.unwrap();
.create_user(
"order@example.com".into(),
"Order Tester".into(),
"hash".into(),
)
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
database database
.add_item( .add_item(
list.id.clone(), list.id.clone(),
+26 -33
View File
@@ -267,7 +267,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/lists/{list_id}/items/{item_id}/edit", post(edit_item)) .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}/items/{item_id}/delete", post(delete_item))
.route("/lists/{list_id}/categories", post(create_category)) .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("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page)) .route("/invite/{token}", get(invitation_page))
.route("/invite/{token}/accept", post(accept_invitation)) .route("/invite/{token}/accept", post(accept_invitation))
@@ -435,7 +435,7 @@ async fn lists_page(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let lists = state let lists = state
.db .db
.list_summaries(user.session.user.id.clone()) .list_summaries()
.await?; .await?;
Ok(html_response(views::lists_page( Ok(html_response(views::lists_page(
&user.session.user, &user.session.user,
@@ -456,7 +456,7 @@ async fn create_list(
"List names must be between 1 and 80 characters.".into(), "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()) Ok(Redirect::to(&format!("/lists/{}", list.id)).into_response())
} }
@@ -465,7 +465,7 @@ async fn list_page(
user: CurrentUser, user: CurrentUser,
Path(list_id): Path<String>, Path(list_id): Path<String>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
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 items = state.db.items(list_id.clone()).await?;
let categories = state.db.categories(list_id.clone()).await?; let categories = state.db.categories(list_id.clone()).await?;
let presence = state.hub.presence(&list_id).await; let presence = state.hub.presence(&list_id).await;
@@ -486,7 +486,7 @@ async fn add_item(
LoggedForm(form): LoggedForm<ItemForm>, LoggedForm(form): LoggedForm<ItemForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; 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 name = form.name.trim().to_owned();
let quantity = form.quantity.trim().to_owned(); let quantity = form.quantity.trim().to_owned();
let note = form.note.trim().to_owned(); let note = form.note.trim().to_owned();
@@ -514,7 +514,7 @@ async fn check_item(
LoggedForm(form): LoggedForm<CheckForm>, LoggedForm(form): LoggedForm<CheckForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; 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() { let checked = match form.checked.as_str() {
"1" | "true" => true, "1" | "true" => true,
"0" | "false" => false, "0" | "false" => false,
@@ -538,7 +538,7 @@ async fn edit_item(
LoggedForm(form): LoggedForm<ItemForm>, LoggedForm(form): LoggedForm<ItemForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; 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 name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 { if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -570,7 +570,7 @@ async fn delete_item(
LoggedForm(form): LoggedForm<CsrfForm>, LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; 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?; let revision = state.db.delete_item(list_id.clone(), item_id).await?;
state state
.hub .hub
@@ -586,7 +586,7 @@ async fn create_category(
LoggedForm(form): LoggedForm<CategoryForm>, LoggedForm(form): LoggedForm<CategoryForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; 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 name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 60 { if name.is_empty() || name.chars().count() > 60 {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -599,7 +599,7 @@ async fn create_category(
.publish_list_changed(list_id.clone(), revision) .publish_list_changed(list_id.clone(), revision)
.await; .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 items = state.db.items(list_id.clone()).await?;
let categories = state.db.categories(list_id).await?; let categories = state.db.categories(list_id).await?;
Ok(html_response(views::category_created( Ok(html_response(views::category_created(
@@ -613,20 +613,13 @@ async fn create_category(
async fn create_invitation( async fn create_invitation(
State(state): State<AppState>, State(state): State<AppState>,
user: CurrentUser, user: CurrentUser,
Path(list_id): Path<String>,
LoggedForm(form): LoggedForm<CsrfForm>, LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; 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(); let token = db::new_secret();
state state
.db .db
.create_invitation(list_id, user.session.user.id, token.clone()) .create_invitation(user.session.user.id, token.clone())
.await?; .await?;
let url = format!( let url = format!(
"{}/invite/{token}", "{}/invite/{token}",
@@ -643,11 +636,12 @@ async fn invitation_page(
let info = state let info = state
.db .db
.invitation(token.clone()) .invitation(token.clone())
.await? .await?;
.ok_or(AppError::NotFound)?; if !info {
return Err(AppError::NotFound);
}
let user = optional_user(&state, &headers).await?; let user = optional_user(&state, &headers).await?;
Ok(html_response(views::invite_page( Ok(html_response(views::invite_page(
&info,
user.as_ref().map(|current| &current.session.user), user.as_ref().map(|current| &current.session.user),
&token, &token,
None, None,
@@ -663,11 +657,11 @@ async fn accept_invitation(
LoggedForm(form): LoggedForm<CsrfForm>, LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?; verify_csrf(&user, &form.csrf)?;
let list_id = state state
.db .db
.accept_invitation(token, user.session.user.id) .accept_invitation(token)
.await?; .await?;
Ok(Redirect::to(&format!("/lists/{list_id}")).into_response()) Ok(Redirect::to("/lists").into_response())
} }
async fn list_stream( async fn list_stream(
@@ -676,7 +670,7 @@ async fn list_stream(
Path(list_id): Path<String>, Path(list_id): Path<String>,
websocket: WebSocketUpgrade, websocket: WebSocketUpgrade,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
require_access(&state, &user, &list_id).await?; require_access(&state, &list_id).await?;
let state_for_socket = state.clone(); let state_for_socket = state.clone();
let user_for_socket = user.clone(); let user_for_socket = user.clone();
Ok(websocket Ok(websocket
@@ -784,7 +778,7 @@ async fn websocket_snapshot(
list_id: &str, list_id: &str,
presence: &[hub::PresenceUser], presence: &[hub::PresenceUser],
) -> Result<String, AppError> { ) -> Result<String, AppError> {
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 items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?; let categories = state.db.categories(list_id.to_owned()).await?;
Ok( Ok(
@@ -799,7 +793,7 @@ async fn websocket_list_update(
user: &CurrentUser, user: &CurrentUser,
list_id: &str, list_id: &str,
) -> Result<String, AppError> { ) -> Result<String, AppError> {
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 items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?; let categories = state.db.categories(list_id.to_owned()).await?;
Ok( Ok(
@@ -813,11 +807,11 @@ async fn list_fragment_response(
user: &CurrentUser, user: &CurrentUser,
list_id: &str, list_id: &str,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
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 items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?; let categories = state.db.categories(list_id.to_owned()).await?;
Ok(html_response(views::list_items_fragment( Ok(html_response(views::list_items_fragment(
&access.list, &access,
&items, &items,
&categories, &categories,
&user.session.csrf_token, &user.session.csrf_token,
@@ -827,12 +821,11 @@ async fn list_fragment_response(
async fn require_access( async fn require_access(
state: &AppState, state: &AppState,
user: &CurrentUser,
list_id: &str, list_id: &str,
) -> Result<db::ListAccess, AppError> { ) -> Result<db::GroceryList, AppError> {
state state
.db .db
.list_access(list_id.to_owned(), user.session.user.id.clone()) .list_access(list_id.to_owned())
.await? .await?
.ok_or(AppError::NotFound) .ok_or(AppError::NotFound)
} }
@@ -877,7 +870,7 @@ async fn can_register(state: &AppState, invite: Option<&str>) -> Result<bool, Ap
let Some(invite) = invite.filter(|invite| !invite.is_empty()) else { let Some(invite) = invite.filter(|invite| !invite.is_empty()) else {
return Ok(false); return Ok(false);
}; };
Ok(state.db.invitation(invite.to_owned()).await?.is_some()) Ok(state.db.invitation(invite.to_owned()).await?)
} }
fn hash_password(password: &str) -> Result<String, String> { fn hash_password(password: &str) -> Result<String, String> {
+42 -49
View File
@@ -1,7 +1,7 @@
use maud::{DOCTYPE, Markup, html}; use maud::{DOCTYPE, Markup, html};
use crate::{ use crate::{
db::{Category, GroceryList, InvitationInfo, Item, ListAccess, ListSummary, User}, db::{Category, GroceryList, Item, User},
hub::PresenceUser, hub::PresenceUser,
}; };
@@ -13,7 +13,7 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
div class="auth-card" { div class="auth-card" {
p class="eyebrow" { "SUSTENANCE" } p class="eyebrow" { "SUSTENANCE" }
h1 { "Welcome back" } 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 { @if let Some(error) = error {
div class="alert alert-error" role="alert" { (error) } div class="alert alert-error" role="alert" { (error) }
} }
@@ -69,23 +69,23 @@ pub fn registration_closed_page() -> Markup {
None, None,
html! { html! {
div class="auth-card" { div class="auth-card" {
p class="eyebrow" { "PRIVATE HOUSEHOLD" } p class="eyebrow" { "PRIVATE LISTS" }
h1 { "Registration is invite-only" } 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" } 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( page(
"Your lists", "Your lists",
Some(user), Some(user),
html! { html! {
div class="page-heading" { div class="page-heading" {
div { div {
p class="eyebrow" { "YOUR HOUSEHOLD" } p class="eyebrow" { "SHARED LISTS" }
h1 { "Grocery lists" } h1 { "Grocery lists" }
p class="lede" { "Everything you need, in one place." } 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 { } @else {
div class="list-cards" { div class="list-cards" {
@for summary in lists { @for list in lists {
a class="list-card" href=(format!("/lists/{}", summary.list.id)) { a class="list-card" href=(format!("/lists/{}", list.id)) {
span class="list-card-icon" { "" } span class="list-card-icon" { "" }
span class="list-card-copy" { span class="list-card-copy" {
strong { (summary.list.name) } strong { (list.name) }
small { @if summary.role == "owner" { "Owner" } @else { "Member" } }
} }
span class="list-card-arrow" { "" } 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" } 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( pub fn list_page(
user: &User, user: &User,
access: &ListAccess, list: &GroceryList,
items: &[Item], items: &[Item],
categories: &[Category], categories: &[Category],
presence: &[PresenceUser], presence: &[PresenceUser],
csrf_token: &str, csrf_token: &str,
) -> Markup { ) -> Markup {
let is_owner = access.role == "owner";
page( page(
&access.list.name, &list.name,
Some(user), Some(user),
html! { html! {
div class="list-topbar" { div class="list-topbar" {
a class="back-link" href="/lists" { "← All lists" } a class="back-link" href="/lists" { "← All lists" }
div class="list-topbar-actions" { div class="list-topbar-actions" {
span class="live-pill" { span class="live-dot" {} "Live" } 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" { div class="list-layout" {
@@ -158,38 +167,22 @@ pub fn list_page(
div class="list-heading" { div class="list-heading" {
div { div {
p class="eyebrow" { "SHARED LIST" } 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" } 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" { aside class="side-column" {
(presence_panel(presence, false)) (presence_panel(presence, false))
(categories_panel(&access.list, categories, csrf_token, false)) (categories_panel(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" {}
}
}
section class="panel tip-panel" { section class="panel tip-panel" {
span class="tip-label" { "TIP" } span class="tip-label" { "TIP" }
p { "Check items off as you go. Everyone viewing this list will see it instantly." } 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( pub fn live_list_fragments(
access: &ListAccess, list: &GroceryList,
items: &[Item], items: &[Item],
categories: &[Category], categories: &[Category],
csrf_token: &str, csrf_token: &str,
) -> Markup { ) -> Markup {
html! { html! {
(list_content_fragment(&access.list, items, categories, csrf_token, true)) (list_content_fragment(list, items, categories, csrf_token, true))
(categories_panel(&access.list, categories, csrf_token, true)) (categories_panel(list, categories, csrf_token, true))
} }
} }
pub fn category_created( pub fn category_created(
access: &ListAccess, list: &GroceryList,
items: &[Item], items: &[Item],
categories: &[Category], categories: &[Category],
csrf_token: &str, csrf_token: &str,
) -> Markup { ) -> Markup {
html! { html! {
p class="category-success" { "Category added." } 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( pub fn invite_page(
info: &InvitationInfo,
user: Option<&User>, user: Option<&User>,
token: &str, token: &str,
error: Option<&str>, error: Option<&str>,
@@ -521,8 +513,8 @@ pub fn invite_page(
html! { html! {
div class="auth-card invite-card" { div class="auth-card invite-card" {
p class="eyebrow" { "YOU'RE INVITED" } p class="eyebrow" { "YOU'RE INVITED" }
h1 { "Join " (info.list_name) } h1 { "Join the shared lists" }
p class="lede" { "Shop together and keep the list in sync." } p class="lede" { "Shop together and keep all the lists in sync." }
@if let Some(error) = error { @if let Some(error) = error {
div class="alert alert-error" role="alert" { (error) } div class="alert alert-error" role="alert" { (error) }
} }
@@ -532,14 +524,15 @@ pub fn invite_page(
@if let Some(csrf_token) = csrf_token { @if let Some(csrf_token) = csrf_token {
input type="hidden" name="csrf" value=(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 { } @else {
p { "Sign in or create an account to accept this invite." } 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" } div class="invite-actions" {
a class="button button-secondary" href=(format!("/register?invite={}", token)) { "Create account" } a class="button button-primary" href=(format!("/register?invite={}", token)) { "Create account" }
} a class="button button-secondary" href=(format!("/login?invite={}", token)) { "Sign in" }
} }
} }
}, },