Edit profile and templates update
This commit is contained in:
62
.sqlx/query-601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b.json
generated
Normal file
62
.sqlx/query-601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b.json
generated
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, username, role as \"role: Role\", full_name, bio, member_since\n FROM users\n WHERE user_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "user_role",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"admin",
|
||||||
|
"writer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "bio",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "member_since",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "601884180bc841dc0762008a819218620fc05169fe3bb80b7635fbe9e227056b"
|
||||||
|
}
|
||||||
17
.sqlx/query-8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd.json
generated
Normal file
17
.sqlx/query-8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd.json
generated
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n UPDATE users\n SET username = $1, full_name = $2, bio = $3\n WHERE user_id = $4\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Text",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "8dc27ae224c7ae3c99c396302357514d66e843dc4b3ee4ab58c628b6c9797fdd"
|
||||||
|
}
|
||||||
22
.sqlx/query-f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05.json
generated
Normal file
22
.sqlx/query-f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05.json
generated
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT user_id FROM users WHERE username = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "f4ea2ad9ba4f26093152e4a0e008ef6c3114fbe9e51301611c5633e1cc944c05"
|
||||||
|
}
|
||||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -4070,7 +4070,6 @@ dependencies = [
|
|||||||
"argon2",
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"claims",
|
"claims",
|
||||||
"config",
|
"config",
|
||||||
@@ -4087,7 +4086,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde-aux",
|
"serde-aux",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -4096,8 +4094,6 @@ dependencies = [
|
|||||||
"tower-sessions-redis-store",
|
"tower-sessions-redis-store",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"unicode-segmentation",
|
|
||||||
"urlencoding",
|
|
||||||
"uuid",
|
"uuid",
|
||||||
"validator",
|
"validator",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ anyhow = "1.0.99"
|
|||||||
argon2 = { version = "0.5.3", features = ["std"] }
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
askama = "0.14.0"
|
askama = "0.14.0"
|
||||||
axum = { version = "0.8.4", features = ["macros"] }
|
axum = { version = "0.8.4", features = ["macros"] }
|
||||||
base64 = "0.22.1"
|
|
||||||
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4.41", default-features = false, features = ["clock"] }
|
||||||
config = "0.15.14"
|
config = "0.15.14"
|
||||||
markdown = "1.0.0"
|
markdown = "1.0.0"
|
||||||
@@ -56,8 +55,6 @@ tower-sessions = "0.14.0"
|
|||||||
tower-sessions-redis-store = "0.16.0"
|
tower-sessions-redis-store = "0.16.0"
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
unicode-segmentation = "1.12.0"
|
|
||||||
urlencoding = "2.1.3"
|
|
||||||
uuid = { version = "1.18.0", features = ["v4", "serde"] }
|
uuid = { version = "1.18.0", features = ["v4", "serde"] }
|
||||||
validator = { version = "0.20.0", features = ["derive"] }
|
validator = { version = "0.20.0", features = ["derive"] }
|
||||||
|
|
||||||
@@ -70,5 +67,4 @@ quickcheck = "1.0.3"
|
|||||||
quickcheck_macros = "1.1.0"
|
quickcheck_macros = "1.1.0"
|
||||||
scraper = "0.24.0"
|
scraper = "0.24.0"
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
serde_urlencoded = "0.7.1"
|
|
||||||
wiremock = "0.6.4"
|
wiremock = "0.6.4"
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ impl From<AuthError> for AppError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn not_found() -> Response {
|
pub async fn not_found() -> Response {
|
||||||
|
tracing::error!("Not found.");
|
||||||
not_found_html()
|
not_found_html()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::{
|
|||||||
templates::{HtmlTemplate, MessageTemplate},
|
templates::{HtmlTemplate, MessageTemplate},
|
||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
use axum::response::Redirect;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::Request,
|
extract::Request,
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
@@ -49,11 +50,17 @@ pub async fn require_auth(
|
|||||||
mut request: Request,
|
mut request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let user_id = session
|
let user_id = match session
|
||||||
.get_user_id()
|
.get_user_id()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AdminError::UnexpectedError(e.into()))?
|
.map_err(|e| AdminError::UnexpectedError(e.into()))?
|
||||||
.ok_or(AdminError::NotAuthenticated)?;
|
{
|
||||||
|
None => {
|
||||||
|
tracing::error!("Not authenticated. Redirecting to /login.");
|
||||||
|
return Ok(Redirect::to("/login").into_response());
|
||||||
|
}
|
||||||
|
Some(user_id) => user_id,
|
||||||
|
};
|
||||||
let username = session
|
let username = session
|
||||||
.get_username()
|
.get_username()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ pub async fn get_login(session: TypedSession) -> Result<Response, AppError> {
|
|||||||
.context("Failed to retrieve user id from data store.")?
|
.context("Failed to retrieve user id from data store.")?
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
Ok(Redirect::to("/admin/dashboard").into_response())
|
Ok(Redirect::to("dashboard").into_response())
|
||||||
} else {
|
} else {
|
||||||
Ok(Html(LoginTemplate.render().unwrap()).into_response())
|
Ok(Html(LoginTemplate.render().unwrap()).into_response())
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,6 @@ pub async fn post_login(
|
|||||||
.context("Failed to insert role in session data store.")?;
|
.context("Failed to insert role in session data store.")?;
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert("HX-Redirect", "/admin/dashboard".parse().unwrap());
|
headers.insert("HX-Redirect", "/dashboard".parse().unwrap());
|
||||||
Ok((StatusCode::OK, headers).into_response())
|
Ok((StatusCode::OK, headers).into_response())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
use crate::authentication::AuthenticatedUser;
|
||||||
use crate::routes::verify_password;
|
use crate::routes::verify_password;
|
||||||
use crate::templates::MessageTemplate;
|
use crate::session_state::TypedSession;
|
||||||
|
use crate::templates::{ErrorTemplate, MessageTemplate, UserEditTemplate};
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::Role,
|
authentication::Role,
|
||||||
domain::{PostEntry, UserEntry},
|
domain::{PostEntry, UserEntry},
|
||||||
@@ -9,7 +11,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Extension, Form,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -17,9 +19,102 @@ use secrecy::{ExposeSecret, SecretString};
|
|||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn get_user_edit(
|
||||||
|
Path(username): Path<String>,
|
||||||
|
Extension(AuthenticatedUser {
|
||||||
|
user_id,
|
||||||
|
username: session_username,
|
||||||
|
..
|
||||||
|
}): Extension<AuthenticatedUser>,
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
if username != session_username {
|
||||||
|
let template = HtmlTemplate(ErrorTemplate::Forbidden);
|
||||||
|
return Ok(template.into_response());
|
||||||
|
}
|
||||||
|
let user = sqlx::query_as!(
|
||||||
|
UserEntry,
|
||||||
|
r#"
|
||||||
|
SELECT user_id, username, role as "role: Role", full_name, bio, member_since
|
||||||
|
FROM users
|
||||||
|
WHERE user_id = $1
|
||||||
|
"#,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_one(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch user in database.")?;
|
||||||
|
let template = HtmlTemplate(UserEditTemplate { user });
|
||||||
|
Ok(template.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
pub struct ProfilePath {
|
pub struct EditProfileForm {
|
||||||
username: String,
|
username: String,
|
||||||
|
full_name: String,
|
||||||
|
bio: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_user_edit(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
session: TypedSession,
|
||||||
|
Extension(AuthenticatedUser {
|
||||||
|
user_id,
|
||||||
|
username: session_username,
|
||||||
|
..
|
||||||
|
}): Extension<AuthenticatedUser>,
|
||||||
|
Path(username): Path<String>,
|
||||||
|
Form(form): Form<EditProfileForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
if username != session_username {
|
||||||
|
let template = HtmlTemplate(ErrorTemplate::Forbidden);
|
||||||
|
return Ok(template.into_response());
|
||||||
|
}
|
||||||
|
let updated_username = form.username.trim();
|
||||||
|
if updated_username != session_username
|
||||||
|
&& sqlx::query!(
|
||||||
|
"SELECT user_id FROM users WHERE username = $1",
|
||||||
|
updated_username
|
||||||
|
)
|
||||||
|
.fetch_optional(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch users table.")?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
let template = HtmlTemplate(MessageTemplate::error(
|
||||||
|
"The username is already taken.".into(),
|
||||||
|
));
|
||||||
|
return Ok(template.into_response());
|
||||||
|
}
|
||||||
|
let updated_full_name = form.full_name.trim();
|
||||||
|
let bio = form.bio.trim();
|
||||||
|
sqlx::query!(
|
||||||
|
"
|
||||||
|
UPDATE users
|
||||||
|
SET username = $1, full_name = $2, bio = $3
|
||||||
|
WHERE user_id = $4
|
||||||
|
",
|
||||||
|
updated_username,
|
||||||
|
updated_full_name,
|
||||||
|
bio,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.execute(&connection_pool)
|
||||||
|
.await
|
||||||
|
.context("Failed to apply changes.")
|
||||||
|
.map_err(AppError::FormError)?;
|
||||||
|
session
|
||||||
|
.insert_username(updated_username.to_owned())
|
||||||
|
.await
|
||||||
|
.context("Could not update session username.")?;
|
||||||
|
let template = HtmlTemplate(MessageTemplate::success(
|
||||||
|
"Your profile has been updated.".into(),
|
||||||
|
));
|
||||||
|
Ok(template.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Get users from database", skip(connection_pool))]
|
#[tracing::instrument(name = "Get users from database", skip(connection_pool))]
|
||||||
@@ -144,12 +239,13 @@ pub async fn delete_user(
|
|||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Fetching user data", skip(connection_pool))]
|
#[tracing::instrument(name = "Fetching user data", skip(connection_pool, session))]
|
||||||
pub async fn user_profile(
|
pub async fn user_profile(
|
||||||
|
session: TypedSession,
|
||||||
State(AppState {
|
State(AppState {
|
||||||
connection_pool, ..
|
connection_pool, ..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Path(ProfilePath { username }): Path<ProfilePath>,
|
Path(username): Path<String>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
match fetch_user_data(&connection_pool, &username)
|
match fetch_user_data(&connection_pool, &username)
|
||||||
.await
|
.await
|
||||||
@@ -159,7 +255,15 @@ pub async fn user_profile(
|
|||||||
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
||||||
.await
|
.await
|
||||||
.context("Could not fetch user posts.")?;
|
.context("Could not fetch user posts.")?;
|
||||||
let template = HtmlTemplate(UserTemplate { user, posts });
|
let session_username = session
|
||||||
|
.get_username()
|
||||||
|
.await
|
||||||
|
.context("Could not fetch session username.")?;
|
||||||
|
let template = HtmlTemplate(UserTemplate {
|
||||||
|
user,
|
||||||
|
session_username,
|
||||||
|
posts,
|
||||||
|
});
|
||||||
Ok(template.into_response())
|
Ok(template.into_response())
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ pub fn app(
|
|||||||
.route("/newsletters", post(publish_newsletter))
|
.route("/newsletters", post(publish_newsletter))
|
||||||
.route("/posts", post(create_post))
|
.route("/posts", post(create_post))
|
||||||
.route("/logout", get(logout))
|
.route("/logout", get(logout))
|
||||||
.merge(admin_routes)
|
.route(
|
||||||
|
"/users/{username}/edit",
|
||||||
|
get(get_user_edit).put(put_user_edit),
|
||||||
|
)
|
||||||
|
.nest("/admin", admin_routes)
|
||||||
.layer(middleware::from_fn(require_auth));
|
.layer(middleware::from_fn(require_auth));
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(home))
|
.route("/", get(home))
|
||||||
@@ -119,7 +123,7 @@ pub fn app(
|
|||||||
)
|
)
|
||||||
.route("/users/{username}", get(user_profile))
|
.route("/users/{username}", get(user_profile))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
.nest("/admin", auth_routes)
|
.merge(auth_routes)
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||||
|
|||||||
@@ -25,9 +25,16 @@ where
|
|||||||
#[template(path = "user/profile.html")]
|
#[template(path = "user/profile.html")]
|
||||||
pub struct UserTemplate {
|
pub struct UserTemplate {
|
||||||
pub user: UserEntry,
|
pub user: UserEntry,
|
||||||
|
pub session_username: Option<String>,
|
||||||
pub posts: Vec<PostEntry>,
|
pub posts: Vec<PostEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "user/edit.html")]
|
||||||
|
pub struct UserEditTemplate {
|
||||||
|
pub user: UserEntry,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "message.html")]
|
#[template(path = "message.html")]
|
||||||
pub struct MessageTemplate {
|
pub struct MessageTemplate {
|
||||||
@@ -147,6 +154,8 @@ pub enum ErrorTemplate {
|
|||||||
NotFound,
|
NotFound,
|
||||||
#[template(path = "error/500.html")]
|
#[template(path = "error/500.html")]
|
||||||
InternalServer,
|
InternalServer,
|
||||||
|
#[template(path = "error/403.html")]
|
||||||
|
Forbidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8"/>
|
||||||
<meta name="description" content="zero2prod newsletter" />
|
<meta name="description" content="zero2prod newsletter"/>
|
||||||
<meta name="keywords" content="newsletter, rust, axum, htmx" />
|
<meta name="keywords" content="newsletter, rust, axum, htmx"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>
|
<title>
|
||||||
{% block title %}{% endblock %}
|
{% block title %}{% endblock %}
|
||||||
- zero2prod
|
- zero2prod
|
||||||
</title>
|
</title>
|
||||||
<link href="/assets/css/main.css" rel="stylesheet" />
|
<link href="/assets/css/main.css" rel="stylesheet"/>
|
||||||
<script src="/assets/js/htmx.min.js"></script>
|
<script src="/assets/js/htmx.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||||
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center h-16">
|
<div class="flex justify-between items-center h-16">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||||
@@ -39,7 +40,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="/admin/dashboard"
|
<a href="/dashboard"
|
||||||
hx-boost="true"
|
hx-boost="true"
|
||||||
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
||||||
Dashboard
|
Dashboard
|
||||||
@@ -47,7 +48,8 @@
|
|||||||
<button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
|
<button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
|
||||||
onclick="toggleMobileMenu()">
|
onclick="toggleMobileMenu()">
|
||||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,7 +61,7 @@
|
|||||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||||
Posts
|
Posts
|
||||||
</a>
|
</a>
|
||||||
<a href="/admin/dashboard"
|
<a href="/dashboard"
|
||||||
hx-boost="true"
|
hx-boost="true"
|
||||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||||
Dashboard
|
Dashboard
|
||||||
@@ -67,13 +69,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="flex flex-1">
|
<div class="flex flex-1">
|
||||||
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
|
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||||
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
|
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<span class="text-gray-300">•</span>
|
<span class="text-gray-300">•</span>
|
||||||
@@ -99,12 +102,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
<script>
|
<script>
|
||||||
function toggleMobileMenu() {
|
function toggleMobileMenu() {
|
||||||
const menu = document.getElementById('mobile-menu');
|
const menu = document.getElementById('mobile-menu');
|
||||||
menu.classList.toggle('hidden');
|
menu.classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 lg:col-span-2">
|
|
||||||
<div class="p-6 border-b border-gray-200">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-green-600 mr-2"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
Change your password
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Set a new password for your account.</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<form hx-post="/admin/password"
|
|
||||||
hx-target="#password-messages"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="current_password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
|
||||||
<input type="password"
|
|
||||||
id="current_password"
|
|
||||||
name="current_password"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="new_password"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
|
|
||||||
<input type="password"
|
|
||||||
id="new_password"
|
|
||||||
name="new_password"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="new_password_check"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
|
|
||||||
<input type="password"
|
|
||||||
id="new_password_check"
|
|
||||||
name="new_password_check"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
|
||||||
</div>
|
|
||||||
<button type="submit"
|
|
||||||
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
|
||||||
<svg class="w-4 h-4 mr-2"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Update password
|
|
||||||
</button>
|
|
||||||
<div id="password-messages" class="mt-4"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button hx-get="/admin/logout"
|
<button hx-get="/logout"
|
||||||
type="submit"
|
type="submit"
|
||||||
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
|
class="flex items-center text-sm text-gray-500 hover:text-red-600 transition-colors cursor-pointer gap-1 mt-2">
|
||||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -29,7 +29,6 @@
|
|||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
{% include "publish.html" %}
|
{% include "publish.html" %}
|
||||||
{% include "send_email.html" %}
|
{% include "send_email.html" %}
|
||||||
{% include "change_password.html" %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if user.is_admin() %}
|
{% if user.is_admin() %}
|
||||||
|
|||||||
@@ -5,25 +5,26 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
|
||||||
</svg>
|
</svg>
|
||||||
Write a new post
|
Write a new post
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
|
<p class="text-sm text-gray-600 mt-1">Publish a new post online. Subscribers will be notified.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form hx-post="/admin/posts"
|
<form hx-post="/posts"
|
||||||
hx-target="#post-messages"
|
hx-target="#post-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}" />
|
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_1 }}"/>
|
||||||
<div>
|
<div>
|
||||||
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
<label for="post-title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
id="post-title"
|
id="post-title"
|
||||||
name="title"
|
name="title"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="post-content"
|
<label for="post-content"
|
||||||
@@ -40,7 +41,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 20h9M12 4h9M5 4h.01M5 20h.01M5 12h.01M9 16h6M9 8h6"/>
|
||||||
</svg>
|
</svg>
|
||||||
Publish
|
Publish
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,18 +5,19 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Send an email
|
Send an email
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
|
<p class="text-sm text-gray-600 mt-1">Contact your subscribers directly.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<form hx-post="/admin/newsletters"
|
<form hx-post="/newsletters"
|
||||||
hx-target="#newsletter-messages"
|
hx-target="#newsletter-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}" />
|
<input type="hidden" name="idempotency_key" value="{{ idempotency_key_2 }}"/>
|
||||||
<div>
|
<div>
|
||||||
<label for="newsletter-title"
|
<label for="newsletter-title"
|
||||||
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
|
class="block text-sm font-medium text-gray-700 mb-2">Subject</label>
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
id="newsletter-title"
|
id="newsletter-title"
|
||||||
name="title"
|
name="title"
|
||||||
required
|
required
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="newsletter-html"
|
<label for="newsletter-html"
|
||||||
@@ -50,7 +51,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
|
||||||
</svg>
|
</svg>
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
39
templates/error/403.html
Normal file
39
templates/error/403.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}403{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-4xl font-semibold text-gray-700 mb-4">403</h1>
|
||||||
|
<h2 class="text-2xl font-semibold text-gray-500 mb-6">Forbidden</h2>
|
||||||
|
<p class="text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||||
|
You don't have permission to access this page.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
|
<a href="/"
|
||||||
|
class="bg-blue-600 text-white hover:bg-blue-700 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||||
|
</svg>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard"
|
||||||
|
class="bg-white text-gray-700 hover:text-blue-600 hover:bg-blue-50 border border-gray-300 px-6 py-3 rounded-md font-medium transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ post.title }}{% endblock %}
|
{% block title %}{{ post.title }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<article>
|
<article>
|
||||||
<header class="pb-4 mb-2 border-b-2 border-gray-300 border-dashed">
|
<header class="mb-4">
|
||||||
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<a href="/users/{{ post.author }}"
|
<a href="/users/{{ post.author }}"
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<time datetime="{{ post.published_at }}">
|
<time datetime="{{ post.published_at }}">
|
||||||
{{ post.formatted_date() }}
|
{{ post.formatted_date() }}
|
||||||
@@ -35,7 +37,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="prose-compact">{{ post.content | safe }}</div>
|
<div class="prose-compact">{{ post.content | safe }}</div>
|
||||||
</article>
|
</article>
|
||||||
<div class="mt-12">{% include "posts/comments/list.html" %}</div>
|
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
|
||||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||||
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
||||||
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
||||||
@@ -44,5 +46,5 @@
|
|||||||
Subscribe
|
Subscribe
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 pb-4 pt-8 px-8">Activity</h2>
|
<h2 class="text-xl font-semibold text-gray-900 px-8 py-6 border-b border-gray-200">Activity</h2>
|
||||||
{% if posts.is_empty() %}
|
{% if posts.is_empty() %}
|
||||||
<div class="text-center text-gray-500 p-8">
|
<div class="text-center text-gray-500 p-8">
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<p>No posts yet</p>
|
<p>No posts yet</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200 pb-8">
|
<div class="divide-y divide-gray-200">
|
||||||
{% for post in posts %}
|
{% for post in posts %}
|
||||||
<a href="/posts/{{ post.post_id }}"
|
<a href="/posts/{{ post.post_id }}"
|
||||||
class="block py-4 hover:bg-gray-50 px-8 transition-colors group">
|
class="block py-4 hover:bg-gray-50 px-8 transition-colors group">
|
||||||
|
|||||||
15
templates/user/edit.html
Normal file
15
templates/user/edit.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Edit profile{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-5xl mx-auto p-4 sm:p-6">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Edit Profile</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Manage your profile and account settings.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{% include "edit/update_profile.html" %}
|
||||||
|
{% include "edit/change_password.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
48
templates/user/edit/change_password.html
Normal file
48
templates/user/edit/change_password.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Change Password</h2>
|
||||||
|
|
||||||
|
<form hx-post="/password"
|
||||||
|
hx-target="#password-messages"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="current_password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="current_password"
|
||||||
|
name="current_password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new_password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new_password_check"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="new_password_check"
|
||||||
|
name="new_password_check"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
Update password
|
||||||
|
</button>
|
||||||
|
<div id="password-messages" class="mt-4"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
53
templates/user/edit/update_profile.html
Normal file
53
templates/user/edit/update_profile.html
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile Information</h2>
|
||||||
|
|
||||||
|
<form hx-put="/users/{{ user.username }}/edit"
|
||||||
|
hx-target="#edit-messages"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="space-y-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Username
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="username"
|
||||||
|
id="username"
|
||||||
|
value="{{ user.username }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
id="full_name"
|
||||||
|
name="full_name"
|
||||||
|
value="{{ user.full_name.as_deref().unwrap_or("") }}"
|
||||||
|
placeholder="John Doe"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Your real name (optional)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Bio
|
||||||
|
</label>
|
||||||
|
<textarea id="bio"
|
||||||
|
name="bio"
|
||||||
|
rows="4"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{ user.bio.as_deref().unwrap_or("") }}</textarea>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Maximum 500 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="edit-messages"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ user.username }}{% endblock %}
|
{% block title %}{{ user.username }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-4xl mx-auto p-4 sm:p-6">
|
<div class="max-w-4xl mx-auto p-4 sm:p-6">
|
||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8 mb-6">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8 mb-6">
|
||||||
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
<div class="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
@@ -11,22 +11,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 text-center sm:text-left">
|
<div class="flex-1 text-center sm:text-left">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
<div class="flex flex-col sm:flex-row sm:items-center gap-2 mb-2">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username) }}</h1>
|
<h1 class="text-3xl font-bold text-gray-900">{{ user.full_name.as_deref().unwrap_or(user.username)
|
||||||
|
}}</h1>
|
||||||
{% if user.is_admin() %}
|
{% if user.is_admin() %}
|
||||||
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
|
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
viewBox="0 0 24 24">
|
viewBox="0 0 24 24">
|
||||||
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" />
|
<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if session_username.as_deref() == Some(user.username) %}
|
||||||
|
<a href="/users/{{ user.username }}/edit"
|
||||||
|
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-700 font-medium mb-3">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<p class="text-gray-500 text-lg mb-3">@{{ user.username }}</p>
|
<p class="text-gray-500 text-lg mb-3">@{{ user.username }}</p>
|
||||||
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
|
<div class="flex items-center justify-center sm:justify-start text-sm text-gray-500">
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor">
|
stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ user.formatted_date() }}
|
{{ user.formatted_date() }}
|
||||||
</div>
|
</div>
|
||||||
@@ -39,5 +51,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% include "activity.html" %}
|
{% include "activity.html" %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async fn logout_clears_session_state(connection_pool: PgPool) {
|
|||||||
"password": &app.test_user.password,
|
"password": &app.test_user.password,
|
||||||
});
|
});
|
||||||
let response = app.post_login(&login_body).await;
|
let response = app.post_login(&login_body).await;
|
||||||
assert_is_redirect_to(&response, "/admin/dashboard");
|
assert_is_redirect_to(&response, "/dashboard");
|
||||||
|
|
||||||
let html_page = app.get_admin_dashboard_html().await;
|
let html_page = app.get_admin_dashboard_html().await;
|
||||||
assert!(html_page.contains("Connected as"));
|
assert!(html_page.contains("Connected as"));
|
||||||
|
|||||||
@@ -85,5 +85,5 @@ async fn changing_password_works(connection_pool: PgPool) {
|
|||||||
"password": new_password,
|
"password": new_password,
|
||||||
});
|
});
|
||||||
let response = app.post_login(login_body).await;
|
let response = app.post_login(login_body).await;
|
||||||
assert_is_redirect_to(&response, "/admin/dashboard");
|
assert_is_redirect_to(&response, "/dashboard");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ impl TestApp {
|
|||||||
|
|
||||||
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
pub async fn get_admin_dashboard(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/admin/dashboard", &self.address))
|
.get(format!("{}/dashboard", &self.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
@@ -328,7 +328,7 @@ impl TestApp {
|
|||||||
Body: serde::Serialize,
|
Body: serde::Serialize,
|
||||||
{
|
{
|
||||||
self.api_client
|
self.api_client
|
||||||
.post(format!("{}/admin/newsletters", self.address))
|
.post(format!("{}/newsletters", self.address))
|
||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -357,7 +357,7 @@ impl TestApp {
|
|||||||
|
|
||||||
pub async fn logout(&self) -> reqwest::Response {
|
pub async fn logout(&self) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.get(format!("{}/admin/logout", self.address))
|
.get(format!("{}/logout", self.address))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
@@ -368,7 +368,7 @@ impl TestApp {
|
|||||||
Body: serde::Serialize,
|
Body: serde::Serialize,
|
||||||
{
|
{
|
||||||
self.api_client
|
self.api_client
|
||||||
.post(format!("{}/admin/password", self.address))
|
.post(format!("{}/password", self.address))
|
||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -380,7 +380,7 @@ impl TestApp {
|
|||||||
Body: serde::Serialize,
|
Body: serde::Serialize,
|
||||||
{
|
{
|
||||||
self.api_client
|
self.api_client
|
||||||
.post(format!("{}/admin/posts", self.address))
|
.post(format!("{}/posts", self.address))
|
||||||
.form(body)
|
.form(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async fn login_redirects_to_admin_dashboard_after_login_success(connection_pool:
|
|||||||
});
|
});
|
||||||
|
|
||||||
let response = app.post_login(&login_body).await;
|
let response = app.post_login(&login_body).await;
|
||||||
assert_is_redirect_to(&response, "/admin/dashboard");
|
assert_is_redirect_to(&response, "/dashboard");
|
||||||
|
|
||||||
let html_page = app.get_admin_dashboard_html().await;
|
let html_page = app.get_admin_dashboard_html().await;
|
||||||
assert!(html_page.contains("Connected as"));
|
assert!(html_page.contains("Connected as"));
|
||||||
|
|||||||
Reference in New Issue
Block a user