feat: admin status, first user is admin, site settings

This commit is contained in:
Per Stark
2025-01-22 12:30:52 +01:00
parent 5a1095f538
commit 16e0611a88
12 changed files with 207 additions and 38 deletions

View File

@@ -27,6 +27,7 @@ use zettle_db::{
},
html::{
account::{delete_account, set_api_key, show_account_page},
admin_panel::show_admin_panel,
documentation::index::show_documentation_index,
gdpr::{accept_gdpr, deny_gdpr},
index::index_handler,
@@ -41,7 +42,10 @@ use zettle_db::{
},
AppState,
},
storage::{db::SurrealDbClient, types::user::User},
storage::{
db::SurrealDbClient,
types::{analytics::Analytics, system_settings::SystemSettings, user::User},
},
utils::{config::get_config, mailer::Mailer},
};
@@ -106,25 +110,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
app_state.surreal_db_client.build_indexes().await?;
setup_auth(&app_state.surreal_db_client).await?;
// app_state.surreal_db_client.drop_table::<FileInfo>().await?;
// app_state.surreal_db_client.drop_table::<User>().await?;
// app_state.surreal_db_client.drop_table::<Job>().await?;
// app_state
// .surreal_db_client
// .drop_table::<KnowledgeEntity>()
// .await?;
// app_state
// .surreal_db_client
// .drop_table::<KnowledgeRelationship>()
// .await?;
// app_state
// .surreal_db_client
// .drop_table::<TextContent>()
// .await?;
// app_state
// .surreal_db_client
// .drop_table::<TextChunk>()
// .await?;
Analytics::ensure_initialized(&app_state.surreal_db_client).await?;
SystemSettings::ensure_initialized(&app_state.surreal_db_client).await?;
// Create Axum router
let app = Router::new()
@@ -181,6 +168,7 @@ fn html_routes(
.route("/queue", get(show_queue_tasks))
.route("/queue/:delivery_tag", delete(delete_task))
.route("/account", get(show_account_page))
.route("/admin", get(show_admin_panel))
.route("/set-api-key", post(set_api_key))
.route("/delete-account", delete(delete_account))
.route(

View File

@@ -0,0 +1,52 @@
use axum::{
extract::State,
http::{StatusCode, Uri},
response::{IntoResponse, Redirect},
};
use axum_htmx::HxRedirect;
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use surrealdb::{engine::any::Any, Surreal};
use crate::{
error::{AppError, HtmlError},
page_data,
server::{routes::html::render_template, AppState},
storage::types::{analytics::Analytics, system_settings::SystemSettings, user::User},
};
page_data!(AdminPanelData, "auth/admin_panel.html", {
user: User,
settings: SystemSettings,
analytics: Analytics
});
pub async fn show_admin_panel(
State(state): State<AppState>,
auth: AuthSession<User, String, SessionSurrealPool<Any>, Surreal<Any>>,
) -> Result<impl IntoResponse, HtmlError> {
// Early return if the user is not authenticated
let user = match auth.current_user {
Some(user) => user,
None => return Ok(Redirect::to("/").into_response()),
};
let settings = SystemSettings::get_current(&state.surreal_db_client)
.await
.map_err(|e| HtmlError::new(e, state.templates.clone()))?;
let analytics = Analytics::get_current(&state.surreal_db_client)
.await
.map_err(|e| HtmlError::new(e, state.templates.clone()))?;
let output = render_template(
AdminPanelData::template_name(),
AdminPanelData {
user,
settings,
analytics,
},
state.templates.clone(),
)?;
Ok(output.into_response())
}

View File

@@ -6,6 +6,7 @@ use minijinja_autoreload::AutoReloader;
use crate::error::{HtmlError, IntoHtmlError};
pub mod account;
pub mod admin_panel;
pub mod documentation;
pub mod gdpr;
pub mod index;

View File

@@ -7,6 +7,7 @@ use axum::{
use axum_htmx::{HxBoosted, HxRedirect};
use axum_session_auth::AuthSession;
use axum_session_surreal::SessionSurrealPool;
use chrono::RoundingError;
use serde::{Deserialize, Serialize};
use surrealdb::{engine::any::Any, Surreal};
use tracing::info;
@@ -59,8 +60,8 @@ pub async fn process_signup_and_show_verification(
let user = match User::create_new(form.email, form.password, &state.surreal_db_client).await {
Ok(user) => user,
Err(e) => {
info!("{:?}", e);
return Ok(Html("<p>User already exists</p>").into_response());
tracing::error!("{:?}", e);
return Ok(Html(format!("<p>{}</p>", e)).into_response());
}
};

View File

@@ -0,0 +1,50 @@
use crate::storage::types::file_info::deserialize_flexible_id;
use serde::{Deserialize, Serialize};
use crate::{error::AppError, storage::db::SurrealDbClient};
#[derive(Debug, Serialize, Deserialize)]
pub struct Analytics {
#[serde(deserialize_with = "deserialize_flexible_id")]
pub id: String,
pub visitors: i64,
}
impl Analytics {
pub async fn ensure_initialized(db: &SurrealDbClient) -> Result<Self, AppError> {
let analytics = db.select(("analytics", "current")).await?;
if analytics.is_none() {
let created: Option<Analytics> = db
.create(("analytics", "current"))
.content(Analytics {
id: "current".to_string(),
visitors: 0,
})
.await?;
return created.ok_or(AppError::Validation("Failed to initialize settings".into()));
};
analytics.ok_or(AppError::Validation("Failed to initialize settings".into()))
}
pub async fn get_current(db: &SurrealDbClient) -> Result<Self, AppError> {
let analytics: Option<Self> = db
.client
.query("SELECT * FROM type::thing('analytics', 'current')")
.await?
.take(0)?;
analytics.ok_or(AppError::NotFound("Analytics not found".into()))
}
pub async fn increment_visitors(db: &SurrealDbClient) -> Result<Self, AppError> {
let updated: Option<Self> = db
.client
.query("UPDATE type::thing('analytics', 'current') SET visitors += 1 RETURN AFTER")
.await?
.take(0)?;
updated.ok_or(AppError::Validation("Failed to update analytics".into()))
}
}

View File

@@ -1,9 +1,11 @@
use axum::async_trait;
use serde::{Deserialize, Serialize};
pub mod analytics;
pub mod file_info;
pub mod job;
pub mod knowledge_entity;
pub mod knowledge_relationship;
pub mod system_settings;
pub mod text_chunk;
pub mod text_content;
pub mod user;
@@ -58,7 +60,7 @@ macro_rules! stored_object {
}
}
fn deserialize_flexible_id<'de, D>(deserializer: D) -> Result<String, D::Error>
pub fn deserialize_flexible_id<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{

View File

@@ -0,0 +1,56 @@
use crate::storage::types::file_info::deserialize_flexible_id;
use serde::{Deserialize, Serialize};
use crate::{error::AppError, storage::db::SurrealDbClient};
#[derive(Debug, Serialize, Deserialize)]
pub struct SystemSettings {
#[serde(deserialize_with = "deserialize_flexible_id")]
pub id: String,
pub registrations_enabled: bool,
pub require_email_verification: bool,
}
impl SystemSettings {
pub async fn ensure_initialized(db: &SurrealDbClient) -> Result<Self, AppError> {
let settings = db.select(("system_settings", "current")).await?;
if settings.is_none() {
let created: Option<SystemSettings> = db
.create(("system_settings", "current"))
.content(SystemSettings {
id: "current".to_string(),
registrations_enabled: true,
require_email_verification: false,
})
.await?;
return created.ok_or(AppError::Validation("Failed to initialize settings".into()));
};
settings.ok_or(AppError::Validation("Failed to initialize settings".into()))
}
pub async fn get_current(db: &SurrealDbClient) -> Result<Self, AppError> {
let settings: Option<Self> = db
.client
.query("SELECT * FROM type::thing('system_settings', 'current')")
.await?
.take(0)?;
settings.ok_or(AppError::NotFound("System settings not found".into()))
}
pub async fn update(db: &SurrealDbClient, changes: Self) -> Result<Self, AppError> {
let updated: Option<Self> = db
.client
.query("UPDATE type::thing('system_settings', 'current') MERGE $changes RETURN AFTER")
.bind(("changes", changes))
.await?
.take(0)?;
updated.ok_or(AppError::Validation(
"Something went wrong updating the settings".into(),
))
}
}

View File

@@ -7,13 +7,16 @@ use axum_session_auth::Authentication;
use surrealdb::{engine::any::Any, Surreal};
use uuid::Uuid;
use super::{knowledge_entity::KnowledgeEntity, text_content::TextContent};
use super::{
knowledge_entity::KnowledgeEntity, system_settings::SystemSettings, text_content::TextContent,
};
stored_object!(User, "user", {
email: String,
password: String,
anonymous: bool,
api_key: Option<String>
api_key: Option<String>,
admin: bool
});
#[async_trait]
@@ -42,31 +45,35 @@ impl User {
password: String,
db: &SurrealDbClient,
) -> Result<Self, AppError> {
// Check if user exists
if (Self::find_by_email(&email, db).await?).is_some() {
return Err(AppError::Auth("User already exists".into()));
// verify that the application allows new creations
let systemsettings = SystemSettings::get_current(db).await?;
if !systemsettings.registrations_enabled {
return Err(AppError::Auth("Registration is not allowed".into()));
}
let now = Utc::now();
let id = Uuid::new_v4().to_string();
let user: Option<User> = db
.client
.query(
"CREATE type::thing('user', $id) SET
email = $email,
"LET $count = (SELECT count() FROM type::table($table))[0].count;
CREATE type::thing('user', $id) SET
email = $email,
password = crypto::argon2::generate($password),
admin = $count < 1, // Changed from == 0 to < 1
anonymous = false,
created_at = $created_at,
updated_at = $updated_at",
)
.bind(("table", "user"))
.bind(("id", id))
.bind(("email", email))
.bind(("password", password))
.bind(("created_at", now))
.bind(("updated_at", now))
.await?
.take(0)?;
.take(1)?;
user.ok_or(AppError::Auth("User failed to create".into()))
}

View File

@@ -5,7 +5,7 @@
opacity: 0.5;
}
</style>
<main class="grow flex flex-col prose container mx-auto mt-2 sm:mt-4">
<main class="grow flex flex-col prose container mx-auto mt-2 p-5 sm:mt-4">
<h1 class="text-center">Account Settings</h1>
<div class="form-control">
<label class="label">

View File

@@ -0,0 +1,13 @@
{% extends 'body_base.html' %}
{% block main %}
<main class="container justify-center flex-grow flex mx-auto mt-4 p-5">
Hello
{% if user.admin %}
admin
{% else %}
user
{% endif %}
{{settings}}
</main>
{% endblock %}

View File

@@ -19,7 +19,7 @@
<summary>Account</summary>
<ul class="bg-base-100 rounded-t-none p-2">
<li><a hx-boost="true" class="" href="/account">Account</a></li>
<li><a hx-boost="true" class="" href="/admin">Admin Panel</a></li>
<li><a hx-boost="true" class="" href="/admin">Admin</a></li>
<li><a hx-boost="true" href="/signout">Sign out</a></li>
</ul>
</details>

View File

@@ -1,7 +1,6 @@
{% extends "body_base.html" %}
{% extends 'body_base.html' %}
{% block main %}
<div class="container mx-auto flex items-center justify-center">
<main class="container justify-center flex-grow flex mx-auto mt-4 p-5">
<div class="flex flex-col space-y-4 text-center">
<h1 class="text-2xl font-bold text-error">
{{ status_code }}
@@ -10,5 +9,5 @@
<p class="text-base-content/60">{{ description }}</p>
<a href="/" class="btn btn-primary mt-8">Go Home</a>
</div>
</div>
</main>
{% endblock %}