Compare commits
10 Commits
48808a447c
...
c2062f3a8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
c2062f3a8a
|
|||
|
9393dc2f21
|
|||
|
241e6988c9
|
|||
|
e115bf4391
|
|||
|
98b93345c2
|
|||
|
acdd89e26f
|
|||
|
4a4e3872da
|
|||
|
1e43b9cf57
|
|||
|
73a6e7d8e6
|
|||
|
cbf344dc25
|
@@ -10,6 +10,7 @@ use crate::{
|
|||||||
quotes::QuoteError,
|
quotes::QuoteError,
|
||||||
tags::TagError,
|
tags::TagError,
|
||||||
users::{UserError, auth::AuthError, sessions::SessionError},
|
users::{UserError, auth::AuthError, sessions::SessionError},
|
||||||
|
web::RedirectViaError,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -83,5 +84,6 @@ composite_from!(
|
|||||||
TagError,
|
TagError,
|
||||||
PersonError,
|
PersonError,
|
||||||
QuoteError,
|
QuoteError,
|
||||||
DatabaseError
|
DatabaseError,
|
||||||
|
RedirectViaError
|
||||||
);
|
);
|
||||||
|
|||||||
26
src/logs.rs
Normal file
26
src/logs.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::users::User;
|
||||||
|
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub actor: User,
|
||||||
|
pub data: LogAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LogAction {
|
||||||
|
Initialize,
|
||||||
|
RegenInfradmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for LogAction {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
LogAction::Initialize => write!(f, "Initialized Mnemosyne."),
|
||||||
|
LogAction::RegenInfradmin => write!(f, "Regenerated the Infradmin account."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use tokio::net::TcpListener;
|
|||||||
mod api;
|
mod api;
|
||||||
mod config;
|
mod config;
|
||||||
mod database;
|
mod database;
|
||||||
|
mod logs;
|
||||||
mod persons;
|
mod persons;
|
||||||
mod quotes;
|
mod quotes;
|
||||||
mod tags;
|
mod tags;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub enum Permission {
|
|||||||
RenameTags,
|
RenameTags,
|
||||||
DeleteTags,
|
DeleteTags,
|
||||||
ChangePersonPrimaryName,
|
ChangePersonPrimaryName,
|
||||||
|
BrowseServerLogs,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const LINKS: &[(&str, &str, &str, bool)] = &[
|
|||||||
("Persons", "/persons", icons::CONTACT, false),
|
("Persons", "/persons", icons::CONTACT, false),
|
||||||
("Tags", "/tags", icons::TAG, false),
|
("Tags", "/tags", icons::TAG, false),
|
||||||
("Users", "/users", icons::USERS, true),
|
("Users", "/users", icons::USERS, true),
|
||||||
("Logs", "#logs", icons::CLIPBOARD_CLOCK, true),
|
("Logs", "/logs", icons::CLIPBOARD_CLOCK, true),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn nav(user: Option<&User>, uri: &str) -> Markup {
|
pub fn nav(user: Option<&User>, uri: &str) -> Markup {
|
||||||
@@ -57,9 +57,11 @@ pub fn nav(user: Option<&User>, uri: &str) -> Markup {
|
|||||||
p {"Settings"}
|
p {"Settings"}
|
||||||
}
|
}
|
||||||
div class="h-px w-full bg-neutral-200/15" {}
|
div class="h-px w-full bg-neutral-200/15" {}
|
||||||
a href="/api/auth/logout-form" class="w-full text-left flex items-center gap-2 px-4 py-2 hover:bg-neutral-200/10 font-lexend text-sm text-red-300 transition-colors" {
|
form action="/api/auth/logout-form" method="post" {
|
||||||
div class="scale-[.7]" {(PreEscaped(icons::LOG_OUT))}
|
button type="submit" class="w-full text-left flex items-center gap-2 px-4 py-2 hover:bg-neutral-200/10 cursor-pointer font-lexend text-sm text-red-300 transition-colors" {
|
||||||
p {"Log out"}
|
div class="scale-[.7]" {(PreEscaped(icons::LOG_OUT))}
|
||||||
|
"Log out"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
src/web/icons/clock.svg
Normal file
1
src/web/icons/clock.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock-icon lucide-clock"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||||
|
After Width: | Height: | Size: 289 B |
@@ -2,6 +2,7 @@
|
|||||||
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
|
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
|
||||||
pub const CALENDAR_1: &str = include_str!("calendar-1.svg");
|
pub const CALENDAR_1: &str = include_str!("calendar-1.svg");
|
||||||
pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg");
|
pub const CLIPBOARD_CLOCK: &str = include_str!("clipboard-clock.svg");
|
||||||
|
pub const CLOCK: &str = include_str!("clock.svg");
|
||||||
pub const CONTACT: &str = include_str!("contact.svg");
|
pub const CONTACT: &str = include_str!("contact.svg");
|
||||||
pub const EYE: &str = include_str!("eye.svg");
|
pub const EYE: &str = include_str!("eye.svg");
|
||||||
pub const FILE_IMAGE: &str = include_str!("file-image.svg");
|
pub const FILE_IMAGE: &str = include_str!("file-image.svg");
|
||||||
@@ -9,6 +10,7 @@ pub const INFO: &str = include_str!("info.svg");
|
|||||||
pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg");
|
pub const LAYOUT_DASHBOARD: &str = include_str!("layout-dashboard.svg");
|
||||||
pub const LOG_OUT: &str = include_str!("log-out.svg");
|
pub const LOG_OUT: &str = include_str!("log-out.svg");
|
||||||
pub const MAP_PIN: &str = include_str!("map-pin.svg");
|
pub const MAP_PIN: &str = include_str!("map-pin.svg");
|
||||||
|
pub const PEN: &str = include_str!("pen.svg");
|
||||||
pub const QUOTE: &str = include_str!("quote.svg");
|
pub const QUOTE: &str = include_str!("quote.svg");
|
||||||
pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg");
|
pub const SCROLL_TEXT: &str = include_str!("scroll-text.svg");
|
||||||
pub const SERVER: &str = include_str!("server.svg");
|
pub const SERVER: &str = include_str!("server.svg");
|
||||||
@@ -16,4 +18,5 @@ pub const SHIELD_USER: &str = include_str!("shield-user.svg");
|
|||||||
pub const TAG: &str = include_str!("tag.svg");
|
pub const TAG: &str = include_str!("tag.svg");
|
||||||
pub const USER: &str = include_str!("user.svg");
|
pub const USER: &str = include_str!("user.svg");
|
||||||
pub const USER_KEY: &str = include_str!("user-key.svg");
|
pub const USER_KEY: &str = include_str!("user-key.svg");
|
||||||
|
pub const USER_PLUS: &str = include_str!("user-plus.svg");
|
||||||
pub const USERS: &str = include_str!("users.svg");
|
pub const USERS: &str = include_str!("users.svg");
|
||||||
|
|||||||
1
src/web/icons/pen.svg
Normal file
1
src/web/icons/pen.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pen-icon lucide-pen"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>
|
||||||
|
After Width: | Height: | Size: 370 B |
1
src/web/icons/user-plus.svg
Normal file
1
src/web/icons/user-plus.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-plus-icon lucide-user-plus"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/></svg>
|
||||||
|
After Width: | Height: | Size: 401 B |
@@ -1,4 +1,7 @@
|
|||||||
use axum::Router;
|
use axum::{
|
||||||
|
Router,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
use tower_http::services::ServeFile;
|
use tower_http::services::ServeFile;
|
||||||
|
|
||||||
mod components;
|
mod components;
|
||||||
@@ -10,3 +13,10 @@ pub fn web_router() -> Router {
|
|||||||
.route_service("/styles.css", ServeFile::new("src/web/styles.css"))
|
.route_service("/styles.css", ServeFile::new("src/web/styles.css"))
|
||||||
.merge(pages::pages())
|
.merge(pages::pages())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct RedirectViaError(Redirect);
|
||||||
|
impl IntoResponse for RedirectViaError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
self.0.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
83
src/web/pages/logs.rs
Normal file
83
src/web/pages/logs.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::Request,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use maud::{PreEscaped, html};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::CompositeError,
|
||||||
|
logs::{LogAction, LogEntry},
|
||||||
|
users::{User, auth::UserAuthenticate, permissions::Permission},
|
||||||
|
web::{RedirectViaError, components::nav::nav, icons, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(req: Request) -> Result<Response, CompositeError> {
|
||||||
|
let u = User::authenticate(req.headers())?
|
||||||
|
.ok_or(RedirectViaError(Redirect::to("/login?re=/logs")))?;
|
||||||
|
let logs: Vec<LogEntry> = vec![
|
||||||
|
LogEntry {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
actor: User::get_by_id(Uuid::nil()).unwrap(),
|
||||||
|
data: LogAction::Initialize,
|
||||||
|
},
|
||||||
|
LogEntry {
|
||||||
|
id: Uuid::now_v7(),
|
||||||
|
actor: User::get_by_id(Uuid::nil()).unwrap(),
|
||||||
|
data: LogAction::RegenInfradmin,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(base(
|
||||||
|
"Persons | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(Some(&u), req.uri().path()))
|
||||||
|
|
||||||
|
@if let Ok(true) = u.has_permission(Permission::BrowseServerLogs) {
|
||||||
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
|
div class="my-4" {
|
||||||
|
p class="flex items-center gap-2" {
|
||||||
|
span class="text-neutral-500" {(PreEscaped(icons::CLIPBOARD_CLOCK))}
|
||||||
|
span class="text-2xl font-semibold font-lora" {"Logs"}
|
||||||
|
}
|
||||||
|
p class="text-neutral-500 text-sm font-light" {
|
||||||
|
"Work in progress."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div class="w-full border border-neutral-200/25 rounded grid grid-cols-[auto_auto_1fr]" {
|
||||||
|
@for (txt, ico) in [("Timestamp", icons::CLOCK), ("Actor", icons::USER), ("Action", icons::PEN)] {
|
||||||
|
div class="p-2 flex gap-1 font-semibold border-b border-neutral-200/25" {
|
||||||
|
span class="text-neutral-500 scale-[.8]" {(PreEscaped(ico))}
|
||||||
|
(txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@for (idx, log) in logs.iter().enumerate() {
|
||||||
|
@let s = if idx % 2 == 0 {"background-color: #e5e5e50b"} else {""};
|
||||||
|
div class="p-2 font-light" style=(s) {
|
||||||
|
(log.id.get_timestamp()
|
||||||
|
.map(|ts| {
|
||||||
|
let (secs, nanos) = ts.to_unix();
|
||||||
|
chrono::DateTime::from_timestamp(secs as i64, nanos)
|
||||||
|
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
|
||||||
|
.unwrap_or_else(|| "invalid date".to_string())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "no timestamp".to_string()))
|
||||||
|
}
|
||||||
|
div class="p-2 font-light" style=(s) {(log.actor.handle)}
|
||||||
|
div class="p-2 font-light" style=(s) {(log.data)}
|
||||||
|
}
|
||||||
|
@if true {
|
||||||
|
div class="p-2 col-span-3 text-center font-light text-neutral-400" {"You've reached the end of all logs."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
p class="text-center p-2" {"You must have permission to view this page."}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
@@ -1,22 +1,35 @@
|
|||||||
use axum::{Router, routing::get};
|
use axum::{
|
||||||
|
Router,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
use maud::{DOCTYPE, Markup, html};
|
use maud::{DOCTYPE, Markup, html};
|
||||||
|
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
|
pub mod logs;
|
||||||
pub mod persons;
|
pub mod persons;
|
||||||
pub mod tags;
|
pub mod tags;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
pub mod usersettings;
|
||||||
|
|
||||||
pub fn pages() -> Router {
|
pub fn pages() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(index::page))
|
.route("/", get(index::page))
|
||||||
.route("/login", get(login::page))
|
.route("/login", get(login::page))
|
||||||
.route("/dashboard", get(dashboard::page))
|
.route("/dashboard", get(dashboard::page))
|
||||||
|
.route("/user-settings", get(usersettings::page))
|
||||||
|
.route("/user-settings/handle", post(usersettings::change_handle))
|
||||||
|
.route("/user-settings/passwd", post(usersettings::change_password))
|
||||||
.route("/users", get(users::page))
|
.route("/users", get(users::page))
|
||||||
.route("/users/{id}", get(users::profile::page))
|
.route("/users/{id}", get(users::profile::page))
|
||||||
|
.route("/users/create", get(users::create::page))
|
||||||
|
.route("/users/create-form", post(users::create::create_user))
|
||||||
.route("/tags", get(tags::page))
|
.route("/tags", get(tags::page))
|
||||||
|
.route("/tags/create", post(tags::create))
|
||||||
.route("/persons", get(persons::page))
|
.route("/persons", get(persons::page))
|
||||||
|
.route("/persons/create", post(persons::create))
|
||||||
|
.route("/logs", get(logs::page))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn base(title: &str, inner: Markup) -> Markup {
|
pub fn base(title: &str, inner: Markup) -> Markup {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
|
Form,
|
||||||
extract::Request,
|
extract::Request,
|
||||||
response::{IntoResponse, Response},
|
http::HeaderMap,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use maud::{PreEscaped, html};
|
use maud::{PreEscaped, html};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
api::CompositeError,
|
||||||
persons::Person,
|
persons::Person,
|
||||||
users::{
|
users::{
|
||||||
User,
|
User,
|
||||||
auth::{AuthError, UserAuthenticate},
|
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||||
},
|
},
|
||||||
web::{components::nav::nav, icons, pages::base},
|
web::{components::nav::nav, icons, pages::base},
|
||||||
};
|
};
|
||||||
@@ -36,10 +40,10 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if let Ok(persons) = Person::get_all() {
|
@if let Ok(persons) = Person::get_all() {
|
||||||
div class="max-w-4xl mx-auto mt-4 flex gap-2" {
|
div class="max-w-4xl mx-auto px-2 mt-4 flex flex-wrap gap-2" {
|
||||||
@for person in &persons {
|
@for person in &persons {
|
||||||
div class="rounded px-4 py-2 bg-neutral-200/10 border border-neutral-200/15 flex items-center" {
|
div class="rounded px-4 py-2 bg-neutral-200/10 border border-neutral-200/15 flex items-center" {
|
||||||
span class="text-neutral-400 mr-1" {"~"}
|
span class="text-neutral-400 mr-1 scale-125" {"~"}
|
||||||
span class="text-sm" {(person.primary_name)}
|
span class="text-sm" {(person.primary_name)}
|
||||||
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
|
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
|
||||||
div class="text-xs flex items-center" {
|
div class="text-xs flex items-center" {
|
||||||
@@ -59,6 +63,18 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
@if persons.is_empty() {
|
@if persons.is_empty() {
|
||||||
p class="text-center p-2" {"No persons yet."}
|
p class="text-center p-2" {"No persons yet."}
|
||||||
}
|
}
|
||||||
|
div class="mx-auto max-w-4xl mt-4 px-2" {
|
||||||
|
h3 class="font-lora font-semibold text-xl" {"Add new person"}
|
||||||
|
form action="/persons/create" method="post" {
|
||||||
|
label for="primary_name" class="text-neutral-500 font-light mt-2" {"Primary Name"}
|
||||||
|
div class="flex gap-2" {
|
||||||
|
input type="text" autocomplete="off" id="primary_name" name="primary_name" placeholder="e.g. Frank"
|
||||||
|
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
|
button type="submit"
|
||||||
|
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-red-400 text-center" {"Failed to load persons."}
|
p class="text-red-400 text-center" {"Failed to load persons."}
|
||||||
}
|
}
|
||||||
@@ -69,3 +85,16 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PersonNameForm {
|
||||||
|
primary_name: String,
|
||||||
|
}
|
||||||
|
pub async fn create(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<PersonNameForm>,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
let u = User::authenticate(&headers)?.required()?;
|
||||||
|
Person::create(form.primary_name, u.id)?;
|
||||||
|
Ok(Redirect::to("/persons").into_response())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
|
Form,
|
||||||
extract::Request,
|
extract::Request,
|
||||||
response::{IntoResponse, Response},
|
http::HeaderMap,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use maud::{PreEscaped, html};
|
use maud::{PreEscaped, html};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
tags::Tag,
|
api::CompositeError,
|
||||||
|
tags::{Tag, TagName},
|
||||||
users::{
|
users::{
|
||||||
User,
|
User,
|
||||||
auth::{AuthError, UserAuthenticate},
|
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||||
},
|
},
|
||||||
web::{components::nav::nav, icons, pages::base},
|
web::{components::nav::nav, icons, pages::base},
|
||||||
};
|
};
|
||||||
@@ -36,7 +40,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if let Ok(tags) = Tag::get_all() {
|
@if let Ok(tags) = Tag::get_all() {
|
||||||
div class="max-w-4xl mx-auto mt-4 flex gap-2" {
|
div class="max-w-4xl mx-auto mt-4 flex flex-wrap gap-2" {
|
||||||
@for tag in &tags {
|
@for tag in &tags {
|
||||||
div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" {
|
div class="rounded-full px-3 py-1 bg-neutral-200/10 border border-neutral-200/15 flex" {
|
||||||
span class="text-neutral-400 text-sm" {"#"}
|
span class="text-neutral-400 text-sm" {"#"}
|
||||||
@@ -59,6 +63,18 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
@if tags.is_empty() {
|
@if tags.is_empty() {
|
||||||
p class="text-center p-2" {"No tags yet. How about making one?"}
|
p class="text-center p-2" {"No tags yet. How about making one?"}
|
||||||
}
|
}
|
||||||
|
div class="mx-auto max-w-4xl mt-4 px-2" {
|
||||||
|
h3 class="font-lora font-semibold text-xl" {"Add new tag"}
|
||||||
|
form action="/tags/create" method="post" {
|
||||||
|
label for="tagname" class="text-neutral-500 font-light mt-2" {"Tag Name"}
|
||||||
|
div class="flex gap-2" {
|
||||||
|
input type="text" autocomplete="off" id="tagname" name="tagname" placeholder="e.g. fashion"
|
||||||
|
class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
|
button type="submit"
|
||||||
|
class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {"Submit"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
p class="text-red-400 text-center" {"Failed to load tags."}
|
p class="text-red-400 text-center" {"Failed to load tags."}
|
||||||
}
|
}
|
||||||
@@ -69,3 +85,16 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TagForm {
|
||||||
|
tagname: TagName,
|
||||||
|
}
|
||||||
|
pub async fn create(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<TagForm>,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
User::authenticate(&headers)?.required()?;
|
||||||
|
Tag::create(form.tagname)?;
|
||||||
|
Ok(Redirect::to("/tags").into_response())
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use crate::{
|
|||||||
users::{
|
users::{
|
||||||
User,
|
User,
|
||||||
auth::{AuthError, UserAuthenticate},
|
auth::{AuthError, UserAuthenticate},
|
||||||
|
permissions::Permission,
|
||||||
},
|
},
|
||||||
web::{
|
web::{
|
||||||
components::{nav::nav, user_miniprofile::user_miniprofile},
|
components::{nav::nav, user_miniprofile::user_miniprofile},
|
||||||
@@ -16,6 +17,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod create;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||||
@@ -30,7 +32,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
html!(
|
html!(
|
||||||
(nav(u.as_ref(), req.uri().path()))
|
(nav(u.as_ref(), req.uri().path()))
|
||||||
|
|
||||||
@if let Some(_) = u {
|
@if let Some(u) = u {
|
||||||
div class="mx-auto max-w-4xl px-2 my-4" {
|
div class="mx-auto max-w-4xl px-2 my-4" {
|
||||||
p class="flex items-center gap-2" {
|
p class="flex items-center gap-2" {
|
||||||
span class="text-neutral-500" {(PreEscaped(icons::USERS))}
|
span class="text-neutral-500" {(PreEscaped(icons::USERS))}
|
||||||
@@ -39,6 +41,14 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
|
|||||||
p class="text-neutral-500 text-sm font-light" {
|
p class="text-neutral-500 text-sm font-light" {
|
||||||
@if let Ok(v) = &us {
|
@if let Ok(v) = &us {
|
||||||
(v.len()) " users registered with Mnemosyne."
|
(v.len()) " users registered with Mnemosyne."
|
||||||
|
} @else {
|
||||||
|
"Could not fetch user count."
|
||||||
|
}
|
||||||
|
@if let Ok(true) = u.has_permission(Permission::ManuallyCreateUsers) {
|
||||||
|
" "
|
||||||
|
a href="/users/create" class="text-blue-500 hover:text-blue-400 hover:underline" {
|
||||||
|
"Create a new user"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/web/pages/users/create.rs
Normal file
79
src/web/pages/users/create.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use axum::{
|
||||||
|
Form,
|
||||||
|
extract::Request,
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use maud::{PreEscaped, html};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::CompositeError,
|
||||||
|
users::{
|
||||||
|
User,
|
||||||
|
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||||
|
handle::UserHandle,
|
||||||
|
permissions::Permission,
|
||||||
|
},
|
||||||
|
web::{components::nav::nav, icons, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||||
|
let u = User::authenticate(req.headers())?;
|
||||||
|
|
||||||
|
Ok(base(
|
||||||
|
"Users | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(u.as_ref(), req.uri().path()))
|
||||||
|
|
||||||
|
@if let Some(u) = u {
|
||||||
|
div class="mx-auto max-w-4xl px-2 my-4" {
|
||||||
|
p class="flex items-center gap-2" {
|
||||||
|
span class="text-neutral-500" {(PreEscaped(icons::USER_PLUS))}
|
||||||
|
span class="text-2xl font-semibold font-lora" {"Create a new user"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if let Ok(true) = u.has_permission(Permission::ManuallyCreateUsers) {
|
||||||
|
div class="mx-auto max-w-4xl px-2 mt-4" {
|
||||||
|
form action="/users/create-form" method="post" class="flex flex-col" {
|
||||||
|
label for="handle" class="font-light text-neutral-500" {"Handle"}
|
||||||
|
div class="flex w-64 items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
|
||||||
|
span class="pl-2 text-neutral-500 select-none" {"@"}
|
||||||
|
input id="handle" name="handle" type="text" autocomplete="off"
|
||||||
|
class="w-fit pl-0.5 pr-1 py-1 outline-none";
|
||||||
|
}
|
||||||
|
label for="password" class="font-light text-neutral-500 mt-4" {"Password"} br;
|
||||||
|
input id="password" name="password" type="password" autocomplete="off"
|
||||||
|
class="px-2 w-64 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
|
input type="submit" value="Create"
|
||||||
|
class="px-4 mt-4 w-64 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
p class="text-center p-2" {"You must have permission to view this page."}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
p class="text-center p-2" {"You must be logged in to view this page."}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateUserWithPasswordForm {
|
||||||
|
handle: UserHandle,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
pub async fn create_user(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<CreateUserWithPasswordForm>,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
let u = User::authenticate(&headers)?.required()?;
|
||||||
|
if !u.has_permission(Permission::ManuallyCreateUsers)? {
|
||||||
|
return Ok((StatusCode::FORBIDDEN).into_response());
|
||||||
|
}
|
||||||
|
let mut nu = User::create(form.handle)?;
|
||||||
|
nu.set_password(Some(&form.password))?;
|
||||||
|
Ok(Redirect::to("/users").into_response())
|
||||||
|
}
|
||||||
98
src/web/pages/usersettings.rs
Normal file
98
src/web/pages/usersettings.rs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
use axum::{
|
||||||
|
Form,
|
||||||
|
extract::Request,
|
||||||
|
http::HeaderMap,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use maud::{PreEscaped, html};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::CompositeError,
|
||||||
|
users::{
|
||||||
|
User,
|
||||||
|
auth::{AuthError, UserAuthRequired, UserAuthenticate},
|
||||||
|
handle::UserHandle,
|
||||||
|
},
|
||||||
|
web::{components::nav::nav, icons, pages::base},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn page(req: Request) -> Result<Response, AuthError> {
|
||||||
|
let u = User::authenticate(req.headers())?;
|
||||||
|
|
||||||
|
Ok(base(
|
||||||
|
"Persons | Mnemosyne",
|
||||||
|
html!(
|
||||||
|
(nav(u.as_ref(), req.uri().path()))
|
||||||
|
|
||||||
|
@if let Some(u) = u {
|
||||||
|
div class="max-w-4xl mx-auto px-2" {
|
||||||
|
div class="mx-auto max-w-4xl my-4" {
|
||||||
|
p class="flex items-center gap-2" {
|
||||||
|
span class="text-neutral-500" {(PreEscaped(icons::SERVER))}
|
||||||
|
span class="text-2xl font-semibold font-lora" {"Your User Settings"}
|
||||||
|
}
|
||||||
|
p class="text-neutral-500 text-sm font-light" {
|
||||||
|
// "Hi, " (u.handle) "!" " " "This is your user settings page." br;
|
||||||
|
"Looking for Mnemosyne settings?" " "
|
||||||
|
a class="text-blue-500 hover:text-blue-400 hover:underline" href="/mnemosyne-settings" {"Here."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
label for="handle" class="font-light text-neutral-500" {"Handle"}
|
||||||
|
form action="/user-settings/handle" method="post" class="flex gap-2" {
|
||||||
|
div class="flex items-center border border-neutral-200/25 rounded bg-neutral-950/50" {
|
||||||
|
span class="pl-2 text-neutral-500 select-none" {"@"}
|
||||||
|
input id="handle" name="handle" type="text" autocomplete="off" value={(u.handle)}
|
||||||
|
class="w-full bg-transparent pl-0.5 pr-1 py-1 outline-none";
|
||||||
|
}
|
||||||
|
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
|
||||||
|
"Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hr class="mt-6 mb-4 border-neutral-600";
|
||||||
|
p class="flex items-center gap-1" {
|
||||||
|
span class="text-neutral-500 scale-[.8]" {(PreEscaped(icons::USER_KEY))}
|
||||||
|
span class="text-lg font-semibold font-lora" {"Change Password"}
|
||||||
|
}
|
||||||
|
label for="password" class="font-light text-neutral-500" {"New password"}
|
||||||
|
form action="/user-settings/passwd" method="post" class="flex gap-2" {
|
||||||
|
input id="password" name="password" type="password" autocomplete="off" class="px-2 py-1 border border-neutral-200/25 bg-neutral-950/50 rounded";
|
||||||
|
button type="submit" class="px-4 py-1 border border-neutral-200/25 bg-neutral-200/5 rounded cursor-pointer hover:border-neutral-200/40" {
|
||||||
|
"Submit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
p class="text-center p-2" {"You must be logged in to view this page."}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct HandleForm {
|
||||||
|
handle: UserHandle,
|
||||||
|
}
|
||||||
|
pub async fn change_handle(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<HandleForm>,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
let mut u = User::authenticate(&headers)?.required()?;
|
||||||
|
u.set_handle(form.handle)?;
|
||||||
|
Ok(Redirect::to("/user-settings").into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PasswordForm {
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
pub async fn change_password(
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<PasswordForm>,
|
||||||
|
) -> Result<Response, CompositeError> {
|
||||||
|
let mut u = User::authenticate(&headers)?.required()?;
|
||||||
|
u.set_password(Some(&form.password))?;
|
||||||
|
Ok(Redirect::to("/user-settings").into_response())
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user