Initial commit: web-app boilerplate with auth, OIDC/SSO, admin panel, i18n
Rust (cot framework) + PostgreSQL boilerplate providing: - Session-based auth with Admin/User roles - OIDC/SSO login with PKCE, group-to-role mapping, auto-provisioning - Admin panel: user management, settings, debug/config inspector - 3-tier config system (compiled default → DB → FURU_* env vars) - i18n (English + Russian) with compile-time translations macro - JSON API skeleton (GET /api/me) - DB-backed sessions (survive server restarts) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
pub mod views;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cot::db::Database;
|
||||
use cot::db::migrations::SyncDynMigration;
|
||||
use cot::request::extractors::{Path, RequestForm, UrlQuery};
|
||||
use cot::response::IntoResponse;
|
||||
use cot::router::method::get;
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::App;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth;
|
||||
use crate::config::AppConfig;
|
||||
use crate::i18n::I18n;
|
||||
use crate::user::User;
|
||||
use views::{OidcSettingsForm, SetupForm, UserForm};
|
||||
|
||||
/// Build-time metadata baked in by `build.rs` and Cargo env vars.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildInfo {
|
||||
pub pkg_name: &'static str,
|
||||
pub pkg_version: &'static str,
|
||||
pub profile: &'static str,
|
||||
pub target: &'static str,
|
||||
pub rustc_version: &'static str,
|
||||
}
|
||||
|
||||
pub static BUILD_INFO: BuildInfo = BuildInfo {
|
||||
pkg_name: env!("CARGO_PKG_NAME"),
|
||||
pkg_version: env!("CARGO_PKG_VERSION"),
|
||||
profile: if cfg!(debug_assertions) {
|
||||
"debug"
|
||||
} else {
|
||||
"release"
|
||||
},
|
||||
target: env!("FURU_TARGET"),
|
||||
rustc_version: env!("FURU_RUSTC_VERSION"),
|
||||
};
|
||||
|
||||
pub struct AdminApp {
|
||||
config: Arc<AppConfig>,
|
||||
}
|
||||
|
||||
impl AdminApp {
|
||||
pub fn new(config: Arc<AppConfig>) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SettingsQuery {
|
||||
saved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PathId {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
impl App for AdminApp {
|
||||
fn name(&self) -> &'static str {
|
||||
"admin"
|
||||
}
|
||||
|
||||
fn router(&self) -> Router {
|
||||
Router::with_urls([
|
||||
// -- Setup (first-run, no auth required) --------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/setup",
|
||||
get(|i18n: I18n, db: Database| async move {
|
||||
let count = User::count_all(&db).await.unwrap_or(1);
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_page(i18n, String::new())
|
||||
.await?
|
||||
.into_response()
|
||||
})
|
||||
.post(
|
||||
|i18n: I18n, db: Database, session: Session,
|
||||
form: RequestForm<SetupForm>| async move {
|
||||
let count = User::count_all(&db).await.unwrap_or(1);
|
||||
if count > 0 {
|
||||
return Ok(auth::redirect("/admin/"));
|
||||
}
|
||||
views::setup_submit(i18n, &db, &session, form).await
|
||||
},
|
||||
),
|
||||
"admin_setup",
|
||||
),
|
||||
// -- Dashboard ----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
// First-run redirect
|
||||
let count = User::count_all(&db).await.unwrap_or(0);
|
||||
if count == 0 {
|
||||
return Ok(auth::redirect("/admin/setup"));
|
||||
}
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::admin_index(admin, i18n).await?.into_response()
|
||||
},
|
||||
"admin_index",
|
||||
),
|
||||
// -- Debug --------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/debug",
|
||||
{
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::debug_handler(admin, i18n, &config, &db)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin_debug",
|
||||
),
|
||||
// -- Settings -----------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/settings",
|
||||
get({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
query: UrlQuery<SettingsQuery>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
let saved = query.0.saved.as_deref() == Some("1");
|
||||
views::settings_handler(admin, i18n, &config, &db, saved)
|
||||
.await?
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
})
|
||||
.post({
|
||||
let config = Arc::clone(&self.config);
|
||||
move |session: Session, db: Database, i18n: I18n,
|
||||
form: RequestForm<OidcSettingsForm>| {
|
||||
let config = Arc::clone(&config);
|
||||
async move {
|
||||
let admin =
|
||||
match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::settings_submit(admin, i18n, &config, &db, form).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_settings",
|
||||
),
|
||||
// -- Users --------------------------------------------------------
|
||||
Route::with_handler_and_name(
|
||||
"/users",
|
||||
|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_list(admin, i18n, &db).await?.into_response()
|
||||
},
|
||||
"admin_users",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/new",
|
||||
get(|session: Session, db: Database, i18n: I18n| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_new(admin, i18n).await?.into_response()
|
||||
})
|
||||
.post(
|
||||
|session: Session, db: Database, form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_create(admin, &db, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_new",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/edit",
|
||||
get(
|
||||
|session: Session, db: Database, i18n: I18n,
|
||||
path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_edit(admin, i18n, &db, path.0.id)
|
||||
.await?
|
||||
.into_response()
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|session: Session, db: Database, path: Path<PathId>,
|
||||
form: RequestForm<UserForm>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_update(admin, &db, path.0.id, form).await
|
||||
},
|
||||
),
|
||||
"admin_users_edit",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/users/{id}/delete",
|
||||
cot::router::method::post(
|
||||
|session: Session, db: Database, path: Path<PathId>| async move {
|
||||
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||
Ok(u) => u,
|
||||
Err(resp) => return Ok(resp),
|
||||
};
|
||||
views::users_delete(admin, &db, path.0.id).await
|
||||
},
|
||||
),
|
||||
"admin_users_delete",
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn migrations(&self) -> Vec<Box<SyncDynMigration>> {
|
||||
let mut all =
|
||||
cot::db::migrations::wrap_migrations(crate::config::db_migrations::MIGRATIONS);
|
||||
all.extend(cot::db::migrations::wrap_migrations(
|
||||
crate::user::db_migrations::MIGRATIONS,
|
||||
));
|
||||
all
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
use cot::db::{Database, Model};
|
||||
use cot::form::{Form, FormResult};
|
||||
use cot::html::Html;
|
||||
use cot::request::extractors::RequestForm;
|
||||
use cot::response::IntoResponse;
|
||||
use cot::session::Session;
|
||||
use cot::{Body, Template};
|
||||
|
||||
use crate::auth::{self, AuthenticatedUser};
|
||||
use crate::config::{AppConfig, ConfigEntry, ConfigSources};
|
||||
use crate::i18n::{I18n, Translations};
|
||||
use crate::user::User;
|
||||
use super::BUILD_INFO;
|
||||
|
||||
/// A config entry for display in the unified debug table.
|
||||
#[derive(Debug)]
|
||||
pub struct ConfigDisplayEntry {
|
||||
pub key: String,
|
||||
pub env_var: String,
|
||||
pub value: String,
|
||||
pub default_value: String,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
/// Secret field names that should be redacted in the debug view.
|
||||
const SECRET_FIELDS: &[&str] = &[
|
||||
"database_url",
|
||||
"oidc_client_secret",
|
||||
];
|
||||
|
||||
fn is_secret(name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
SECRET_FIELDS.iter().any(|s| lower.contains(s))
|
||||
|| lower.contains("secret")
|
||||
|| lower.contains("token")
|
||||
}
|
||||
|
||||
fn redact(value: &str) -> String {
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
"********".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/debug.html")]
|
||||
struct DebugTemplate<'a> {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
build: &'a super::BuildInfo,
|
||||
config_entries: Vec<ConfigDisplayEntry>,
|
||||
db_status: String,
|
||||
}
|
||||
|
||||
fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<ConfigDisplayEntry> {
|
||||
let defaults = AppConfig::default();
|
||||
|
||||
macro_rules! entry {
|
||||
($field:ident, $value:expr, $default:expr) => {
|
||||
{
|
||||
let raw = $value;
|
||||
let default_raw = $default;
|
||||
let secret = is_secret(stringify!($field));
|
||||
let display = if secret { redact(&raw) } else { raw };
|
||||
let default_display = if secret { redact(&default_raw) } else { default_raw };
|
||||
ConfigDisplayEntry {
|
||||
key: stringify!($field).into(),
|
||||
env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()),
|
||||
value: display,
|
||||
default_value: default_display,
|
||||
source: sources.$field.code(),
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
vec![
|
||||
entry!(database_url, config.database_url.clone(), defaults.database_url.clone()),
|
||||
entry!(oidc_issuer, config.oidc_issuer.clone(), defaults.oidc_issuer.clone()),
|
||||
entry!(oidc_client_id, config.oidc_client_id.clone(), defaults.oidc_client_id.clone()),
|
||||
entry!(oidc_client_secret, config.oidc_client_secret.clone(), defaults.oidc_client_secret.clone()),
|
||||
entry!(log_level, config.log_level.clone(), defaults.log_level.clone()),
|
||||
entry!(auth_password_enabled, config.auth_password_enabled.to_string(), defaults.auth_password_enabled.to_string()),
|
||||
entry!(auth_sso_enabled, config.auth_sso_enabled.to_string(), defaults.auth_sso_enabled.to_string()),
|
||||
entry!(oidc_button_text, config.oidc_button_text.clone(), defaults.oidc_button_text.clone()),
|
||||
entry!(oidc_admin_groups, config.oidc_admin_groups.clone(), defaults.oidc_admin_groups.clone()),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn debug_handler(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, sources) = AppConfig::load_with_db(db).await;
|
||||
|
||||
let db_status = match db.raw("SELECT 1").await {
|
||||
Ok(_) => i18n.t.debug_db_connected.to_owned(),
|
||||
Err(e) => format!("{}: {e}", i18n.t.debug_db_error),
|
||||
};
|
||||
|
||||
let template = DebugTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
build: &BUILD_INFO,
|
||||
config_entries: config_display_entries(&config, &sources),
|
||||
db_status,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/index.html")]
|
||||
struct AdminIndexTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
}
|
||||
|
||||
pub async fn admin_index(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = AdminIndexTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/settings.html")]
|
||||
struct SettingsTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
saved: bool,
|
||||
auth_password_enabled: bool,
|
||||
auth_password_enabled_source: &'static str,
|
||||
auth_sso_enabled: bool,
|
||||
auth_sso_enabled_source: &'static str,
|
||||
oidc_button_text: String,
|
||||
oidc_button_text_source: &'static str,
|
||||
oidc_issuer: String,
|
||||
oidc_issuer_source: &'static str,
|
||||
oidc_client_id: String,
|
||||
oidc_client_id_source: &'static str,
|
||||
oidc_client_secret: String,
|
||||
oidc_client_secret_source: &'static str,
|
||||
oidc_admin_groups: String,
|
||||
oidc_admin_groups_source: &'static str,
|
||||
}
|
||||
|
||||
pub async fn settings_handler(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
saved: bool,
|
||||
) -> cot::Result<Html> {
|
||||
let (config, sources) = AppConfig::load_with_db(db).await;
|
||||
|
||||
let template = SettingsTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
saved,
|
||||
auth_password_enabled: config.auth_password_enabled,
|
||||
auth_password_enabled_source: sources.auth_password_enabled.code(),
|
||||
auth_sso_enabled: config.auth_sso_enabled,
|
||||
auth_sso_enabled_source: sources.auth_sso_enabled.code(),
|
||||
oidc_button_text: config.oidc_button_text,
|
||||
oidc_button_text_source: sources.oidc_button_text.code(),
|
||||
oidc_issuer: config.oidc_issuer,
|
||||
oidc_issuer_source: sources.oidc_issuer.code(),
|
||||
oidc_client_id: config.oidc_client_id,
|
||||
oidc_client_id_source: sources.oidc_client_id.code(),
|
||||
oidc_client_secret: config.oidc_client_secret,
|
||||
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
||||
oidc_admin_groups: config.oidc_admin_groups,
|
||||
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct OidcSettingsForm {
|
||||
auth_password_enabled: Option<String>,
|
||||
auth_sso_enabled: Option<String>,
|
||||
oidc_button_text: String,
|
||||
oidc_issuer: String,
|
||||
oidc_client_id: String,
|
||||
oidc_client_secret: String,
|
||||
oidc_admin_groups: String,
|
||||
}
|
||||
|
||||
pub async fn settings_submit(
|
||||
_admin: AuthenticatedUser,
|
||||
_i18n: I18n,
|
||||
_startup_config: &AppConfig,
|
||||
db: &Database,
|
||||
form: RequestForm<OidcSettingsForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" };
|
||||
let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" };
|
||||
let fields: [(&str, &str); 7] = [
|
||||
("auth_password_enabled", pw_enabled),
|
||||
("auth_sso_enabled", sso_enabled),
|
||||
("oidc_button_text", &data.oidc_button_text),
|
||||
("oidc_issuer", &data.oidc_issuer),
|
||||
("oidc_client_id", &data.oidc_client_id),
|
||||
("oidc_client_secret", &data.oidc_client_secret),
|
||||
("oidc_admin_groups", &data.oidc_admin_groups),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned());
|
||||
if let Err(e) = entry.save(db).await {
|
||||
tracing::error!(key, error = %e, "failed to save config entry");
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(auth::redirect("/admin/settings?saved=1"))
|
||||
}
|
||||
FormResult::ValidationError(_ctx) => {
|
||||
Ok(auth::redirect("/admin/settings"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/users.html")]
|
||||
struct UsersTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
users: Vec<User>,
|
||||
}
|
||||
|
||||
pub async fn users_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result<Html> {
|
||||
let users = User::list_all(db).await.unwrap_or_default();
|
||||
let template = UsersTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
users,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/user_form.html")]
|
||||
struct UserFormTemplate {
|
||||
t: &'static Translations,
|
||||
user_name: String,
|
||||
user_role: String,
|
||||
is_edit: bool,
|
||||
form_user_id: i64,
|
||||
form_username: String,
|
||||
form_email: String,
|
||||
form_display_name: String,
|
||||
form_role: String,
|
||||
}
|
||||
|
||||
pub async fn users_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = UserFormTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
is_edit: false,
|
||||
form_user_id: 0,
|
||||
form_username: String::new(),
|
||||
form_email: String::new(),
|
||||
form_display_name: String::new(),
|
||||
form_role: "user".into(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct UserForm {
|
||||
username: String,
|
||||
email: String,
|
||||
display_name: String,
|
||||
password: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
pub async fn users_create(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
form: RequestForm<UserForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
User::create(db, &data.username, email, display_name, &data.password, &data.role).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect("/admin/users/new"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_edit(
|
||||
admin: AuthenticatedUser,
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<Html> {
|
||||
let target = User::get_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let template = UserFormTemplate {
|
||||
t: i18n.t,
|
||||
user_name: admin.name,
|
||||
user_role: admin.role.code().to_owned(),
|
||||
is_edit: true,
|
||||
form_user_id: target.id_val(),
|
||||
form_username: target.username_str().to_owned(),
|
||||
form_email: target.email_str(),
|
||||
form_display_name: target.display_name_str(),
|
||||
form_role: target.role_str().to_owned(),
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
pub async fn users_update(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
form: RequestForm<UserForm>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
let RequestForm(result) = form;
|
||||
match result {
|
||||
FormResult::Ok(data) => {
|
||||
let mut target = User::get_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("db error: {e}")))?
|
||||
.ok_or_else(|| cot::Error::internal("user not found"))?;
|
||||
let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) };
|
||||
let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) };
|
||||
let new_password = if data.password.is_empty() { None } else { Some(data.password.as_str()) };
|
||||
target.update_fields(db, &data.username, email, display_name, new_password, &data.role).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
FormResult::ValidationError(_) => {
|
||||
Ok(auth::redirect(&format!("/admin/users/{user_id}/edit")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_delete(
|
||||
_admin: AuthenticatedUser,
|
||||
db: &Database,
|
||||
user_id: i64,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
User::delete_by_id(db, user_id).await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?;
|
||||
Ok(auth::redirect("/admin/users"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// First-run setup page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Template)]
|
||||
#[template(path = "admin/setup.html")]
|
||||
struct SetupTemplate {
|
||||
t: &'static Translations,
|
||||
message: String,
|
||||
}
|
||||
|
||||
pub async fn setup_page(i18n: I18n, message: String) -> cot::Result<Html> {
|
||||
let template = SetupTemplate {
|
||||
t: i18n.t,
|
||||
message,
|
||||
};
|
||||
Ok(Html::new(template.render()?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Form)]
|
||||
pub struct SetupForm {
|
||||
username: String,
|
||||
password: String,
|
||||
confirm_password: String,
|
||||
}
|
||||
|
||||
pub async fn setup_submit(
|
||||
i18n: I18n,
|
||||
db: &Database,
|
||||
session: &Session,
|
||||
form: RequestForm<SetupForm>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let RequestForm(result) = form;
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
return setup_page(i18n, String::new()).await?.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if data.password != data.confirm_password {
|
||||
let msg = i18n.t.setup_mismatch.to_owned();
|
||||
return setup_page(i18n, msg).await?.into_response();
|
||||
}
|
||||
|
||||
let user = User::create(db, &data.username, None, None, &data.password, "admin")
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?;
|
||||
|
||||
auth::login(session, user.id_val()).await?;
|
||||
Ok(auth::redirect("/admin/"))
|
||||
}
|
||||
Reference in New Issue
Block a user