diff --git a/.gitignore b/.gitignore
index 036760e..de83574 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,5 +2,3 @@
.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 b5cbc60..fcec49f 100644
--- a/apps/chattyness-app/src/main.rs
+++ b/apps/chattyness-app/src/main.rs
@@ -178,22 +178,16 @@ 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()
- .layer(admin_conn_layer)
- .with_state(admin_api_state);
+ let admin_api_router =
+ chattyness_admin_ui::api::admin_api_router().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 c90e817..65dcdf1 100644
--- a/apps/chattyness-owner/src/main.rs
+++ b/apps/chattyness-owner/src/main.rs
@@ -108,10 +108,6 @@ 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
@@ -119,9 +115,7 @@ 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()
- .layer(admin_conn_layer)
- .with_state(app_state.clone()),
+ chattyness_admin_ui::api::admin_api_router().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 23e138f..1d30ae2 100644
--- a/config.toml
+++ b/config.toml
@@ -16,13 +16,3 @@ 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 c60070f..2627bbe 100644
--- a/crates/chattyness-admin-ui/Cargo.toml
+++ b/crates/chattyness-admin-ui/Cargo.toml
@@ -24,7 +24,6 @@ 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 }
@@ -53,7 +52,6 @@ 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 1c6c1db..b457a21 100644
--- a/crates/chattyness-admin-ui/src/api.rs
+++ b/crates/chattyness-admin-ui/src/api.rs
@@ -3,8 +3,6 @@
#[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
deleted file mode 100644
index 4eb8723..0000000
--- a/crates/chattyness-admin-ui/src/api/avatars.rs
+++ /dev/null
@@ -1,138 +0,0 @@
-//! 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 c7c7d7f..bad536a 100644
--- a/crates/chattyness-admin-ui/src/api/props.rs
+++ b/crates/chattyness-admin-ui/src/api/props.rs
@@ -15,8 +15,6 @@ use sqlx::PgPool;
use std::path::PathBuf;
use uuid::Uuid;
-use crate::auth::AdminConn;
-
// =============================================================================
// API Types
// =============================================================================
@@ -117,12 +115,10 @@ 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(
- admin_conn: AdminConn,
+ State(pool): State,
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)
@@ -186,23 +182,20 @@ 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(&mut *guard, &metadata, &asset_path, None).await?
+ props::upsert_server_prop(&pool, &metadata, &asset_path, None).await?
} else {
// Normal mode: check availability first
let slug = metadata.slug_or_generate();
- let available = props::is_prop_slug_available(&mut *guard, &slug).await?;
+ let available = props::is_prop_slug_available(&pool, &slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Prop slug '{}' is already taken",
slug
)));
}
- props::create_server_prop(&mut *guard, &metadata, &asset_path, None).await?
+ props::create_server_prop(&pool, &metadata, &asset_path, None).await?
};
let action = if query.force { "Upserted" } else { "Created" };
@@ -224,19 +217,16 @@ pub async fn get_prop(
/// Delete a server prop.
pub async fn delete_prop(
- admin_conn: AdminConn,
+ State(pool): State,
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(&mut *guard, prop_id)
+ let prop = props::get_server_prop_by_id(&pool, prop_id)
.await?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?;
// Delete from database
- props::delete_server_prop(&mut *guard, prop_id).await?;
+ props::delete_server_prop(&pool, 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 0cb1cbd..d2f31ff 100644
--- a/crates/chattyness-admin-ui/src/api/realms.rs
+++ b/crates/chattyness-admin-ui/src/api/realms.rs
@@ -5,19 +5,14 @@ use axum::{
extract::{Path, Query, State},
};
use chattyness_db::{
- models::{
- CreateRealmAvatarRequest, OwnerCreateRealmRequest, RealmAvatar, RealmAvatarSummary,
- RealmDetail, RealmListItem, UpdateRealmAvatarRequest, UpdateRealmRequest,
- },
- queries::{owner as queries, realm_avatars},
+ models::{OwnerCreateRealmRequest, RealmDetail, RealmListItem, UpdateRealmRequest},
+ queries::owner as queries,
};
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 {
@@ -68,9 +63,6 @@ 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,
@@ -115,8 +107,6 @@ 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,
@@ -131,9 +121,6 @@ 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,
@@ -144,157 +131,3 @@ 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 3d85a34..fda79d0 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, avatars, config, dashboard, props, realms, scenes, spots, staff, users};
+use super::{auth, config, dashboard, props, realms, scenes, spots, staff, users};
use crate::app::AdminAppState;
/// Create the admin API router.
@@ -85,28 +85,6 @@ 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 c5d3d74..cebc006 100644
--- a/crates/chattyness-admin-ui/src/api/scenes.rs
+++ b/crates/chattyness-admin-ui/src/api/scenes.rs
@@ -14,8 +14,6 @@ use sqlx::PgPool;
use std::path::PathBuf;
use uuid::Uuid;
-use crate::auth::AdminConn;
-
// =============================================================================
// Image Processing Helpers
// =============================================================================
@@ -212,20 +210,17 @@ pub struct CreateSceneResponse {
/// Create a new scene in a realm.
pub async fn create_scene(
- admin_conn: AdminConn,
+ State(pool): State,
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(&mut *guard, &slug)
+ let realm = realms::get_realm_by_slug(&pool, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("Realm '{}' not found", slug)))?;
// Check if slug is available
- let available = scenes::is_scene_slug_available(&mut *guard, realm.id, &req.slug).await?;
+ let available = scenes::is_scene_slug_available(&pool, realm.id, &req.slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Scene slug '{}' is already taken in this realm",
@@ -254,7 +249,7 @@ pub async fn create_scene(
}
}
- let scene = scenes::create_scene_with_id(&mut *guard, scene_id, realm.id, &req).await?;
+ let scene = scenes::create_scene_with_id(&pool, scene_id, realm.id, &req).await?;
Ok(Json(CreateSceneResponse {
id: scene.id,
slug: scene.slug,
@@ -263,15 +258,12 @@ pub async fn create_scene(
/// Update a scene.
pub async fn update_scene(
- admin_conn: AdminConn,
+ State(pool): State,
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(&mut *guard, scene_id)
+ let existing_scene = scenes::get_scene_by_id(&pool, scene_id)
.await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?;
@@ -304,17 +296,15 @@ pub async fn update_scene(
}
}
- let scene = scenes::update_scene(&mut *guard, scene_id, &req).await?;
+ let scene = scenes::update_scene(&pool, scene_id, &req).await?;
Ok(Json(scene))
}
/// Delete a scene.
pub async fn delete_scene(
- admin_conn: AdminConn,
+ State(pool): State,
Path(scene_id): Path,
) -> Result, AppError> {
- let conn = admin_conn.0;
- let mut guard = conn.acquire().await;
- scenes::delete_scene(&mut *guard, scene_id).await?;
+ scenes::delete_scene(&pool, 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 7500f7b..527f86b 100644
--- a/crates/chattyness-admin-ui/src/api/spots.rs
+++ b/crates/chattyness-admin-ui/src/api/spots.rs
@@ -13,8 +13,6 @@ 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,
@@ -43,16 +41,13 @@ pub struct CreateSpotResponse {
/// Create a new spot in a scene.
pub async fn create_spot(
- admin_conn: AdminConn,
+ State(pool): State,
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(&mut *guard, scene_id, slug).await?;
+ let available = spots::is_spot_slug_available(&pool, scene_id, slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Spot slug '{}' is already taken in this scene",
@@ -61,28 +56,25 @@ pub async fn create_spot(
}
}
- let spot = spots::create_spot(&mut *guard, scene_id, &req).await?;
+ let spot = spots::create_spot(&pool, scene_id, &req).await?;
Ok(Json(CreateSpotResponse { id: spot.id }))
}
/// Update a spot.
pub async fn update_spot(
- admin_conn: AdminConn,
+ State(pool): State,
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(&mut *guard, spot_id)
+ let existing = spots::get_spot_by_id(&pool, 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(&mut *guard, existing.scene_id, new_slug).await?;
+ spots::is_spot_slug_available(&pool, existing.scene_id, new_slug).await?;
if !available {
return Err(AppError::Conflict(format!(
"Spot slug '{}' is already taken in this scene",
@@ -92,17 +84,15 @@ pub async fn update_spot(
}
}
- let spot = spots::update_spot(&mut *guard, spot_id, &req).await?;
+ let spot = spots::update_spot(&pool, spot_id, &req).await?;
Ok(Json(spot))
}
/// Delete a spot.
pub async fn delete_spot(
- admin_conn: AdminConn,
+ State(pool): State,
Path(spot_id): Path,
) -> Result, AppError> {
- let conn = admin_conn.0;
- let mut guard = conn.acquire().await;
- spots::delete_spot(&mut *guard, spot_id).await?;
+ spots::delete_spot(&pool, spot_id).await?;
Ok(Json(()))
}
diff --git a/crates/chattyness-admin-ui/src/auth.rs b/crates/chattyness-admin-ui/src/auth.rs
index 7096bf2..fc328a2 100644
--- a/crates/chattyness-admin-ui/src/auth.rs
+++ b/crates/chattyness-admin-ui/src/auth.rs
@@ -4,12 +4,6 @@
//! - 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
deleted file mode 100644
index 94f4979..0000000
--- a/crates/chattyness-admin-ui/src/auth/admin_conn.rs
+++ /dev/null
@@ -1,315 +0,0 @@
-//! 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 204760e..1931959 100644
--- a/crates/chattyness-admin-ui/src/components.rs
+++ b/crates/chattyness-admin-ui/src/components.rs
@@ -177,8 +177,6 @@ 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
deleted file mode 100644
index 1935e25..0000000
--- a/crates/chattyness-admin-ui/src/pages/avatars_detail.rs
+++ /dev/null
@@ -1,306 +0,0 @@
-//! 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