simplify list ownership & invitations
This commit is contained in:
@@ -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
@@ -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| ¤t.session.user),
|
user.as_ref().map(|current| ¤t.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
@@ -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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user