diff --git a/.gitignore b/.gitignore
index de83574..036760e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
.run-dev.lock
chattyness-webserver/static/
*/target/*
+e2e/node_modules
+e2e/playwright-report
diff --git a/apps/chattyness-app/src/main.rs b/apps/chattyness-app/src/main.rs
index fcec49f..b5cbc60 100644
--- a/apps/chattyness-app/src/main.rs
+++ b/apps/chattyness-app/src/main.rs
@@ -178,16 +178,22 @@ mod server {
leptos_options: leptos_options.clone(),
ws_state: ws_state.clone(),
ws_config: config.websocket.clone(),
+ signup_config: config.signup.clone(),
};
let admin_api_state = chattyness_admin_ui::AdminAppState {
pool: pool.clone(),
leptos_options: leptos_options.clone(),
};
+ // Create admin connection layer for RLS context on admin API routes
+ let admin_conn_layer =
+ chattyness_admin_ui::auth::AdminConnLayer::new(pool.clone());
+
// Build nested API routers with their own state
let user_api_router = chattyness_user_ui::api::api_router().with_state(user_api_state);
- let admin_api_router =
- chattyness_admin_ui::api::admin_api_router().with_state(admin_api_state);
+ let admin_api_router = chattyness_admin_ui::api::admin_api_router()
+ .layer(admin_conn_layer)
+ .with_state(admin_api_state);
// Create RLS layer for row-level security
let rls_layer = chattyness_user_ui::auth::RlsLayer::new(pool.clone());
diff --git a/apps/chattyness-owner/src/main.rs b/apps/chattyness-owner/src/main.rs
index 65dcdf1..c90e817 100644
--- a/apps/chattyness-owner/src/main.rs
+++ b/apps/chattyness-owner/src/main.rs
@@ -108,6 +108,10 @@ mod server {
// Shared assets directory for uploaded files (realm images, etc.)
let assets_dir = Path::new("/srv/chattyness/assets");
+ // Create admin connection layer for RLS context
+ let admin_conn_layer =
+ chattyness_admin_ui::auth::AdminConnLayer::new(pool.clone());
+
// Build the app
let app = Router::new()
// Redirect root to admin
@@ -115,7 +119,9 @@ mod server {
// Nest API routes under /api/admin (matches frontend expectations when UI is at /admin)
.nest(
"/api/admin",
- chattyness_admin_ui::api::admin_api_router().with_state(app_state.clone()),
+ chattyness_admin_ui::api::admin_api_router()
+ .layer(admin_conn_layer)
+ .with_state(app_state.clone()),
)
// Uploaded assets (realm backgrounds, props, etc.) - must come before /static
.nest_service("/assets/server", ServeDir::new(assets_dir.join("server")))
diff --git a/config.toml b/config.toml
index 1d30ae2..23e138f 100644
--- a/config.toml
+++ b/config.toml
@@ -16,3 +16,13 @@ stale_threshold_secs = 120
# Clear all instance_members on server startup (recommended for single-server deployments)
clear_on_startup = true
+
+[signup]
+# birthday: "ask" = show field during signup, omit = don't ask
+# birthday = "ask"
+
+# age: "ask" = user selects, "infer" = from birthday, "default_adult", "default_child"
+age = "default_adult"
+
+# gender: "ask" = user selects, "default_neutral", "default_male", "default_female"
+gender = "default_neutral"
diff --git a/crates/chattyness-admin-ui/Cargo.toml b/crates/chattyness-admin-ui/Cargo.toml
index 2627bbe..c60070f 100644
--- a/crates/chattyness-admin-ui/Cargo.toml
+++ b/crates/chattyness-admin-ui/Cargo.toml
@@ -24,6 +24,7 @@ leptos_router = { workspace = true }
axum = { workspace = true, optional = true }
axum-extra = { workspace = true, optional = true }
sqlx = { workspace = true, optional = true }
+tower = { workspace = true, optional = true }
tower-sessions = { workspace = true, optional = true }
tower-sessions-sqlx-store = { workspace = true, optional = true }
argon2 = { workspace = true, optional = true }
@@ -52,6 +53,7 @@ ssr = [
"dep:axum",
"dep:axum-extra",
"dep:sqlx",
+ "dep:tower",
"dep:tracing",
"dep:tower-sessions",
"dep:tower-sessions-sqlx-store",
diff --git a/crates/chattyness-admin-ui/src/api.rs b/crates/chattyness-admin-ui/src/api.rs
index b457a21..1c6c1db 100644
--- a/crates/chattyness-admin-ui/src/api.rs
+++ b/crates/chattyness-admin-ui/src/api.rs
@@ -3,6 +3,8 @@
#[cfg(feature = "ssr")]
pub mod auth;
#[cfg(feature = "ssr")]
+pub mod avatars;
+#[cfg(feature = "ssr")]
pub mod config;
#[cfg(feature = "ssr")]
pub mod dashboard;
diff --git a/crates/chattyness-admin-ui/src/api/avatars.rs b/crates/chattyness-admin-ui/src/api/avatars.rs
new file mode 100644
index 0000000..4eb8723
--- /dev/null
+++ b/crates/chattyness-admin-ui/src/api/avatars.rs
@@ -0,0 +1,138 @@
+//! Server avatars management API handlers for admin UI.
+
+use axum::Json;
+use axum::extract::State;
+use chattyness_db::{
+ models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest},
+ queries::server_avatars,
+};
+use chattyness_error::AppError;
+use serde::Serialize;
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use crate::auth::AdminConn;
+
+// =============================================================================
+// API Types
+// =============================================================================
+
+/// Response for avatar creation.
+#[derive(Debug, Serialize)]
+pub struct CreateAvatarResponse {
+ pub id: Uuid,
+ pub slug: String,
+ pub name: String,
+ pub is_public: bool,
+ pub created_at: chrono::DateTime,
+}
+
+impl From for CreateAvatarResponse {
+ fn from(avatar: ServerAvatar) -> Self {
+ Self {
+ id: avatar.id,
+ slug: avatar.slug,
+ name: avatar.name,
+ is_public: avatar.is_public,
+ created_at: avatar.created_at,
+ }
+ }
+}
+
+// =============================================================================
+// API Handlers
+// =============================================================================
+
+/// List all server avatars.
+pub async fn list_avatars(
+ State(pool): State,
+) -> Result>, AppError> {
+ let avatars = server_avatars::list_all_server_avatars(&pool).await?;
+ Ok(Json(avatars))
+}
+
+/// Create a new server avatar.
+pub async fn create_avatar(
+ admin_conn: AdminConn,
+ Json(req): Json,
+) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
+ // Validate the request
+ req.validate()?;
+
+ // Check slug availability
+ let slug = req.slug_or_generate();
+ let available = server_avatars::is_avatar_slug_available(&mut *guard, &slug).await?;
+ if !available {
+ return Err(AppError::Conflict(format!(
+ "Avatar slug '{}' is already taken",
+ slug
+ )));
+ }
+
+ // Create the avatar
+ let avatar = server_avatars::create_server_avatar(&mut *guard, &req, None).await?;
+
+ tracing::info!("Created server avatar: {} ({})", avatar.name, avatar.id);
+
+ Ok(Json(CreateAvatarResponse::from(avatar)))
+}
+
+/// Get a server avatar by ID.
+pub async fn get_avatar(
+ State(pool): State,
+ axum::extract::Path(avatar_id): axum::extract::Path,
+) -> Result, AppError> {
+ let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
+ .await?
+ .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
+ Ok(Json(avatar))
+}
+
+/// Update a server avatar.
+pub async fn update_avatar(
+ admin_conn: AdminConn,
+ axum::extract::Path(avatar_id): axum::extract::Path,
+ Json(req): Json,
+) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
+ // Validate the request
+ req.validate()?;
+
+ // Check avatar exists
+ let existing = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id)
+ .await?
+ .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
+
+ // Update the avatar
+ let avatar = server_avatars::update_server_avatar(&mut *guard, avatar_id, &req).await?;
+
+ tracing::info!("Updated server avatar: {} ({})", existing.name, avatar_id);
+
+ Ok(Json(avatar))
+}
+
+/// Delete a server avatar.
+pub async fn delete_avatar(
+ admin_conn: AdminConn,
+ axum::extract::Path(avatar_id): axum::extract::Path,
+) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
+ // Get the avatar first to log its name
+ let avatar = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id)
+ .await?
+ .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
+
+ // Delete from database
+ server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?;
+
+ tracing::info!("Deleted server avatar: {} ({})", avatar.name, avatar_id);
+
+ Ok(Json(()))
+}
diff --git a/crates/chattyness-admin-ui/src/api/props.rs b/crates/chattyness-admin-ui/src/api/props.rs
index bad536a..c7c7d7f 100644
--- a/crates/chattyness-admin-ui/src/api/props.rs
+++ b/crates/chattyness-admin-ui/src/api/props.rs
@@ -15,6 +15,8 @@ use sqlx::PgPool;
use std::path::PathBuf;
use uuid::Uuid;
+use crate::auth::AdminConn;
+
// =============================================================================
// API Types
// =============================================================================
@@ -115,10 +117,12 @@ pub async fn list_props(
/// Query parameters:
/// - `force`: If true, update existing prop with same slug instead of returning 409 Conflict.
pub async fn create_prop(
- State(pool): State,
+ admin_conn: AdminConn,
Query(query): Query,
mut multipart: Multipart,
) -> Result, AppError> {
+ let conn = admin_conn.0;
+
let mut metadata: Option = None;
let mut file_data: Option<(Vec, String)> = None; // (bytes, extension)
@@ -182,20 +186,23 @@ pub async fn create_prop(
// Store the file first (SHA256-based, safe to run even if prop exists)
let asset_path = store_prop_file(&file_bytes, &extension).await?;
+ // Acquire the connection for database operations
+ let mut guard = conn.acquire().await;
+
let prop = if query.force {
// Force mode: upsert (insert or update)
- props::upsert_server_prop(&pool, &metadata, &asset_path, None).await?
+ props::upsert_server_prop(&mut *guard, &metadata, &asset_path, None).await?
} else {
// Normal mode: check availability first
let slug = metadata.slug_or_generate();
- let available = props::is_prop_slug_available(&pool, &slug).await?;
+ let available = props::is_prop_slug_available(&mut *guard, &slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Prop slug '{}' is already taken",
slug
)));
}
- props::create_server_prop(&pool, &metadata, &asset_path, None).await?
+ props::create_server_prop(&mut *guard, &metadata, &asset_path, None).await?
};
let action = if query.force { "Upserted" } else { "Created" };
@@ -217,16 +224,19 @@ pub async fn get_prop(
/// Delete a server prop.
pub async fn delete_prop(
- State(pool): State,
+ admin_conn: AdminConn,
axum::extract::Path(prop_id): axum::extract::Path,
) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
// Get the prop first to get the asset path
- let prop = props::get_server_prop_by_id(&pool, prop_id)
+ let prop = props::get_server_prop_by_id(&mut *guard, prop_id)
.await?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
// Delete from database
- props::delete_server_prop(&pool, prop_id).await?;
+ props::delete_server_prop(&mut *guard, prop_id).await?;
// Try to delete the file (don't fail if file doesn't exist)
let file_path = PathBuf::from("/srv/chattyness/assets").join(&prop.asset_path);
diff --git a/crates/chattyness-admin-ui/src/api/realms.rs b/crates/chattyness-admin-ui/src/api/realms.rs
index d2f31ff..0cb1cbd 100644
--- a/crates/chattyness-admin-ui/src/api/realms.rs
+++ b/crates/chattyness-admin-ui/src/api/realms.rs
@@ -5,14 +5,19 @@ use axum::{
extract::{Path, Query, State},
};
use chattyness_db::{
- models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
- queries::owner as queries,
+ models::{
+ CreateRealmAvatarRequest, OwnerCreateRealmRequest, RealmAvatar, RealmAvatarSummary,
+ RealmDetail, RealmListItem, UpdateRealmAvatarRequest, UpdateRealmRequest,
+ },
+ queries::{owner as queries, realm_avatars},
};
use chattyness_error::AppError;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
+use crate::auth::AdminConn;
+
/// Create realm response.
#[derive(Debug, Serialize)]
pub struct CreateRealmResponse {
@@ -63,6 +68,9 @@ pub async fn get_realm(
}
/// Create a new realm.
+///
+/// Note: Uses State(pool) instead of AdminConn because create_realm_with_new_owner
+/// uses transactions internally that require pool access.
pub async fn create_realm(
State(pool): State,
Json(req): Json,
@@ -107,6 +115,8 @@ pub async fn create_realm(
}
/// Update a realm.
+///
+/// Note: Uses State(pool) because owner queries use &PgPool directly.
pub async fn update_realm(
State(pool): State,
Path(slug): Path,
@@ -121,6 +131,9 @@ pub async fn update_realm(
}
/// Transfer realm ownership.
+///
+/// Note: Uses State(pool) instead of AdminConn because transfer_realm_ownership
+/// uses transactions internally that require pool access.
pub async fn transfer_ownership(
State(pool): State,
Path(slug): Path,
@@ -131,3 +144,157 @@ pub async fn transfer_ownership(
queries::transfer_realm_ownership(&pool, existing.id, req.new_owner_id).await?;
Ok(Json(()))
}
+
+// =============================================================================
+// Realm Avatar Handlers
+// =============================================================================
+
+/// Response for realm avatar creation.
+#[derive(Debug, Serialize)]
+pub struct CreateRealmAvatarResponse {
+ pub id: Uuid,
+ pub slug: String,
+ pub name: String,
+ pub is_public: bool,
+ pub created_at: chrono::DateTime,
+}
+
+impl From for CreateRealmAvatarResponse {
+ fn from(avatar: RealmAvatar) -> Self {
+ Self {
+ id: avatar.id,
+ slug: avatar.slug,
+ name: avatar.name,
+ is_public: avatar.is_public,
+ created_at: avatar.created_at,
+ }
+ }
+}
+
+/// List all avatars for a realm.
+pub async fn list_realm_avatars(
+ State(pool): State,
+ Path(slug): Path,
+) -> Result>, AppError> {
+ let realm = queries::get_realm_by_slug(&pool, &slug).await?;
+ let avatars = realm_avatars::list_all_realm_avatars(&pool, realm.id).await?;
+ Ok(Json(avatars))
+}
+
+/// Create a new realm avatar.
+pub async fn create_realm_avatar(
+ State(pool): State,
+ admin_conn: AdminConn,
+ Path(slug): Path,
+ Json(req): Json,
+) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
+ // Validate the request
+ req.validate()?;
+
+ // Get realm ID (uses pool because owner queries require it)
+ let realm = queries::get_realm_by_slug(&pool, &slug).await?;
+
+ // Check slug availability
+ let avatar_slug = req.slug_or_generate();
+ let available =
+ realm_avatars::is_avatar_slug_available(&mut *guard, realm.id, &avatar_slug).await?;
+ if !available {
+ return Err(AppError::Conflict(format!(
+ "Avatar slug '{}' is already taken in this realm",
+ avatar_slug
+ )));
+ }
+
+ // Create the avatar
+ let avatar = realm_avatars::create_realm_avatar(&mut *guard, realm.id, &req, None).await?;
+
+ tracing::info!(
+ "Created realm avatar: {} ({}) in realm {}",
+ avatar.name,
+ avatar.id,
+ slug
+ );
+
+ Ok(Json(CreateRealmAvatarResponse::from(avatar)))
+}
+
+/// Get a realm avatar by ID.
+pub async fn get_realm_avatar(
+ State(pool): State,
+ Path((slug, avatar_id)): Path<(String, Uuid)>,
+) -> Result, AppError> {
+ // Verify realm exists
+ let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
+
+ let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
+ .await?
+ .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
+ Ok(Json(avatar))
+}
+
+/// Update a realm avatar.
+pub async fn update_realm_avatar(
+ State(pool): State,
+ admin_conn: AdminConn,
+ Path((slug, avatar_id)): Path<(String, Uuid)>,
+ Json(req): Json,
+) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
+ // Validate the request
+ req.validate()?;
+
+ // Verify realm exists (uses pool because owner queries require it)
+ let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
+
+ // Check avatar exists
+ let existing = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id)
+ .await?
+ .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
+
+ // Update the avatar
+ let avatar = realm_avatars::update_realm_avatar(&mut *guard, avatar_id, &req).await?;
+
+ tracing::info!(
+ "Updated realm avatar: {} ({}) in realm {}",
+ existing.name,
+ avatar_id,
+ slug
+ );
+
+ Ok(Json(avatar))
+}
+
+/// Delete a realm avatar.
+pub async fn delete_realm_avatar(
+ State(pool): State,
+ admin_conn: AdminConn,
+ Path((slug, avatar_id)): Path<(String, Uuid)>,
+) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
+ // Verify realm exists (uses pool because owner queries require it)
+ let _realm = queries::get_realm_by_slug(&pool, &slug).await?;
+
+ // Get the avatar first to log its name
+ let avatar = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id)
+ .await?
+ .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?;
+
+ // Delete from database
+ realm_avatars::delete_realm_avatar(&mut *guard, avatar_id).await?;
+
+ tracing::info!(
+ "Deleted realm avatar: {} ({}) from realm {}",
+ avatar.name,
+ avatar_id,
+ slug
+ );
+
+ Ok(Json(()))
+}
diff --git a/crates/chattyness-admin-ui/src/api/routes.rs b/crates/chattyness-admin-ui/src/api/routes.rs
index fda79d0..3d85a34 100644
--- a/crates/chattyness-admin-ui/src/api/routes.rs
+++ b/crates/chattyness-admin-ui/src/api/routes.rs
@@ -5,7 +5,7 @@ use axum::{
routing::{delete, get, post, put},
};
-use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
+use super::{auth, avatars, config, dashboard, props, realms, scenes, spots, staff, users};
use crate::app::AdminAppState;
/// Create the admin API router.
@@ -85,6 +85,28 @@ pub fn admin_api_router() -> Router {
"/props/{prop_id}",
get(props::get_prop).delete(props::delete_prop),
)
+ // API - Server Avatars
+ .route(
+ "/avatars",
+ get(avatars::list_avatars).post(avatars::create_avatar),
+ )
+ .route(
+ "/avatars/{avatar_id}",
+ get(avatars::get_avatar)
+ .put(avatars::update_avatar)
+ .delete(avatars::delete_avatar),
+ )
+ // API - Realm Avatars
+ .route(
+ "/realms/{slug}/avatars",
+ get(realms::list_realm_avatars).post(realms::create_realm_avatar),
+ )
+ .route(
+ "/realms/{slug}/avatars/{avatar_id}",
+ get(realms::get_realm_avatar)
+ .put(realms::update_realm_avatar)
+ .delete(realms::delete_realm_avatar),
+ )
}
/// Health check endpoint.
diff --git a/crates/chattyness-admin-ui/src/api/scenes.rs b/crates/chattyness-admin-ui/src/api/scenes.rs
index cebc006..c5d3d74 100644
--- a/crates/chattyness-admin-ui/src/api/scenes.rs
+++ b/crates/chattyness-admin-ui/src/api/scenes.rs
@@ -14,6 +14,8 @@ use sqlx::PgPool;
use std::path::PathBuf;
use uuid::Uuid;
+use crate::auth::AdminConn;
+
// =============================================================================
// Image Processing Helpers
// =============================================================================
@@ -210,17 +212,20 @@ pub struct CreateSceneResponse {
/// Create a new scene in a realm.
pub async fn create_scene(
- State(pool): State,
+ admin_conn: AdminConn,
Path(slug): Path,
Json(mut req): Json,
) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
// Get the realm
- let realm = realms::get_realm_by_slug(&pool, &slug)
+ let realm = realms::get_realm_by_slug(&mut *guard, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
// Check if slug is available
- let available = scenes::is_scene_slug_available(&pool, realm.id, &req.slug).await?;
+ let available = scenes::is_scene_slug_available(&mut *guard, realm.id, &req.slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Scene slug '{}' is already taken in this realm",
@@ -249,7 +254,7 @@ pub async fn create_scene(
}
}
- let scene = scenes::create_scene_with_id(&pool, scene_id, realm.id, &req).await?;
+ let scene = scenes::create_scene_with_id(&mut *guard, scene_id, realm.id, &req).await?;
Ok(Json(CreateSceneResponse {
id: scene.id,
slug: scene.slug,
@@ -258,12 +263,15 @@ pub async fn create_scene(
/// Update a scene.
pub async fn update_scene(
- State(pool): State,
+ admin_conn: AdminConn,
Path(scene_id): Path,
Json(mut req): Json,
) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
// Get the existing scene to get realm_id
- let existing_scene = scenes::get_scene_by_id(&pool, scene_id)
+ let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id)
.await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
@@ -296,15 +304,17 @@ pub async fn update_scene(
}
}
- let scene = scenes::update_scene(&pool, scene_id, &req).await?;
+ let scene = scenes::update_scene(&mut *guard, scene_id, &req).await?;
Ok(Json(scene))
}
/// Delete a scene.
pub async fn delete_scene(
- State(pool): State,
+ admin_conn: AdminConn,
Path(scene_id): Path,
) -> Result, AppError> {
- scenes::delete_scene(&pool, scene_id).await?;
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+ scenes::delete_scene(&mut *guard, scene_id).await?;
Ok(Json(()))
}
diff --git a/crates/chattyness-admin-ui/src/api/spots.rs b/crates/chattyness-admin-ui/src/api/spots.rs
index 527f86b..7500f7b 100644
--- a/crates/chattyness-admin-ui/src/api/spots.rs
+++ b/crates/chattyness-admin-ui/src/api/spots.rs
@@ -13,6 +13,8 @@ use serde::Serialize;
use sqlx::PgPool;
use uuid::Uuid;
+use crate::auth::AdminConn;
+
/// List all spots for a scene.
pub async fn list_spots(
State(pool): State,
@@ -41,13 +43,16 @@ pub struct CreateSpotResponse {
/// Create a new spot in a scene.
pub async fn create_spot(
- State(pool): State,
+ admin_conn: AdminConn,
Path(scene_id): Path,
Json(req): Json,
) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
// Check if slug is available (if provided)
if let Some(ref slug) = req.slug {
- let available = spots::is_spot_slug_available(&pool, scene_id, slug).await?;
+ let available = spots::is_spot_slug_available(&mut *guard, scene_id, slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Spot slug '{}' is already taken in this scene",
@@ -56,25 +61,28 @@ pub async fn create_spot(
}
}
- let spot = spots::create_spot(&pool, scene_id, &req).await?;
+ let spot = spots::create_spot(&mut *guard, scene_id, &req).await?;
Ok(Json(CreateSpotResponse { id: spot.id }))
}
/// Update a spot.
pub async fn update_spot(
- State(pool): State,
+ admin_conn: AdminConn,
Path(spot_id): Path,
Json(req): Json,
) -> Result, AppError> {
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+
// If updating slug, check availability
if let Some(ref new_slug) = req.slug {
- let existing = spots::get_spot_by_id(&pool, spot_id)
+ let existing = spots::get_spot_by_id(&mut *guard, spot_id)
.await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?;
if Some(new_slug.clone()) != existing.slug {
let available =
- spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?;
+ spots::is_spot_slug_available(&mut *guard, existing.scene_id, new_slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Spot slug '{}' is already taken in this scene",
@@ -84,15 +92,17 @@ pub async fn update_spot(
}
}
- let spot = spots::update_spot(&pool, spot_id, &req).await?;
+ let spot = spots::update_spot(&mut *guard, spot_id, &req).await?;
Ok(Json(spot))
}
/// Delete a spot.
pub async fn delete_spot(
- State(pool): State,
+ admin_conn: AdminConn,
Path(spot_id): Path,
) -> Result, AppError> {
- spots::delete_spot(&pool, spot_id).await?;
+ let conn = admin_conn.0;
+ let mut guard = conn.acquire().await;
+ spots::delete_spot(&mut *guard, spot_id).await?;
Ok(Json(()))
}
diff --git a/crates/chattyness-admin-ui/src/auth.rs b/crates/chattyness-admin-ui/src/auth.rs
index fc328a2..7096bf2 100644
--- a/crates/chattyness-admin-ui/src/auth.rs
+++ b/crates/chattyness-admin-ui/src/auth.rs
@@ -4,6 +4,12 @@
//! - Server staff: Uses chattyness_owner pool (bypasses RLS, full access)
//! - Realm admins: Uses chattyness_app pool (RLS enforces permissions)
+#[cfg(feature = "ssr")]
+mod admin_conn;
+
+#[cfg(feature = "ssr")]
+pub use admin_conn::{AdminConn, AdminConnError, AdminConnGuard, AdminConnLayer, AdminConnection};
+
#[cfg(feature = "ssr")]
use axum::{
http::StatusCode,
diff --git a/crates/chattyness-admin-ui/src/auth/admin_conn.rs b/crates/chattyness-admin-ui/src/auth/admin_conn.rs
new file mode 100644
index 0000000..94f4979
--- /dev/null
+++ b/crates/chattyness-admin-ui/src/auth/admin_conn.rs
@@ -0,0 +1,315 @@
+//! Admin connection extractor with RLS context.
+//!
+//! Provides database connections that set RLS context when a user is authenticated.
+//! Used by admin API handlers to ensure write operations respect RLS policies.
+//!
+//! - In owner app: No session, uses plain connection (RLS bypassed by chattyness_owner role)
+//! - In user app: Session exists, sets current_user_id() for RLS enforcement
+
+use axum::{
+ Json,
+ extract::FromRequestParts,
+ http::{Request, StatusCode, request::Parts},
+ response::{IntoResponse, Response},
+};
+use sqlx::{PgPool, Postgres, pool::PoolConnection, postgres::PgConnection};
+use std::{
+ future::Future,
+ ops::{Deref, DerefMut},
+ pin::Pin,
+ sync::Arc,
+ task::{Context, Poll},
+};
+use tokio::sync::{Mutex, MutexGuard};
+use tower::{Layer, Service};
+use tower_sessions::Session;
+use uuid::Uuid;
+
+use super::{ADMIN_SESSION_STAFF_ID_KEY, SESSION_USER_ID_KEY};
+use chattyness_error::ErrorResponse;
+
+// =============================================================================
+// Admin Connection Wrapper
+// =============================================================================
+
+struct AdminConnectionInner {
+ conn: Option>,
+ pool: PgPool,
+ had_user_context: bool,
+}
+
+impl Drop for AdminConnectionInner {
+ fn drop(&mut self) {
+ if let Some(mut conn) = self.conn.take() {
+ // Only clear context if we set it
+ if self.had_user_context {
+ let pool = self.pool.clone();
+ tokio::spawn(async move {
+ let _ = sqlx::query("SELECT public.set_current_user_id(NULL)")
+ .execute(&mut *conn)
+ .await;
+ drop(conn);
+ drop(pool);
+ });
+ }
+ }
+ }
+}
+
+/// A database connection with optional RLS user context set.
+#[derive(Clone)]
+pub struct AdminConnection {
+ inner: Arc>,
+}
+
+impl AdminConnection {
+ fn new(conn: PoolConnection, pool: PgPool, had_user_context: bool) -> Self {
+ Self {
+ inner: Arc::new(Mutex::new(AdminConnectionInner {
+ conn: Some(conn),
+ pool,
+ had_user_context,
+ })),
+ }
+ }
+
+ /// Acquire exclusive access to the admin connection.
+ pub async fn acquire(&self) -> AdminConnGuard<'_> {
+ AdminConnGuard {
+ guard: self.inner.lock().await,
+ }
+ }
+}
+
+/// A guard providing mutable access to the admin database connection.
+pub struct AdminConnGuard<'a> {
+ guard: MutexGuard<'a, AdminConnectionInner>,
+}
+
+impl Deref for AdminConnGuard<'_> {
+ type Target = PgConnection;
+
+ fn deref(&self) -> &Self::Target {
+ self.guard
+ .conn
+ .as_ref()
+ .expect("AdminConnection already consumed")
+ .deref()
+ }
+}
+
+impl DerefMut for AdminConnGuard<'_> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ self.guard
+ .conn
+ .as_mut()
+ .expect("AdminConnection already consumed")
+ .deref_mut()
+ }
+}
+
+// =============================================================================
+// Admin Connection Extractor
+// =============================================================================
+
+/// Extractor for an admin database connection with RLS context.
+///
+/// Usage in handlers:
+/// ```ignore
+/// pub async fn create_scene(
+/// admin_conn: AdminConn,
+/// Json(req): Json,
+/// ) -> Result, AppError> {
+/// let mut conn = admin_conn.0;
+/// let mut guard = conn.acquire().await;
+/// scenes::create_scene(&mut *guard, ...).await
+/// }
+/// ```
+pub struct AdminConn(pub AdminConnection);
+
+impl FromRequestParts for AdminConn
+where
+ S: Send + Sync,
+{
+ type Rejection = AdminConnError;
+
+ async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result {
+ parts
+ .extensions
+ .remove::()
+ .map(AdminConn)
+ .ok_or(AdminConnError::NoConnection)
+ }
+}
+
+impl Deref for AdminConn {
+ type Target = AdminConnection;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+}
+
+impl DerefMut for AdminConn {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+}
+
+/// Errors related to admin connection handling.
+#[derive(Debug)]
+pub enum AdminConnError {
+ NoConnection,
+ DatabaseError(String),
+}
+
+impl IntoResponse for AdminConnError {
+ fn into_response(self) -> Response {
+ let (status, message) = match self {
+ AdminConnError::NoConnection => (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "Admin connection not available - is AdminConnLayer middleware configured?",
+ ),
+ AdminConnError::DatabaseError(msg) => (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ msg.leak() as &'static str,
+ ),
+ };
+
+ let body = ErrorResponse {
+ error: message.to_string(),
+ code: Some("ADMIN_CONN_ERROR".to_string()),
+ };
+
+ (status, Json(body)).into_response()
+ }
+}
+
+// =============================================================================
+// Admin Connection Middleware Layer
+// =============================================================================
+
+/// Layer that provides admin database connections with RLS context per request.
+///
+/// This middleware:
+/// 1. Checks for user_id in session (staff_id or user_id)
+/// 2. Acquires a connection from the pool
+/// 3. If user_id exists, calls `set_current_user_id($1)` for RLS
+/// 4. Inserts the connection into request extensions
+///
+/// Usage:
+/// ```ignore
+/// let app = Router::new()
+/// .nest("/api/admin", admin_api_router())
+/// .layer(AdminConnLayer::new(pool.clone()))
+/// .layer(session_layer);
+/// ```
+#[derive(Clone)]
+pub struct AdminConnLayer {
+ pool: PgPool,
+}
+
+impl AdminConnLayer {
+ pub fn new(pool: PgPool) -> Self {
+ Self { pool }
+ }
+}
+
+impl Layer for AdminConnLayer {
+ type Service = AdminConnMiddleware;
+
+ fn layer(&self, inner: S) -> Self::Service {
+ AdminConnMiddleware {
+ inner,
+ pool: self.pool.clone(),
+ }
+ }
+}
+
+/// Middleware that sets up admin connections with RLS context per request.
+#[derive(Clone)]
+pub struct AdminConnMiddleware {
+ inner: S,
+ pool: PgPool,
+}
+
+impl Service> for AdminConnMiddleware
+where
+ S: Service, Response = Response> + Clone + Send + 'static,
+ S::Future: Send,
+ B: Send + 'static,
+{
+ type Response = Response;
+ type Error = S::Error;
+ type Future = Pin> + Send>>;
+
+ fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> {
+ self.inner.poll_ready(cx)
+ }
+
+ fn call(&mut self, mut request: Request) -> Self::Future {
+ let pool = self.pool.clone();
+ let mut inner = self.inner.clone();
+
+ let session = request.extensions().get::().cloned();
+
+ Box::pin(async move {
+ let user_id = get_admin_user_id(session).await;
+
+ match acquire_admin_connection(&pool, user_id).await {
+ Ok(admin_conn) => {
+ request.extensions_mut().insert(admin_conn);
+ inner.call(request).await
+ }
+ Err(e) => {
+ tracing::error!("Failed to acquire admin connection: {}", e);
+ Ok(AdminConnError::DatabaseError(e.to_string()).into_response())
+ }
+ }
+ })
+ }
+}
+
+/// Get user ID from session for RLS context.
+///
+/// Checks in order:
+/// 1. staff_id - server staff member
+/// 2. user_id - realm admin user
+///
+/// Returns None if no session or no user ID (owner app context).
+async fn get_admin_user_id(session: Option) -> Option {
+ let Some(session) = session else {
+ return None;
+ };
+
+ // Try staff_id first (server staff)
+ if let Ok(Some(staff_id)) = session.get::(ADMIN_SESSION_STAFF_ID_KEY).await {
+ return Some(staff_id);
+ }
+
+ // Try user_id (realm admin)
+ if let Ok(Some(user_id)) = session.get::(SESSION_USER_ID_KEY).await {
+ return Some(user_id);
+ }
+
+ None
+}
+
+/// Acquire a database connection and set RLS context if user_id is provided.
+async fn acquire_admin_connection(
+ pool: &PgPool,
+ user_id: Option,
+) -> Result {
+ let mut conn = pool.acquire().await?;
+
+ let had_user_context = user_id.is_some();
+
+ if let Some(user_id) = user_id {
+ sqlx::query("SELECT public.set_current_user_id($1)")
+ .bind(user_id)
+ .execute(&mut *conn)
+ .await?;
+ }
+
+ Ok(AdminConnection::new(conn, pool.clone(), had_user_context))
+}
diff --git a/crates/chattyness-admin-ui/src/components.rs b/crates/chattyness-admin-ui/src/components.rs
index 1931959..204760e 100644
--- a/crates/chattyness-admin-ui/src/components.rs
+++ b/crates/chattyness-admin-ui/src/components.rs
@@ -177,6 +177,8 @@ fn Sidebar(
let realms_new_href = format!("{}/realms/new", base_path);
let props_href = format!("{}/props", base_path);
let props_new_href = format!("{}/props/new", base_path);
+ let avatars_href = format!("{}/avatars", base_path);
+ let avatars_new_href = format!("{}/avatars/new", base_path);
view! {
}>
+ {move || {
+ avatars.get().map(|maybe_avatars: Option>| {
+ match maybe_avatars {
+ Some(avatar_list) if !avatar_list.is_empty() => {
+ if view_mode.get() == ViewMode::Table {
+ view! { }.into_any()
+ } else {
+ view! { }.into_any()
+ }
+ }
+ _ => view! {
+
+ }.into_any()
+ }
+ })
+ }}
+
+
+ }
+}
+
+/// Table view for avatars.
+#[component]
+fn AvatarsTable(avatars: Vec) -> impl IntoView {
+ view! {
+
+
+
+
+ | "Thumbnail" |
+ "Name" |
+ "Slug" |
+ "Public" |
+ "Active" |
+ "Created" |
+
+
+
+ {avatars.into_iter().map(|avatar| {
+ let thumbnail_url = avatar.thumbnail_path.clone()
+ .map(|p| format!("/assets/{}", p));
+ view! {
+
+
+ {thumbnail_url.map(|url| view! {
+
+ })}
+ |
+
+
+ {avatar.name}
+
+ |
+ {avatar.slug} |
+
+ {if avatar.is_public {
+ view! { "Public" }.into_any()
+ } else {
+ view! { "Private" }.into_any()
+ }}
+ |
+
+ {if avatar.is_active {
+ view! { "Active" }.into_any()
+ } else {
+ view! { "Inactive" }.into_any()
+ }}
+ |
+ {avatar.created_at} |
+
+ }
+ }).collect_view()}
+
+
+
+ }
+}
+
+/// Grid view for avatars with thumbnails.
+#[component]
+fn AvatarsGrid(avatars: Vec) -> impl IntoView {
+ view! {
+
+ }
+}
diff --git a/crates/chattyness-admin-ui/src/pages/avatars_detail.rs b/crates/chattyness-admin-ui/src/pages/avatars_detail.rs
new file mode 100644
index 0000000..1935e25
--- /dev/null
+++ b/crates/chattyness-admin-ui/src/pages/avatars_detail.rs
@@ -0,0 +1,306 @@
+//! Server avatar detail/edit page component.
+
+use leptos::prelude::*;
+#[cfg(feature = "hydrate")]
+use leptos::task::spawn_local;
+use leptos_router::hooks::use_params_map;
+
+use crate::components::{Card, DeleteConfirmation, DetailGrid, DetailItem, MessageAlert, PageHeader};
+use crate::hooks::use_fetch_if;
+use crate::models::AvatarDetail;
+use crate::utils::get_api_base;
+
+/// Server avatar detail page component.
+#[component]
+pub fn AvatarsDetailPage() -> impl IntoView {
+ let params = use_params_map();
+ let avatar_id = move || params.get().get("avatar_id").unwrap_or_default();
+ let initial_avatar_id = params.get_untracked().get("avatar_id").unwrap_or_default();
+
+ let (message, set_message) = signal(Option::<(String, bool)>::None);
+
+ let avatar = use_fetch_if::(
+ move || !avatar_id().is_empty(),
+ move || format!("{}/avatars/{}", get_api_base(), avatar_id()),
+ );
+
+ view! {
+
+ "Back to Avatars"
+
+
+ "Loading avatar..." }>
+ {move || {
+ avatar.get().map(|maybe_avatar| {
+ match maybe_avatar {
+ Some(a) => view! {
+
+ }.into_any(),
+ None => view! {
+
+ "Avatar not found or you don't have permission to view."
+
+ }.into_any()
+ }
+ })
+ }}
+
+ }
+}
+
+#[component]
+#[allow(unused_variables)]
+fn AvatarDetailView(
+ avatar: AvatarDetail,
+ message: ReadSignal