WIP: feat: add user details update

This commit is contained in:
Matthieu Bessat 2024-11-02 17:37:57 +01:00
parent 8285ca230c
commit d908586dfa
27 changed files with 871 additions and 38 deletions

View file

@ -1,14 +1,17 @@
use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}};
use axum::{extract::State, http::HeaderMap, response::{Html, IntoResponse}, Extension};
use minijinja::context;
use crate::server::AppState;
use crate::{server::AppState, services::session::TokenClaims};
pub async fn authorize_form(
State(app_state): State<AppState>
State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse {
// 1. Verify if login
// 1. Check if the app is already authorized
// 2. Query the app details
Html(
app_state.templating_env.get_template("pages/authorize.html").unwrap()

View file

@ -1,18 +1,118 @@
use axum::{extract::State, response::{Html, IntoResponse}, Extension};
use axum::{body::Bytes, extract::State, http::StatusCode, response::{Html, IntoResponse}, Extension};
use axum_typed_multipart::{FieldData, TryFromMultipart, TypedMultipart};
use base64::prelude::{Engine, BASE64_STANDARD};
use fully_pub::fully_pub;
use minijinja::context;
use crate::{server::AppState, services::session::TokenClaims};
use crate::{models::user::User, server::AppState, services::session::TokenClaims};
pub async fn me_page(
State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse {
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db)
.await
.expect("To get user from claim");
Html(
app_state.templating_env.get_template("pages/me.html").unwrap()
app_state.templating_env.get_template("pages/me/index.html").unwrap()
.render(context!(
token_claims => token_claims
user => user_res,
user_picture => user_res.picture.map(|x| BASE64_STANDARD.encode(x))
))
.unwrap()
)
}
pub async fn me_update_details_form(
State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims>
) -> impl IntoResponse {
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db)
.await
.expect("To get user from claim");
Html(
app_state.templating_env.get_template("pages/me/details-form.html").unwrap()
.render(context!(
user => user_res
))
.unwrap()
)
}
#[fully_pub]
#[derive(Debug, TryFromMultipart)]
struct UserDetailsUpdateForm {
handle: String,
email: String,
full_name: String,
website: String,
#[form_data(limit = "5MiB")]
picture: FieldData<Bytes>
}
pub async fn me_perform_update_details(
State(app_state): State<AppState>,
Extension(token_claims): Extension<TokenClaims>,
TypedMultipart(details_update): TypedMultipart<UserDetailsUpdateForm>
) -> impl IntoResponse {
let template = app_state.templating_env.get_template("pages/me/details-form.html").unwrap();
let update_res = sqlx::query("UPDATE users SET handle = $2, email = $3, full_name = $4, website = $5, picture = $6 WHERE id = $1")
.bind(&token_claims.sub)
.bind(details_update.handle)
.bind(details_update.email)
.bind(details_update.full_name)
.bind(details_update.website)
.bind(details_update.picture.contents.to_vec())
.execute(&app_state.db)
.await;
dbg!(&update_res);
let user_res = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(&token_claims.sub)
.fetch_one(&app_state.db)
.await
.expect("To get user from claim");
match update_res {
Ok(_) => {
(
StatusCode::OK,
Html(
template.render(context!(
success => true,
user => user_res
))
.unwrap()
)
).into_response()
},
Err(err) => {
dbg!(&err);
(
StatusCode::BAD_REQUEST,
Html(
template.render(context!(
error => Some("Cannot update user details".to_string()),
user => user_res
)).unwrap()
)
).into_response()
}
}
}

View file

@ -1,4 +1,4 @@
use axum::{extract::{Request, State}, http::{HeaderMap, StatusCode}, middleware::Next, response::{Response, Html, IntoResponse}};
use axum::{extract::{Request, State}, http::StatusCode, middleware::Next, response::Response};
use axum_extra::extract::CookieJar;
use crate::{server::AppState, services::session::verify_token};

View file

@ -0,0 +1,22 @@
use fully_pub::fully_pub;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
enum Permissions {
ReadBasics
}
#[derive(sqlx::FromRow, Deserialize, Serialize, Debug)]
#[fully_pub]
struct Authorization {
/// uuid
id: String,
user_id: String,
app_id: String,
permissions: Vec<Permissions>,
last_used_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>
}

View file

@ -19,7 +19,7 @@ struct User {
full_name: Option<String>,
email: Option<String>,
website: Option<String>,
picture: Option<String>, // embeded blob to store profile pic
picture: Option<Vec<u8>>, // embeded blob to store profile pic
password_hash: Option<String>, // argon2 password hash
status: UserStatus,
activation_token: Option<String>,

View file

@ -13,6 +13,8 @@ pub fn build_router(server_config: &ServerConfig, app_state: AppState) -> Router
let user_routes = Router::new()
.route("/me", get(ui::me::me_page))
.route("/me/details-form", get(ui::me::me_update_details_form))
.route("/me/details-form", post(ui::me::me_perform_update_details))
.route("/logout", get(ui::logout::perform_logout))
.route("/authorize", get(ui::authorize::authorize_form))
.layer(middleware::from_fn_with_state(app_state, auth_middleware));

View file

@ -1,6 +1,5 @@
use fully_pub::fully_pub;
use tower_http::services::ServeDir;
use anyhow::{Result, Context, anyhow};
use anyhow::{Result, Context};
use log::info;
use minijinja::{context, Environment};
use sqlx::{Pool, Sqlite};

View file

@ -9,7 +9,8 @@ use crate::models::{config::AppSecrets, user::User};
#[derive(Serialize, Deserialize, Clone)]
#[fully_pub]
struct TokenClaims {
sub: String, // user id
/// user id
sub: String,
exp: u64
}

View file

@ -1,15 +1,15 @@
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
<p class="col-md-4 mb-0 text-muted">© 2022 Company, Inc</p>
<div class="container-fluid">
<p class="col-md-4 mb-0 text-muted">Minauth</p>
<a href="/" class="col-md-4 d-flex align-items-center justify-content-center mb-3 mb-md-0 me-md-auto link-dark text-decoration-none">
<svg class="bi me-2" width="40" height="32"><use xlink:href="#bootstrap"></use></svg>
</a>
<ul class="nav col-md-4 justify-content-end">
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Home</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Features</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">Pricing</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">FAQs</a></li>
<li class="nav-item"><a href="#" class="nav-link px-2 text-muted">About</a></li>
<li class="nav-item"><a href="/" class="nav-link px-2 text-muted">Home</a></li>
<li class="nav-item"><a href="/about" class="nav-link px-2 text-muted">About</a></li>
<li class="nav-item"><a href="/help" class="nav-link px-2 text-muted">Help</a></li>
</ul>
</div>
</footer>

View file

@ -1,21 +1,24 @@
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Minauth</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<a class="navbar-brand" href="/">Minauth</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/register">Register</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/me">Me</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logout">Logout</a>
</li>

View file

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Minauth</title>
<link href="/assets/node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/style/app.css" rel="stylesheet">
</head>
<body>
{% include "components/header.html" %}

View file

@ -3,7 +3,7 @@
<h1>Login</h1>
<!-- Login form -->
{% if error %}
<div>
<div class="alert alert-danger">
Error: {{ error }}
</div>
{% endif %}

View file

@ -1,9 +0,0 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Me page</h1>
<p>
{{ token_claims.sub }}
</p>
{% endblock %}

View file

@ -0,0 +1,72 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Update your user details</h1>
{% if error %}
<div class="alert alert-danger">
Error: {{ error }}
</div>
{% endif %}
{% if success %}
<div class="alert alert-success">
Your details have been updated.
</div>
{% endif %}
<form
id="register-form"
enctype="multipart/form-data"
method="post"
>
<div class="mb-3">
<label for="handle" class="form-label">Handle</label>
<input
id="handle" name="handle" type="text"
minlength="2"
maxlength="255"
required
class="form-control"
value="{{ user.handle }}"
/>
</div>
<div class="mb-3">
<label for="email">Email</label>
<input
id="email" name="email" type="email"
required
class="form-control"
value="{{ user.email }}"
/>
</div>
<div class="mb-3">
<label for="full_name">Full name</label>
<input
id="full_name" name="full_name" type="text"
maxlength="255"
class="form-control"
value="{{ user.full_name or '' }}"
/>
</div>
<div class="mb-3">
<label for="website">Public website</label>
<input
id="website" name="website" type="url"
maxlength="512"
class="form-control"
value="{{ user.website or '' }}"
/>
</div>
<div class="mb-3">
<label for="picture">Profile picture</label>
<!-- for now, no JPEG -->
<input
id="picture" name="picture"
type="file"
accept="image/gif, image/png, image/jpeg"
class="form-control"
>
</div>
<button type="submit" class="btn btn-primary">
Update details
</button>
</form>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "layouts/base.html" %}
{% block body %}
<h1>Welcome {{ user.full_name or user.handle }}!</h1>
<a href="/me/details-form">Update details</a>
<p>
{% if user_picture %}
<img src="data:image/*;base64,{{ user_picture }}" style="width: 150px; height: 150px; object-fit: contain">
{% endif %}
<ul>
<li>
My user id: {{ user.id }}
</li>
<li>
My handle: {{ user.handle }}
</li>
<li>
My full name: {{ user.full_name }}
</li>
<li>
My email: {{ user.email }}
</li>
</ul>
</p>
{% endblock %}