major features update
This commit is contained in:
parent
519fb49901
commit
4e2fab67c5
48 changed files with 3925 additions and 208 deletions
114
src/web/auth.rs
Normal file
114
src/web/auth.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::{
|
||||
Form, Router,
|
||||
extract::Query,
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::{get, post},
|
||||
};
|
||||
use maud::{DOCTYPE, html};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::user::{AuthSession, Credentials};
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct NextUrl {
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(self::post::login))
|
||||
.route("/login", get(self::get::login))
|
||||
.route("/logout", get(self::get::logout))
|
||||
}
|
||||
|
||||
mod post {
|
||||
use super::*;
|
||||
|
||||
#[axum::debug_handler]
|
||||
pub async fn login(
|
||||
mut auth_session: AuthSession,
|
||||
State(mut state): State<AppState>,
|
||||
Query(NextUrl { next }): Query<NextUrl>,
|
||||
Form(creds): Form<Credentials>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
let user = match auth_session.authenticate(creds.clone()).await {
|
||||
Ok(Some(user)) => user,
|
||||
Ok(None) => {
|
||||
return Err(AppError(anyhow::Error::msg(
|
||||
"Username and password do not match",
|
||||
)));
|
||||
}
|
||||
Err(_) => return Err(AppError(anyhow::Error::msg("Internal server error"))),
|
||||
};
|
||||
|
||||
if auth_session.login(&user).await.is_err() {
|
||||
return Err(AppError(anyhow::Error::msg("Server error during login")));
|
||||
}
|
||||
|
||||
state.init(&user).await?;
|
||||
|
||||
if let Some(url) = next {
|
||||
headers.insert("HX-Redirect", url.parse()?);
|
||||
} else {
|
||||
headers.insert("HX-Redirect", "/".parse()?);
|
||||
}
|
||||
|
||||
Ok((headers, "ok"))
|
||||
}
|
||||
}
|
||||
|
||||
mod get {
|
||||
use super::*;
|
||||
|
||||
pub async fn login(Query(NextUrl { next }): Query<NextUrl>) -> impl IntoResponse {
|
||||
let post_url = format!(
|
||||
"/login{}",
|
||||
next.map_or("".to_string(), |n| format!("?next={}", n))
|
||||
);
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
meta name="viewport" content="width=device-width";
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
|
||||
link rel="stylesheet" type="text/css" href="/static/index.css";
|
||||
link rel="stylesheet" type="text/css" href="/static/login.css";
|
||||
title { "Mascarpone" }
|
||||
}
|
||||
body hx-ext="response-targets" {
|
||||
h1 { "Mascarpone" }
|
||||
form hx-post=(post_url) hx-target-error="#error" x-data="{ user: '', pass: '' }" {
|
||||
label for="username" { "Username" }
|
||||
input name="username" #username autofocus x-model="user";
|
||||
label for="password" { "Password" }
|
||||
input name="password" #password type="password" x-model="pass";
|
||||
|
||||
input type="submit" value="login" x-bind:disabled="!(user.length && pass.length)";
|
||||
#error {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
mut auth_session: AuthSession,
|
||||
State(mut state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = auth_session.user.clone();
|
||||
auth_session.logout().await?;
|
||||
|
||||
if let Some(user) = user {
|
||||
state.remove(&user);
|
||||
}
|
||||
|
||||
Ok(Redirect::to("/login").into_response())
|
||||
}
|
||||
}
|
||||
443
src/web/contact.rs
Normal file
443
src/web/contact.rs
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
use axum::{
|
||||
Router,
|
||||
extract::{State, path::Path},
|
||||
http::HeaderMap,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
use chrono::DateTime;
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::{QueryBuilder, Sqlite};
|
||||
|
||||
use super::Layout;
|
||||
use super::home::journal_section;
|
||||
use crate::db::DbId;
|
||||
use crate::models::user::AuthSession;
|
||||
use crate::models::{HydratedContact, JournalEntry};
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Address {
|
||||
pub id: DbId,
|
||||
pub contact_id: DbId,
|
||||
pub label: Option<String>,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/contact/new", post(self::post::contact))
|
||||
.route("/contact/{contact_id}", get(self::get::contact))
|
||||
.route("/contact/{contact_id}", put(self::put::contact))
|
||||
.route("/contact/{contact_id}", delete(self::delete::contact))
|
||||
.route("/contact/{contact_id}/edit", get(self::get::contact_edit))
|
||||
}
|
||||
|
||||
fn human_delta(delta: &chrono::TimeDelta) -> String {
|
||||
if delta.num_days() == 0 {
|
||||
return "today".to_string();
|
||||
}
|
||||
|
||||
let mut result = "in ".to_string();
|
||||
let mut rem = delta.clone();
|
||||
if rem.num_days().abs() >= 7 {
|
||||
let weeks = rem.num_days() / 7;
|
||||
rem -= chrono::TimeDelta::days(weeks * 7);
|
||||
result.push_str(&format!("{}w ", weeks));
|
||||
}
|
||||
if rem.num_days().abs() > 0 {
|
||||
result.push_str(&format!("{}d ", rem.num_days()));
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
mod get {
|
||||
use super::*;
|
||||
|
||||
pub async fn contact(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(contact_id): Path<u32>,
|
||||
layout: Layout,
|
||||
) -> Result<Markup, AppError> {
|
||||
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||||
let contact: HydratedContact = sqlx::query_as(
|
||||
"select id, birthday, manually_freshened_at, (
|
||||
select string_agg(name,'\x1c' order by sort)
|
||||
from names where contact_id = c.id
|
||||
) as names
|
||||
from contacts c
|
||||
where c.id = $1",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let entries: Vec<JournalEntry> = sqlx::query_as(
|
||||
"select j.id, j.value, j.date from journal_entries j
|
||||
join contact_mentions cm on j.id = cm.entry_id
|
||||
where cm.contact_id = $1",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let addresses: Vec<Address> = sqlx::query_as!(
|
||||
Address,
|
||||
"select * from addresses where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(layout.render(
|
||||
Some(vec!["/static/contact.css", "/static/journal.css"]),
|
||||
html! {
|
||||
a href=(format!("/contact/{}/edit", contact_id)) { "Edit" }
|
||||
|
||||
div id="fields" {
|
||||
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
|
||||
div {
|
||||
@for name in &contact.names {
|
||||
div { (name) }
|
||||
}
|
||||
}
|
||||
@if let Some(bday) = &contact.birthday {
|
||||
label { "birthday" }
|
||||
div {
|
||||
(bday.to_string())
|
||||
@if let Some(delta) = &bday.until_next() {
|
||||
" ("
|
||||
(human_delta(delta))
|
||||
@if let Some(age) = &bday.age() {
|
||||
", turning " (age + 1)
|
||||
}
|
||||
")"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
label { "freshened" }
|
||||
div {
|
||||
@if let Some(when) = &contact.manually_freshened_at {
|
||||
(when.date_naive().to_string())
|
||||
} @else {
|
||||
"(never)"
|
||||
}
|
||||
}
|
||||
@if addresses.len() == 1 {
|
||||
label { "address" }
|
||||
#addresses {
|
||||
.value { (addresses[0].value) }
|
||||
}
|
||||
} @else if addresses.len() > 0 {
|
||||
label { "addresses" }
|
||||
#addresses {
|
||||
@for address in addresses {
|
||||
.label {
|
||||
span { (address.label.unwrap_or(String::new())) }
|
||||
// raw nbsp instead of col-gap since i want no
|
||||
// gap when all labels are empty
|
||||
span { (PreEscaped(" ")) }
|
||||
}
|
||||
.value { (address.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(journal_section(pool, &entries).await?)
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn contact_edit(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(contact_id): Path<u32>,
|
||||
layout: Layout,
|
||||
) -> Result<Markup, AppError> {
|
||||
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||||
let contact: HydratedContact = sqlx::query_as(
|
||||
"select id, birthday, manually_freshened_at, (
|
||||
select string_agg(name,'\x1c' order by sort)
|
||||
from names where contact_id = c.id
|
||||
) as names, (
|
||||
select jes.date from journal_entries jes
|
||||
join contact_mentions cms on cms.entry_id = jes.id
|
||||
where cms.contact_id = c.id
|
||||
order by jes.date desc limit 1
|
||||
) as last_mention_date from contacts c
|
||||
where c.id = $1",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let addresses: Vec<Address> = sqlx::query_as!(
|
||||
Address,
|
||||
"select * from addresses where contact_id = $1",
|
||||
contact_id
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let cid_url = format!("/contact/{}", contact.id);
|
||||
let mfresh_str = contact
|
||||
.manually_freshened_at
|
||||
.clone()
|
||||
.map_or("".to_string(), |m| m.to_rfc3339());
|
||||
Ok(layout.render(Some(vec!["/static/contact.css"]), html! {
|
||||
form hx-ext="response-targets" {
|
||||
div {
|
||||
input type="button" value="Save" hx-put=(cid_url) hx-target-error="#error";
|
||||
input type="button" value="Delete" hx-delete=(cid_url) hx-target-error="#error";
|
||||
div #error;
|
||||
}
|
||||
|
||||
div #fields {
|
||||
label { @if contact.names.len() > 1 { "names" } @else { "name" }}
|
||||
div #names x-data=(format!("{{ names: {:?}, new_name: '' }}", &contact.names)) {
|
||||
template x-for="(name, idx) in names" {
|
||||
div {
|
||||
input name="name" x-model="name";
|
||||
input type="button" value="×" x-bind:disabled="idx == 0" x-on:click="names.splice(idx, 1)";
|
||||
input type="button" value="↑" x-bind:disabled="idx == 0" x-on:click="[names[idx-1], names[idx]] = [names[idx], names[idx-1]]";
|
||||
input type="button" value="↓" x-bind:disabled="idx == names.length - 1" x-on:click="[names[idx+1], names[idx]] = [names[idx], names[idx+1]]";
|
||||
}
|
||||
}
|
||||
div {
|
||||
input name="name" x-model="new_name" placeholder="New name";
|
||||
input type="button" value="Add" x-on:click="names.push(new_name); new_name = ''";
|
||||
}
|
||||
}
|
||||
label { "birthday" }
|
||||
div {
|
||||
input name="birthday" value=(contact.birthday.clone().map_or("".to_string(), |b| b.serialize()));
|
||||
span .hint { code { "(yyyy|--)mmdd" } " or free text" }
|
||||
}
|
||||
label { "freshened" }
|
||||
div x-data=(json!({ "date": mfresh_str })) {
|
||||
input type="hidden" name="manually_freshened_at" x-model="date";
|
||||
span x-text="date.length ? date.split('T')[0] : '(never)'" {}
|
||||
input type="button" value="Mark fresh now" x-on:click="date = new Date().toISOString()";
|
||||
}
|
||||
label { "addresses" }
|
||||
div x-data=(json!({ "addresses": addresses, "new_label": "", "new_address": "" })) {
|
||||
template x-for="(address, index) in addresses" x-bind:key="index" {
|
||||
div {
|
||||
input name="address_label" x-show="addresses.length > 1" x-model="address.label" placeholder="label";
|
||||
input name="address_value" x-model="address.value" placeholder="address";
|
||||
}
|
||||
}
|
||||
div {
|
||||
input x-show="addresses.length > 1" name="address_label" x-model="new_label" placeholder="label";
|
||||
input name="address_value" x-model="new_address" placeholder="new address";
|
||||
}
|
||||
input type="button" value="Add" x-on:click="addresses.push({ label: new_label, value: new_address }); new_label = ''; new_address = ''";
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
mod post {
|
||||
use super::*;
|
||||
|
||||
pub async fn contact(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
let pool = &state.db(&user).pool;
|
||||
|
||||
let contact_id: (u32,) =
|
||||
sqlx::query_as("insert into contacts (birthday) values (null) returning id")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"HX-Redirect",
|
||||
format!("/contact/{}/edit", contact_id.0).parse()?,
|
||||
);
|
||||
Ok((headers, "ok"))
|
||||
}
|
||||
}
|
||||
|
||||
mod put {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PutContact {
|
||||
name: Option<Vec<String>>,
|
||||
birthday: String,
|
||||
manually_freshened_at: String,
|
||||
address_label: Option<Vec<String>>,
|
||||
address_value: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
pub async fn contact(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(contact_id): Path<u32>,
|
||||
Form(payload): Form<PutContact>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
let pool = &state.db(&user).pool;
|
||||
|
||||
let birthday = if payload.birthday.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(payload.birthday)
|
||||
};
|
||||
|
||||
let manually_freshened_at = if payload.manually_freshened_at.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
DateTime::parse_from_str(&payload.manually_freshened_at, "%+")
|
||||
.map_err(|_| anyhow::Error::msg("Could not parse freshened-at string"))?
|
||||
.to_utc()
|
||||
.to_rfc3339(),
|
||||
)
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"update contacts set (birthday, manually_freshened_at) = ($1, $2) where id = $3",
|
||||
birthday,
|
||||
manually_freshened_at,
|
||||
contact_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
{
|
||||
// update addresses
|
||||
sqlx::query!("delete from addresses where contact_id = $1", contact_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(values) = payload.address_value {
|
||||
let labels = if values.len() == 1 {
|
||||
Some(vec![String::new()])
|
||||
} else {
|
||||
payload.address_label
|
||||
};
|
||||
if let Some(labels) = labels {
|
||||
let new_addresses = labels
|
||||
.into_iter()
|
||||
.zip(values)
|
||||
.filter(|(_, val)| val.len() > 0);
|
||||
for (label, value) in new_addresses {
|
||||
sqlx::query!(
|
||||
"insert into addresses (contact_id, label, value) values ($1, $2, $3)",
|
||||
contact_id,
|
||||
label,
|
||||
value
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let old_names: Vec<(String,)> = sqlx::query_as(
|
||||
"delete from contact_mentions;
|
||||
delete from names where contact_id = $1 returning name;",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut recalc_counts: QueryBuilder<Sqlite> = QueryBuilder::new(
|
||||
"select name, contact_id from (
|
||||
select name, contact_id, count(name) as ct from names where name in (",
|
||||
);
|
||||
let mut name_list = recalc_counts.separated(", ");
|
||||
for (name,) in &old_names {
|
||||
name_list.push_bind(name);
|
||||
}
|
||||
|
||||
if let Some(names) = payload.name {
|
||||
let names: Vec<String> = names.into_iter().filter(|n| n.len() > 0).collect();
|
||||
if !names.is_empty() {
|
||||
for name in &names {
|
||||
name_list.push_bind(name.clone());
|
||||
}
|
||||
|
||||
let mut name_insert: QueryBuilder<Sqlite> =
|
||||
QueryBuilder::new("insert into names (contact_id, sort, name) ");
|
||||
name_insert.push_values(names.iter().enumerate(), |mut builder, (sort, name)| {
|
||||
builder
|
||||
.push_bind(contact_id)
|
||||
.push_bind(DbId::try_from(sort).unwrap())
|
||||
.push_bind(name);
|
||||
});
|
||||
name_insert.build().persistent(false).execute(pool).await?;
|
||||
}
|
||||
}
|
||||
|
||||
name_list.push_unseparated(") group by name) where ct = 1");
|
||||
let recalc_names: Vec<(String, DbId)> = recalc_counts
|
||||
.build_query_as()
|
||||
.persistent(false)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let trie_mutex = state.contact_search(&user);
|
||||
let mut trie = trie_mutex.write().unwrap();
|
||||
for name in &old_names {
|
||||
trie.remove(&name.0);
|
||||
}
|
||||
|
||||
for name in recalc_names {
|
||||
trie.insert(name.0, name.1);
|
||||
}
|
||||
}
|
||||
|
||||
let journal_entries: Vec<JournalEntry> = sqlx::query_as("select * from journal_entries")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
for entry in journal_entries {
|
||||
entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Redirect", format!("/contact/{}", contact_id).parse()?);
|
||||
Ok((headers, "ok"))
|
||||
}
|
||||
}
|
||||
mod delete {
|
||||
use super::*;
|
||||
pub async fn contact(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(contact_id): Path<u32>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
let pool = &state.db(&user).pool;
|
||||
|
||||
sqlx::query(
|
||||
"delete from contact_mentions where contact_id = $1;
|
||||
delete from names where contact_id = $1;
|
||||
delete from contacts where id = $1;",
|
||||
)
|
||||
.bind(contact_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("HX-Redirect", "/".parse()?);
|
||||
Ok((headers, "ok"))
|
||||
}
|
||||
}
|
||||
241
src/web/home.rs
Normal file
241
src/web/home.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{Local, NaiveDate, TimeDelta};
|
||||
use maud::{Markup, html};
|
||||
use sqlx::sqlite::SqlitePool;
|
||||
|
||||
use super::Layout;
|
||||
use crate::db::DbId;
|
||||
use crate::models::user::AuthSession;
|
||||
use crate::models::{Birthday, HydratedContact, JournalEntry};
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ContactFreshness {
|
||||
contact_id: DbId,
|
||||
display: String,
|
||||
fresh_date: NaiveDate,
|
||||
fresh_str: String,
|
||||
elapsed_str: String,
|
||||
}
|
||||
|
||||
fn freshness_section(freshens: &Vec<ContactFreshness>) -> Result<Markup, AppError> {
|
||||
Ok(html! {
|
||||
div id="freshness" {
|
||||
h2 { "Stale Contacts" }
|
||||
div class="grid" {
|
||||
span .th { "name" }
|
||||
span .th { "freshened" }
|
||||
span .th { "elapsed" }
|
||||
@for contact in &freshens[0..std::cmp::min(5, freshens.len())] {
|
||||
span {
|
||||
a href=(format!("/contact/{}", contact.contact_id)) {
|
||||
(contact.display)
|
||||
}
|
||||
}
|
||||
span { (contact.fresh_str) }
|
||||
span { (contact.elapsed_str) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct KnownBirthdayContact {
|
||||
contact_id: i64,
|
||||
display: String,
|
||||
prev_birthday: NaiveDate,
|
||||
next_birthday: NaiveDate,
|
||||
}
|
||||
fn birthdays_section(
|
||||
prev_birthdays: &Vec<KnownBirthdayContact>,
|
||||
upcoming_birthdays: &Vec<KnownBirthdayContact>,
|
||||
) -> Result<Markup, AppError> {
|
||||
Ok(html! {
|
||||
div id="birthdays" {
|
||||
h2 { "Birthdays" }
|
||||
#birthday-sections {
|
||||
.datelist {
|
||||
h3 { "upcoming" }
|
||||
@for contact in &upcoming_birthdays[0..std::cmp::min(3, upcoming_birthdays.len())] {
|
||||
a href=(format!("/contact/{}", contact.contact_id)) {
|
||||
(contact.display)
|
||||
}
|
||||
span {
|
||||
(contact.next_birthday.format("%m-%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.datelist {
|
||||
h3 { "recent" }
|
||||
@for contact in &prev_birthdays[0..std::cmp::min(3, prev_birthdays.len())] {
|
||||
a href=(format!("/contact/{}", contact.contact_id)) {
|
||||
(contact.display)
|
||||
}
|
||||
span {
|
||||
(contact.prev_birthday.format("%m-%d"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn journal_section(
|
||||
pool: &SqlitePool,
|
||||
entries: &Vec<JournalEntry>,
|
||||
) -> Result<Markup, AppError> {
|
||||
Ok(html! {
|
||||
div id="journal" x-data="{ edit: false }" {
|
||||
header {
|
||||
h2 { "Journal" }
|
||||
input id="journal-edit-mode" type="checkbox" x-model="edit" {
|
||||
label for="journal-edit-mode" { "Edit" }
|
||||
}
|
||||
}
|
||||
.disclaimer {
|
||||
"Leave off year or year and month in the date field to default to what they
|
||||
are now, or leave everything blank to default to 'today'. Entries will be
|
||||
added to the top of the list regardless of date; refresh the page to re-sort."
|
||||
}
|
||||
form hx-post="/journal_entry" hx-target="next .entries" hx-swap="afterbegin" hx-on::after-request="if(event.detail.successful) this.reset()" {
|
||||
input name="date" placeholder=(Local::now().date_naive().to_string());
|
||||
textarea name="value" placeholder="New entry..." autofocus {}
|
||||
input type="submit" value="Add Entry";
|
||||
}
|
||||
|
||||
.entries {
|
||||
@for entry in entries {
|
||||
(entry.to_html(pool).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub mod get {
|
||||
use super::*;
|
||||
|
||||
pub async fn home(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
layout: Layout,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||||
let contacts: Vec<HydratedContact> = sqlx::query_as(
|
||||
"select id, birthday, manually_freshened_at, (
|
||||
select string_agg(name,'\x1c' order by sort)
|
||||
from names where contact_id = c.id
|
||||
) as names, (
|
||||
select jes.date from journal_entries jes
|
||||
join contact_mentions cms on cms.entry_id = jes.id
|
||||
where cms.contact_id = c.id
|
||||
order by jes.date desc limit 1
|
||||
) as last_mention_date from contacts c",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut freshens: Vec<ContactFreshness> = contacts
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|contact| {
|
||||
let zero = NaiveDate::from_epoch_days(0).unwrap();
|
||||
let fresh_date = std::cmp::max(
|
||||
contact
|
||||
.manually_freshened_at
|
||||
.map(|x| x.date_naive())
|
||||
.unwrap_or(zero),
|
||||
contact.last_mention_date.unwrap_or(zero),
|
||||
);
|
||||
if fresh_date == zero {
|
||||
ContactFreshness {
|
||||
contact_id: contact.id,
|
||||
display: contact.display_name(),
|
||||
fresh_date,
|
||||
fresh_str: "never".to_string(),
|
||||
elapsed_str: "".to_string(),
|
||||
}
|
||||
} else {
|
||||
let mut duration = Local::now().date_naive().signed_duration_since(fresh_date);
|
||||
let mut elapsed: Vec<String> = Vec::new();
|
||||
let y = duration.num_weeks() / 52;
|
||||
let count = |n: i64, noun: &str| {
|
||||
format!("{} {}{}", n, noun, if n > 1 { "s" } else { "" })
|
||||
};
|
||||
if y > 0 {
|
||||
elapsed.push(count(y, "year"));
|
||||
duration -= TimeDelta::weeks(y * 52);
|
||||
}
|
||||
let w = duration.num_weeks();
|
||||
if w > 0 {
|
||||
elapsed.push(count(w, "week"));
|
||||
duration -= TimeDelta::weeks(w);
|
||||
}
|
||||
let d = duration.num_days();
|
||||
if d > 0 {
|
||||
elapsed.push(count(d, "day"));
|
||||
}
|
||||
|
||||
let elapsed_str = if elapsed.is_empty() {
|
||||
"today".to_string()
|
||||
} else {
|
||||
elapsed.join(", ")
|
||||
};
|
||||
|
||||
ContactFreshness {
|
||||
contact_id: contact.id,
|
||||
display: contact.display_name(),
|
||||
fresh_date,
|
||||
fresh_str: fresh_date.to_string(),
|
||||
elapsed_str,
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
freshens.sort_by_key(|x| x.fresh_date);
|
||||
|
||||
let birthdays = contacts
|
||||
.into_iter()
|
||||
.map(|contact| {
|
||||
if let Some(Birthday::Date(date)) = &contact.birthday {
|
||||
Some(KnownBirthdayContact {
|
||||
contact_id: contact.id,
|
||||
display: contact.display_name(),
|
||||
prev_birthday: date.prev_month_day_occurrence().unwrap(),
|
||||
next_birthday: date.next_month_day_occurrence().unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.filter(|x| x.is_some())
|
||||
.map(|x| x.unwrap())
|
||||
.collect::<Vec<KnownBirthdayContact>>();
|
||||
|
||||
let mut prev_birthdays = birthdays.clone();
|
||||
prev_birthdays.sort_by_key(|x| x.prev_birthday);
|
||||
prev_birthdays.reverse();
|
||||
|
||||
let mut upcoming_birthdays = birthdays;
|
||||
upcoming_birthdays.sort_by_key(|x| x.next_birthday);
|
||||
|
||||
// I'm writing this as an n+1 query pattern deliberately
|
||||
// since I *think* the overhead of string_agg+split might
|
||||
// be worse than that of the n+1 since we're in sqlite.
|
||||
let entries: Vec<JournalEntry> =
|
||||
sqlx::query_as("select id,value,date from journal_entries order by date desc")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(layout.render(
|
||||
Some(vec!["/static/home.css", "/static/journal.css"]),
|
||||
html! {
|
||||
(freshness_section(&freshens)?)
|
||||
(birthdays_section(&prev_birthdays, &upcoming_birthdays)?)
|
||||
(journal_section(pool, &entries).await?)
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
88
src/web/ics.rs
Normal file
88
src/web/ics.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
use axum::{Router, extract::Path, response::IntoResponse, routing::get};
|
||||
use chrono::NaiveDate;
|
||||
use icalendar::{Calendar, Component, Event, EventLike};
|
||||
use regex::Regex;
|
||||
|
||||
use crate::models::user::{AuthSession, User};
|
||||
use crate::models::{Birthday, HydratedContact};
|
||||
use crate::{AppError, AppState, Database};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/cal/{path}", get(self::get::calendar))
|
||||
}
|
||||
|
||||
mod get {
|
||||
use super::*;
|
||||
|
||||
pub async fn calendar(
|
||||
auth_session: AuthSession,
|
||||
Path(ics_path): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let path_re = Regex::new(r"^(?<username>.+)-(?<hash>[0-9a-zA-Z]+).ics$").unwrap();
|
||||
|
||||
let username = if let Some(caps) = path_re.captures(&ics_path) {
|
||||
caps.name("username").unwrap().as_str()
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"No username match in path {:?} for re /^.+-[0-9a-zA-Z]+.ics$/",
|
||||
ics_path
|
||||
);
|
||||
return Err(AppError(anyhow::Error::msg("TODO: 404")));
|
||||
};
|
||||
|
||||
let user: Option<User> = auth_session.backend.find_user(username).await?;
|
||||
if user.is_none() {
|
||||
tracing::debug!("No matching user for username {:?}", username);
|
||||
return Err(AppError(anyhow::Error::msg("TODO: 404")));
|
||||
}
|
||||
|
||||
let user = user.unwrap();
|
||||
let pool = Database::for_user(&user).await?.pool;
|
||||
let expected_path: (Option<String>,) = sqlx::query_as("select ics_path from settings")
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
let debug_ics_path = ics_path.clone();
|
||||
if expected_path.0 != Some(ics_path) {
|
||||
tracing::debug!(
|
||||
"Expected path {:?} did not match request path {:?}",
|
||||
expected_path.0,
|
||||
debug_ics_path
|
||||
);
|
||||
return Err(AppError(anyhow::Error::msg("TODO: 404")));
|
||||
}
|
||||
|
||||
let calname = format!("Contact birthdays for {}", user.username);
|
||||
let mut calendar = Calendar::new();
|
||||
calendar.name(&calname);
|
||||
calendar.append_property(("PRODID", "Mascarpone CRM"));
|
||||
let contacts: Vec<HydratedContact> = sqlx::query_as(
|
||||
"select id, birthday, (
|
||||
select string_agg(name,'\x1c' order by sort)
|
||||
from names where contact_id = c.id
|
||||
) as names
|
||||
from contacts c",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
for contact in &contacts {
|
||||
if let Some(Birthday::Date(yo_date)) = &contact.birthday {
|
||||
if let Some(date) = NaiveDate::from_ymd_opt(
|
||||
yo_date.year.unwrap_or(1900),
|
||||
yo_date.month,
|
||||
yo_date.day,
|
||||
) {
|
||||
calendar.push(
|
||||
Event::new()
|
||||
.starts(date) // start-with-no-end is "all day"
|
||||
.summary(&format!("{}'s Birthday", &contact.display_name()))
|
||||
.add_property("RRULE", "FREQ=YEARLY"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("{}", calendar);
|
||||
|
||||
Ok(calendar.to_string())
|
||||
}
|
||||
}
|
||||
146
src/web/journal.rs
Normal file
146
src/web/journal.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
use axum::{
|
||||
Form, Router,
|
||||
extract::{State, path::Path},
|
||||
response::IntoResponse,
|
||||
routing::{delete, patch, post},
|
||||
};
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use maud::Markup;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::JournalEntry;
|
||||
use crate::models::user::AuthSession;
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/journal_entry", post(self::post::entry))
|
||||
.route("/journal_entry/{entry_id}", patch(self::patch::entry))
|
||||
.route("/journal_entry/{entry_id}", delete(self::delete::entry))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PostJournalEntryBody {
|
||||
date: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
mod post {
|
||||
use super::*;
|
||||
|
||||
pub async fn entry(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Form(payload): Form<PostJournalEntryBody>,
|
||||
) -> Result<Markup, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
let pool = &state.db(&user).pool;
|
||||
let now = Local::now().date_naive();
|
||||
|
||||
let date = if payload.date.is_empty() {
|
||||
now
|
||||
} else {
|
||||
let date_re =
|
||||
Regex::new(r"^(?:(?<year>[0-9]{4})-)?(?:(?<month>[0-9]{2})-)?(?<day>[0-9]{2})$")
|
||||
.unwrap();
|
||||
let caps = date_re.captures(&payload.date).ok_or(anyhow::Error::msg(
|
||||
"invalid date: must match (yyyy-)?(mm-)?dd",
|
||||
))?;
|
||||
|
||||
// unwrapping these parses is safe since it's matching [0-9]{2,4}
|
||||
let year = caps
|
||||
.name("year")
|
||||
.map(|m| m.as_str().parse::<i32>().unwrap())
|
||||
.unwrap_or(now.year());
|
||||
let month = caps
|
||||
.name("month")
|
||||
.map(|m| m.as_str().parse::<u32>().unwrap())
|
||||
.unwrap_or(now.month());
|
||||
let day = caps.name("day").unwrap().as_str().parse::<u32>().unwrap();
|
||||
|
||||
NaiveDate::from_ymd_opt(year, month, day).ok_or(anyhow::Error::msg(
|
||||
"invalid date: failed NaiveDate construction",
|
||||
))?
|
||||
};
|
||||
|
||||
// not a macro query, we want to use JournalEntry's custom FromRow
|
||||
let entry: JournalEntry = sqlx::query_as(
|
||||
"insert into journal_entries (value, date) values ($1, $2) returning id, value, date",
|
||||
)
|
||||
.bind(payload.value)
|
||||
.bind(date.to_string())
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
.await?;
|
||||
|
||||
Ok(entry.to_html(pool).await?)
|
||||
}
|
||||
}
|
||||
|
||||
mod patch {
|
||||
use super::*;
|
||||
|
||||
pub async fn entry(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(entry_id): Path<u32>,
|
||||
Form(payload): Form<PostJournalEntryBody>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
let pool = &state.db(&user).pool;
|
||||
// not a macro query, we want to use JournalEntry's custom FromRow
|
||||
let entry: JournalEntry = sqlx::query_as("select * from journal_entries where id = $1")
|
||||
.bind(entry_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"update journal_entries set date = $1, value = $2 where id = $3",
|
||||
payload.date,
|
||||
payload.value,
|
||||
entry_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if entry.value != payload.value {
|
||||
sqlx::query!("delete from contact_mentions where entry_id = $1", entry_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
entry
|
||||
.insert_mentions(state.contact_search(&user), pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(entry.to_html(pool).await?)
|
||||
}
|
||||
}
|
||||
|
||||
mod delete {
|
||||
use super::*;
|
||||
|
||||
pub async fn entry(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
Path(entry_id): Path<u32>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
let pool = &state.db(&user).pool;
|
||||
|
||||
sqlx::query(
|
||||
"delete from contact_mentions where entry_id = $1;
|
||||
delete from journal_entries where id = $2 returning id,date,value",
|
||||
)
|
||||
.bind(entry_id)
|
||||
.bind(entry_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
105
src/web/mod.rs
Normal file
105
src/web/mod.rs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
use axum::RequestPartsExt;
|
||||
use axum::extract::FromRequestParts;
|
||||
// use axum::response::{IntoResponse, Redirect};
|
||||
use http::request::Parts;
|
||||
use maud::{DOCTYPE, Markup, html};
|
||||
use sqlx::FromRow;
|
||||
|
||||
use super::models::user::{AuthSession, User};
|
||||
use super::{AppError, AppState};
|
||||
|
||||
pub mod auth;
|
||||
pub mod contact;
|
||||
pub mod home;
|
||||
pub mod ics;
|
||||
pub mod journal;
|
||||
pub mod settings;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct ContactLink {
|
||||
name: String,
|
||||
contact_id: u32,
|
||||
}
|
||||
pub struct Layout {
|
||||
contact_links: Vec<ContactLink>,
|
||||
user: User,
|
||||
}
|
||||
|
||||
impl FromRequestParts<AppState> for Layout {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let auth_session = parts
|
||||
.extract::<AuthSession>()
|
||||
.await
|
||||
.map_err(|_| anyhow::Error::msg("could not get session"))?;
|
||||
let user = auth_session.user.unwrap();
|
||||
|
||||
let contact_links: Vec<ContactLink> = sqlx::query_as(
|
||||
"select c.id as contact_id,
|
||||
coalesce(n.name, '(unnamed)') as name
|
||||
from contacts c
|
||||
left join names n on c.id = n.contact_id
|
||||
where n.sort is null or n.sort = 0
|
||||
order by name asc",
|
||||
)
|
||||
.fetch_all(&state.db(&user).pool)
|
||||
.await?;
|
||||
|
||||
Ok(Layout {
|
||||
contact_links,
|
||||
user,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub fn render(&self, css: Option<Vec<&str>>, content: Markup) -> Markup {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
link rel="stylesheet" type="text/css" href="/static/index.css";
|
||||
meta name="viewport" content="width=device-width";
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4" integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg" crossorigin="anonymous" {}
|
||||
script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.0/dist/cdn.min.js" defer {}
|
||||
@if let Some(hrefs) = css {
|
||||
@for href in hrefs {
|
||||
link rel="stylesheet" type="text/css" href=(href);
|
||||
}
|
||||
}
|
||||
}
|
||||
body x-data="{ sidebar: false }" {
|
||||
header {
|
||||
input #sidebar-show-hide type="button" x-on:click="sidebar = !sidebar" value="☰";
|
||||
h1 { a href="/" { "Mascarpone" } }
|
||||
span { (self.user.username) }
|
||||
a href="/settings" { "Settings" }
|
||||
a href="/logout" { "Logout" }
|
||||
}
|
||||
section #content {
|
||||
nav #contacts-sidebar x-bind:class="sidebar ? 'show' : 'hide'" {
|
||||
ul {
|
||||
li { button hx-post="/contact/new" { "+ Add Contact" } }
|
||||
@for link in &self.contact_links {
|
||||
li {
|
||||
a href=(format!("/contact/{}", link.contact_id)) {
|
||||
(link.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
main {
|
||||
(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
src/web/settings.rs
Normal file
167
src/web/settings.rs
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
use axum::{
|
||||
Router,
|
||||
extract::State,
|
||||
routing::{delete, get, post, put},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
use maud::{Markup, html};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use short_uuid::ShortUuid;
|
||||
|
||||
use super::Layout;
|
||||
use crate::models::user::{AuthSession, Credentials};
|
||||
use crate::{AppError, AppState};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/settings", get(self::get::settings))
|
||||
.route("/settings/ics_path", post(self::post::ics_path))
|
||||
.route("/settings/ics_path", delete(self::delete::ics_path))
|
||||
.route("/password", put(self::put::password))
|
||||
}
|
||||
|
||||
fn calendar_link(path: Option<String>) -> Markup {
|
||||
if let Some(path) = path {
|
||||
html! {
|
||||
#cal-link x-data=(json!({ "path": path })) hx-target="this" hx-swap="outerHTML" {
|
||||
a x-bind:href="window.location.origin + '/cal/' + path" {
|
||||
span x-text="window.location.origin + '/cal/'" {}
|
||||
span { (path) }
|
||||
}
|
||||
p {
|
||||
"Warning: These actions unrecoverably change your calendar's URL."
|
||||
}
|
||||
button hx-post="/settings/ics_path" { "Regenerate path" }
|
||||
button hx-delete="/settings/ics_path" { "Destroy calendar" }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
#cal-link hx-target="this" hx-swap="outerHTML" {
|
||||
div { "Birthdays calendar is disabled." }
|
||||
button hx-post="/settings/ics_path" { "Enable calendar" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod get {
|
||||
use super::*;
|
||||
|
||||
pub async fn settings(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
layout: Layout,
|
||||
) -> Result<Markup, AppError> {
|
||||
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||||
let ics_path: (Option<String>,) = sqlx::query_as("select ics_path from settings")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let ics_path: Option<String> = ics_path.0;
|
||||
|
||||
Ok(layout.render(
|
||||
Some(vec!["static/settings.css"]),
|
||||
html! {
|
||||
h2 { "Birthdays Calendar URL" }
|
||||
(calendar_link(ics_path))
|
||||
|
||||
h2 { "Change Password" }
|
||||
form x-data="{ old_p: '', new_p: '', confirm: '' }" hx-put="/password"
|
||||
hx-on::after-request="if(event.detail.successful) { this.reset(); setTimeout(() => window.location.reload(), 5000); }"
|
||||
hx-target="this" hx-target-error="this" hx-swap="beforeend" {
|
||||
label for="old" { "Current password:" }
|
||||
input id="old" name="current" x-model="old_p" type="password";
|
||||
|
||||
label for="new" { "New password:" }
|
||||
input id="new" name="new_password" x-model="new_p" type="password";
|
||||
|
||||
label for="confirm" { "Confirm:" }
|
||||
input id="confirm" x-model="confirm" type="password";
|
||||
|
||||
button type="submit" x-bind:disabled="!(new_p.length && new_p === confirm)" { "Submit" }
|
||||
.error x-show="new_p.length && confirm.length && new_p !== confirm" {
|
||||
"Passwords do not match"
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
mod post {
|
||||
use super::*;
|
||||
|
||||
pub async fn ics_path(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Markup, AppError> {
|
||||
let user = auth_session.user.unwrap();
|
||||
let pool = &state.db(&user).pool;
|
||||
|
||||
let ics_path = format!("{}-{}.ics", &user.username, ShortUuid::generate());
|
||||
|
||||
sqlx::query!("update settings set ics_path=$1", ics_path)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(calendar_link(Some(ics_path)))
|
||||
}
|
||||
}
|
||||
|
||||
mod delete {
|
||||
use super::*;
|
||||
|
||||
pub async fn ics_path(
|
||||
auth_session: AuthSession,
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Markup, AppError> {
|
||||
let pool = &state.db(&auth_session.user.unwrap()).pool;
|
||||
|
||||
sqlx::query!("update settings set ics_path=null")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(calendar_link(None))
|
||||
}
|
||||
}
|
||||
|
||||
mod put {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PassChange {
|
||||
current: String,
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
pub async fn password(
|
||||
auth_session: AuthSession,
|
||||
Form(payload): Form<PassChange>,
|
||||
) -> Result<Markup, AppError> {
|
||||
let username = auth_session.user.as_ref().unwrap().username.clone();
|
||||
|
||||
tracing::debug!("Resetting password for {}...", username);
|
||||
|
||||
let current_creds = Credentials {
|
||||
username: username.clone(),
|
||||
password: payload.current,
|
||||
};
|
||||
|
||||
let new_creds = Credentials {
|
||||
username: username,
|
||||
password: payload.new_password,
|
||||
};
|
||||
|
||||
match auth_session.authenticate(current_creds).await {
|
||||
Err(_) => Ok(html! { .error { "Server error; could not verify authentication." } }),
|
||||
Ok(None) => Ok(html! { .error { "Current password is incorrect." } }),
|
||||
Ok(Some(_)) => {
|
||||
auth_session.backend.set_password(new_creds).await?;
|
||||
Ok(html! { .msg {
|
||||
"Password changed successfully. Redirecting to login page after 5 seconds..."
|
||||
} })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue