Compare commits

..

10 Commits

18 changed files with 406 additions and 17 deletions

View File

@@ -10,6 +10,7 @@ use crate::{
quotes::QuoteError,
tags::TagError,
users::{UserError, auth::AuthError, sessions::SessionError},
web::RedirectViaError,
};
mod auth;
@@ -83,5 +84,6 @@ composite_from!(
TagError,
PersonError,
QuoteError,
DatabaseError
DatabaseError,
RedirectViaError
);

26
src/logs.rs Normal file
View 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."),
}
}
}

View File

@@ -6,6 +6,7 @@ use tokio::net::TcpListener;
mod api;
mod config;
mod database;
mod logs;
mod persons;
mod quotes;
mod tags;

View File

@@ -15,6 +15,7 @@ pub enum Permission {
RenameTags,
DeleteTags,
ChangePersonPrimaryName,
BrowseServerLogs,
}
impl User {

View File

@@ -10,7 +10,7 @@ const LINKS: &[(&str, &str, &str, bool)] = &[
("Persons", "/persons", icons::CONTACT, false),
("Tags", "/tags", icons::TAG, false),
("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 {
@@ -57,9 +57,11 @@ pub fn nav(user: Option<&User>, uri: &str) -> Markup {
p {"Settings"}
}
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" {
div class="scale-[.7]" {(PreEscaped(icons::LOG_OUT))}
p {"Log out"}
form action="/api/auth/logout-form" method="post" {
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" {
div class="scale-[.7]" {(PreEscaped(icons::LOG_OUT))}
"Log out"
}
}
}
}

1
src/web/icons/clock.svg Normal file
View 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

View File

@@ -2,6 +2,7 @@
pub const ARROW_RIGHT: &str = include_str!("arrow-right.svg");
pub const CALENDAR_1: &str = include_str!("calendar-1.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 EYE: &str = include_str!("eye.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 LOG_OUT: &str = include_str!("log-out.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 SCROLL_TEXT: &str = include_str!("scroll-text.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 USER: &str = include_str!("user.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");

1
src/web/icons/pen.svg Normal file
View 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

View 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

View File

@@ -1,4 +1,7 @@
use axum::Router;
use axum::{
Router,
response::{IntoResponse, Redirect, Response},
};
use tower_http::services::ServeFile;
mod components;
@@ -10,3 +13,10 @@ pub fn web_router() -> Router {
.route_service("/styles.css", ServeFile::new("src/web/styles.css"))
.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
View 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())
}

View File

@@ -1,22 +1,35 @@
use axum::{Router, routing::get};
use axum::{
Router,
routing::{get, post},
};
use maud::{DOCTYPE, Markup, html};
pub mod dashboard;
pub mod index;
pub mod login;
pub mod logs;
pub mod persons;
pub mod tags;
pub mod users;
pub mod usersettings;
pub fn pages() -> Router {
Router::new()
.route("/", get(index::page))
.route("/login", get(login::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/{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/create", post(tags::create))
.route("/persons", get(persons::page))
.route("/persons/create", post(persons::create))
.route("/logs", get(logs::page))
}
pub fn base(title: &str, inner: Markup) -> Markup {

View File

@@ -1,14 +1,18 @@
use axum::{
Form,
extract::Request,
response::{IntoResponse, Response},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
};
use maud::{PreEscaped, html};
use serde::Deserialize;
use crate::{
api::CompositeError,
persons::Person,
users::{
User,
auth::{AuthError, UserAuthenticate},
auth::{AuthError, UserAuthRequired, UserAuthenticate},
},
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() {
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 {
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)}
div class="w-px h-2/3 my-auto mx-2 bg-neutral-200/15" {}
div class="text-xs flex items-center" {
@@ -59,6 +63,18 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
@if persons.is_empty() {
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 {
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())
}
#[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())
}

View File

@@ -1,14 +1,18 @@
use axum::{
Form,
extract::Request,
response::{IntoResponse, Response},
http::HeaderMap,
response::{IntoResponse, Redirect, Response},
};
use maud::{PreEscaped, html};
use serde::Deserialize;
use crate::{
tags::Tag,
api::CompositeError,
tags::{Tag, TagName},
users::{
User,
auth::{AuthError, UserAuthenticate},
auth::{AuthError, UserAuthRequired, UserAuthenticate},
},
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() {
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 {
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" {"#"}
@@ -59,6 +63,18 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
@if tags.is_empty() {
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 {
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())
}
#[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())
}

View File

@@ -8,6 +8,7 @@ use crate::{
users::{
User,
auth::{AuthError, UserAuthenticate},
permissions::Permission,
},
web::{
components::{nav::nav, user_miniprofile::user_miniprofile},
@@ -16,6 +17,7 @@ use crate::{
},
};
pub mod create;
pub mod profile;
pub async fn page(req: Request) -> Result<Response, AuthError> {
@@ -30,7 +32,7 @@ pub async fn page(req: Request) -> Result<Response, AuthError> {
html!(
(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" {
p class="flex items-center gap-2" {
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" {
@if let Ok(v) = &us {
(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"
}
}
}
}

View 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())
}

View 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