Compare commits
3 Commits
8f62c2513e
...
da590fb7c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da590fb7c6 | ||
|
|
04c2d2b7f5 | ||
|
|
d96a29ee73 |
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal file
14
.sqlx/query-3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65.json
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n INSERT INTO idempotency (idempotency_key, created_at)\n VALUES ($1, now())\n ON CONFLICT DO NOTHING\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "3124db53d9e1fe0701a2fc70eea98e001fef4b75c24d33d8dd595f6b483e8f65"
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "PostgreSQL",
|
|
||||||
"query": "\n INSERT INTO idempotency (user_id, idempotency_key, created_at)\n VALUES ($1, $2, now())\n ON CONFLICT DO NOTHING\n ",
|
|
||||||
"describe": {
|
|
||||||
"columns": [],
|
|
||||||
"parameters": {
|
|
||||||
"Left": [
|
|
||||||
"Uuid",
|
|
||||||
"Text"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"nullable": []
|
|
||||||
},
|
|
||||||
"hash": "409cb2c83e34fba77b76f031cb0846a8f2716d775c3748887fb0c50f0e0a565b"
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec<HeaderPairRecord>\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
|
"query": "\n SELECT\n response_status_code as \"response_status_code!\",\n response_headers as \"response_headers!: Vec<HeaderPairRecord>\",\n response_body as \"response_body!\"\n FROM idempotency\n WHERE idempotency_key = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -44,7 +44,6 @@
|
|||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Uuid",
|
|
||||||
"Text"
|
"Text"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -54,5 +53,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "1fc498c8ccbf46f3e00b915e3b3973eb8d44a83a7df6dd7744dc56a2e94a0aa5"
|
"hash": "74d92b078198c3f73edc272c788249b14b62c59365d745d6a2e314cd9c5db1e9"
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "\n UPDATE idempotency\n SET\n response_status_code = $3,\n response_headers = $4,\n response_body = $5\n WHERE\n user_id = $1\n AND idempotency_key = $2\n ",
|
"query": "\n UPDATE idempotency\n SET\n response_status_code = $2,\n response_headers = $3,\n response_body = $4\n WHERE idempotency_key = $1\n ",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"Left": [
|
"Left": [
|
||||||
"Uuid",
|
|
||||||
"Text",
|
"Text",
|
||||||
"Int2",
|
"Int2",
|
||||||
{
|
{
|
||||||
@@ -37,5 +36,5 @@
|
|||||||
},
|
},
|
||||||
"nullable": []
|
"nullable": []
|
||||||
},
|
},
|
||||||
"hash": "32701e61ea14e25608b5f6b05289d08d422e9629d6aee98ac1dcbd50f1edbfe1"
|
"hash": "b64d5c2e51f328effc8f4687066db96ad695c575fb66195febcdf95c1539a153"
|
||||||
}
|
}
|
||||||
@@ -261,6 +261,9 @@
|
|||||||
.sticky {
|
.sticky {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
}
|
}
|
||||||
|
.inset-0 {
|
||||||
|
inset: calc(var(--spacing) * 0);
|
||||||
|
}
|
||||||
.inset-x-4 {
|
.inset-x-4 {
|
||||||
inset-inline: calc(var(--spacing) * 4);
|
inset-inline: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -291,18 +294,15 @@
|
|||||||
max-width: 96rem;
|
max-width: 96rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.-mx-6 {
|
|
||||||
margin-inline: calc(var(--spacing) * -6);
|
|
||||||
}
|
|
||||||
.-mx-8 {
|
|
||||||
margin-inline: calc(var(--spacing) * -8);
|
|
||||||
}
|
|
||||||
.mx-2 {
|
.mx-2 {
|
||||||
margin-inline: calc(var(--spacing) * 2);
|
margin-inline: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
.my-12 {
|
||||||
|
margin-block: calc(var(--spacing) * 12);
|
||||||
|
}
|
||||||
.mt-0\.5 {
|
.mt-0\.5 {
|
||||||
margin-top: calc(var(--spacing) * 0.5);
|
margin-top: calc(var(--spacing) * 0.5);
|
||||||
}
|
}
|
||||||
@@ -312,9 +312,6 @@
|
|||||||
.mt-2 {
|
.mt-2 {
|
||||||
margin-top: calc(var(--spacing) * 2);
|
margin-top: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
.mt-3 {
|
|
||||||
margin-top: calc(var(--spacing) * 3);
|
|
||||||
}
|
|
||||||
.mt-4 {
|
.mt-4 {
|
||||||
margin-top: calc(var(--spacing) * 4);
|
margin-top: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -366,6 +363,12 @@
|
|||||||
.ml-4 {
|
.ml-4 {
|
||||||
margin-left: calc(var(--spacing) * 4);
|
margin-left: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
|
.line-clamp-2 {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -535,13 +538,6 @@
|
|||||||
.gap-8 {
|
.gap-8 {
|
||||||
gap: calc(var(--spacing) * 8);
|
gap: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
.space-y-1 {
|
|
||||||
:where(& > :not(:last-child)) {
|
|
||||||
--tw-space-y-reverse: 0;
|
|
||||||
margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
|
|
||||||
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.space-y-2 {
|
.space-y-2 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -600,15 +596,6 @@
|
|||||||
border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.divide-y-2 {
|
|
||||||
: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(2px * var(--tw-divide-y-reverse));
|
|
||||||
border-bottom-width: calc(2px * calc(1 - var(--tw-divide-y-reverse)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.divide-gray-200 {
|
.divide-gray-200 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
border-color: var(--color-gray-200);
|
border-color: var(--color-gray-200);
|
||||||
@@ -669,8 +656,8 @@
|
|||||||
.border-amber-200 {
|
.border-amber-200 {
|
||||||
border-color: var(--color-amber-200);
|
border-color: var(--color-amber-200);
|
||||||
}
|
}
|
||||||
.border-blue-200 {
|
.border-blue-300 {
|
||||||
border-color: var(--color-blue-200);
|
border-color: var(--color-blue-300);
|
||||||
}
|
}
|
||||||
.border-gray-100 {
|
.border-gray-100 {
|
||||||
border-color: var(--color-gray-100);
|
border-color: var(--color-gray-100);
|
||||||
@@ -708,9 +695,6 @@
|
|||||||
.bg-amber-100 {
|
.bg-amber-100 {
|
||||||
background-color: var(--color-amber-100);
|
background-color: var(--color-amber-100);
|
||||||
}
|
}
|
||||||
.bg-blue-50 {
|
|
||||||
background-color: var(--color-blue-50);
|
|
||||||
}
|
|
||||||
.bg-blue-100 {
|
.bg-blue-100 {
|
||||||
background-color: var(--color-blue-100);
|
background-color: var(--color-blue-100);
|
||||||
}
|
}
|
||||||
@@ -767,10 +751,6 @@
|
|||||||
--tw-gradient-position: to right in oklab;
|
--tw-gradient-position: to right in oklab;
|
||||||
background-image: linear-gradient(var(--tw-gradient-stops));
|
background-image: linear-gradient(var(--tw-gradient-stops));
|
||||||
}
|
}
|
||||||
.from-blue-50 {
|
|
||||||
--tw-gradient-from: var(--color-blue-50);
|
|
||||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
|
||||||
}
|
|
||||||
.from-blue-500 {
|
.from-blue-500 {
|
||||||
--tw-gradient-from: var(--color-blue-500);
|
--tw-gradient-from: var(--color-blue-500);
|
||||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||||
@@ -783,10 +763,6 @@
|
|||||||
--tw-gradient-to: var(--color-blue-700);
|
--tw-gradient-to: var(--color-blue-700);
|
||||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||||
}
|
}
|
||||||
.to-indigo-50 {
|
|
||||||
--tw-gradient-to: var(--color-indigo-50);
|
|
||||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
|
||||||
}
|
|
||||||
.to-indigo-600 {
|
.to-indigo-600 {
|
||||||
--tw-gradient-to: var(--color-indigo-600);
|
--tw-gradient-to: var(--color-indigo-600);
|
||||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||||
@@ -798,9 +774,6 @@
|
|||||||
.bg-clip-text {
|
.bg-clip-text {
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
.p-1 {
|
|
||||||
padding: calc(var(--spacing) * 1);
|
|
||||||
}
|
|
||||||
.p-2 {
|
.p-2 {
|
||||||
padding: calc(var(--spacing) * 2);
|
padding: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -864,8 +837,8 @@
|
|||||||
.pb-4 {
|
.pb-4 {
|
||||||
padding-bottom: calc(var(--spacing) * 4);
|
padding-bottom: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
.pb-6 {
|
.pb-8 {
|
||||||
padding-bottom: calc(var(--spacing) * 6);
|
padding-bottom: calc(var(--spacing) * 8);
|
||||||
}
|
}
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -981,6 +954,9 @@
|
|||||||
.text-green-800 {
|
.text-green-800 {
|
||||||
color: var(--color-green-800);
|
color: var(--color-green-800);
|
||||||
}
|
}
|
||||||
|
.text-indigo-600 {
|
||||||
|
color: var(--color-indigo-600);
|
||||||
|
}
|
||||||
.text-orange-600 {
|
.text-orange-600 {
|
||||||
color: var(--color-orange-600);
|
color: var(--color-orange-600);
|
||||||
}
|
}
|
||||||
@@ -1319,16 +1295,6 @@
|
|||||||
margin-inline: calc(var(--spacing) * 0);
|
margin-inline: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sm\:mt-0 {
|
|
||||||
@media (width >= 40rem) {
|
|
||||||
margin-top: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sm\:ml-4 {
|
|
||||||
@media (width >= 40rem) {
|
|
||||||
margin-left: calc(var(--spacing) * 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sm\:inline {
|
.sm\:inline {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE idempotency
|
||||||
|
DROP CONSTRAINT idempotency_user_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE idempotency
|
||||||
|
DROP CONSTRAINT idempotency_pkey;
|
||||||
|
|
||||||
|
ALTER TABLE idempotency
|
||||||
|
ADD PRIMARY KEY (idempotency_key);
|
||||||
|
|
||||||
|
ALTER TABLE idempotency
|
||||||
|
DROP COLUMN user_id;
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
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::{
|
||||||
@@ -8,6 +6,7 @@ use argon2::{
|
|||||||
};
|
};
|
||||||
use secrecy::{ExposeSecret, SecretString};
|
use secrecy::{ExposeSecret, SecretString};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::fmt::Display;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ use axum::{
|
|||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::Type)]
|
#[derive(Debug, sqlx::Type)]
|
||||||
#[sqlx(type_name = "header_pair")]
|
#[sqlx(type_name = "header_pair")]
|
||||||
@@ -23,7 +22,6 @@ struct HeaderPairRecord {
|
|||||||
pub async fn get_saved_response(
|
pub async fn get_saved_response(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<Option<Response>, anyhow::Error> {
|
) -> Result<Option<Response>, anyhow::Error> {
|
||||||
let saved_response = sqlx::query!(
|
let saved_response = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
@@ -32,11 +30,8 @@ pub async fn get_saved_response(
|
|||||||
response_headers as "response_headers!: Vec<HeaderPairRecord>",
|
response_headers as "response_headers!: Vec<HeaderPairRecord>",
|
||||||
response_body as "response_body!"
|
response_body as "response_body!"
|
||||||
FROM idempotency
|
FROM idempotency
|
||||||
WHERE
|
WHERE idempotency_key = $1
|
||||||
user_id = $1
|
|
||||||
AND idempotency_key = $2
|
|
||||||
"#,
|
"#,
|
||||||
user_id,
|
|
||||||
idempotency_key.as_ref()
|
idempotency_key.as_ref()
|
||||||
)
|
)
|
||||||
.fetch_optional(connection_pool)
|
.fetch_optional(connection_pool)
|
||||||
@@ -61,7 +56,6 @@ pub async fn get_saved_response(
|
|||||||
pub async fn save_response(
|
pub async fn save_response(
|
||||||
mut transaction: Transaction<'static, Postgres>,
|
mut transaction: Transaction<'static, Postgres>,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
user_id: Uuid,
|
|
||||||
response: Response<Body>,
|
response: Response<Body>,
|
||||||
) -> Result<Response<Body>, anyhow::Error> {
|
) -> Result<Response<Body>, anyhow::Error> {
|
||||||
let status_code = response.status().as_u16() as i16;
|
let status_code = response.status().as_u16() as i16;
|
||||||
@@ -80,14 +74,11 @@ pub async fn save_response(
|
|||||||
r#"
|
r#"
|
||||||
UPDATE idempotency
|
UPDATE idempotency
|
||||||
SET
|
SET
|
||||||
response_status_code = $3,
|
response_status_code = $2,
|
||||||
response_headers = $4,
|
response_headers = $3,
|
||||||
response_body = $5
|
response_body = $4
|
||||||
WHERE
|
WHERE idempotency_key = $1
|
||||||
user_id = $1
|
|
||||||
AND idempotency_key = $2
|
|
||||||
"#,
|
"#,
|
||||||
user_id,
|
|
||||||
idempotency_key.as_ref(),
|
idempotency_key.as_ref(),
|
||||||
status_code,
|
status_code,
|
||||||
headers,
|
headers,
|
||||||
@@ -109,23 +100,21 @@ pub enum NextAction {
|
|||||||
pub async fn try_processing(
|
pub async fn try_processing(
|
||||||
connection_pool: &PgPool,
|
connection_pool: &PgPool,
|
||||||
idempotency_key: &IdempotencyKey,
|
idempotency_key: &IdempotencyKey,
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<NextAction, anyhow::Error> {
|
) -> Result<NextAction, anyhow::Error> {
|
||||||
let mut transaction = connection_pool.begin().await?;
|
let mut transaction = connection_pool.begin().await?;
|
||||||
let query = sqlx::query!(
|
let query = sqlx::query!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO idempotency (user_id, idempotency_key, created_at)
|
INSERT INTO idempotency (idempotency_key, created_at)
|
||||||
VALUES ($1, $2, now())
|
VALUES ($1, now())
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
"#,
|
"#,
|
||||||
user_id,
|
|
||||||
idempotency_key.as_ref()
|
idempotency_key.as_ref()
|
||||||
);
|
);
|
||||||
let n_inserted_rows = transaction.execute(query).await?.rows_affected();
|
let n_inserted_rows = transaction.execute(query).await?.rows_affected();
|
||||||
if n_inserted_rows > 0 {
|
if n_inserted_rows > 0 {
|
||||||
Ok(NextAction::StartProcessing(transaction))
|
Ok(NextAction::StartProcessing(transaction))
|
||||||
} else {
|
} else {
|
||||||
let saved_response = get_saved_response(connection_pool, idempotency_key, user_id)
|
let saved_response = get_saved_response(connection_pool, idempotency_key)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
|
.ok_or_else(|| anyhow::anyhow!("Could not find saved response."))?;
|
||||||
Ok(NextAction::ReturnSavedResponse(saved_response))
|
Ok(NextAction::ReturnSavedResponse(saved_response))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod posts;
|
|||||||
mod subscribers;
|
mod subscribers;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authentication::{AuthenticatedUser, Role},
|
authentication::AuthenticatedUser,
|
||||||
routes::{AppError, error_chain_fmt},
|
routes::{AppError, error_chain_fmt},
|
||||||
session_state::TypedSession,
|
session_state::TypedSession,
|
||||||
templates::{HtmlTemplate, MessageTemplate},
|
templates::{HtmlTemplate, MessageTemplate},
|
||||||
@@ -81,11 +81,10 @@ pub async fn require_admin(
|
|||||||
request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
if let Role::Admin = session
|
if session
|
||||||
.get_role()
|
.has_admin_permissions()
|
||||||
.await
|
.await
|
||||||
.context("Error retrieving user role in session.")?
|
.context("Error retrieving user role in session.")?
|
||||||
.ok_or(anyhow::anyhow!("Could not find user role in session."))?
|
|
||||||
{
|
{
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
authentication::AuthenticatedUser,
|
|
||||||
idempotency::{IdempotencyKey, save_response, try_processing},
|
idempotency::{IdempotencyKey, save_response, try_processing},
|
||||||
routes::{AdminError, AppError},
|
routes::{AdminError, AppError},
|
||||||
startup::AppState,
|
startup::AppState,
|
||||||
@@ -8,7 +7,7 @@ use crate::{
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension, Form,
|
Form,
|
||||||
extract::State,
|
extract::State,
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
@@ -95,7 +94,6 @@ pub async fn publish_newsletter(
|
|||||||
base_url,
|
base_url,
|
||||||
..
|
..
|
||||||
}): State<AppState>,
|
}): State<AppState>,
|
||||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
|
||||||
Form(form): Form<BodyData>,
|
Form(form): Form<BodyData>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
validate_form(&form).map_err(|e| AdminError::Publish(anyhow::anyhow!(e)))?;
|
||||||
@@ -105,7 +103,7 @@ pub async fn publish_newsletter(
|
|||||||
.try_into()
|
.try_into()
|
||||||
.map_err(AdminError::Idempotency)?;
|
.map_err(AdminError::Idempotency)?;
|
||||||
|
|
||||||
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
|
||||||
crate::idempotency::NextAction::StartProcessing(t) => t,
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
@@ -129,7 +127,7 @@ pub async fn publish_newsletter(
|
|||||||
let message = String::from("Your email has been queued for delivery.");
|
let message = String::from("Your email has been queued for delivery.");
|
||||||
let template = MessageTemplate::success(message);
|
let template = MessageTemplate::success(message);
|
||||||
let response = Html(template.render().unwrap()).into_response();
|
let response = Html(template.render().unwrap()).into_response();
|
||||||
let response = save_response(transaction, &idempotency_key, user_id, response)
|
let response = save_response(transaction, &idempotency_key, response)
|
||||||
.await
|
.await
|
||||||
.map_err(AdminError::UnexpectedError)?;
|
.map_err(AdminError::UnexpectedError)?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ pub async fn create_post(
|
|||||||
.try_into()
|
.try_into()
|
||||||
.map_err(AdminError::Idempotency)?;
|
.map_err(AdminError::Idempotency)?;
|
||||||
|
|
||||||
let mut transaction = match try_processing(&connection_pool, &idempotency_key, user_id).await? {
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
|
||||||
crate::idempotency::NextAction::StartProcessing(t) => t,
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
@@ -75,7 +75,7 @@ pub async fn create_post(
|
|||||||
|
|
||||||
let template = MessageTemplate::success("Your new post has been published!".into());
|
let template = MessageTemplate::success("Your new post has been published!".into());
|
||||||
let response = Html(template.render().unwrap()).into_response();
|
let response = Html(template.render().unwrap()).into_response();
|
||||||
let response = save_response(transaction, &idempotency_key, user_id, response)
|
let response = save_response(transaction, &idempotency_key, response)
|
||||||
.await
|
.await
|
||||||
.map_err(AdminError::UnexpectedError)?;
|
.map_err(AdminError::UnexpectedError)?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::routes::get_max_page;
|
use crate::idempotency::{IdempotencyKey, save_response, try_processing};
|
||||||
|
use crate::routes::{AdminError, get_max_page};
|
||||||
use crate::templates::CommentsPageDashboardTemplate;
|
use crate::templates::CommentsPageDashboardTemplate;
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::CommentEntry,
|
domain::CommentEntry,
|
||||||
@@ -13,7 +14,7 @@ use axum::{
|
|||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::{Executor, PgPool, Postgres, Transaction};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -25,6 +26,7 @@ pub struct CommentPathParam {
|
|||||||
pub struct CommentForm {
|
pub struct CommentForm {
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub idempotency_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
|
#[tracing::instrument(name = "Posting new comment", skip_all, fields(post_id = %post_id))]
|
||||||
@@ -36,14 +38,29 @@ pub async fn post_comment(
|
|||||||
Form(form): Form<CommentForm>,
|
Form(form): Form<CommentForm>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
validate_form(&form)?;
|
validate_form(&form)?;
|
||||||
let comment_id = insert_comment(&connection_pool, post_id, form)
|
|
||||||
|
let idempotency_key: IdempotencyKey = form
|
||||||
|
.idempotency_key
|
||||||
|
.try_into()
|
||||||
|
.map_err(AdminError::Idempotency)?;
|
||||||
|
|
||||||
|
let mut transaction = match try_processing(&connection_pool, &idempotency_key).await? {
|
||||||
|
crate::idempotency::NextAction::StartProcessing(t) => t,
|
||||||
|
crate::idempotency::NextAction::ReturnSavedResponse(response) => {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
insert_comment(&mut transaction, post_id, form.author, form.content)
|
||||||
.await
|
.await
|
||||||
.context("Could not insert comment into database.")?;
|
.context("Could not insert comment into database.")?;
|
||||||
tracing::info!("new comment with id {} has been inserted", comment_id);
|
|
||||||
let template = HtmlTemplate(MessageTemplate::success(
|
let template = HtmlTemplate(MessageTemplate::success(
|
||||||
"Your comment has been posted.".into(),
|
"Your comment has been posted.".into(),
|
||||||
));
|
));
|
||||||
Ok(template.into_response())
|
let response = template.into_response();
|
||||||
|
let response = save_response(transaction, &idempotency_key, response).await?;
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
|
fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
|
||||||
@@ -55,17 +72,19 @@ fn validate_form(form: &CommentForm) -> Result<(), anyhow::Error> {
|
|||||||
|
|
||||||
#[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))]
|
#[tracing::instrument(name = "Inserting new comment in database", skip_all, fields(comment_id = tracing::field::Empty))]
|
||||||
async fn insert_comment(
|
async fn insert_comment(
|
||||||
connection_pool: &PgPool,
|
transaction: &mut Transaction<'static, Postgres>,
|
||||||
post_id: Uuid,
|
post_id: Uuid,
|
||||||
form: CommentForm,
|
author: Option<String>,
|
||||||
|
content: String,
|
||||||
) -> Result<Uuid, sqlx::Error> {
|
) -> Result<Uuid, sqlx::Error> {
|
||||||
let author = form
|
let author = author
|
||||||
.author
|
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.map(|s| s.trim().to_string());
|
.map(|s| s.trim().to_string());
|
||||||
|
let content = content.trim();
|
||||||
|
|
||||||
let comment_id = Uuid::new_v4();
|
let comment_id = Uuid::new_v4();
|
||||||
tracing::Span::current().record("comment_id", comment_id.to_string());
|
tracing::Span::current().record("comment_id", comment_id.to_string());
|
||||||
sqlx::query!(
|
let query = sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO comments (comment_id, post_id, author, content)
|
INSERT INTO comments (comment_id, post_id, author, content)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
@@ -73,10 +92,9 @@ async fn insert_comment(
|
|||||||
comment_id,
|
comment_id,
|
||||||
post_id,
|
post_id,
|
||||||
author,
|
author,
|
||||||
form.content.trim()
|
content,
|
||||||
)
|
);
|
||||||
.execute(connection_pool)
|
transaction.execute(query).await?;
|
||||||
.await?;
|
|
||||||
Ok(comment_id)
|
Ok(comment_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,9 +151,11 @@ pub async fn see_post(
|
|||||||
let comments = get_comments_page_for_post(&connection_pool, post_id, 1)
|
let comments = get_comments_page_for_post(&connection_pool, post_id, 1)
|
||||||
.await
|
.await
|
||||||
.context("Failed to fetch latest comments")?;
|
.context("Failed to fetch latest comments")?;
|
||||||
|
let idempotency_key = Uuid::new_v4().to_string();
|
||||||
let template = HtmlTemplate(PostTemplate {
|
let template = HtmlTemplate(PostTemplate {
|
||||||
post,
|
post,
|
||||||
comments,
|
comments,
|
||||||
|
idempotency_key,
|
||||||
current_page,
|
current_page,
|
||||||
max_page,
|
max_page,
|
||||||
comments_count,
|
comments_count,
|
||||||
|
|||||||
@@ -62,7 +62,10 @@ impl TryFrom<CreateUserForm> for NewUser {
|
|||||||
anyhow::bail!("Password mismatch.");
|
anyhow::bail!("Password mismatch.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let role = value.admin.map(|_| Role::Admin).unwrap_or(Role::Writer);
|
let role = match value.admin {
|
||||||
|
Some(true) => Role::Admin,
|
||||||
|
_ => Role::Writer,
|
||||||
|
};
|
||||||
let password_hash = crate::authentication::compute_pasword_hash(value.password)
|
let password_hash = crate::authentication::compute_pasword_hash(value.password)
|
||||||
.context("Failed to hash password.")?;
|
.context("Failed to hash password.")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ impl TypedSession {
|
|||||||
self.0.get(Self::ROLE_KEY).await
|
self.0.get(Self::ROLE_KEY).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn has_admin_permissions(&self) -> Result<bool> {
|
||||||
|
let role = self.0.get(Self::ROLE_KEY).await?;
|
||||||
|
if let Some(Role::Admin) = role {
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn clear(&self) {
|
pub async fn clear(&self) {
|
||||||
self.0.clear().await;
|
self.0.clear().await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ pub struct PostListTemplate {
|
|||||||
#[template(path = "posts/page.html")]
|
#[template(path = "posts/page.html")]
|
||||||
pub struct PostTemplate {
|
pub struct PostTemplate {
|
||||||
pub post: PostEntry,
|
pub post: PostEntry,
|
||||||
|
pub idempotency_key: String,
|
||||||
pub comments: Vec<CommentEntry>,
|
pub comments: Vec<CommentEntry>,
|
||||||
pub current_page: i64,
|
pub current_page: i64,
|
||||||
pub max_page: i64,
|
pub max_page: i64,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
class="block py-4 hover:bg-gray-50 px-6 transition-colors group">
|
class="block py-4 hover:bg-gray-50 px-6 transition-colors group">
|
||||||
<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">
|
||||||
<div class="flex items-center space-x-2 mb-2">
|
<div class="mb-1">
|
||||||
<span class="text-sm font-medium text-gray-900">
|
<span class="text-sm font-medium text-gray-900">
|
||||||
{% if let Some(name) = comment.author %}
|
{% if let Some(name) = comment.author %}
|
||||||
{{ name }}
|
{{ name }}
|
||||||
@@ -10,14 +10,9 @@
|
|||||||
Anonymous
|
Anonymous
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-gray-400">•</span>
|
|
||||||
<span class="text-xs text-gray-500">on</span>
|
|
||||||
<a href="/posts/{{ comment.post_id }}" class="text-sm text-blue-600 hover:underline truncate">
|
|
||||||
#{{ comment.post_id.to_string()[..8] }}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p>
|
<p class="text-sm text-gray-700 mb-2 line-clamp-2">{{ comment.content }}</p>
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-xs text-gray-500 mb-1">
|
||||||
<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"
|
||||||
@@ -29,6 +24,12 @@
|
|||||||
{{ comment.formatted_date() }}
|
{{ comment.formatted_date() }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center text-xs text-gray-500">
|
||||||
|
<span class="mr-1">on</span>
|
||||||
|
<a href="/posts/{{ comment.post_id }}" class="text-blue-600 hover:underline truncate">
|
||||||
|
#{{ comment.post_id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button hx-delete="/admin/comments/{{ comment.comment_id }}"
|
<button hx-delete="/admin/comments/{{ comment.comment_id }}"
|
||||||
hx-target="#comment-{{ comment.comment_id }}"
|
hx-target="#comment-{{ comment.comment_id }}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Comments management ({{ comments_count }})</span>
|
<span>Comments ({{ comments_count }})</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and moderate all comments.</p>
|
<p class="text-sm text-gray-600 mt-1">View and moderate all comments.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-list" class="space-y-6">
|
<div id="comments-list">
|
||||||
{% block comments %}
|
{% block comments %}
|
||||||
{% if comments.is_empty() %}
|
{% if comments.is_empty() %}
|
||||||
<div class="p-8 text-center">
|
<div class="p-8 text-center">
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No data to display</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No comments to display</h3>
|
||||||
<p class="text-gray-600">The request did not return any data.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200 mb-6">
|
||||||
{% for comment in comments %}
|
{% for comment in comments %}
|
||||||
{% include "dashboard/comments/card.html" %}
|
{% include "dashboard/comments/card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
<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>Connected as
|
||||||
<a href="/users/{{ user.username }}"
|
<a href="/users/{{ user.username }}"
|
||||||
class="hover:text-blue-600 hover:underline font-bold">{{ user.username }}</a></span>
|
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
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button hx-get="/admin/logout"
|
<button hx-get="/admin/logout"
|
||||||
@@ -24,23 +24,37 @@
|
|||||||
<span>Logout</span>
|
<span>Logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% if user.is_admin() %}
|
|
||||||
<div class="mb-8 p-6 bg-gradient-to-br from-blue-50 to-indigo-50 bg-blue-50 rounded-lg border border-blue-200">
|
{% include "stats.html" %}
|
||||||
<h2 class="text-lg font-semibold text-blue-900 mb-6">Administration</h2>
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
{% include "stats.html" %}
|
|
||||||
{% include "subscribers/list.html" %}
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{% include "users/list.html" %}
|
|
||||||
{% include "users/form.html" %}
|
|
||||||
</div>
|
|
||||||
{% include "posts/list.html" %}
|
|
||||||
{% include "comments/list.html" %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{% include "publish.html" %}
|
{% include "publish.html" %}
|
||||||
{% include "send_email.html" %}
|
{% include "send_email.html" %}
|
||||||
{% include "change_password.html" %}
|
{% include "change_password.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if user.is_admin() %}
|
||||||
|
<div class="relative my-12">
|
||||||
|
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div class="w-full border-t-2 border-blue-300"></div>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center">
|
||||||
|
<span class="bg-gray-50 px-4 text-sm font-semibold text-blue-900 flex items-center gap-2">
|
||||||
|
<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="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>
|
||||||
|
Administration
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{% include "subscribers/list.html" %}
|
||||||
|
{% include "users/list.html" %}
|
||||||
|
{% include "users/form.html" %}
|
||||||
|
{% include "posts/list.html" %}
|
||||||
|
{% include "comments/list.html" %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -7,31 +7,29 @@
|
|||||||
{{ post.title }}
|
{{ post.title }}
|
||||||
</h3>
|
</h3>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center text-sm text-gray-500 space-x-4">
|
<div class="flex items-center text-sm text-gray-500 mb-1">
|
||||||
<div class="flex items-center">
|
<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"
|
||||||
<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"/>
|
||||||
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>
|
<a href="/users/{{ post.author }}" class="hover:text-blue-600 hover:underline">
|
||||||
<a href="/users/{{ post.author }}" class="hover:text-blue-600 hover:underline">
|
{{ post.author }}
|
||||||
{{ post.author }}
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<div class="flex items-center">
|
<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"
|
||||||
<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"/>
|
||||||
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() }}
|
</time>
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button hx-delete="/admin/posts/{{ post.post_id }}"
|
<button hx-delete="/admin/posts/{{ post.post_id }}"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<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"/>
|
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>
|
</svg>
|
||||||
<span>Posts management ({{ posts_count }})</span>
|
<span>Posts ({{ posts_count }})</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and manage all published posts.</p>
|
<p class="text-sm text-gray-600 mt-1">View and manage all published posts.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="posts-list" class="space-y-6">
|
<div id="posts-list">
|
||||||
{% block posts %}
|
{% block posts %}
|
||||||
{% if posts.is_empty() %}
|
{% if posts.is_empty() %}
|
||||||
<div class="p-8 text-center">
|
<div class="p-8 text-center">
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
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"/>
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No data to display</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No posts to display</h3>
|
||||||
<p class="text-gray-600">The request did not return any data.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200 mb-6">
|
||||||
{% for post in posts %}
|
{% for post in posts %}
|
||||||
{% include "dashboard/posts/card.html" %}
|
{% include "dashboard/posts/card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
class="block py-4 hover:bg-gray-50 px-6 transition-colors group {% if subscriber.confirmed() %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-yellow-500{% endif %}">
|
class="block py-4 hover:bg-gray-50 px-6 transition-colors group {% if subscriber.confirmed() %}border-l-4 border-l-green-500{% else %}border-l-4 border-l-yellow-500{% endif %}">
|
||||||
<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">
|
||||||
<h3 class="text-sm font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1 break-all">
|
<h3 class="text-sm truncate font-medium text-gray-900 group-hover:text-blue-600 transition-colors mb-1 break-all">
|
||||||
{{ subscriber.email }}</h3>
|
{{ subscriber.email }}</h3>
|
||||||
<div class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<svg class="w-4 h-4 mr-1.5"
|
<svg class="w-4 h-4 mr-1.5"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -13,16 +13,16 @@
|
|||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||||
</svg>
|
</svg>
|
||||||
Subscribers management ({{ count }})
|
Subscribers ({{ count }})
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
|
<p class="text-sm text-gray-600 mt-1">View and manage your subscribers.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="subscribers-list" class="space-y-6">
|
<div id="subscribers-list">
|
||||||
{% block subs %}
|
{% block subs %}
|
||||||
{% if subscribers.is_empty() %}
|
{% if subscribers.is_empty() %}
|
||||||
<div class="g p-8 text-center">
|
<div class="p-8 text-center">
|
||||||
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div class="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg class="w-8 h-8 text-gray-500"
|
<svg class="w-8 h-8 text-gray-500"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
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"/>
|
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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No data available</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No subscribers to display</h3>
|
||||||
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200 mb-6">
|
||||||
{% for subscriber in subscribers %}
|
{% for subscriber in subscribers %}
|
||||||
{% include "dashboard/subscribers/card.html" %}
|
{% include "dashboard/subscribers/card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 mb-8">
|
<div class="bg-white rounded-lg shadow-md border border-gray-200">
|
||||||
<div class="p-6 border-b border-gray-200">
|
<div class="p-6 border-b border-gray-200">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Users management
|
Users
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-gray-600 mt-1">View and manage users.</p>
|
<p class="text-sm text-gray-600 mt-1">View and manage users.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
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>
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No users found</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No users to display</h3>
|
||||||
<p class="text-gray-600">No users in the system.</p>
|
<p class="text-gray-600">Content may have shifted due to recent updates or list is empty.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="divide-y divide-gray-200">
|
<div class="divide-y divide-gray-200">
|
||||||
|
|||||||
@@ -3,31 +3,31 @@
|
|||||||
<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 }}">
|
<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>
|
</a>
|
||||||
|
<div class="flex items-center text-sm text-gray-500 mb-1">
|
||||||
|
<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="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 class="flex items-center text-sm text-gray-500">
|
<div class="flex items-center text-sm text-gray-500">
|
||||||
<div class="flex items-center">
|
<svg class="w-4 h-4 mr-1"
|
||||||
<svg class="w-4 h-4 mr-1"
|
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"
|
||||||
<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" />
|
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>
|
||||||
<time datetime="{{ post.published_at }}">
|
<a href="/users/{{ post.author }}"
|
||||||
{{ post.formatted_date() }}
|
class="hover:text-blue-600 hover:underline">{{ post.author }}</a>
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
<span class="mx-2">•</span>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
||||||
</svg>
|
|
||||||
<a href="/users/{{ post.author }}"
|
|
||||||
class="hover:text-blue-600 hover:underline">{{ post.author }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
|
<a href="/posts/{{ post.post_id }}" class="flex-shrink-0 ml-4">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
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>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
hx-target="#form-messages"
|
hx-target="#form-messages"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
class="space-y-4">
|
class="space-y-4">
|
||||||
|
<input type="hidden" name="idempotency_key" value="{{ idempotency_key }}"/>
|
||||||
<div>
|
<div>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
name="author"
|
name="author"
|
||||||
@@ -28,41 +29,42 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% block comments %}
|
{% block comments %}
|
||||||
{% if comments.is_empty() %}
|
{% if comments.is_empty() %}
|
||||||
<div id="comments-list" class="text-center py-8 text-gray-500">
|
<div id="comments-list" class="text-center py-8 text-gray-500">
|
||||||
<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"
|
||||||
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 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</svg>
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
|
||||||
<p>No comments yet. Be the first to comment!</p>
|
</svg>
|
||||||
</div>
|
<p>No comments yet. Be the first to comment!</p>
|
||||||
{% else %}
|
</div>
|
||||||
{% let post_id = comments[0].post_id %}
|
{% else %}
|
||||||
<div id="comments-list" class="space-y-4">
|
{% let post_id = comments[0].post_id %}
|
||||||
{% for comment in comments %}
|
<div id="comments-list" class="space-y-4">
|
||||||
{% include "posts/comments/card.html" %}
|
{% for comment in comments %}
|
||||||
{% endfor %}
|
{% include "posts/comments/card.html" %}
|
||||||
<div id="load-more-comments" class="text-center mt-6">
|
{% endfor %}
|
||||||
{% if current_page < max_page %}
|
<div id="load-more-comments" class="text-center mt-6">
|
||||||
<div class="flex flex-col items-center space-y-6">
|
{% if current_page < max_page %}
|
||||||
<button hx-get="/posts/{{ post_id }}/comments?page={{ current_page + 1 }}"
|
<div class="flex flex-col items-center space-y-6">
|
||||||
hx-target="#load-more-comments"
|
<button hx-get="/posts/{{ post_id }}/comments?page={{ current_page + 1 }}"
|
||||||
hx-swap="outerHTML"
|
hx-target="#load-more-comments"
|
||||||
hx-indicator="#comment-indicator"
|
hx-swap="outerHTML"
|
||||||
class="text-center bg-gray-200 text-gray-700 hover:bg-gray-300 font-medium py-2 px-6 rounded-md transition-colors">
|
hx-indicator="#comment-indicator"
|
||||||
Load more comments
|
class="text-center bg-gray-200 text-gray-700 hover:bg-gray-300 font-medium py-2 px-6 rounded-md transition-colors">
|
||||||
</button>
|
Load more comments
|
||||||
<span id="comment-indicator" class="htmx-indicator">
|
</button>
|
||||||
|
<span id="comment-indicator" class="htmx-indicator">
|
||||||
{% call macros::spinner(class="text-gray-300 w-6 h-6", highlight_class="text-gray-700", size=24) %}
|
{% call macros::spinner(class="text-gray-300 w-6 h-6", highlight_class="text-gray-700", size=24) %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-600">No more comments. Check back later for more!</p>
|
<p class="text-gray-600">No more comments. Check back later for more!</p>
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
|||||||
app.admin_login().await;
|
app.admin_login().await;
|
||||||
|
|
||||||
let response = app.get_admin_dashboard_html().await;
|
let response = app.get_admin_dashboard_html().await;
|
||||||
assert!(response.contains("No data available"));
|
assert!(response.contains("No subscribers to display"));
|
||||||
|
|
||||||
app.create_confirmed_subscriber().await;
|
app.create_confirmed_subscriber().await;
|
||||||
let subscriber = sqlx::query!("SELECT id, email FROM subscriptions")
|
let subscriber = sqlx::query!("SELECT id, email FROM subscriptions")
|
||||||
@@ -53,10 +53,90 @@ async fn subscribers_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
|||||||
|
|
||||||
app.delete_subscriber(subscriber.id).await;
|
app.delete_subscriber(subscriber.id).await;
|
||||||
let response = app.get_admin_dashboard_html().await;
|
let response = app.get_admin_dashboard_html().await;
|
||||||
assert!(response.contains("No data available"));
|
assert!(response.contains("No subscribers to display"));
|
||||||
assert!(!response.contains(&subscriber.email));
|
assert!(!response.contains(&subscriber.email));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn posts_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No posts to display"));
|
||||||
|
|
||||||
|
let response = app.post_create_post(&fake_post_body()).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("Your new post has been published")
|
||||||
|
);
|
||||||
|
|
||||||
|
let (post_id, post_title) = {
|
||||||
|
let record = sqlx::query!("SELECT post_id, title FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(record.post_id, record.title)
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(&post_title));
|
||||||
|
|
||||||
|
app.delete_post(post_id).await;
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No posts to display"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comments_are_visible_on_the_dashboard(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No comments to display"));
|
||||||
|
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
|
||||||
|
let (post_id, post_title) = {
|
||||||
|
let record = sqlx::query!("SELECT post_id, title FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(record.post_id, record.title)
|
||||||
|
};
|
||||||
|
|
||||||
|
let author = "author";
|
||||||
|
let content = "comment";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": author,
|
||||||
|
"content": content,
|
||||||
|
"idempotency_key": "key"
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains(author));
|
||||||
|
assert!(response.contains(content));
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(&post_title));
|
||||||
|
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
|
||||||
|
app.delete_comment(comment_id).await;
|
||||||
|
let response = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(response.contains("No comments to display"));
|
||||||
|
}
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
async fn dashboard_shows_correct_stats(connection_pool: PgPool) {
|
async fn dashboard_shows_correct_stats(connection_pool: PgPool) {
|
||||||
let app = TestApp::spawn(connection_pool).await;
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
|||||||
163
tests/api/comments.rs
Normal file
163
tests/api/comments.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use crate::helpers::{TestApp, fake_post_body};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn visitor_can_leave_a_comment(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_author = "Author";
|
||||||
|
let comment_content = "Content";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": comment_author,
|
||||||
|
"content": comment_content,
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let post = app.get_post_html(post_id).await;
|
||||||
|
assert!(post.contains(comment_author));
|
||||||
|
assert!(post.contains(comment_content));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn visitor_can_comment_anonymously(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_content = "Content";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"content": comment_content,
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let post = app.get_post_html(post_id).await;
|
||||||
|
assert!(post.contains("Anonymous"));
|
||||||
|
assert!(post.contains(comment_content));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comment_with_invalid_body_is_rejected(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let test_cases = [
|
||||||
|
(
|
||||||
|
serde_json::json!({ "idempotency_key": "key" }),
|
||||||
|
"a missing content",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
serde_json::json!({ "idempotency_key": "key", "content": "" }),
|
||||||
|
"an empty content",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (invalid_body, message) in test_cases {
|
||||||
|
let response = app.post_comment(&post_id, &invalid_body).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
dbg!(&html);
|
||||||
|
assert!(
|
||||||
|
!html.contains("Your comment has been posted"),
|
||||||
|
"The API did not reject the request when the body had {}",
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comment_is_deleted_when_post_is_deleted(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_author = "Author";
|
||||||
|
let comment_content = "Content";
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": comment_author,
|
||||||
|
"content": comment_content,
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
app.delete_post(post_id).await;
|
||||||
|
let record = sqlx::query!("SELECT * FROM comments WHERE comment_id = $1", comment_id)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn comment_posting_is_idempotent(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": "author",
|
||||||
|
"content": "content",
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
let response = app.post_comment(&post_id, &comment_body).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("Your comment has been posted")
|
||||||
|
);
|
||||||
|
let response = app.post_comment(&post_id, &comment_body).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("Your comment has been posted")
|
||||||
|
);
|
||||||
|
|
||||||
|
let count = sqlx::query_scalar!("SELECT count(*) FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
@@ -130,6 +130,26 @@ impl TestApp {
|
|||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_user(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
admin: bool,
|
||||||
|
) -> reqwest::Response {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"password_check": password,
|
||||||
|
"admin": admin,
|
||||||
|
});
|
||||||
|
self.api_client
|
||||||
|
.post(format!("{}/admin/users", self.address))
|
||||||
|
.form(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
|
pub async fn create_unconfirmed_subscriber(&self) -> ConfirmationLinks {
|
||||||
let email: String = SafeEmail().fake();
|
let email: String = SafeEmail().fake();
|
||||||
let body = format!("email={email}");
|
let body = format!("email={email}");
|
||||||
@@ -166,7 +186,7 @@ impl TestApp {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_subscriber(&self, subscriber_id: Uuid) {
|
pub async fn delete_subscriber(&self, subscriber_id: Uuid) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.delete(format!(
|
.delete(format!(
|
||||||
"{}/admin/subscribers/{}",
|
"{}/admin/subscribers/{}",
|
||||||
@@ -174,7 +194,15 @@ impl TestApp {
|
|||||||
))
|
))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Could not delete subscriber");
|
.expect("Could not delete subscriber")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_user(&self, user_id: Uuid) -> reqwest::Response {
|
||||||
|
self.api_client
|
||||||
|
.delete(format!("{}/admin/users/{}", self.address, user_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Could not delete user")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn dispatch_all_pending_emails(&self) {
|
pub async fn dispatch_all_pending_emails(&self) {
|
||||||
@@ -359,12 +387,32 @@ impl TestApp {
|
|||||||
.expect("Failed to execute request")
|
.expect("Failed to execute request")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_post(&self, post_id: Uuid) {
|
pub async fn post_comment<Body>(&self, post_id: &Uuid, body: &Body) -> reqwest::Response
|
||||||
|
where
|
||||||
|
Body: serde::Serialize,
|
||||||
|
{
|
||||||
|
self.api_client
|
||||||
|
.post(format!("{}/posts/{post_id}/comments", self.address))
|
||||||
|
.form(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to execute request")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_post(&self, post_id: Uuid) -> reqwest::Response {
|
||||||
self.api_client
|
self.api_client
|
||||||
.delete(format!("{}/admin/posts/{}", self.address, post_id))
|
.delete(format!("{}/admin/posts/{}", self.address, post_id))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.expect("Could not delete post");
|
.expect("Could not delete post")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_comment(&self, comment_id: Uuid) -> reqwest::Response {
|
||||||
|
self.api_client
|
||||||
|
.delete(format!("{}/admin/comments/{}", self.address, comment_id))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Could not delete comment")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response
|
pub async fn post_unsubscribe<Body>(&self, body: &Body) -> reqwest::Response
|
||||||
@@ -406,8 +454,7 @@ pub fn fake_post_body() -> serde_json::Value {
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"title": "Post title",
|
"title": "Post title",
|
||||||
"content": "Post content",
|
"content": "Post content",
|
||||||
"idempotency_key": Uuid::new_v4().to_string(),
|
"idempotency_key": Uuid::new_v4().to_string()
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod admin_dashboard;
|
mod admin_dashboard;
|
||||||
mod change_password;
|
mod change_password;
|
||||||
|
mod comments;
|
||||||
mod health_check;
|
mod health_check;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod login;
|
mod login;
|
||||||
@@ -9,3 +10,4 @@ mod subscriptions;
|
|||||||
mod subscriptions_confirm;
|
mod subscriptions_confirm;
|
||||||
mod unsubscribe;
|
mod unsubscribe;
|
||||||
mod unsubscribe_confirm;
|
mod unsubscribe_confirm;
|
||||||
|
mod users;
|
||||||
|
|||||||
346
tests/api/users.rs
Normal file
346
tests/api/users.rs
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
use crate::helpers::{TestApp, fake_newsletter_body, fake_post_body, when_sending_an_email};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use wiremock::ResponseTemplate;
|
||||||
|
use zero2prod::authentication::Role;
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_can_create_user(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_can_create_admin_user(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
|
||||||
|
let record = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT role as "role: Role"
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
"#,
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
matches!(record.role, Role::Admin);
|
||||||
|
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains("Administration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_create_posts(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_send_emails(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
app.post_newsletters(&fake_newsletter_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_create_users(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
let username = "other_user";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
let html = app.get_admin_dashboard_html().await;
|
||||||
|
assert!(html.contains(username));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_users_can_delete_contents(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, true).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
let (subscriber_id, email) = {
|
||||||
|
let record = sqlx::query!("SELECT id, email FROM subscriptions")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(record.id, record.email)
|
||||||
|
};
|
||||||
|
let response = app.delete_subscriber(subscriber_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains(&email));
|
||||||
|
assert!(text.contains("has been deleted"));
|
||||||
|
|
||||||
|
app.create_user("other_user", password, true).await;
|
||||||
|
let user_id = {
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.user_id
|
||||||
|
};
|
||||||
|
let response = app.delete_user(user_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains("The user has been deleted"));
|
||||||
|
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": "author",
|
||||||
|
"content": "comment",
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
let response = app.delete_comment(comment_id).await;
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.contains("The comment has been deleted")
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = app.delete_post(post_id).await;
|
||||||
|
let text = response.text().await.unwrap();
|
||||||
|
assert!(text.contains("The post has been deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn admin_functions_are_hidden_for_non_admin_users(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
let record = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT role as "role: Role"
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
"#,
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
matches!(record.role, Role::Writer);
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
let response = app.post_login(&login_body).await;
|
||||||
|
assert!(!response.text().await.unwrap().contains("Administration"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn writers_can_publish_posts_and_send_emails(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
when_sending_an_email()
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.expect(2)
|
||||||
|
.mount(&app.email_server)
|
||||||
|
.await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
app.post_newsletters(&fake_newsletter_body()).await;
|
||||||
|
app.dispatch_all_pending_emails().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[sqlx::test]
|
||||||
|
async fn writers_cannot_perform_admin_functions(connection_pool: PgPool) {
|
||||||
|
let app = TestApp::spawn(connection_pool).await;
|
||||||
|
app.admin_login().await;
|
||||||
|
let username = "alphonse";
|
||||||
|
let password = "123456789abc";
|
||||||
|
app.create_user(username, password, false).await;
|
||||||
|
app.post_create_post(&fake_post_body()).await;
|
||||||
|
let post_id = {
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.post_id
|
||||||
|
};
|
||||||
|
app.create_confirmed_subscriber().await;
|
||||||
|
let subscriber_id = {
|
||||||
|
let record = sqlx::query!("SELECT id FROM subscriptions")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.id
|
||||||
|
};
|
||||||
|
let comment_body = serde_json::json!({
|
||||||
|
"author": "author",
|
||||||
|
"content": "comment",
|
||||||
|
"idempotency_key": "key",
|
||||||
|
});
|
||||||
|
app.post_comment(&post_id, &comment_body).await;
|
||||||
|
let comment_id = {
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.comment_id
|
||||||
|
};
|
||||||
|
|
||||||
|
app.logout().await;
|
||||||
|
let login_body = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"password": password
|
||||||
|
});
|
||||||
|
app.post_login(&login_body).await;
|
||||||
|
|
||||||
|
let response = app.delete_subscriber(subscriber_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT id FROM subscriptions")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let response = app.delete_comment(comment_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT comment_id FROM comments")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let response = app.delete_post(post_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT post_id FROM posts")
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let user_id = {
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users")
|
||||||
|
.fetch_one(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
record.user_id
|
||||||
|
};
|
||||||
|
let response = app.delete_user(user_id).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
|
||||||
|
let record = sqlx::query_scalar!("SELECT username FROM users WHERE user_id = $1", user_id)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_some());
|
||||||
|
|
||||||
|
let username = "friend";
|
||||||
|
let password = "123456789abc";
|
||||||
|
let response = app.create_user(username, password, false).await;
|
||||||
|
let html = response.text().await.unwrap();
|
||||||
|
assert!(html.contains("requires administrator privileges"));
|
||||||
|
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||||
|
.fetch_optional(&app.connection_pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(record.is_none());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user