mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-03-26 19:03:08 +00:00
feat(admin): dashboard + community creator role
This commit is contained in:
parent
932c514666
commit
cfa74f214c
19 changed files with 828 additions and 45 deletions
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -20,41 +20,46 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "platform_mode",
|
"name": "instance_type",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "theme_id",
|
"name": "platform_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
|
"name": "theme_id",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
"name": "registration_enabled",
|
"name": "registration_enabled",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 7,
|
||||||
"name": "registration_mode",
|
"name": "registration_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 8,
|
||||||
"name": "default_community_visibility",
|
"name": "default_community_visibility",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 9,
|
||||||
"name": "allow_private_communities",
|
"name": "allow_private_communities",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 10,
|
||||||
"name": "default_plugin_policy",
|
"name": "default_plugin_policy",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "default_moderation_mode",
|
"name": "default_moderation_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
}
|
}
|
||||||
|
|
@ -73,8 +78,9 @@
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f"
|
"hash": "709e697a9c2e689a114c9bf3f1877bd5ad8b39f1803a0f86a421d467368015bd"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -20,21 +20,26 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "platform_mode",
|
"name": "instance_type",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
|
"name": "platform_mode",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
"name": "registration_enabled",
|
"name": "registration_enabled",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 6,
|
||||||
"name": "registration_mode",
|
"name": "registration_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 7,
|
||||||
"name": "single_community_id",
|
"name": "single_community_id",
|
||||||
"type_info": "Uuid"
|
"type_info": "Uuid"
|
||||||
}
|
}
|
||||||
|
|
@ -49,8 +54,9 @@
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502"
|
"hash": "85b30d0c2440ee40790c496173ebd1f93f6514df19c9b83969f0d58900f461ec"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -20,41 +20,46 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "platform_mode",
|
"name": "instance_type",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "theme_id",
|
"name": "platform_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
|
"name": "theme_id",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
"name": "registration_enabled",
|
"name": "registration_enabled",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 7,
|
||||||
"name": "registration_mode",
|
"name": "registration_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 8,
|
||||||
"name": "default_community_visibility",
|
"name": "default_community_visibility",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 9,
|
||||||
"name": "allow_private_communities",
|
"name": "allow_private_communities",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 10,
|
||||||
"name": "default_plugin_policy",
|
"name": "default_plugin_policy",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "default_moderation_mode",
|
"name": "default_moderation_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
}
|
}
|
||||||
|
|
@ -82,8 +87,9 @@
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "786d22de6313d554e95069d020362b4880f40187cb022d94126df9ced0f3f162"
|
"hash": "8f7e57bc074a8a9c80e20ae51d497582ed79f8dc5fd135ea7117bfc3d1d33c11"
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
|
|
@ -20,41 +20,46 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 3,
|
"ordinal": 3,
|
||||||
"name": "platform_mode",
|
"name": "instance_type",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 4,
|
"ordinal": 4,
|
||||||
"name": "theme_id",
|
"name": "platform_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
|
"name": "theme_id",
|
||||||
|
"type_info": "Varchar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
"name": "registration_enabled",
|
"name": "registration_enabled",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 6,
|
"ordinal": 7,
|
||||||
"name": "registration_mode",
|
"name": "registration_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 7,
|
"ordinal": 8,
|
||||||
"name": "default_community_visibility",
|
"name": "default_community_visibility",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 8,
|
"ordinal": 9,
|
||||||
"name": "allow_private_communities",
|
"name": "allow_private_communities",
|
||||||
"type_info": "Bool"
|
"type_info": "Bool"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 9,
|
"ordinal": 10,
|
||||||
"name": "default_plugin_policy",
|
"name": "default_plugin_policy",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"ordinal": 10,
|
"ordinal": 11,
|
||||||
"name": "default_moderation_mode",
|
"name": "default_moderation_mode",
|
||||||
"type_info": "Varchar"
|
"type_info": "Varchar"
|
||||||
}
|
}
|
||||||
|
|
@ -64,6 +69,7 @@
|
||||||
"Uuid",
|
"Uuid",
|
||||||
"Varchar",
|
"Varchar",
|
||||||
"Varchar",
|
"Varchar",
|
||||||
|
"Varchar",
|
||||||
"Uuid"
|
"Uuid"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -78,8 +84,9 @@
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb"
|
"hash": "a1e179a81e94c91a7ad6201cea880a8e133766adfa97bad4bb22936317ddcd6c"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -14,6 +14,7 @@ use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
use crate::api::permissions::{perms, require_permission};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -84,6 +85,13 @@ pub struct AssignRoleRequest {
|
||||||
pub expires_at: Option<DateTime<Utc>>,
|
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.
|
/// User with their assigned roles. Designed for user role listing.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -342,6 +350,92 @@ async fn assign_role(
|
||||||
Ok(Json(serde_json::json!({"success": true})))
|
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
|
/// Remove role from user
|
||||||
async fn remove_role(
|
async fn remove_role(
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
|
|
@ -445,6 +539,12 @@ pub fn router(pool: PgPool) -> Router {
|
||||||
.route("/api/permissions", get(list_permissions))
|
.route("/api/permissions", get(list_permissions))
|
||||||
// Platform roles
|
// Platform roles
|
||||||
.route("/api/roles", get(list_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
|
// Community roles
|
||||||
.route(
|
.route(
|
||||||
"/api/communities/{community_id}/roles",
|
"/api/communities/{community_id}/roles",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ pub struct PublicInstanceSettings {
|
||||||
pub setup_completed: bool,
|
pub setup_completed: bool,
|
||||||
pub instance_name: String,
|
pub instance_name: String,
|
||||||
pub theme_id: String,
|
pub theme_id: String,
|
||||||
|
pub instance_type: String,
|
||||||
pub platform_mode: String,
|
pub platform_mode: String,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
pub registration_mode: String,
|
pub registration_mode: String,
|
||||||
|
|
@ -44,6 +45,7 @@ pub struct InstanceSettings {
|
||||||
pub setup_completed: bool,
|
pub setup_completed: bool,
|
||||||
pub instance_name: String,
|
pub instance_name: String,
|
||||||
pub theme_id: String,
|
pub theme_id: String,
|
||||||
|
pub instance_type: String,
|
||||||
pub platform_mode: String,
|
pub platform_mode: String,
|
||||||
pub registration_enabled: bool,
|
pub registration_enabled: bool,
|
||||||
pub registration_mode: String,
|
pub registration_mode: String,
|
||||||
|
|
@ -56,6 +58,7 @@ pub struct InstanceSettings {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct SetupRequest {
|
pub struct SetupRequest {
|
||||||
pub instance_name: String,
|
pub instance_name: String,
|
||||||
|
pub instance_type: String,
|
||||||
pub platform_mode: String,
|
pub platform_mode: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub single_community_name: Option<String>,
|
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_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)> {
|
fn validate_theme_id(theme_id: &str) -> Result<(), (StatusCode, String)> {
|
||||||
if theme_id.trim().is_empty() {
|
if theme_id.trim().is_empty() {
|
||||||
return Err((StatusCode::BAD_REQUEST, "Theme cannot be empty".to_string()));
|
return Err((StatusCode::BAD_REQUEST, "Theme cannot be empty".to_string()));
|
||||||
|
|
@ -181,7 +186,7 @@ async fn get_public_settings(
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<PublicInstanceSettings>, String> {
|
) -> Result<Json<PublicInstanceSettings>, String> {
|
||||||
let row = sqlx::query!(
|
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,
|
registration_enabled, registration_mode,
|
||||||
single_community_id
|
single_community_id
|
||||||
FROM instance_settings
|
FROM instance_settings
|
||||||
|
|
@ -196,6 +201,7 @@ async fn get_public_settings(
|
||||||
setup_completed: false,
|
setup_completed: false,
|
||||||
instance_name: "Likwid".to_string(),
|
instance_name: "Likwid".to_string(),
|
||||||
theme_id: "neutral".to_string(),
|
theme_id: "neutral".to_string(),
|
||||||
|
instance_type: "multi_community".to_string(),
|
||||||
platform_mode: "open".to_string(),
|
platform_mode: "open".to_string(),
|
||||||
registration_enabled: true,
|
registration_enabled: true,
|
||||||
registration_mode: "open".to_string(),
|
registration_mode: "open".to_string(),
|
||||||
|
|
@ -223,6 +229,7 @@ async fn get_public_settings(
|
||||||
setup_completed: r.setup_completed,
|
setup_completed: r.setup_completed,
|
||||||
instance_name: r.instance_name,
|
instance_name: r.instance_name,
|
||||||
theme_id: r.theme_id,
|
theme_id: r.theme_id,
|
||||||
|
instance_type: r.instance_type,
|
||||||
platform_mode: r.platform_mode,
|
platform_mode: r.platform_mode,
|
||||||
registration_enabled: r.registration_enabled,
|
registration_enabled: r.registration_enabled,
|
||||||
registration_mode: r.registration_mode,
|
registration_mode: r.registration_mode,
|
||||||
|
|
@ -258,6 +265,21 @@ async fn complete_setup(
|
||||||
{
|
{
|
||||||
return Err((StatusCode::BAD_REQUEST, "Invalid platform mode".to_string()));
|
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" {
|
if req.platform_mode == "single_community" {
|
||||||
let name = req.single_community_name.as_deref().unwrap_or("");
|
let name = req.single_community_name.as_deref().unwrap_or("");
|
||||||
if name.trim().is_empty() {
|
if name.trim().is_empty() {
|
||||||
|
|
@ -362,15 +384,17 @@ async fn complete_setup(
|
||||||
setup_completed_at = NOW(),
|
setup_completed_at = NOW(),
|
||||||
setup_completed_by = $1,
|
setup_completed_by = $1,
|
||||||
instance_name = $2,
|
instance_name = $2,
|
||||||
platform_mode = $3,
|
instance_type = $3,
|
||||||
single_community_id = $4
|
platform_mode = $4,
|
||||||
RETURNING id, setup_completed, instance_name, platform_mode,
|
single_community_id = $5
|
||||||
|
RETURNING id, setup_completed, instance_name, instance_type, platform_mode,
|
||||||
theme_id,
|
theme_id,
|
||||||
registration_enabled, registration_mode,
|
registration_enabled, registration_mode,
|
||||||
default_community_visibility, allow_private_communities,
|
default_community_visibility, allow_private_communities,
|
||||||
default_plugin_policy, default_moderation_mode"#,
|
default_plugin_policy, default_moderation_mode"#,
|
||||||
auth.user_id,
|
auth.user_id,
|
||||||
req.instance_name,
|
req.instance_name,
|
||||||
|
req.instance_type,
|
||||||
req.platform_mode,
|
req.platform_mode,
|
||||||
single_community_id
|
single_community_id
|
||||||
)
|
)
|
||||||
|
|
@ -383,6 +407,7 @@ async fn complete_setup(
|
||||||
setup_completed: settings.setup_completed,
|
setup_completed: settings.setup_completed,
|
||||||
instance_name: settings.instance_name,
|
instance_name: settings.instance_name,
|
||||||
theme_id: settings.theme_id,
|
theme_id: settings.theme_id,
|
||||||
|
instance_type: settings.instance_type,
|
||||||
platform_mode: settings.platform_mode,
|
platform_mode: settings.platform_mode,
|
||||||
registration_enabled: settings.registration_enabled,
|
registration_enabled: settings.registration_enabled,
|
||||||
registration_mode: settings.registration_mode,
|
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?;
|
require_permission(&pool, auth.user_id, perms::PLATFORM_SETTINGS, None).await?;
|
||||||
|
|
||||||
let s = sqlx::query!(
|
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,
|
theme_id, registration_enabled, registration_mode,
|
||||||
default_community_visibility, allow_private_communities,
|
default_community_visibility, allow_private_communities,
|
||||||
default_plugin_policy, default_moderation_mode
|
default_plugin_policy, default_moderation_mode
|
||||||
|
|
@ -417,6 +442,7 @@ async fn get_instance_settings(
|
||||||
setup_completed: s.setup_completed,
|
setup_completed: s.setup_completed,
|
||||||
instance_name: s.instance_name,
|
instance_name: s.instance_name,
|
||||||
theme_id: s.theme_id,
|
theme_id: s.theme_id,
|
||||||
|
instance_type: s.instance_type,
|
||||||
platform_mode: s.platform_mode,
|
platform_mode: s.platform_mode,
|
||||||
registration_enabled: s.registration_enabled,
|
registration_enabled: s.registration_enabled,
|
||||||
registration_mode: s.registration_mode,
|
registration_mode: s.registration_mode,
|
||||||
|
|
@ -484,7 +510,7 @@ async fn update_instance_settings(
|
||||||
default_community_visibility = COALESCE($6, default_community_visibility),
|
default_community_visibility = COALESCE($6, default_community_visibility),
|
||||||
default_plugin_policy = COALESCE($7, default_plugin_policy),
|
default_plugin_policy = COALESCE($7, default_plugin_policy),
|
||||||
default_moderation_mode = COALESCE($8, default_moderation_mode)
|
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,
|
theme_id, registration_enabled, registration_mode,
|
||||||
default_community_visibility, allow_private_communities,
|
default_community_visibility, allow_private_communities,
|
||||||
default_plugin_policy, default_moderation_mode"#,
|
default_plugin_policy, default_moderation_mode"#,
|
||||||
|
|
@ -506,6 +532,7 @@ async fn update_instance_settings(
|
||||||
setup_completed: s.setup_completed,
|
setup_completed: s.setup_completed,
|
||||||
instance_name: s.instance_name,
|
instance_name: s.instance_name,
|
||||||
theme_id: s.theme_id,
|
theme_id: s.theme_id,
|
||||||
|
instance_type: s.instance_type,
|
||||||
platform_mode: s.platform_mode,
|
platform_mode: s.platform_mode,
|
||||||
registration_enabled: s.registration_enabled,
|
registration_enabled: s.registration_enabled,
|
||||||
registration_mode: s.registration_mode,
|
registration_mode: s.registration_mode,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
|
use crate::api::permissions::{perms, require_permission};
|
||||||
use crate::models::user::UserResponse;
|
use crate::models::user::UserResponse;
|
||||||
|
|
||||||
pub fn router(pool: PgPool) -> Router {
|
pub fn router(pool: PgPool) -> Router {
|
||||||
|
|
@ -21,14 +22,19 @@ pub fn router(pool: PgPool) -> Router {
|
||||||
.with_state(pool)
|
.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!(
|
let users = sqlx::query_as!(
|
||||||
crate::models::User,
|
crate::models::User,
|
||||||
"SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC LIMIT 100"
|
"SELECT * FROM users WHERE is_active = true ORDER BY created_at DESC LIMIT 100"
|
||||||
)
|
)
|
||||||
.fetch_all(&pool)
|
.fetch_all(&pool)
|
||||||
.await
|
.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()))
|
Ok(Json(users.into_iter().map(UserResponse::from).collect()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ interface Props {
|
||||||
const { currentPage } = Astro.props;
|
const { currentPage } = Astro.props;
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{ href: '/admin', label: 'Dashboard', icon: '📊' },
|
||||||
{ href: '/admin/settings', label: 'Instance Settings', icon: '⚙️' },
|
{ href: '/admin/settings', label: 'Instance Settings', icon: '⚙️' },
|
||||||
|
{ href: '/admin/community-creators', label: 'Community Creators', icon: '🏗️' },
|
||||||
{ href: '/admin/approvals', label: 'Approvals', icon: '✅' },
|
{ href: '/admin/approvals', label: 'Approvals', icon: '✅' },
|
||||||
{ href: '/admin/invitations', label: 'Invitations', icon: '📨' },
|
{ href: '/admin/invitations', label: 'Invitations', icon: '📨' },
|
||||||
{ href: '/admin/roles', label: 'Roles & Permissions', icon: '🔐' },
|
{ href: '/admin/roles', label: 'Roles & Permissions', icon: '🔐' },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<span class="notif-badge" id="notif-badge" style="display:none;">0</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/settings">Settings</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>
|
<span class="user-name">${userData.display_name || userData.username}</span>
|
||||||
`;
|
`;
|
||||||
loadNotificationCount();
|
loadNotificationCount();
|
||||||
|
|
|
||||||
271
frontend/src/pages/admin/community-creators.astro
Normal file
271
frontend/src/pages/admin/community-creators.astro
Normal 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 '&';
|
||||||
|
case '<': return '<';
|
||||||
|
case '>': return '>';
|
||||||
|
case '"': return '"';
|
||||||
|
case "'": return ''';
|
||||||
|
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>
|
||||||
137
frontend/src/pages/admin/index.astro
Normal file
137
frontend/src/pages/admin/index.astro
Normal 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>
|
||||||
|
|
@ -70,8 +70,19 @@ import { API_BASE as apiBase } from '../../lib/api';
|
||||||
body: JSON.stringify(data),
|
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) {
|
if (!res.ok) {
|
||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
|
errorEl.style.color = 'var(--color-error)';
|
||||||
errorEl.textContent = err || 'Failed to create community';
|
errorEl.textContent = err || 'Failed to create community';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,14 +49,45 @@ if (!setupRequired) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Step 2: Platform Mode -->
|
|
||||||
<section class="setup-section" data-step="2">
|
<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>
|
<h2>Platform Mode</h2>
|
||||||
<p class="section-desc">How should communities be created on this platform?</p>
|
<p class="section-desc">How should communities be created on this platform?</p>
|
||||||
|
|
||||||
<div class="mode-options">
|
<div class="mode-options">
|
||||||
<label class="mode-option">
|
<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">
|
<div class="mode-card">
|
||||||
<strong>Open</strong>
|
<strong>Open</strong>
|
||||||
<p>Any registered user can create communities</p>
|
<p>Any registered user can create communities</p>
|
||||||
|
|
@ -64,7 +95,7 @@ if (!setupRequired) {
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="mode-option">
|
<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">
|
<div class="mode-card">
|
||||||
<strong>Approval Required</strong>
|
<strong>Approval Required</strong>
|
||||||
<p>Users can request to create communities, admins approve</p>
|
<p>Users can request to create communities, admins approve</p>
|
||||||
|
|
@ -96,7 +127,7 @@ if (!setupRequired) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Admin Login Notice -->
|
<!-- Admin Login Notice -->
|
||||||
<section class="setup-section" data-step="3">
|
<section class="setup-section" data-step="4">
|
||||||
<h2>Admin Account</h2>
|
<h2>Admin Account</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
You need to be logged in as an admin to complete setup.
|
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 setupSuccess = document.getElementById('setup-success');
|
||||||
const singleCommunityName = document.getElementById('single-community-name');
|
const singleCommunityName = document.getElementById('single-community-name');
|
||||||
const platformModeInputs = document.querySelectorAll('input[name="platform_mode"]');
|
const platformModeInputs = document.querySelectorAll('input[name="platform_mode"]');
|
||||||
|
const instanceTypeInputs = document.querySelectorAll('input[name="instance_type"]');
|
||||||
|
|
||||||
const initialInstanceName = (document.getElementById('instance_name'));
|
const initialInstanceName = (document.getElementById('instance_name'));
|
||||||
if (initialInstanceName && !initialInstanceName.value) {
|
if (initialInstanceName && !initialInstanceName.value) {
|
||||||
|
|
@ -311,6 +343,39 @@ if (!setupRequired) {
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener('change', (e) => {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
singleCommunityName.style.display = target.value === 'single_community' ? 'block' : 'none';
|
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 formData = new FormData(form);
|
||||||
const data = {
|
const data = {
|
||||||
instance_name: formData.get('instance_name'),
|
instance_name: formData.get('instance_name'),
|
||||||
|
instance_type: formData.get('instance_type') || 'multi_community',
|
||||||
platform_mode: formData.get('platform_mode'),
|
platform_mode: formData.get('platform_mode'),
|
||||||
single_community_name: formData.get('single_community_name') || undefined
|
single_community_name: formData.get('single_community_name') || undefined
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue