User profile and admin privileges
This commit is contained in:
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[target.x86_64-unknown-linux-gnu]
|
||||||
|
linker = "clang"
|
||||||
|
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||||
44
.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json
generated
Normal file
44
.sqlx/query-22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1.json
generated
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, password_hash, role as \"role: Role\"\n FROM users\n WHERE username = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "password_hash",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "user_role",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"admin",
|
||||||
|
"writer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "22c9449522dcf495d9f49c16ca433aa07a0d1daae4884789ba1e36a918e7dfd1"
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n SELECT user_id, password_hash\n FROM users\n WHERE username = $1\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"ordinal": 0,
|
|
||||||
"name": "user_id",
|
|
||||||
"type_info": "Uuid"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ordinal": 1,
|
|
||||||
"name": "password_hash",
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "acf1b96c82ddf18db02e71a0e297c822b46f10add52c54649cf599b883165e58"
|
|
||||||
}
|
|
||||||
62
.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json
generated
Normal file
62
.sqlx/query-e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271.json
generated
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT user_id, username, full_name, role as \"role: Role\", member_since, bio\n FROM users\n WHERE username = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "user_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "username",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "full_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "role: Role",
|
||||||
|
"type_info": {
|
||||||
|
"Custom": {
|
||||||
|
"name": "user_role",
|
||||||
|
"kind": {
|
||||||
|
"Enum": [
|
||||||
|
"admin",
|
||||||
|
"writer"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "member_since",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "bio",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "e049f4db1020c0a2979d5ee3c1c0519de59eee8594eb2e472877e5db6bf25271"
|
||||||
|
}
|
||||||
46
.sqlx/query-fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75.json
generated
Normal file
46
.sqlx/query-fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75.json
generated
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT u.username as author, p.post_id, p.title, p.content, p.published_at\n FROM posts p\n INNER JOIN users u ON p.author_id = u.user_id\n WHERE p.author_id = $1\n ORDER BY p.published_at DESC\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "author",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "post_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "title",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "content",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "published_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "fba37bafbf190369575b92c91d32f57336e8c7d42d5698e2b22e2b5c0427de75"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM lukemathwalker/cargo-chef:latest-rust-1.90.0 AS chef
|
FROM lukemathwalker/cargo-chef:latest-rust-1.90.0 AS chef
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt update && apt install -y nodejs npm && rm -rf /var/lib/apt/lists/*
|
RUN apt update && apt install -y nodejs npm clang mold && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM chef AS planner
|
FROM chef AS planner
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
--text-xs--line-height: calc(1 / 0.75);
|
--text-xs--line-height: calc(1 / 0.75);
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
--text-sm--line-height: calc(1.25 / 0.875);
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-base--line-height: calc(1.5 / 1);
|
||||||
--text-lg: 1.125rem;
|
--text-lg: 1.125rem;
|
||||||
--text-lg--line-height: calc(1.75 / 1.125);
|
--text-lg--line-height: calc(1.75 / 1.125);
|
||||||
--text-xl: 1.25rem;
|
--text-xl: 1.25rem;
|
||||||
@@ -288,6 +290,9 @@
|
|||||||
max-width: 96rem;
|
max-width: 96rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.-mx-8 {
|
||||||
|
margin-inline: calc(var(--spacing) * -8);
|
||||||
|
}
|
||||||
.mx-2 {
|
.mx-2 {
|
||||||
margin-inline: calc(var(--spacing) * 2);
|
margin-inline: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -727,6 +732,9 @@
|
|||||||
.mt-4 {
|
.mt-4 {
|
||||||
margin-top: calc(var(--spacing) * 4);
|
margin-top: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.mt-6 {
|
||||||
|
margin-top: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
.mt-8 {
|
.mt-8 {
|
||||||
margin-top: calc(var(--spacing) * 8);
|
margin-top: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
@@ -736,9 +744,15 @@
|
|||||||
.mr-1 {
|
.mr-1 {
|
||||||
margin-right: calc(var(--spacing) * 1);
|
margin-right: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
|
.mr-1\.5 {
|
||||||
|
margin-right: calc(var(--spacing) * 1.5);
|
||||||
|
}
|
||||||
.mr-2 {
|
.mr-2 {
|
||||||
margin-right: calc(var(--spacing) * 2);
|
margin-right: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.mb-1 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 1);
|
||||||
|
}
|
||||||
.mb-2 {
|
.mb-2 {
|
||||||
margin-bottom: calc(var(--spacing) * 2);
|
margin-bottom: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -989,6 +1003,20 @@
|
|||||||
margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
|
margin-inline-end: calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.divide-y {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
--tw-divide-y-reverse: 0;
|
||||||
|
border-bottom-style: var(--tw-border-style);
|
||||||
|
border-top-style: var(--tw-border-style);
|
||||||
|
border-top-width: calc(1px * var(--tw-divide-y-reverse));
|
||||||
|
border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.divide-gray-200 {
|
||||||
|
:where(& > :not(:last-child)) {
|
||||||
|
border-color: var(--color-gray-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
.rounded-full {
|
.rounded-full {
|
||||||
border-radius: calc(infinity * 1px);
|
border-radius: calc(infinity * 1px);
|
||||||
}
|
}
|
||||||
@@ -1188,6 +1216,9 @@
|
|||||||
.px-6 {
|
.px-6 {
|
||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
|
.px-8 {
|
||||||
|
padding-inline: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.py-0 {
|
.py-0 {
|
||||||
padding-block: calc(var(--spacing) * 0);
|
padding-block: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
@@ -1203,6 +1234,9 @@
|
|||||||
.py-3 {
|
.py-3 {
|
||||||
padding-block: calc(var(--spacing) * 3);
|
padding-block: calc(var(--spacing) * 3);
|
||||||
}
|
}
|
||||||
|
.py-4 {
|
||||||
|
padding-block: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
.py-6 {
|
.py-6 {
|
||||||
padding-block: calc(var(--spacing) * 6);
|
padding-block: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
@@ -1239,6 +1273,10 @@
|
|||||||
font-size: var(--text-4xl);
|
font-size: var(--text-4xl);
|
||||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||||
}
|
}
|
||||||
|
.text-base {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--tw-leading, var(--text-base--line-height));
|
||||||
|
}
|
||||||
.text-lg {
|
.text-lg {
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-lg);
|
||||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||||
@@ -1274,6 +1312,9 @@
|
|||||||
.break-all {
|
.break-all {
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
.whitespace-pre-line {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
.text-amber-600 {
|
.text-amber-600 {
|
||||||
color: var(--color-amber-600);
|
color: var(--color-amber-600);
|
||||||
}
|
}
|
||||||
@@ -1584,6 +1625,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.hover\:underline {
|
||||||
|
&:hover {
|
||||||
|
@media (hover: hover) {
|
||||||
|
text-decoration-line: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.hover\:shadow-lg {
|
.hover\:shadow-lg {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -1655,6 +1703,11 @@
|
|||||||
outline-style: none;
|
outline-style: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:mx-0 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
margin-inline: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:mt-0 {
|
.sm\:mt-0 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
margin-top: calc(var(--spacing) * 0);
|
margin-top: calc(var(--spacing) * 0);
|
||||||
@@ -1695,6 +1748,11 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:justify-start {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:p-6 {
|
.sm\:p-6 {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding: calc(var(--spacing) * 6);
|
padding: calc(var(--spacing) * 6);
|
||||||
@@ -1705,6 +1763,11 @@
|
|||||||
padding-inline: calc(var(--spacing) * 6);
|
padding-inline: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:text-left {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
.md\:mt-0 {
|
.md\:mt-0 {
|
||||||
@media (width >= 48rem) {
|
@media (width >= 48rem) {
|
||||||
margin-top: calc(var(--spacing) * 0);
|
margin-top: calc(var(--spacing) * 0);
|
||||||
@@ -1725,6 +1788,11 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.md\:grid-cols-3 {
|
||||||
|
@media (width >= 48rem) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.md\:flex-row {
|
.md\:flex-row {
|
||||||
@media (width >= 48rem) {
|
@media (width >= 48rem) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -2425,6 +2493,11 @@
|
|||||||
inherits: false;
|
inherits: false;
|
||||||
initial-value: 0;
|
initial-value: 0;
|
||||||
}
|
}
|
||||||
|
@property --tw-divide-y-reverse {
|
||||||
|
syntax: "*";
|
||||||
|
inherits: false;
|
||||||
|
initial-value: 0;
|
||||||
|
}
|
||||||
@property --tw-border-style {
|
@property --tw-border-style {
|
||||||
syntax: "*";
|
syntax: "*";
|
||||||
inherits: false;
|
inherits: false;
|
||||||
@@ -2628,6 +2701,7 @@
|
|||||||
--tw-skew-y: initial;
|
--tw-skew-y: initial;
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
--tw-space-x-reverse: 0;
|
--tw-space-x-reverse: 0;
|
||||||
|
--tw-divide-y-reverse: 0;
|
||||||
--tw-border-style: solid;
|
--tw-border-style: solid;
|
||||||
--tw-gradient-position: initial;
|
--tw-gradient-position: initial;
|
||||||
--tw-gradient-from: #0000;
|
--tw-gradient-from: #0000;
|
||||||
|
|||||||
4
migrations/20250930181830_add_data_fields_to_users.sql
Normal file
4
migrations/20250930181830_add_data_fields_to_users.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN full_name TEXT,
|
||||||
|
ADD COLUMN bio TEXT,
|
||||||
|
ADD COLUMN member_since TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
use crate::telemetry::spawn_blocking_with_tracing;
|
use crate::telemetry::spawn_blocking_with_tracing;
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use argon2::{
|
use argon2::{
|
||||||
@@ -138,6 +140,15 @@ pub enum Role {
|
|||||||
Writer,
|
Writer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for Role {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Role::Admin => write!(f, "admin"),
|
||||||
|
Role::Writer => write!(f, "writer"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ mod new_subscriber;
|
|||||||
mod post;
|
mod post;
|
||||||
mod subscriber_email;
|
mod subscriber_email;
|
||||||
mod subscribers;
|
mod subscribers;
|
||||||
|
mod user;
|
||||||
|
|
||||||
pub use new_subscriber::NewSubscriber;
|
pub use new_subscriber::NewSubscriber;
|
||||||
pub use post::PostEntry;
|
pub use post::PostEntry;
|
||||||
pub use subscriber_email::SubscriberEmail;
|
pub use subscriber_email::SubscriberEmail;
|
||||||
pub use subscribers::SubscriberEntry;
|
pub use subscribers::SubscriberEntry;
|
||||||
|
pub use user::UserEntry;
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
pub struct PostEntry {
|
pub struct PostEntry {
|
||||||
pub post_id: Uuid,
|
pub post_id: Uuid,
|
||||||
pub author: Option<String>,
|
pub author: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub published_at: DateTime<Utc>,
|
pub published_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PostEntry {
|
impl PostEntry {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn formatted_date(&self) -> String {
|
pub fn formatted_date(&self) -> String {
|
||||||
self.published_at.format("%B %d, %Y").to_string()
|
self.published_at.format("%B %d, %Y").to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/domain/user.rs
Normal file
23
src/domain/user.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::authentication::Role;
|
||||||
|
|
||||||
|
pub struct UserEntry {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub role: Role,
|
||||||
|
pub full_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub member_since: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserEntry {
|
||||||
|
pub fn formatted_date(&self) -> String {
|
||||||
|
self.member_since.format("%B %d, %Y").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
matches!(self.role, Role::Admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ mod posts;
|
|||||||
mod subscriptions;
|
mod subscriptions;
|
||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
mod unsubscribe;
|
mod unsubscribe;
|
||||||
|
mod users;
|
||||||
|
|
||||||
pub use admin::*;
|
pub use admin::*;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -24,6 +25,7 @@ use serde::de::DeserializeOwned;
|
|||||||
pub use subscriptions::*;
|
pub use subscriptions::*;
|
||||||
pub use subscriptions_confirm::*;
|
pub use subscriptions_confirm::*;
|
||||||
pub use unsubscribe::*;
|
pub use unsubscribe::*;
|
||||||
|
pub use users::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthError,
|
authentication::AuthError,
|
||||||
|
|||||||
82
src/routes/users.rs
Normal file
82
src/routes/users.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use crate::{
|
||||||
|
authentication::Role,
|
||||||
|
domain::{PostEntry, UserEntry},
|
||||||
|
routes::{AppError, not_found_html},
|
||||||
|
startup::AppState,
|
||||||
|
templates::{HtmlTemplate, UserTemplate},
|
||||||
|
};
|
||||||
|
use anyhow::Context;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct ProfilePath {
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching user data", skip(connection_pool))]
|
||||||
|
pub async fn user_profile(
|
||||||
|
State(AppState {
|
||||||
|
connection_pool, ..
|
||||||
|
}): State<AppState>,
|
||||||
|
Path(ProfilePath { username }): Path<ProfilePath>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
match fetch_user_data(&connection_pool, &username)
|
||||||
|
.await
|
||||||
|
.context("Failed to fetch user data.")?
|
||||||
|
{
|
||||||
|
Some(user) => {
|
||||||
|
let posts = fetch_user_posts(&connection_pool, &user.user_id)
|
||||||
|
.await
|
||||||
|
.context("Could not fetch user posts.")?;
|
||||||
|
let template = HtmlTemplate(UserTemplate { user, posts });
|
||||||
|
Ok(template.into_response())
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::error!(username = %username, "user not found");
|
||||||
|
Ok(not_found_html().into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching user profile", skip_all)]
|
||||||
|
async fn fetch_user_data(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<UserEntry>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
UserEntry,
|
||||||
|
r#"
|
||||||
|
SELECT user_id, username, full_name, role as "role: Role", member_since, bio
|
||||||
|
FROM users
|
||||||
|
WHERE username = $1
|
||||||
|
"#,
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_optional(connection_pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(name = "Fetching user posts", skip_all)]
|
||||||
|
async fn fetch_user_posts(
|
||||||
|
connection_pool: &PgPool,
|
||||||
|
user_id: &Uuid,
|
||||||
|
) -> Result<Vec<PostEntry>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
PostEntry,
|
||||||
|
r#"
|
||||||
|
SELECT u.username as author, p.post_id, p.title, p.content, p.published_at
|
||||||
|
FROM posts p
|
||||||
|
INNER JOIN users u ON p.author_id = u.user_id
|
||||||
|
WHERE p.author_id = $1
|
||||||
|
ORDER BY p.published_at DESC
|
||||||
|
"#,
|
||||||
|
user_id
|
||||||
|
)
|
||||||
|
.fetch_all(connection_pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
@@ -108,6 +108,7 @@ pub fn app(
|
|||||||
.route("/posts", get(list_posts))
|
.route("/posts", get(list_posts))
|
||||||
.route("/posts/load_more", get(load_more))
|
.route("/posts/load_more", get(load_more))
|
||||||
.route("/posts/{post_id}", get(see_post))
|
.route("/posts/{post_id}", get(see_post))
|
||||||
|
.route("/users/{username}", get(user_profile))
|
||||||
.route("/favicon.ico", get(favicon))
|
.route("/favicon.ico", get(favicon))
|
||||||
.nest("/admin", auth_routes)
|
.nest("/admin", auth_routes)
|
||||||
.nest_service("/assets", ServeDir::new("assets"))
|
.nest_service("/assets", ServeDir::new("assets"))
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ where
|
|||||||
)
|
)
|
||||||
.with(
|
.with(
|
||||||
tracing_subscriber::fmt::layer()
|
tracing_subscriber::fmt::layer()
|
||||||
.pretty()
|
.compact()
|
||||||
.with_writer(sink)
|
.with_writer(sink)
|
||||||
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthenticatedUser,
|
authentication::AuthenticatedUser,
|
||||||
domain::{PostEntry, SubscriberEntry},
|
domain::{PostEntry, SubscriberEntry, UserEntry},
|
||||||
routes::{AppError, DashboardStats},
|
routes::{AppError, DashboardStats},
|
||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
@@ -21,6 +21,13 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "user/profile.html")]
|
||||||
|
pub struct UserTemplate {
|
||||||
|
pub user: UserEntry,
|
||||||
|
pub posts: Vec<PostEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "message.html")]
|
#[template(path = "message.html")]
|
||||||
pub struct MessageTemplate {
|
pub struct MessageTemplate {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<p class="mt-2 text-gray-600 items-start">
|
<p class="mt-2 text-gray-600 items-start">
|
||||||
<span>Connected as <span class="font-bold">{{ user.username }}</span></span>
|
<span>Connected as
|
||||||
|
<a href="/users/{{ user.username }}"
|
||||||
|
class="hover:text-blue-600 hover:underline font-bold">{{ user.username }}</a></span>
|
||||||
{% if user.is_admin() %}
|
{% if user.is_admin() %}
|
||||||
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
|
||||||
admin
|
admin
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
|
<article class="bg-white rounded-lg shadow-md border border-gray-200 hover:shadow-lg transition-shadow duration-200">
|
||||||
<a href="/posts/{{ post.post_id }}"
|
<div class="block p-6 hover:bg-gray-50 transition-colors duration-200">
|
||||||
class="block p-6 hover:bg-gray-50 transition-colors duration-200">
|
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
|
<a href="/posts/{{ post.post_id }}">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors">{{ post.title }}</h2>
|
||||||
|
</a>
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1"
|
<svg class="w-4 h-4 mr-1"
|
||||||
@@ -24,18 +25,19 @@
|
|||||||
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>
|
||||||
<span>{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
|
<a href="/users/{{ post.author }}"
|
||||||
|
class="hover:text-blue-600 hover:underline">{{ post.author }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 ml-4">
|
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
|
||||||
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors"
|
<svg class="w-5 h-5 text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
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="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<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>
|
||||||
<span class="font-medium">{{ post.author.as_deref().unwrap_or("Unknown") }}</span>
|
<a href="/users/{{ post.author }}"
|
||||||
|
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-4 h-4 mr-1 text-gray-400"
|
<svg class="w-4 h-4 mr-1 text-gray-400"
|
||||||
|
|||||||
44
templates/user/activity.html
Normal file
44
templates/user/activity.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Activity</h2>
|
||||||
|
{% if posts.is_empty() %}
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p>No posts yet</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
{% for post in posts %}
|
||||||
|
<a href="/posts/{{ post.post_id }}"
|
||||||
|
class="block py-4 hover:bg-gray-50 -mx-8 px-8 transition-colors group">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-base font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1">{{ post.title }}</h3>
|
||||||
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
<time datetime="{{ post.published_at }}">
|
||||||
|
{{ post.formatted_date() }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 ml-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
43
templates/user/profile.html
Normal file
43
templates/user/profile.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ user.username }}{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<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="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="w-24 h-24 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg">
|
||||||
|
{{ user.username }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 text-center sm:text-left">
|
||||||
|
<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>
|
||||||
|
{% if user.is_admin() %}
|
||||||
|
<svg class="w-6 h-6 text-blue-600 flex-shrink-0 mx-auto sm:mx-0"
|
||||||
|
fill="currentColor"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
{{ user.formatted_date() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if user.bio.is_some() %}
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<p class="text-gray-700 whitespace-pre-line">{{ user.bio.as_deref().unwrap() }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% include "activity.html" %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user