simplify list ownership & invitations
This commit is contained in:
@@ -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<Path>) -> DbResult<Self> {
|
||||
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<Vec<ListSummary>> {
|
||||
pub async fn list_summaries(&self) -> DbResult<Vec<GroceryList>> {
|
||||
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 {
|
||||
.query_map([], |row| {
|
||||
Ok(GroceryList {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
owner_id: row.get(2)?,
|
||||
revision: row.get(3)?,
|
||||
},
|
||||
role: row.get(4)?,
|
||||
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<GroceryList> {
|
||||
pub async fn create_list(&self, name: String) -> DbResult<GroceryList> {
|
||||
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<Option<ListAccess>> {
|
||||
pub async fn list_access(&self, list_id: String) -> DbResult<Option<GroceryList>> {
|
||||
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 {
|
||||
Ok(GroceryList {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
owner_id: row.get(2)?,
|
||||
revision: row.get(3)?,
|
||||
},
|
||||
role: row.get(4)?,
|
||||
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<i64> {
|
||||
pub async fn create_invitation(&self, created_by: String, token: String) -> DbResult<i64> {
|
||||
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<Option<InvitationInfo>> {
|
||||
pub async fn invitation(&self, token: String) -> DbResult<bool> {
|
||||
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<String> {
|
||||
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(),
|
||||
|
||||
+26
-33
@@ -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}/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<Response, AppError> {
|
||||
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<String>,
|
||||
) -> 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 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<ItemForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<CheckForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<ItemForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<CsrfForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<CategoryForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<AppState>,
|
||||
user: CurrentUser,
|
||||
Path(list_id): Path<String>,
|
||||
LoggedForm(form): LoggedForm<CsrfForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<CsrfForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<String>,
|
||||
websocket: WebSocketUpgrade,
|
||||
) -> Result<Response, AppError> {
|
||||
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<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 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<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 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<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 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<db::ListAccess, AppError> {
|
||||
) -> Result<db::GroceryList, AppError> {
|
||||
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<bool, Ap
|
||||
let Some(invite) = invite.filter(|invite| !invite.is_empty()) else {
|
||||
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> {
|
||||
|
||||
+61
-68
@@ -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,53 +125,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku
|
||||
button class="button button-primary" type="submit" { "Create list" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_page(
|
||||
user: &User,
|
||||
access: &ListAccess,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
presence: &[PresenceUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let is_owner = access.role == "owner";
|
||||
page(
|
||||
&access.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" {
|
||||
section class="panel list-panel" {
|
||||
div class="list-heading" {
|
||||
div {
|
||||
p class="eyebrow" { "SHARED LIST" }
|
||||
h1 { (access.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))
|
||||
}
|
||||
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." }
|
||||
div class="panel-heading" { h2 { "Invite someone" } }
|
||||
p { "Create a one-time invite link so a new person can join." }
|
||||
form
|
||||
hx-post=(format!("/lists/{}/invitations", access.list.id))
|
||||
hx-post="/invitations"
|
||||
hx-target="#invite-result"
|
||||
hx-swap="innerHTML"
|
||||
class="stack"
|
||||
@@ -183,13 +140,49 @@ pub fn list_page(
|
||||
div id="invite-result" class="invite-result" {}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_page(
|
||||
user: &User,
|
||||
list: &GroceryList,
|
||||
items: &[Item],
|
||||
categories: &[Category],
|
||||
presence: &[PresenceUser],
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
page(
|
||||
&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" }
|
||||
}
|
||||
}
|
||||
div class="list-layout" {
|
||||
section class="panel list-panel" {
|
||||
div class="list-heading" {
|
||||
div {
|
||||
p class="eyebrow" { "SHARED LIST" }
|
||||
h1 { (list.name) }
|
||||
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
|
||||
}
|
||||
}
|
||||
(list_content_fragment(list, items, categories, csrf_token, false))
|
||||
}
|
||||
aside class="side-column" {
|
||||
(presence_panel(presence, false))
|
||||
(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" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user