WIP: feat: add user details update
This commit is contained in:
parent
8285ca230c
commit
d908586dfa
27 changed files with 871 additions and 38 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
22
src/models/authorization.rs
Normal file
22
src/models/authorization.rs
Normal 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>
|
||||
}
|
||||
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<h1>Login</h1>
|
||||
<!-- Login form -->
|
||||
{% if error %}
|
||||
<div>
|
||||
<div class="alert alert-danger">
|
||||
Error: {{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
{% extends "layouts/base.html" %}
|
||||
{% block body %}
|
||||
<h1>Me page</h1>
|
||||
|
||||
<p>
|
||||
{{ token_claims.sub }}
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
72
src/templates/pages/me/details-form.html
Normal file
72
src/templates/pages/me/details-form.html
Normal 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 %}
|
||||
27
src/templates/pages/me/index.html
Normal file
27
src/templates/pages/me/index.html
Normal 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 %}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue