From cfa74f214cf701174ba81ab78ae6b7e426565681 Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Sat, 21 Feb 2026 16:52:50 +0100 Subject: [PATCH] feat(admin): dashboard + community creator role --- ...19c0f702cb6530e056fd1504227f3aa1fd930.json | 34 +++ ...7bd5ad8b39f1803a0f86a421d467368015bd.json} | 24 +- ...6b032d0607f699a477487a6e4924bd75cef41.json | 15 + ...d1f93f6514df19c9b83969f0d58900f461ec.json} | 16 +- ...7ad6184392718f1da98b793a8ba2dfaf84c2b.json | 17 ++ ...7582ed79f8dc5fd135ea7117bfc3d1d33c11.json} | 24 +- ...0a8e133766adfa97bad4bb22936317ddcd6c.json} | 25 +- ...db844e5210ef3ad0205ae2515ab7e4589240c.json | 22 ++ ...stance_type_and_community_creator_role.sql | 37 +++ ...60221163000_user_roles_unique_platform.sql | 13 + backend/src/api/roles.rs | 100 +++++++ backend/src/api/settings.rs | 39 ++- backend/src/api/users.rs | 10 +- frontend/src/components/AdminNav.astro | 2 + frontend/src/layouts/Layout.astro | 2 +- .../src/pages/admin/community-creators.astro | 271 ++++++++++++++++++ frontend/src/pages/admin/index.astro | 137 +++++++++ frontend/src/pages/communities/new.astro | 11 + frontend/src/pages/setup.astro | 74 ++++- 19 files changed, 828 insertions(+), 45 deletions(-) create mode 100644 backend/.sqlx/query-6c6f48705ccafb301ec03c8a0b719c0f702cb6530e056fd1504227f3aa1fd930.json rename backend/.sqlx/{query-0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f.json => query-709e697a9c2e689a114c9bf3f1877bd5ad8b39f1803a0f86a421d467368015bd.json} (72%) create mode 100644 backend/.sqlx/query-71eef9a9c46cd41a8272aeaa9d86b032d0607f699a477487a6e4924bd75cef41.json rename backend/.sqlx/{query-593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502.json => query-85b30d0c2440ee40790c496173ebd1f93f6514df19c9b83969f0d58900f461ec.json} (68%) create mode 100644 backend/.sqlx/query-8d5731d3f05af6b7069068752bd7ad6184392718f1da98b793a8ba2dfaf84c2b.json rename backend/.sqlx/{query-786d22de6313d554e95069d020362b4880f40187cb022d94126df9ced0f3f162.json => query-8f7e57bc074a8a9c80e20ae51d497582ed79f8dc5fd135ea7117bfc3d1d33c11.json} (83%) rename backend/.sqlx/{query-8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb.json => query-a1e179a81e94c91a7ad6201cea880a8e133766adfa97bad4bb22936317ddcd6c.json} (72%) create mode 100644 backend/.sqlx/query-cf573b897b379059a8a14132fdedb844e5210ef3ad0205ae2515ab7e4589240c.json create mode 100644 backend/migrations/20260221160000_instance_type_and_community_creator_role.sql create mode 100644 backend/migrations/20260221163000_user_roles_unique_platform.sql create mode 100644 frontend/src/pages/admin/community-creators.astro create mode 100644 frontend/src/pages/admin/index.astro diff --git a/backend/.sqlx/query-6c6f48705ccafb301ec03c8a0b719c0f702cb6530e056fd1504227f3aa1fd930.json b/backend/.sqlx/query-6c6f48705ccafb301ec03c8a0b719c0f702cb6530e056fd1504227f3aa1fd930.json new file mode 100644 index 0000000..0af7d90 --- /dev/null +++ b/backend/.sqlx/query-6c6f48705ccafb301ec03c8a0b719c0f702cb6530e056fd1504227f3aa1fd930.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT u.id, u.username, u.display_name\n FROM user_roles ur\n JOIN users u ON u.id = ur.user_id\n WHERE ur.role_id = $1\n AND ur.community_id IS NULL\n AND (ur.expires_at IS NULL OR ur.expires_at > NOW())\n AND u.is_active = true\n ORDER BY u.username", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "6c6f48705ccafb301ec03c8a0b719c0f702cb6530e056fd1504227f3aa1fd930" +} diff --git a/backend/.sqlx/query-0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f.json b/backend/.sqlx/query-709e697a9c2e689a114c9bf3f1877bd5ad8b39f1803a0f86a421d467368015bd.json similarity index 72% rename from backend/.sqlx/query-0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f.json rename to backend/.sqlx/query-709e697a9c2e689a114c9bf3f1877bd5ad8b39f1803a0f86a421d467368015bd.json index d58d161..d31d931 100644 --- a/backend/.sqlx/query-0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f.json +++ b/backend/.sqlx/query-709e697a9c2e689a114c9bf3f1877bd5ad8b39f1803a0f86a421d467368015bd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, setup_completed, instance_name, platform_mode,\n theme_id, registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode\n FROM instance_settings LIMIT 1", + "query": "SELECT id, setup_completed, instance_name, instance_type, platform_mode,\n theme_id, registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode\n FROM instance_settings LIMIT 1", "describe": { "columns": [ { @@ -20,41 +20,46 @@ }, { "ordinal": 3, - "name": "platform_mode", + "name": "instance_type", "type_info": "Varchar" }, { "ordinal": 4, - "name": "theme_id", + "name": "platform_mode", "type_info": "Varchar" }, { "ordinal": 5, + "name": "theme_id", + "type_info": "Varchar" + }, + { + "ordinal": 6, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 8, "name": "default_community_visibility", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 9, "name": "allow_private_communities", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 10, "name": "default_plugin_policy", "type_info": "Varchar" }, { - "ordinal": 10, + "ordinal": 11, "name": "default_moderation_mode", "type_info": "Varchar" } @@ -73,8 +78,9 @@ false, false, false, + false, false ] }, - "hash": "0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f" + "hash": "709e697a9c2e689a114c9bf3f1877bd5ad8b39f1803a0f86a421d467368015bd" } diff --git a/backend/.sqlx/query-71eef9a9c46cd41a8272aeaa9d86b032d0607f699a477487a6e4924bd75cef41.json b/backend/.sqlx/query-71eef9a9c46cd41a8272aeaa9d86b032d0607f699a477487a6e4924bd75cef41.json new file mode 100644 index 0000000..204537b --- /dev/null +++ b/backend/.sqlx/query-71eef9a9c46cd41a8272aeaa9d86b032d0607f699a477487a6e4924bd75cef41.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2 AND community_id IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "71eef9a9c46cd41a8272aeaa9d86b032d0607f699a477487a6e4924bd75cef41" +} diff --git a/backend/.sqlx/query-593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502.json b/backend/.sqlx/query-85b30d0c2440ee40790c496173ebd1f93f6514df19c9b83969f0d58900f461ec.json similarity index 68% rename from backend/.sqlx/query-593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502.json rename to backend/.sqlx/query-85b30d0c2440ee40790c496173ebd1f93f6514df19c9b83969f0d58900f461ec.json index cdafddb..ec615ac 100644 --- a/backend/.sqlx/query-593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502.json +++ b/backend/.sqlx/query-85b30d0c2440ee40790c496173ebd1f93f6514df19c9b83969f0d58900f461ec.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT setup_completed, instance_name, theme_id, platform_mode,\n registration_enabled, registration_mode,\n single_community_id\n FROM instance_settings\n LIMIT 1", + "query": "SELECT setup_completed, instance_name, theme_id, instance_type, platform_mode,\n registration_enabled, registration_mode,\n single_community_id\n FROM instance_settings\n LIMIT 1", "describe": { "columns": [ { @@ -20,21 +20,26 @@ }, { "ordinal": 3, - "name": "platform_mode", + "name": "instance_type", "type_info": "Varchar" }, { "ordinal": 4, + "name": "platform_mode", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 7, "name": "single_community_id", "type_info": "Uuid" } @@ -49,8 +54,9 @@ false, false, false, + false, true ] }, - "hash": "593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502" + "hash": "85b30d0c2440ee40790c496173ebd1f93f6514df19c9b83969f0d58900f461ec" } diff --git a/backend/.sqlx/query-8d5731d3f05af6b7069068752bd7ad6184392718f1da98b793a8ba2dfaf84c2b.json b/backend/.sqlx/query-8d5731d3f05af6b7069068752bd7ad6184392718f1da98b793a8ba2dfaf84c2b.json new file mode 100644 index 0000000..52110bf --- /dev/null +++ b/backend/.sqlx/query-8d5731d3f05af6b7069068752bd7ad6184392718f1da98b793a8ba2dfaf84c2b.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO user_roles (user_id, role_id, community_id, granted_by, expires_at)\n VALUES ($1, $2, NULL, $3, $4)\n ON CONFLICT (user_id, role_id) WHERE community_id IS NULL DO UPDATE SET\n granted_by = $3, expires_at = $4, granted_at = NOW()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8d5731d3f05af6b7069068752bd7ad6184392718f1da98b793a8ba2dfaf84c2b" +} diff --git a/backend/.sqlx/query-786d22de6313d554e95069d020362b4880f40187cb022d94126df9ced0f3f162.json b/backend/.sqlx/query-8f7e57bc074a8a9c80e20ae51d497582ed79f8dc5fd135ea7117bfc3d1d33c11.json similarity index 83% rename from backend/.sqlx/query-786d22de6313d554e95069d020362b4880f40187cb022d94126df9ced0f3f162.json rename to backend/.sqlx/query-8f7e57bc074a8a9c80e20ae51d497582ed79f8dc5fd135ea7117bfc3d1d33c11.json index 0f2237e..b07af99 100644 --- a/backend/.sqlx/query-786d22de6313d554e95069d020362b4880f40187cb022d94126df9ced0f3f162.json +++ b/backend/.sqlx/query-8f7e57bc074a8a9c80e20ae51d497582ed79f8dc5fd135ea7117bfc3d1d33c11.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE instance_settings SET\n instance_name = COALESCE($1, instance_name),\n theme_id = COALESCE($2, theme_id),\n platform_mode = COALESCE($3, platform_mode),\n registration_enabled = COALESCE($4, registration_enabled),\n registration_mode = COALESCE($5, registration_mode),\n default_community_visibility = COALESCE($6, default_community_visibility),\n default_plugin_policy = COALESCE($7, default_plugin_policy),\n default_moderation_mode = COALESCE($8, default_moderation_mode)\n RETURNING id, setup_completed, instance_name, platform_mode,\n theme_id, registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode", + "query": "UPDATE instance_settings SET\n instance_name = COALESCE($1, instance_name),\n theme_id = COALESCE($2, theme_id),\n platform_mode = COALESCE($3, platform_mode),\n registration_enabled = COALESCE($4, registration_enabled),\n registration_mode = COALESCE($5, registration_mode),\n default_community_visibility = COALESCE($6, default_community_visibility),\n default_plugin_policy = COALESCE($7, default_plugin_policy),\n default_moderation_mode = COALESCE($8, default_moderation_mode)\n RETURNING id, setup_completed, instance_name, instance_type, platform_mode,\n theme_id, registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode", "describe": { "columns": [ { @@ -20,41 +20,46 @@ }, { "ordinal": 3, - "name": "platform_mode", + "name": "instance_type", "type_info": "Varchar" }, { "ordinal": 4, - "name": "theme_id", + "name": "platform_mode", "type_info": "Varchar" }, { "ordinal": 5, + "name": "theme_id", + "type_info": "Varchar" + }, + { + "ordinal": 6, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 8, "name": "default_community_visibility", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 9, "name": "allow_private_communities", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 10, "name": "default_plugin_policy", "type_info": "Varchar" }, { - "ordinal": 10, + "ordinal": 11, "name": "default_moderation_mode", "type_info": "Varchar" } @@ -82,8 +87,9 @@ false, false, false, + false, false ] }, - "hash": "786d22de6313d554e95069d020362b4880f40187cb022d94126df9ced0f3f162" + "hash": "8f7e57bc074a8a9c80e20ae51d497582ed79f8dc5fd135ea7117bfc3d1d33c11" } diff --git a/backend/.sqlx/query-8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb.json b/backend/.sqlx/query-a1e179a81e94c91a7ad6201cea880a8e133766adfa97bad4bb22936317ddcd6c.json similarity index 72% rename from backend/.sqlx/query-8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb.json rename to backend/.sqlx/query-a1e179a81e94c91a7ad6201cea880a8e133766adfa97bad4bb22936317ddcd6c.json index 7ca9d9d..501e302 100644 --- a/backend/.sqlx/query-8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb.json +++ b/backend/.sqlx/query-a1e179a81e94c91a7ad6201cea880a8e133766adfa97bad4bb22936317ddcd6c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE instance_settings SET\n setup_completed = true,\n setup_completed_at = NOW(),\n setup_completed_by = $1,\n instance_name = $2,\n platform_mode = $3,\n single_community_id = $4\n RETURNING id, setup_completed, instance_name, platform_mode,\n theme_id,\n registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode", + "query": "UPDATE instance_settings SET\n setup_completed = true,\n setup_completed_at = NOW(),\n setup_completed_by = $1,\n instance_name = $2,\n instance_type = $3,\n platform_mode = $4,\n single_community_id = $5\n RETURNING id, setup_completed, instance_name, instance_type, platform_mode,\n theme_id,\n registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode", "describe": { "columns": [ { @@ -20,41 +20,46 @@ }, { "ordinal": 3, - "name": "platform_mode", + "name": "instance_type", "type_info": "Varchar" }, { "ordinal": 4, - "name": "theme_id", + "name": "platform_mode", "type_info": "Varchar" }, { "ordinal": 5, + "name": "theme_id", + "type_info": "Varchar" + }, + { + "ordinal": 6, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 6, + "ordinal": 7, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 8, "name": "default_community_visibility", "type_info": "Varchar" }, { - "ordinal": 8, + "ordinal": 9, "name": "allow_private_communities", "type_info": "Bool" }, { - "ordinal": 9, + "ordinal": 10, "name": "default_plugin_policy", "type_info": "Varchar" }, { - "ordinal": 10, + "ordinal": 11, "name": "default_moderation_mode", "type_info": "Varchar" } @@ -64,6 +69,7 @@ "Uuid", "Varchar", "Varchar", + "Varchar", "Uuid" ] }, @@ -78,8 +84,9 @@ false, false, false, + false, false ] }, - "hash": "8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb" + "hash": "a1e179a81e94c91a7ad6201cea880a8e133766adfa97bad4bb22936317ddcd6c" } diff --git a/backend/.sqlx/query-cf573b897b379059a8a14132fdedb844e5210ef3ad0205ae2515ab7e4589240c.json b/backend/.sqlx/query-cf573b897b379059a8a14132fdedb844e5210ef3ad0205ae2515ab7e4589240c.json new file mode 100644 index 0000000..0c7c472 --- /dev/null +++ b/backend/.sqlx/query-cf573b897b379059a8a14132fdedb844e5210ef3ad0205ae2515ab7e4589240c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM roles WHERE id = $1 AND community_id IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "cf573b897b379059a8a14132fdedb844e5210ef3ad0205ae2515ab7e4589240c" +} diff --git a/backend/migrations/20260221160000_instance_type_and_community_creator_role.sql b/backend/migrations/20260221160000_instance_type_and_community_creator_role.sql new file mode 100644 index 0000000..25944a1 --- /dev/null +++ b/backend/migrations/20260221160000_instance_type_and_community_creator_role.sql @@ -0,0 +1,37 @@ +-- Add instance type and a platform-level "community creator" role + +ALTER TABLE instance_settings +ADD COLUMN IF NOT EXISTS instance_type VARCHAR(20) NOT NULL DEFAULT 'multi_community'; + +INSERT INTO roles (name, display_name, description, is_system, priority, community_id) +VALUES ( + 'community_creator', + 'Community Creator', + 'Can create new communities when community creation is restricted', + TRUE, + 300, + NULL +) +ON CONFLICT (name, community_id) DO NOTHING; + +WITH creator_role AS ( + SELECT id FROM roles WHERE name = 'community_creator' AND community_id IS NULL LIMIT 1 +), +perm AS ( + SELECT id FROM permissions WHERE name = 'community.create' LIMIT 1 +) +INSERT INTO role_permissions (role_id, permission_id, granted) +SELECT creator_role.id, perm.id, TRUE +FROM creator_role, perm +ON CONFLICT (role_id, permission_id) DO UPDATE SET granted = TRUE; + +WITH user_role AS ( + SELECT id FROM roles WHERE name = 'user' AND community_id IS NULL LIMIT 1 +), +perm AS ( + SELECT id FROM permissions WHERE name = 'community.create' LIMIT 1 +) +INSERT INTO role_permissions (role_id, permission_id, granted) +SELECT user_role.id, perm.id, FALSE +FROM user_role, perm +ON CONFLICT (role_id, permission_id) DO UPDATE SET granted = FALSE; diff --git a/backend/migrations/20260221163000_user_roles_unique_platform.sql b/backend/migrations/20260221163000_user_roles_unique_platform.sql new file mode 100644 index 0000000..804d53d --- /dev/null +++ b/backend/migrations/20260221163000_user_roles_unique_platform.sql @@ -0,0 +1,13 @@ +-- Ensure platform role assignments are unique when community_id IS NULL + +DELETE FROM user_roles a +USING user_roles b +WHERE a.community_id IS NULL + AND b.community_id IS NULL + AND a.user_id = b.user_id + AND a.role_id = b.role_id + AND a.id > b.id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_roles_unique_platform +ON user_roles (user_id, role_id) +WHERE community_id IS NULL; diff --git a/backend/src/api/roles.rs b/backend/src/api/roles.rs index 4eae29f..182cd06 100644 --- a/backend/src/api/roles.rs +++ b/backend/src/api/roles.rs @@ -14,6 +14,7 @@ use sqlx::PgPool; use uuid::Uuid; use crate::auth::AuthUser; +use crate::api::permissions::{perms, require_permission}; // ============================================================================ // Types @@ -84,6 +85,13 @@ pub struct AssignRoleRequest { pub expires_at: Option>, } +#[derive(Debug, Serialize)] +pub struct UserSummary { + pub id: Uuid, + pub username: String, + pub display_name: Option, +} + /// User with their assigned roles. Designed for user role listing. #[allow(dead_code)] #[derive(Debug, Serialize)] @@ -342,6 +350,92 @@ async fn assign_role( Ok(Json(serde_json::json!({"success": true}))) } +async fn assign_platform_role( + State(pool): State, + auth: AuthUser, + Path(role_id): Path, + Json(req): Json, +) -> Result, (StatusCode, String)> { + require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; + + let _role = sqlx::query!( + "SELECT id FROM roles WHERE id = $1 AND community_id IS NULL", + role_id + ) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?; + + sqlx::query!( + r#"INSERT INTO user_roles (user_id, role_id, community_id, granted_by, expires_at) + VALUES ($1, $2, NULL, $3, $4) + ON CONFLICT (user_id, role_id) WHERE community_id IS NULL DO UPDATE SET + granted_by = $3, expires_at = $4, granted_at = NOW()"#, + req.user_id, + role_id, + auth.user_id, + req.expires_at + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({"success": true}))) +} + +async fn remove_platform_role( + State(pool): State, + auth: AuthUser, + Path((role_id, user_id)): Path<(Uuid, Uuid)>, +) -> Result, (StatusCode, String)> { + require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; + + sqlx::query!( + "DELETE FROM user_roles WHERE user_id = $1 AND role_id = $2 AND community_id IS NULL", + user_id, + role_id + ) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({"success": true}))) +} + +async fn list_platform_role_users( + State(pool): State, + auth: AuthUser, + Path(role_id): Path, +) -> Result>, (StatusCode, String)> { + require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; + + let rows = sqlx::query!( + r#"SELECT u.id, u.username, u.display_name + FROM user_roles ur + JOIN users u ON u.id = ur.user_id + WHERE ur.role_id = $1 + AND ur.community_id IS NULL + AND (ur.expires_at IS NULL OR ur.expires_at > NOW()) + AND u.is_active = true + ORDER BY u.username"#, + role_id + ) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json( + rows.into_iter() + .map(|r| UserSummary { + id: r.id, + username: r.username, + display_name: r.display_name, + }) + .collect(), + )) +} + /// Remove role from user async fn remove_role( auth: AuthUser, @@ -445,6 +539,12 @@ pub fn router(pool: PgPool) -> Router { .route("/api/permissions", get(list_permissions)) // Platform roles .route("/api/roles", get(list_platform_roles)) + .route("/api/roles/{role_id}/assign", post(assign_platform_role)) + .route( + "/api/roles/{role_id}/users/{user_id}", + delete(remove_platform_role), + ) + .route("/api/roles/{role_id}/users", get(list_platform_role_users)) // Community roles .route( "/api/communities/{community_id}/roles", diff --git a/backend/src/api/settings.rs b/backend/src/api/settings.rs index 32f38d0..635631a 100644 --- a/backend/src/api/settings.rs +++ b/backend/src/api/settings.rs @@ -31,6 +31,7 @@ pub struct PublicInstanceSettings { pub setup_completed: bool, pub instance_name: String, pub theme_id: String, + pub instance_type: String, pub platform_mode: String, pub registration_enabled: bool, pub registration_mode: String, @@ -44,6 +45,7 @@ pub struct InstanceSettings { pub setup_completed: bool, pub instance_name: String, pub theme_id: String, + pub instance_type: String, pub platform_mode: String, pub registration_enabled: bool, pub registration_mode: String, @@ -56,6 +58,7 @@ pub struct InstanceSettings { #[derive(Debug, Deserialize)] pub struct SetupRequest { pub instance_name: String, + pub instance_type: String, pub platform_mode: String, #[serde(default)] pub single_community_name: Option, @@ -89,6 +92,8 @@ const KNOWN_MODERATION_MODES: [&str; 4] = ["minimal", "standard", "strict", "cus const KNOWN_PLATFORM_MODES: [&str; 4] = ["open", "approval", "admin_only", "single_community"]; +const KNOWN_INSTANCE_TYPES: [&str; 3] = ["single_community", "multi_community", "federation"]; + fn validate_theme_id(theme_id: &str) -> Result<(), (StatusCode, String)> { if theme_id.trim().is_empty() { return Err((StatusCode::BAD_REQUEST, "Theme cannot be empty".to_string())); @@ -181,7 +186,7 @@ async fn get_public_settings( State(pool): State, ) -> Result, String> { let row = sqlx::query!( - r#"SELECT setup_completed, instance_name, theme_id, platform_mode, + r#"SELECT setup_completed, instance_name, theme_id, instance_type, platform_mode, registration_enabled, registration_mode, single_community_id FROM instance_settings @@ -196,6 +201,7 @@ async fn get_public_settings( setup_completed: false, instance_name: "Likwid".to_string(), theme_id: "neutral".to_string(), + instance_type: "multi_community".to_string(), platform_mode: "open".to_string(), registration_enabled: true, registration_mode: "open".to_string(), @@ -223,6 +229,7 @@ async fn get_public_settings( setup_completed: r.setup_completed, instance_name: r.instance_name, theme_id: r.theme_id, + instance_type: r.instance_type, platform_mode: r.platform_mode, registration_enabled: r.registration_enabled, registration_mode: r.registration_mode, @@ -258,6 +265,21 @@ async fn complete_setup( { return Err((StatusCode::BAD_REQUEST, "Invalid platform mode".to_string())); } + + validate_known_value(&req.instance_type, &KNOWN_INSTANCE_TYPES, "instance type")?; + + if req.instance_type == "single_community" && req.platform_mode != "single_community" { + return Err(( + StatusCode::BAD_REQUEST, + "Single community instance type requires platform mode 'single_community'".to_string(), + )); + } + if req.instance_type != "single_community" && req.platform_mode == "single_community" { + return Err(( + StatusCode::BAD_REQUEST, + "Platform mode 'single_community' requires instance type 'single_community'".to_string(), + )); + } if req.platform_mode == "single_community" { let name = req.single_community_name.as_deref().unwrap_or(""); if name.trim().is_empty() { @@ -362,15 +384,17 @@ async fn complete_setup( setup_completed_at = NOW(), setup_completed_by = $1, instance_name = $2, - platform_mode = $3, - single_community_id = $4 - RETURNING id, setup_completed, instance_name, platform_mode, + instance_type = $3, + platform_mode = $4, + single_community_id = $5 + RETURNING id, setup_completed, instance_name, instance_type, platform_mode, theme_id, registration_enabled, registration_mode, default_community_visibility, allow_private_communities, default_plugin_policy, default_moderation_mode"#, auth.user_id, req.instance_name, + req.instance_type, req.platform_mode, single_community_id ) @@ -383,6 +407,7 @@ async fn complete_setup( setup_completed: settings.setup_completed, instance_name: settings.instance_name, theme_id: settings.theme_id, + instance_type: settings.instance_type, platform_mode: settings.platform_mode, registration_enabled: settings.registration_enabled, registration_mode: settings.registration_mode, @@ -402,7 +427,7 @@ async fn get_instance_settings( require_permission(&pool, auth.user_id, perms::PLATFORM_SETTINGS, None).await?; let s = sqlx::query!( - r#"SELECT id, setup_completed, instance_name, platform_mode, + r#"SELECT id, setup_completed, instance_name, instance_type, platform_mode, theme_id, registration_enabled, registration_mode, default_community_visibility, allow_private_communities, default_plugin_policy, default_moderation_mode @@ -417,6 +442,7 @@ async fn get_instance_settings( setup_completed: s.setup_completed, instance_name: s.instance_name, theme_id: s.theme_id, + instance_type: s.instance_type, platform_mode: s.platform_mode, registration_enabled: s.registration_enabled, registration_mode: s.registration_mode, @@ -484,7 +510,7 @@ async fn update_instance_settings( default_community_visibility = COALESCE($6, default_community_visibility), default_plugin_policy = COALESCE($7, default_plugin_policy), default_moderation_mode = COALESCE($8, default_moderation_mode) - RETURNING id, setup_completed, instance_name, platform_mode, + RETURNING id, setup_completed, instance_name, instance_type, platform_mode, theme_id, registration_enabled, registration_mode, default_community_visibility, allow_private_communities, default_plugin_policy, default_moderation_mode"#, @@ -506,6 +532,7 @@ async fn update_instance_settings( setup_completed: s.setup_completed, instance_name: s.instance_name, theme_id: s.theme_id, + instance_type: s.instance_type, platform_mode: s.platform_mode, registration_enabled: s.registration_enabled, registration_mode: s.registration_mode, diff --git a/backend/src/api/users.rs b/backend/src/api/users.rs index e4326fb..2a76d04 100644 --- a/backend/src/api/users.rs +++ b/backend/src/api/users.rs @@ -10,6 +10,7 @@ use sqlx::PgPool; use uuid::Uuid; use crate::auth::AuthUser; +use crate::api::permissions::{perms, require_permission}; use crate::models::user::UserResponse; pub fn router(pool: PgPool) -> Router { @@ -21,14 +22,19 @@ pub fn router(pool: PgPool) -> Router { .with_state(pool) } -async fn list_users(State(pool): State) -> Result>, String> { +async fn list_users( + auth: AuthUser, + State(pool): State, +) -> Result>, (StatusCode, String)> { + require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; + let users = sqlx::query_as!( crate::models::User, "SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC LIMIT 100" ) .fetch_all(&pool) .await - .map_err(|e| e.to_string())?; + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(users.into_iter().map(UserResponse::from).collect())) } diff --git a/frontend/src/components/AdminNav.astro b/frontend/src/components/AdminNav.astro index 8520954..59b382d 100644 --- a/frontend/src/components/AdminNav.astro +++ b/frontend/src/components/AdminNav.astro @@ -6,7 +6,9 @@ interface Props { const { currentPage } = Astro.props; const navItems = [ + { href: '/admin', label: 'Dashboard', icon: '📊' }, { href: '/admin/settings', label: 'Instance Settings', icon: '⚙️' }, + { href: '/admin/community-creators', label: 'Community Creators', icon: '🏗️' }, { href: '/admin/approvals', label: 'Approvals', icon: '✅' }, { href: '/admin/invitations', label: 'Invitations', icon: '📨' }, { href: '/admin/roles', label: 'Roles & Permissions', icon: '🔐' }, diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro index 74c70a1..b795a86 100644 --- a/frontend/src/layouts/Layout.astro +++ b/frontend/src/layouts/Layout.astro @@ -215,7 +215,7 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S Settings - + ${userData.display_name || userData.username} `; loadNotificationCount(); diff --git a/frontend/src/pages/admin/community-creators.astro b/frontend/src/pages/admin/community-creators.astro new file mode 100644 index 0000000..2030702 --- /dev/null +++ b/frontend/src/pages/admin/community-creators.astro @@ -0,0 +1,271 @@ +--- +export const prerender = false; +import Layout from '../../layouts/Layout.astro'; +import AdminNav from '../../components/AdminNav.astro'; +import { API_BASE as apiBase } from '../../lib/api'; +--- + + +
+ + +
+
+
+

Community Creators

+

Grant who can create communities when the platform is restricted

+
+
+ +
Loading...
+ + + +
+

Assign creator role

+
+
+ + + Select a user to grant the Community Creator platform role. +
+
+ +
+
+
+ +
+

Current community creators

+
+

Loading...

+
+
+
+
+
+ + + + diff --git a/frontend/src/pages/admin/index.astro b/frontend/src/pages/admin/index.astro new file mode 100644 index 0000000..8f23d89 --- /dev/null +++ b/frontend/src/pages/admin/index.astro @@ -0,0 +1,137 @@ +--- +export const prerender = false; +import Layout from '../../layouts/Layout.astro'; +import AdminNav from '../../components/AdminNav.astro'; +import { API_BASE as apiBase } from '../../lib/api'; +--- + + +
+ + +
+
+
+

Admin Dashboard

+

Setup status and platform management shortcuts

+
+
+ +
Loading...
+ + + +
+
+
+ + + + diff --git a/frontend/src/pages/communities/new.astro b/frontend/src/pages/communities/new.astro index f0fa440..9fd1a02 100644 --- a/frontend/src/pages/communities/new.astro +++ b/frontend/src/pages/communities/new.astro @@ -70,8 +70,19 @@ import { API_BASE as apiBase } from '../../lib/api'; body: JSON.stringify(data), }); + if (res.status === 202) { + const msg = await res.text(); + errorEl.style.color = 'var(--color-success)'; + errorEl.textContent = msg || 'Request submitted for approval.'; + setTimeout(() => { + window.location.href = '/dashboard'; + }, 1200); + return; + } + if (!res.ok) { const err = await res.text(); + errorEl.style.color = 'var(--color-error)'; errorEl.textContent = err || 'Failed to create community'; return; } diff --git a/frontend/src/pages/setup.astro b/frontend/src/pages/setup.astro index f27893b..9b1e633 100644 --- a/frontend/src/pages/setup.astro +++ b/frontend/src/pages/setup.astro @@ -49,14 +49,45 @@ if (!setupRequired) { -
+

Instance Type

+

Choose what kind of Likwid instance you want to run.

+ +
+ + + + + +
+
+ + +

Platform Mode

How should communities be created on this platform?

-
+

Admin Account

You need to be logged in as an admin to complete setup. @@ -273,6 +304,7 @@ if (!setupRequired) { const setupSuccess = document.getElementById('setup-success'); const singleCommunityName = document.getElementById('single-community-name'); const platformModeInputs = document.querySelectorAll('input[name="platform_mode"]'); + const instanceTypeInputs = document.querySelectorAll('input[name="instance_type"]'); const initialInstanceName = (document.getElementById('instance_name')); if (initialInstanceName && !initialInstanceName.value) { @@ -311,6 +343,39 @@ if (!setupRequired) { input.addEventListener('change', (e) => { const target = e.target; singleCommunityName.style.display = target.value === 'single_community' ? 'block' : 'none'; + + if (target.value === 'single_community') { + const singleType = document.querySelector('input[name="instance_type"][value="single_community"]'); + if (singleType && singleType instanceof HTMLInputElement) { + singleType.checked = true; + } + } + }); + }); + + instanceTypeInputs.forEach(input => { + input.addEventListener('change', (e) => { + const target = e.target; + if (!(target instanceof HTMLInputElement)) return; + + if (target.value === 'single_community') { + const singleMode = document.querySelector('input[name="platform_mode"][value="single_community"]'); + if (singleMode && singleMode instanceof HTMLInputElement) { + singleMode.checked = true; + singleCommunityName.style.display = 'block'; + } + } else { + const selectedMode = document.querySelector('input[name="platform_mode"]:checked'); + if (selectedMode && selectedMode instanceof HTMLInputElement) { + if (selectedMode.value === 'single_community') { + const approvalMode = document.querySelector('input[name="platform_mode"][value="approval"]'); + if (approvalMode && approvalMode instanceof HTMLInputElement) { + approvalMode.checked = true; + } + } + } + singleCommunityName.style.display = 'none'; + } }); }); @@ -385,6 +450,7 @@ if (!setupRequired) { const formData = new FormData(form); const data = { instance_name: formData.get('instance_name'), + instance_type: formData.get('instance_type') || 'multi_community', platform_mode: formData.get('platform_mode'), single_community_name: formData.get('single_community_name') || undefined };