feat(admin): dashboard + community creator role

This commit is contained in:
Marco Allegretti 2026-02-21 16:52:50 +01:00
parent 932c514666
commit cfa74f214c
19 changed files with 828 additions and 45 deletions

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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;

View file

@ -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;

View file

@ -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<DateTime<Utc>>,
}
#[derive(Debug, Serialize)]
pub struct UserSummary {
pub id: Uuid,
pub username: String,
pub display_name: Option<String>,
}
/// 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<PgPool>,
auth: AuthUser,
Path(role_id): Path<Uuid>,
Json(req): Json<AssignRoleRequest>,
) -> Result<Json<serde_json::Value>, (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<PgPool>,
auth: AuthUser,
Path((role_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, (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<PgPool>,
auth: AuthUser,
Path(role_id): Path<Uuid>,
) -> Result<Json<Vec<UserSummary>>, (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",

View file

@ -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<String>,
@ -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<PgPool>,
) -> Result<Json<PublicInstanceSettings>, 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,

View file

@ -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<PgPool>) -> Result<Json<Vec<UserResponse>>, String> {
async fn list_users(
auth: AuthUser,
State(pool): State<PgPool>,
) -> Result<Json<Vec<UserResponse>>, (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()))
}

View file

@ -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: '🔐' },

View file

@ -215,7 +215,7 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
<span class="notif-badge" id="notif-badge" style="display:none;">0</span>
</a>
<a href="/settings">Settings</a>
<a href="/admin/settings" id="admin-link" style="display:none;">Admin</a>
<a href="/admin" id="admin-link" style="display:none;">Admin</a>
<span class="user-name">${userData.display_name || userData.username}</span>
`;
loadNotificationCount();

View file

@ -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';
---
<Layout title="Admin - Community Creators">
<div class="admin-container">
<AdminNav currentPage="/admin/community-creators" />
<main class="admin-content">
<header class="ui-page-header">
<div class="ui-page-title">
<h1>Community Creators</h1>
<p class="ui-subtitle">Grant who can create communities when the platform is restricted</p>
</div>
</header>
<div id="loading" class="loading">Loading...</div>
<div id="error" class="error-box" style="display: none;"></div>
<div id="success" class="success-box" style="display: none;"></div>
<section class="ui-card ui-card-soft ui-card-pad-md" style="margin-bottom: 1.25rem;">
<h2 style="margin-top: 0;">Assign creator role</h2>
<form id="assign-form" class="ui-form" style="--ui-form-control-max-width: 420px; --ui-form-group-mb: 0.75rem;">
<div class="form-group">
<label for="user_id">User</label>
<select id="user_id" name="user_id" required></select>
<small class="help-text">Select a user to grant the Community Creator platform role.</small>
</div>
<div class="form-actions">
<button class="ui-btn ui-btn-primary" type="submit">Grant role</button>
</div>
</form>
</section>
<section class="ui-card ui-card-soft ui-card-pad-md">
<h2 style="margin-top: 0;">Current community creators</h2>
<div id="creators" class="list">
<p class="loading-small">Loading...</p>
</div>
</section>
</main>
</div>
</Layout>
<style>
.admin-container {
display: flex;
min-height: calc(100vh - 60px);
}
.admin-content {
flex: 1;
padding: 2rem;
max-width: 1000px;
}
.loading {
color: var(--color-text-muted);
padding: 2rem;
text-align: center;
}
.error-box {
background: color-mix(in srgb, var(--color-error) 18%, transparent);
color: var(--color-error);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.success-box {
background: color-mix(in srgb, var(--color-success) 18%, transparent);
color: var(--color-success);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.list {
display: grid;
gap: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.meta {
color: var(--color-text-muted);
font-size: 0.875rem;
}
</style>
<script define:vars={{ apiBase }}>
(function() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login?redirect=/admin/community-creators';
return;
}
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const successEl = document.getElementById('success');
const creatorsEl = document.getElementById('creators');
const assignForm = document.getElementById('assign-form');
const userSelect = document.getElementById('user_id');
let creatorRoleId = null;
function showError(msg) {
if (!errorEl) return;
errorEl.textContent = msg;
errorEl.style.display = 'block';
}
function showSuccess(msg) {
if (!successEl) return;
successEl.textContent = msg;
successEl.style.display = 'block';
setTimeout(() => {
successEl.style.display = 'none';
successEl.textContent = '';
}, 2500);
}
function clearError() {
if (!errorEl) return;
errorEl.style.display = 'none';
errorEl.textContent = '';
}
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, function(ch) {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#39;';
default: return ch;
}
});
}
async function fetchJson(url, init) {
const res = await fetch(url, init);
if (res.status === 401) {
window.location.href = '/login?redirect=/admin/community-creators';
return null;
}
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch (e) {}
if (!res.ok) {
throw new Error((json && json.error) || text || 'Request failed');
}
return json;
}
async function loadRoles() {
const roles = await fetchJson(`${apiBase}/api/roles`, {
headers: { 'Authorization': `Bearer ${token}` },
});
const found = (roles || []).find(r => r && r.name === 'community_creator');
if (!found) {
throw new Error('community_creator role not found (migration not applied yet?)');
}
creatorRoleId = found.id;
}
async function loadUsers() {
const users = await fetchJson(`${apiBase}/api/users`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!userSelect) return;
userSelect.innerHTML = (users || []).map(u => {
const label = (u.display_name ? `${u.display_name} (@${u.username})` : `@${u.username}`);
return `<option value="${escapeHtml(u.id)}">${escapeHtml(label)}</option>`;
}).join('');
}
async function loadCreators() {
if (!creatorRoleId) return;
const list = await fetchJson(`${apiBase}/api/roles/${creatorRoleId}/users`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (!creatorsEl) return;
if (!list || list.length === 0) {
creatorsEl.innerHTML = '<p class="meta">No community creators assigned yet.</p>';
return;
}
creatorsEl.innerHTML = list.map(u => {
const label = (u.display_name ? `${u.display_name} (@${u.username})` : `@${u.username}`);
return `
<div class="row ui-card ui-card-pad-md ui-card-soft">
<div>
<div><strong>${escapeHtml(label)}</strong></div>
<div class="meta">User id: ${escapeHtml(u.id)}</div>
</div>
<button class="ui-btn ui-btn-danger js-remove" type="button" data-user-id="${escapeHtml(u.id)}">Remove</button>
</div>
`;
}).join('');
creatorsEl.querySelectorAll('.js-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const userId = btn.getAttribute('data-user-id');
if (!userId) return;
try {
clearError();
await fetchJson(`${apiBase}/api/roles/${creatorRoleId}/users/${userId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` },
});
showSuccess('Role removed.');
await loadCreators();
} catch (e) {
showError(e && e.message ? e.message : String(e));
}
});
});
}
assignForm?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!creatorRoleId) return;
if (!(userSelect instanceof HTMLSelectElement)) return;
try {
clearError();
await fetchJson(`${apiBase}/api/roles/${creatorRoleId}/assign`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ user_id: userSelect.value, expires_at: null }),
});
showSuccess('Role granted.');
await loadCreators();
} catch (e) {
showError(e && e.message ? e.message : String(e));
}
});
(async function init() {
try {
await loadRoles();
await loadUsers();
await loadCreators();
if (loadingEl) loadingEl.style.display = 'none';
} catch (e) {
showError(e && e.message ? e.message : String(e));
if (loadingEl) loadingEl.style.display = 'none';
}
})();
})();
</script>

View file

@ -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';
---
<Layout title="Admin - Dashboard">
<div class="admin-container">
<AdminNav currentPage="/admin" />
<main class="admin-content">
<header class="ui-page-header">
<div class="ui-page-title">
<h1>Admin Dashboard</h1>
<p class="ui-subtitle">Setup status and platform management shortcuts</p>
</div>
</header>
<div id="loading" class="loading">Loading...</div>
<div id="error" class="error-box" style="display: none;"></div>
<section id="summary" style="display:none;">
<div class="ui-card ui-card-soft ui-card-pad-md">
<div class="ui-kpis">
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-setup">—</div>
<div class="ui-kpi-label">Setup</div>
</div>
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-instance-type">—</div>
<div class="ui-kpi-label">Instance Type</div>
</div>
<div class="ui-kpi">
<div class="ui-kpi-value" id="kpi-platform-mode">—</div>
<div class="ui-kpi-label">Platform Mode</div>
</div>
</div>
<div class="panel-actions" style="margin-top: 1rem; display:flex; gap: .75rem; flex-wrap: wrap;">
<a class="ui-btn ui-btn-primary" href="/admin/settings">Instance Settings</a>
<a class="ui-btn ui-btn-secondary" href="/admin/community-creators">Community Creators</a>
<a class="ui-btn ui-btn-secondary" href="/admin/approvals">Approvals</a>
<a class="ui-btn ui-btn-secondary" href="/admin/roles">Roles</a>
</div>
</div>
</section>
</main>
</div>
</Layout>
<style>
.admin-container {
display: flex;
min-height: calc(100vh - 60px);
}
.admin-content {
flex: 1;
padding: 2rem;
max-width: 1000px;
}
.loading {
color: var(--color-text-muted);
padding: 2rem;
text-align: center;
}
.error-box {
background: color-mix(in srgb, var(--color-error) 18%, transparent);
color: var(--color-error);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
</style>
<script define:vars={{ apiBase }}>
(function() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login?redirect=/admin';
return;
}
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const summaryEl = document.getElementById('summary');
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = String(value);
}
async function load() {
try {
const res = await fetch(`${apiBase}/api/settings/instance`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.status === 401) {
window.location.href = '/login?redirect=/admin';
return;
}
if (res.status === 403) {
if (errorEl) {
errorEl.textContent = 'Admin access required';
errorEl.style.display = 'block';
}
if (loadingEl) loadingEl.style.display = 'none';
return;
}
if (!res.ok) throw new Error(await res.text());
const s = await res.json();
setText('kpi-setup', s.setup_completed ? 'Complete' : 'Required');
setText('kpi-instance-type', s.instance_type || '—');
setText('kpi-platform-mode', s.platform_mode || '—');
if (loadingEl) loadingEl.style.display = 'none';
if (summaryEl) summaryEl.style.display = 'block';
} catch (e) {
if (errorEl) {
errorEl.textContent = 'Failed to load admin dashboard: ' + (e && e.message ? e.message : String(e));
errorEl.style.display = 'block';
}
if (loadingEl) loadingEl.style.display = 'none';
}
}
load();
})();
</script>

View file

@ -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;
}

View file

@ -49,14 +49,45 @@ if (!setupRequired) {
</div>
</section>
<!-- Step 2: Platform Mode -->
<section class="setup-section" data-step="2">
<h2>Instance Type</h2>
<p class="section-desc">Choose what kind of Likwid instance you want to run.</p>
<div class="mode-options">
<label class="mode-option">
<input type="radio" name="instance_type" value="multi_community" checked />
<div class="mode-card">
<strong>Multi-community</strong>
<p>Host multiple communities with shared platform policies</p>
</div>
</label>
<label class="mode-option">
<input type="radio" name="instance_type" value="single_community" />
<div class="mode-card">
<strong>Single community</strong>
<p>Dedicated instance with one main community</p>
</div>
</label>
<label class="mode-option">
<input type="radio" name="instance_type" value="federation" />
<div class="mode-card">
<strong>Federation</strong>
<p>Prepare for federation features and cross-instance collaboration</p>
</div>
</label>
</div>
</section>
<!-- Step 2: Platform Mode -->
<section class="setup-section" data-step="3">
<h2>Platform Mode</h2>
<p class="section-desc">How should communities be created on this platform?</p>
<div class="mode-options">
<label class="mode-option">
<input type="radio" name="platform_mode" value="open" checked />
<input type="radio" name="platform_mode" value="open" />
<div class="mode-card">
<strong>Open</strong>
<p>Any registered user can create communities</p>
@ -64,7 +95,7 @@ if (!setupRequired) {
</label>
<label class="mode-option">
<input type="radio" name="platform_mode" value="approval" />
<input type="radio" name="platform_mode" value="approval" checked />
<div class="mode-card">
<strong>Approval Required</strong>
<p>Users can request to create communities, admins approve</p>
@ -96,7 +127,7 @@ if (!setupRequired) {
</section>
<!-- Admin Login Notice -->
<section class="setup-section" data-step="3">
<section class="setup-section" data-step="4">
<h2>Admin Account</h2>
<p class="section-desc">
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
};