Compare commits

...

3 commits

24 changed files with 889 additions and 143 deletions

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n INSERT INTO communities (name, slug, description)\n VALUES ($1, $2, $3)\n RETURNING *\n ", "query": "\n INSERT INTO communities (name, slug, description, settings, created_by)\n VALUES ($1, $2, $3, $4, $5)\n RETURNING *\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -53,7 +53,9 @@
"Left": [ "Left": [
"Varchar", "Varchar",
"Varchar", "Varchar",
"Text" "Text",
"Jsonb",
"Uuid"
] ]
}, },
"nullable": [ "nullable": [
@ -68,5 +70,5 @@
true true
] ]
}, },
"hash": "92461256ad7b62764b2bd75674ccbfc11df6648d6d856e3e68fc80801457c555" "hash": "277e2cb84c2809b3077e36083e61a348d439578815db96969d524ccbb3f83816"
} }

View file

@ -1,20 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT platform_mode FROM instance_settings LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "platform_mode",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "3d9153f242fa24637d71a4b4f0a76edee15892248acb6b281ffdbab11a4bff0f"
}

View file

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)\n VALUES ($1, $2, $3)\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "683446d56fc5fbb31800fda950adf0e80fcb3769fc4e32c396ac246b038eb6d1"
}

View file

@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)\n VALUES ($1, $2, $3)\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "6a09254b2c257b9d281177cc012b9e85aab46b843ce5b58720743463051227fe"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "SELECT platform_mode,\n default_community_visibility,\n default_plugin_policy,\n default_moderation_mode\n FROM instance_settings\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "platform_mode",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "default_community_visibility",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "default_plugin_policy",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "default_moderation_mode",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "6f4895e8ee8fd91b4266d36e0e60088e54e843b17af24b6f7381831b179bef40"
}

View file

@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT default_community_visibility,\n default_plugin_policy,\n default_moderation_mode\n FROM instance_settings\n LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "default_community_visibility",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "default_plugin_policy",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "default_moderation_mode",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false
]
},
"hash": "7704c0adfa1399b3f95db1fff2f8b83ac7c10efcff1ff52da930c72c71cac614"
}

View file

@ -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 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, 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": [
{ {
@ -65,6 +65,9 @@
"Varchar", "Varchar",
"Varchar", "Varchar",
"Bool", "Bool",
"Varchar",
"Varchar",
"Varchar",
"Varchar" "Varchar"
] ]
}, },
@ -82,5 +85,5 @@
false false
] ]
}, },
"hash": "a903d88370faa52169ffd4ec6a54a789ee4a6173fe84aca0ef8dedaa46b1f93c" "hash": "786d22de6313d554e95069d020362b4880f40187cb022d94126df9ced0f3f162"
} }

View file

@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO community_settings (community_id) VALUES ($1) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "957b131c5ae23e306fe4634db068c611122ae61057c805c82413fb69ed015c58"
}

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, 'admin') ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "a1d971610cd2f33b2e2f47b5ee38ccbf26c373207b8fb76bf738fd5bfd98f861"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "INSERT INTO communities (name, slug, description, is_active, created_by)\n VALUES ($1, $2, $3, true, $4)\n RETURNING id", "query": "INSERT INTO communities (name, slug, description, settings, is_active, created_by)\n VALUES ($1, $2, $3, $4, true, $5)\n RETURNING id",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@ -14,6 +14,7 @@
"Varchar", "Varchar",
"Varchar", "Varchar",
"Text", "Text",
"Jsonb",
"Uuid" "Uuid"
] ]
}, },
@ -21,5 +22,5 @@
false false
] ]
}, },
"hash": "cc97b910b8afcfd348d5fe69f7e75862ddd7e31680e46a61170a467b64cdf547" "hash": "ca0137d7aa900603770ccc69ef628d6505fea09a24414d34141691f032e1f8f2"
} }

View file

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)\n SELECT $1, default_moderation_mode, default_plugin_policy\n FROM instance_settings\n LIMIT 1\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "fbd1e5ae4d4bf826df698eed56170cbb980e205b8d7ac4e27409f807680c7034"
}

View file

@ -0,0 +1,57 @@
-- Ensure approved communities inherit instance defaults
-- Applies instance default visibility and initializes community_settings with instance defaults
CREATE OR REPLACE FUNCTION approve_community(
p_pending_id UUID,
p_reviewer_id UUID
) RETURNS UUID AS $$
DECLARE
v_pending pending_communities%ROWTYPE;
v_community_id UUID;
v_default_visibility VARCHAR(20);
v_default_plugin_policy VARCHAR(20);
v_default_moderation_mode VARCHAR(20);
BEGIN
-- Get pending community
SELECT * INTO v_pending FROM pending_communities WHERE id = p_pending_id AND status = 'pending';
IF NOT FOUND THEN
RAISE EXCEPTION 'Pending community not found or already processed';
END IF;
SELECT default_community_visibility,
default_plugin_policy,
default_moderation_mode
INTO v_default_visibility,
v_default_plugin_policy,
v_default_moderation_mode
FROM instance_settings
LIMIT 1;
-- Create the community
INSERT INTO communities (name, slug, description, settings, created_by, is_active)
VALUES (
v_pending.name,
v_pending.slug,
v_pending.description,
jsonb_build_object('visibility', v_default_visibility),
v_pending.requested_by,
true
)
RETURNING id INTO v_community_id;
-- Add requester as admin
INSERT INTO community_members (community_id, user_id, role)
VALUES (v_community_id, v_pending.requested_by, 'admin');
INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)
VALUES (v_community_id, v_default_moderation_mode, v_default_plugin_policy)
ON CONFLICT DO NOTHING;
-- Mark as approved
UPDATE pending_communities
SET status = 'approved', reviewed_by = p_reviewer_id, reviewed_at = NOW()
WHERE id = p_pending_id;
RETURN v_community_id;
END;
$$ LANGUAGE plpgsql;

View file

@ -6,6 +6,7 @@ use axum::{
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@ -75,78 +76,106 @@ async fn create_community(
Json(req): Json<CreateCommunityRequest>, Json(req): Json<CreateCommunityRequest>,
) -> Result<Json<CommunityResponse>, (StatusCode, String)> { ) -> Result<Json<CommunityResponse>, (StatusCode, String)> {
// Check platform mode for community creation permissions // Check platform mode for community creation permissions
let settings = sqlx::query!("SELECT platform_mode FROM instance_settings LIMIT 1") let settings = sqlx::query!(
.fetch_optional(&pool) r#"SELECT platform_mode,
.await default_community_visibility,
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; default_plugin_policy,
default_moderation_mode
FROM instance_settings
LIMIT 1"#
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(s) = settings { let (platform_mode, default_visibility, default_plugin_policy, default_moderation_mode) =
match s.platform_mode.as_str() { if let Some(s) = settings {
"single_community" => { (
s.platform_mode,
s.default_community_visibility,
s.default_plugin_policy,
s.default_moderation_mode,
)
} else {
(
"open".to_string(),
"public".to_string(),
"curated".to_string(),
"standard".to_string(),
)
};
match platform_mode.as_str() {
"single_community" => {
return Err((
StatusCode::FORBIDDEN,
"This platform is dedicated to a single community".to_string(),
));
}
"admin_only" => {
// Check platform admin or community create permission
let can_create =
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
if !can_create {
return Err(( return Err((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"This platform is dedicated to a single community".to_string(), "Only administrators can create communities".to_string(),
)); ));
} }
"admin_only" => {
// Check platform admin or community create permission
let can_create =
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
if !can_create {
return Err((
StatusCode::FORBIDDEN,
"Only administrators can create communities".to_string(),
));
}
}
"approval" => {
// Check if user has direct create permission (admins bypass approval)
let can_create =
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
if !can_create {
// Create pending community request instead
sqlx::query!(
r#"INSERT INTO pending_communities (name, slug, description, requested_by)
VALUES ($1, $2, $3, $4)"#,
req.name,
req.slug,
req.description,
auth.user_id
)
.execute(&pool)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
(
StatusCode::CONFLICT,
"A community with this slug already exists or is pending approval"
.to_string(),
)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
})?;
return Err((
StatusCode::ACCEPTED,
"Community request submitted for approval".to_string(),
));
}
}
_ => {} // "open" mode - anyone can create
} }
"approval" => {
// Check if user has direct create permission (admins bypass approval)
let can_create =
user_has_permission(&pool, auth.user_id, perms::COMMUNITY_CREATE, None).await?;
if !can_create {
// Create pending community request instead
sqlx::query!(
r#"INSERT INTO pending_communities (name, slug, description, requested_by)
VALUES ($1, $2, $3, $4)"#,
req.name,
req.slug,
req.description,
auth.user_id
)
.execute(&pool)
.await
.map_err(|e| {
if e.to_string().contains("duplicate key") {
(
StatusCode::CONFLICT,
"A community with this slug already exists or is pending approval"
.to_string(),
)
} else {
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
}
})?;
return Err((
StatusCode::ACCEPTED,
"Community request submitted for approval".to_string(),
));
}
}
_ => {} // "open" mode - anyone can create
} }
let community_settings = json!({
"visibility": default_visibility
});
let community = sqlx::query_as!( let community = sqlx::query_as!(
crate::models::Community, crate::models::Community,
r#" r#"
INSERT INTO communities (name, slug, description) INSERT INTO communities (name, slug, description, settings, created_by)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4, $5)
RETURNING * RETURNING *
"#, "#,
req.name, req.name,
req.slug, req.slug,
req.description req.description,
community_settings,
auth.user_id
) )
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@ -171,6 +200,18 @@ async fn create_community(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query!(
r#"INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING"#,
community.id,
default_moderation_mode,
default_plugin_policy
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
plugins plugins
.ensure_default_community_plugins(community.id, Some(auth.user_id)) .ensure_default_community_plugins(community.id, Some(auth.user_id))
.await .await

View file

@ -73,10 +73,22 @@ pub struct UpdateInstanceRequest {
pub registration_enabled: Option<bool>, pub registration_enabled: Option<bool>,
#[serde(default)] #[serde(default)]
pub registration_mode: Option<String>, pub registration_mode: Option<String>,
#[serde(default)]
pub default_community_visibility: Option<String>,
#[serde(default)]
pub default_plugin_policy: Option<String>,
#[serde(default)]
pub default_moderation_mode: Option<String>,
} }
const KNOWN_THEME_IDS: [&str; 4] = ["neutral", "breeze-light", "breeze-dark", "opensuse"]; const KNOWN_THEME_IDS: [&str; 4] = ["neutral", "breeze-light", "breeze-dark", "opensuse"];
const KNOWN_COMMUNITY_VISIBILITIES: [&str; 3] = ["public", "unlisted", "private"];
const KNOWN_PLUGIN_POLICIES: [&str; 3] = ["permissive", "curated", "strict"];
const KNOWN_MODERATION_MODES: [&str; 4] = ["minimal", "standard", "strict", "custom"];
const KNOWN_PLATFORM_MODES: [&str; 4] = ["open", "approval", "admin_only", "single_community"];
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()));
@ -101,6 +113,24 @@ fn validate_theme_id(theme_id: &str) -> Result<(), (StatusCode, String)> {
Ok(()) Ok(())
} }
fn validate_known_value(
value: &str,
allowed: &[&str],
field_name: &'static str,
) -> Result<(), (StatusCode, String)> {
if value.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
format!("{} cannot be empty", field_name),
));
}
if !allowed.iter().any(|v| v == &value) {
return Err((StatusCode::BAD_REQUEST, format!("Invalid {}", field_name)));
}
Ok(())
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct CommunitySettings { pub struct CommunitySettings {
pub community_id: Uuid, pub community_id: Uuid,
@ -210,6 +240,34 @@ async fn complete_setup(
// Check platform admin permission // Check platform admin permission
require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?; require_permission(&pool, auth.user_id, perms::PLATFORM_ADMIN, None).await?;
if req.instance_name.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Platform name cannot be empty".to_string(),
));
}
if req.instance_name.len() > 100 {
return Err((
StatusCode::BAD_REQUEST,
"Platform name is too long".to_string(),
));
}
if !KNOWN_PLATFORM_MODES
.iter()
.any(|m| m == &req.platform_mode.as_str())
{
return Err((StatusCode::BAD_REQUEST, "Invalid platform mode".to_string()));
}
if req.platform_mode == "single_community" {
let name = req.single_community_name.as_deref().unwrap_or("");
if name.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"Community name is required in single community mode".to_string(),
));
}
}
// Check if already set up // Check if already set up
let existing = sqlx::query!("SELECT setup_completed FROM instance_settings LIMIT 1") let existing = sqlx::query!("SELECT setup_completed FROM instance_settings LIMIT 1")
.fetch_optional(&pool) .fetch_optional(&pool)
@ -223,24 +281,75 @@ async fn complete_setup(
)); ));
} }
let defaults = sqlx::query!(
r#"SELECT default_community_visibility,
default_plugin_policy,
default_moderation_mode
FROM instance_settings
LIMIT 1"#
)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let (default_visibility, default_plugin_policy, default_moderation_mode) =
if let Some(d) = defaults {
(
d.default_community_visibility,
d.default_plugin_policy,
d.default_moderation_mode,
)
} else {
(
"public".to_string(),
"curated".to_string(),
"standard".to_string(),
)
};
// Handle single_community mode // Handle single_community mode
let single_community_id: Option<Uuid> = if req.platform_mode == "single_community" { let single_community_id: Option<Uuid> = if req.platform_mode == "single_community" {
let name = req let name = req
.single_community_name .single_community_name
.as_deref() .as_deref()
.unwrap_or("Main Community"); .unwrap_or("Main Community");
let community_settings = serde_json::json!({
"visibility": default_visibility
});
let community = sqlx::query!( let community = sqlx::query!(
r#"INSERT INTO communities (name, slug, description, is_active, created_by) r#"INSERT INTO communities (name, slug, description, settings, is_active, created_by)
VALUES ($1, $2, $3, true, $4) VALUES ($1, $2, $3, $4, true, $5)
RETURNING id"#, RETURNING id"#,
name, name,
slug::slugify(name), slug::slugify(name),
format!("The {} community", name), format!("The {} community", name),
community_settings,
auth.user_id auth.user_id
) )
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query!(
"INSERT INTO community_members (user_id, community_id, role) VALUES ($1, $2, 'admin') ON CONFLICT DO NOTHING",
auth.user_id,
community.id
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
sqlx::query!(
r#"INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING"#,
community.id,
default_moderation_mode,
default_plugin_policy
)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Some(community.id) Some(community.id)
} else { } else {
None None
@ -332,8 +441,27 @@ async fn update_instance_settings(
validate_theme_id(theme_id)?; validate_theme_id(theme_id)?;
} }
if let Some(v) = req.default_community_visibility.as_deref() {
validate_known_value(
v,
&KNOWN_COMMUNITY_VISIBILITIES,
"default community visibility",
)?;
}
if let Some(v) = req.default_plugin_policy.as_deref() {
validate_known_value(v, &KNOWN_PLUGIN_POLICIES, "default plugin policy")?;
}
if let Some(v) = req.default_moderation_mode.as_deref() {
validate_known_value(v, &KNOWN_MODERATION_MODES, "default moderation mode")?;
}
if config.is_demo() { if config.is_demo() {
let allowed = req.theme_id.is_some() let allowed = (req.theme_id.is_some()
|| req.default_community_visibility.is_some()
|| req.default_plugin_policy.is_some()
|| req.default_moderation_mode.is_some())
&& req.instance_name.is_none() && req.instance_name.is_none()
&& req.platform_mode.is_none() && req.platform_mode.is_none()
&& req.registration_enabled.is_none() && req.registration_enabled.is_none()
@ -341,7 +469,7 @@ async fn update_instance_settings(
if !allowed { if !allowed {
return Err(( return Err((
StatusCode::FORBIDDEN, StatusCode::FORBIDDEN,
"Only theme updates are allowed in demo mode".to_string(), "Only theme/defaults updates are allowed in demo mode".to_string(),
)); ));
} }
} }
@ -352,7 +480,10 @@ async fn update_instance_settings(
theme_id = COALESCE($2, theme_id), theme_id = COALESCE($2, theme_id),
platform_mode = COALESCE($3, platform_mode), platform_mode = COALESCE($3, platform_mode),
registration_enabled = COALESCE($4, registration_enabled), registration_enabled = COALESCE($4, registration_enabled),
registration_mode = COALESCE($5, registration_mode) registration_mode = COALESCE($5, registration_mode),
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, 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,
@ -361,7 +492,10 @@ async fn update_instance_settings(
req.theme_id, req.theme_id,
req.platform_mode, req.platform_mode,
req.registration_enabled, req.registration_enabled,
req.registration_mode req.registration_mode,
req.default_community_visibility,
req.default_plugin_policy,
req.default_moderation_mode
) )
.fetch_one(&pool) .fetch_one(&pool)
.await .await
@ -389,7 +523,11 @@ async fn get_community_settings(
) -> Result<Json<CommunitySettings>, (StatusCode, String)> { ) -> Result<Json<CommunitySettings>, (StatusCode, String)> {
// Ensure settings exist // Ensure settings exist
sqlx::query!( sqlx::query!(
"INSERT INTO community_settings (community_id) VALUES ($1) ON CONFLICT DO NOTHING", r#"INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)
SELECT $1, default_moderation_mode, default_plugin_policy
FROM instance_settings
LIMIT 1
ON CONFLICT DO NOTHING"#,
community_id community_id
) )
.execute(&pool) .execute(&pool)
@ -434,7 +572,11 @@ async fn update_community_settings(
// Ensure settings exist // Ensure settings exist
sqlx::query!( sqlx::query!(
"INSERT INTO community_settings (community_id) VALUES ($1) ON CONFLICT DO NOTHING", r#"INSERT INTO community_settings (community_id, moderation_mode, plugin_policy)
SELECT $1, default_moderation_mode, default_plugin_policy
FROM instance_settings
LIMIT 1
ON CONFLICT DO NOTHING"#,
community_id community_id
) )
.execute(&pool) .execute(&pool)

View file

@ -126,6 +126,22 @@ server {
## Next Steps ## Next Steps
## First Admin Bootstrap (no manual DB edits)
After the containers are running, you must create the first platform admin and complete the web setup flow.
1. Open the site in your browser.
2. Register the **first** user account at `/register`.
- The first registered user is automatically granted platform admin permissions.
3. Visit `/setup`.
4. Complete instance setup:
- Set the platform name.
- Choose a platform mode.
- If using **Single Community** mode, provide the community name.
5. After setup completes:
- Configure instance settings at `/admin/settings`.
- Create or browse communities at `/communities`.
- [Configuration](configuration.md) - Detailed settings - [Configuration](configuration.md) - Detailed settings
- [Database](database.md) - Database management - [Database](database.md) - Database management
- [Security](security.md) - Hardening your instance - [Security](security.md) - Hardening your instance

View file

@ -0,0 +1,264 @@
<div id="likwid-toasts" class="ui-toasts" aria-live="polite" aria-atomic="true"></div>
<dialog id="likwid-dialog" class="ui-dialog">
<form method="dialog" class="ui-dialog-form">
<div class="ui-dialog-head">
<h3 id="likwid-dialog-title" class="ui-dialog-title"></h3>
<p id="likwid-dialog-message" class="ui-dialog-message"></p>
</div>
<div id="likwid-dialog-input-wrap" class="ui-dialog-input" style="display: none;">
<label class="ui-label" for="likwid-dialog-textarea" id="likwid-dialog-label"></label>
<textarea id="likwid-dialog-textarea" class="ui-textarea" rows="4"></textarea>
</div>
<div class="ui-dialog-actions">
<button id="likwid-dialog-cancel" class="ui-btn ui-btn-secondary" value="cancel" type="submit">Cancel</button>
<button id="likwid-dialog-confirm" class="ui-btn ui-btn-primary" value="confirm" type="submit">Confirm</button>
</div>
</form>
</dialog>
<script is:inline>
(function() {
const toastsEl = document.getElementById('likwid-toasts');
const dialogEl = document.getElementById('likwid-dialog');
const dialogTitleEl = document.getElementById('likwid-dialog-title');
const dialogMessageEl = document.getElementById('likwid-dialog-message');
const dialogInputWrapEl = document.getElementById('likwid-dialog-input-wrap');
const dialogLabelEl = document.getElementById('likwid-dialog-label');
const dialogTextareaEl = document.getElementById('likwid-dialog-textarea');
const dialogConfirmEl = document.getElementById('likwid-dialog-confirm');
const dialogCancelEl = document.getElementById('likwid-dialog-cancel');
function toast(type, message, opts) {
if (!toastsEl) return;
const options = opts || {};
const id = 't_' + Math.random().toString(16).slice(2);
const el = document.createElement('div');
el.className = 'ui-toast ui-toast-' + (type || 'info');
el.setAttribute('role', type === 'error' ? 'alert' : 'status');
el.dataset.toastId = id;
const text = document.createElement('div');
text.className = 'ui-toast-text';
text.textContent = String(message || '');
const close = document.createElement('button');
close.type = 'button';
close.className = 'ui-toast-close';
close.setAttribute('aria-label', 'Dismiss notification');
close.textContent = '×';
close.addEventListener('click', () => {
el.remove();
});
el.appendChild(text);
el.appendChild(close);
toastsEl.appendChild(el);
const timeoutMs = typeof options.timeoutMs === 'number' ? options.timeoutMs : 4500;
if (timeoutMs > 0) {
window.setTimeout(() => {
el.remove();
}, timeoutMs);
}
}
function openDialog(mode, opts) {
const options = opts || {};
if (!dialogEl || !dialogTitleEl || !dialogMessageEl || !dialogConfirmEl || !dialogCancelEl) {
return Promise.resolve(mode === 'prompt' ? null : false);
}
if (typeof dialogEl.showModal !== 'function') {
if (mode === 'prompt') {
return Promise.resolve(window.prompt(options.message || options.title || '', ''));
}
return Promise.resolve(window.confirm(options.message || options.title || 'Confirm?'));
}
dialogTitleEl.textContent = String(options.title || '');
dialogMessageEl.textContent = String(options.message || '');
const isPrompt = mode === 'prompt';
if (dialogInputWrapEl && dialogTextareaEl && dialogLabelEl) {
dialogInputWrapEl.style.display = isPrompt ? 'block' : 'none';
dialogLabelEl.textContent = String(options.label || '');
dialogTextareaEl.value = String(options.defaultValue || '');
dialogTextareaEl.placeholder = String(options.placeholder || '');
}
dialogConfirmEl.textContent = String(options.confirmText || (isPrompt ? 'Submit' : 'Confirm'));
dialogCancelEl.textContent = String(options.cancelText || 'Cancel');
return new Promise((resolve) => {
function cleanup() {
dialogEl.removeEventListener('close', onClose);
}
function onClose() {
const rv = dialogEl.returnValue;
if (mode === 'prompt') {
if (rv !== 'confirm') {
cleanup();
resolve(null);
return;
}
const v = dialogTextareaEl ? dialogTextareaEl.value : '';
cleanup();
resolve(v);
return;
}
cleanup();
resolve(rv === 'confirm');
}
dialogEl.addEventListener('close', onClose);
dialogEl.showModal();
if (mode === 'prompt' && dialogTextareaEl) {
dialogTextareaEl.focus();
} else {
dialogConfirmEl.focus();
}
});
}
window.likwidUi = window.likwidUi || {};
window.likwidUi.toast = toast;
window.likwidUi.confirm = function(opts) { return openDialog('confirm', opts); };
window.likwidUi.prompt = function(opts) { return openDialog('prompt', opts); };
})();
</script>
<style>
.ui-toasts {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 9999;
width: min(420px, calc(100vw - 2rem));
pointer-events: none;
}
.ui-toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-radius: 12px;
border: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-surface) 92%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.22);
}
.ui-toast-text {
color: var(--color-text);
font-size: 0.9375rem;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
}
.ui-toast-close {
appearance: none;
background: transparent;
border: 0;
color: var(--color-text-muted);
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
padding: 0;
margin: -2px 0 0 0;
}
.ui-toast-close:hover {
color: var(--color-text);
}
.ui-toast-success {
border-color: color-mix(in srgb, var(--color-success) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-success-muted) 65%, var(--color-surface));
}
.ui-toast-error {
border-color: color-mix(in srgb, var(--color-error) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-error-muted) 65%, var(--color-surface));
}
.ui-toast-info {
border-color: color-mix(in srgb, var(--color-info) 45%, var(--color-border));
background: color-mix(in srgb, var(--color-info-muted) 65%, var(--color-surface));
}
.ui-dialog {
border: 1px solid var(--color-border);
border-radius: 14px;
padding: 0;
background: var(--color-surface);
color: var(--color-text);
width: min(560px, calc(100vw - 2rem));
}
.ui-dialog::backdrop {
background: var(--color-overlay);
}
.ui-dialog-form {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.ui-dialog-title {
margin: 0;
font-size: 1.05rem;
}
.ui-dialog-message {
margin: 0.25rem 0 0 0;
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.4;
}
.ui-dialog-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ui-label {
font-weight: 600;
font-size: 0.9375rem;
}
.ui-textarea {
width: 100%;
border-radius: 12px;
border: 1px solid var(--color-border);
background: var(--color-field-bg);
color: var(--color-text);
padding: 0.75rem;
font-size: 0.9375rem;
resize: vertical;
min-height: 88px;
}
.ui-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
</style>

View file

@ -7,6 +7,7 @@ import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes';
import { API_BASE as apiBase, SERVER_API_BASE } from '../lib/api'; import { API_BASE as apiBase, SERVER_API_BASE } from '../lib/api';
import VotingIcons from '../components/icons/VotingIcons.astro'; import VotingIcons from '../components/icons/VotingIcons.astro';
import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro'; import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro';
import FeedbackHost from '../components/ui/FeedbackHost.astro';
function isEnabled(v: string | undefined): boolean { function isEnabled(v: string | undefined): boolean {
if (!v) return false; if (!v) return false;
@ -96,6 +97,7 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
<body> <body>
<a class="skip-link" href="#main-content">Skip to content</a> <a class="skip-link" href="#main-content">Skip to content</a>
<VotingIcons /> <VotingIcons />
<FeedbackHost />
<div class="app"> <div class="app">
<header class="header"> <header class="header">
<nav class="nav" id="site-nav"> <nav class="nav" id="site-nav">

View file

@ -168,7 +168,15 @@ import { API_BASE as apiBase } from '../../lib/api';
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const type = btn.dataset.type; const type = btn.dataset.type;
const id = btn.dataset.id; const id = btn.dataset.id;
const reason = prompt('Rejection reason (optional):'); const reason = window.likwidUi?.prompt
? await window.likwidUi.prompt({
title: 'Reject request',
message: 'Rejection reason (optional)',
label: 'Reason',
placeholder: 'Optional',
confirmText: 'Reject',
})
: null;
await reviewItem(type, id, false, reason); await reviewItem(type, id, false, reason);
}); });
}); });
@ -195,7 +203,7 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
@ -208,7 +216,7 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.ok && data && data.success) { if (res.ok && data && data.success) {
alert(data.message); window.likwidUi?.toast?.('success', data.message);
// Remove item from list // Remove item from list
document.querySelector(`.pending-item[data-id="${id}"]`)?.remove(); document.querySelector(`.pending-item[data-id="${id}"]`)?.remove();
@ -221,10 +229,13 @@ import { API_BASE as apiBase } from '../../lib/api';
container.innerHTML = `<p class="empty">No pending ${type} requests</p>`; container.innerHTML = `<p class="empty">No pending ${type} requests</p>`;
} }
} else { } else {
alert('Error: ' + ((data && (data.message || data.error)) || raw || 'Failed to process')); window.likwidUi?.toast?.(
'error',
'Error: ' + ((data && (data.message || data.error)) || raw || 'Failed to process')
);
} }
} catch (error) { } catch (error) {
alert('Error processing request'); window.likwidUi?.toast?.('error', 'Error processing request');
} }
} }

View file

@ -93,21 +93,21 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
alert(`Invitation created!\nCode: ${data.code}`); window.likwidUi?.toast?.('success', `Invitation created!\nCode: ${data.code}`, { timeoutMs: 8000 });
(document.getElementById('email')).value = ''; (document.getElementById('email')).value = '';
loadInvitations(); loadInvitations();
} else { } else {
const err = await res.text(); const err = await res.text();
alert('Error: ' + err); window.likwidUi?.toast?.('error', 'Error: ' + err);
} }
} catch (error) { } catch (error) {
alert('Error creating invitation'); window.likwidUi?.toast?.('error', 'Error creating invitation');
} }
}); });
@ -169,13 +169,20 @@ import { API_BASE as apiBase } from '../../lib/api';
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const code = (btn).dataset.code; const code = (btn).dataset.code;
navigator.clipboard.writeText(code || ''); navigator.clipboard.writeText(code || '');
alert('Code copied to clipboard!'); window.likwidUi?.toast?.('success', 'Code copied to clipboard!');
}); });
}); });
document.querySelectorAll('.js-revoke').forEach(btn => { document.querySelectorAll('.js-revoke').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('Revoke this invitation?')) return; const ok = window.likwidUi?.confirm
? await window.likwidUi.confirm({
title: 'Revoke invitation',
message: 'Revoke this invitation?',
confirmText: 'Revoke',
})
: false;
if (!ok) return;
const id = (btn).dataset.id; const id = (btn).dataset.id;
try { try {
const res = await fetch(`${apiBase}/api/invitations/${id}`, { const res = await fetch(`${apiBase}/api/invitations/${id}`, {
@ -189,17 +196,17 @@ import { API_BASE as apiBase } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
if (res.ok) { if (res.ok) {
loadInvitations(); loadInvitations();
} else { } else {
alert('Error: ' + (await res.text())); window.likwidUi?.toast?.('error', 'Error: ' + (await res.text()));
} }
} catch (error) { } catch (error) {
alert('Error revoking invitation'); window.likwidUi?.toast?.('error', 'Error revoking invitation');
} }
}); });
}); });

View file

@ -251,21 +251,22 @@ import { API_BASE } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
e.target.checked = !isEnabled; e.target.checked = !isEnabled;
return; return;
} }
if (!res.ok) { if (!res.ok) {
const err = await res.text(); const err = await res.text();
alert('Failed to update plugin: ' + err); window.likwidUi?.toast?.('error', 'Failed to update plugin: ' + err);
e.target.checked = !isEnabled; e.target.checked = !isEnabled;
} else { } else {
const card = e.target.closest('.plugin-card'); const card = e.target.closest('.plugin-card');
card.classList.toggle('disabled', !isEnabled); card.classList.toggle('disabled', !isEnabled);
window.likwidUi?.toast?.('success', `Plugin ${isEnabled ? 'enabled' : 'disabled'}`);
} }
} catch (err) { } catch (err) {
alert('Failed to update plugin'); window.likwidUi?.toast?.('error', 'Failed to update plugin');
e.target.checked = !isEnabled; e.target.checked = !isEnabled;
} }
}); });

View file

@ -83,7 +83,7 @@ import { API_BASE as apiBase } from '../../lib/api';
<div class="form-group"> <div class="form-group">
<label for="default_community_visibility">Default Community Visibility</label> <label for="default_community_visibility">Default Community Visibility</label>
<select id="default_community_visibility" name="default_community_visibility" disabled> <select id="default_community_visibility" name="default_community_visibility">
<option value="public">Public</option> <option value="public">Public</option>
<option value="unlisted">Unlisted</option> <option value="unlisted">Unlisted</option>
<option value="private">Private</option> <option value="private">Private</option>
@ -92,7 +92,7 @@ import { API_BASE as apiBase } from '../../lib/api';
<div class="form-group"> <div class="form-group">
<label for="default_plugin_policy">Default Plugin Policy</label> <label for="default_plugin_policy">Default Plugin Policy</label>
<select id="default_plugin_policy" name="default_plugin_policy" disabled> <select id="default_plugin_policy" name="default_plugin_policy">
<option value="permissive">Permissive - All plugins allowed</option> <option value="permissive">Permissive - All plugins allowed</option>
<option value="curated">Curated - Only approved plugins</option> <option value="curated">Curated - Only approved plugins</option>
<option value="strict">Strict - Signed plugins only</option> <option value="strict">Strict - Signed plugins only</option>
@ -101,10 +101,11 @@ import { API_BASE as apiBase } from '../../lib/api';
<div class="form-group"> <div class="form-group">
<label for="default_moderation_mode">Default Moderation Mode</label> <label for="default_moderation_mode">Default Moderation Mode</label>
<select id="default_moderation_mode" name="default_moderation_mode" disabled> <select id="default_moderation_mode" name="default_moderation_mode">
<option value="community">Community - Self-moderation</option> <option value="minimal">Minimal</option>
<option value="centralized">Centralized - Admin moderation</option> <option value="standard">Standard</option>
<option value="hybrid">Hybrid - Mixed approach</option> <option value="strict">Strict</option>
<option value="custom">Custom</option>
</select> </select>
</div> </div>
</section> </section>
@ -262,6 +263,9 @@ import { API_BASE as apiBase } from '../../lib/api';
platform_mode: (document.getElementById('platform_mode')).value, platform_mode: (document.getElementById('platform_mode')).value,
registration_enabled: (document.getElementById('registration_enabled')).checked, registration_enabled: (document.getElementById('registration_enabled')).checked,
registration_mode: (document.getElementById('registration_mode')).value, registration_mode: (document.getElementById('registration_mode')).value,
default_community_visibility: (document.getElementById('default_community_visibility')).value,
default_plugin_policy: (document.getElementById('default_plugin_policy')).value,
default_moderation_mode: (document.getElementById('default_moderation_mode')).value,
}; };
const data = {}; const data = {};
@ -280,6 +284,15 @@ import { API_BASE as apiBase } from '../../lib/api';
if (!initialSettings || current.registration_mode !== initialSettings.registration_mode) { if (!initialSettings || current.registration_mode !== initialSettings.registration_mode) {
data.registration_mode = current.registration_mode; data.registration_mode = current.registration_mode;
} }
if (!initialSettings || current.default_community_visibility !== initialSettings.default_community_visibility) {
data.default_community_visibility = current.default_community_visibility;
}
if (!initialSettings || current.default_plugin_policy !== initialSettings.default_plugin_policy) {
data.default_plugin_policy = current.default_plugin_policy;
}
if (!initialSettings || current.default_moderation_mode !== initialSettings.default_moderation_mode) {
data.default_moderation_mode = current.default_moderation_mode;
}
if (Object.keys(data).length === 0) { if (Object.keys(data).length === 0) {
saveStatus.textContent = 'No changes to save.'; saveStatus.textContent = 'No changes to save.';

View file

@ -584,18 +584,21 @@ import { API_BASE } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
e.target.checked = !isActive; e.target.checked = !isActive;
return; return;
} }
if (!res.ok) { if (!res.ok) {
window.likwidUi?.toast?.('error', 'Failed to update voting method');
e.target.checked = !isActive; e.target.checked = !isActive;
} else { } else {
const row = e.target.closest('.method-row'); const row = e.target.closest('.method-row');
row.classList.toggle('disabled', !isActive); row.classList.toggle('disabled', !isActive);
window.likwidUi?.toast?.('success', `Voting method ${isActive ? 'enabled' : 'disabled'}`);
} }
} catch (err) { } catch (err) {
window.likwidUi?.toast?.('error', 'Failed to update voting method');
e.target.checked = !isActive; e.target.checked = !isActive;
} }
}); });
@ -622,15 +625,18 @@ import { API_BASE } from '../../lib/api';
} }
if (res.status === 403) { if (res.status === 403) {
alert('Admin access required'); window.likwidUi?.toast?.('error', 'Admin access required');
return; return;
} }
if (res.ok) { if (res.ok) {
window.likwidUi?.toast?.('success', 'Default voting method updated');
loadVotingMethods(); loadVotingMethods();
} else {
window.likwidUi?.toast?.('error', 'Failed to set default voting method');
} }
} catch (err) { } catch (err) {
console.error('Failed to set default'); window.likwidUi?.toast?.('error', 'Failed to set default voting method');
} }
}); });
}); });

View file

@ -43,10 +43,10 @@ const { slug } = Astro.params;
<div class="form-group"> <div class="form-group">
<label for="moderation_mode">Moderation Mode</label> <label for="moderation_mode">Moderation Mode</label>
<select id="moderation_mode" name="moderation_mode"> <select id="moderation_mode" name="moderation_mode">
<option value="community">Community - Members can flag, admins review</option> <option value="minimal">Minimal</option>
<option value="centralized">Centralized - Only admins moderate</option> <option value="standard">Standard</option>
<option value="democratic">Democratic - Community votes on moderation</option> <option value="strict">Strict</option>
<option value="automated">Automated - Plugin-based moderation</option> <option value="custom">Custom</option>
</select> </select>
</div> </div>
</section> </section>

View file

@ -28,13 +28,15 @@ if (!setupRequired) {
<Layout title="Setup - Likwid"> <Layout title="Setup - Likwid">
<div class="setup-container"> <div class="setup-container">
<div class="setup-card ui-card"> <div class="setup-card ui-card" data-setup-instance-name={instanceName ?? ''}>
<div class="setup-header"> <div class="setup-header">
<h1>Welcome to Likwid</h1> <h1>Welcome to Likwid</h1>
<p>Let's configure your governance platform</p> <p>Let's configure your governance platform</p>
</div> </div>
<form id="setup-form" class="setup-form ui-form" style="--ui-form-group-mb: 1rem;"> <form id="setup-form" class="setup-form ui-form" style="--ui-form-group-mb: 1rem;">
<div id="setup-error" class="setup-banner error" style="display:none;"></div>
<div id="setup-success" class="setup-banner success" style="display:none;"></div>
<!-- Step 1: Instance Identity --> <!-- Step 1: Instance Identity -->
<section class="setup-section" data-step="1"> <section class="setup-section" data-step="1">
<h2>Instance Identity</h2> <h2>Instance Identity</h2>
@ -151,6 +153,26 @@ if (!setupRequired) {
padding: 2rem; padding: 2rem;
} }
.setup-banner {
padding: 1rem;
border-radius: 10px;
margin-bottom: 1.25rem;
border: 1px solid var(--color-border);
background: var(--color-bg-alt);
}
.setup-banner.error {
border-color: color-mix(in srgb, var(--color-error) 40%, var(--color-border));
background: var(--color-error-muted);
color: var(--color-error);
}
.setup-banner.success {
border-color: color-mix(in srgb, var(--color-success) 40%, var(--color-border));
background: var(--color-success-muted);
color: var(--color-success);
}
.setup-section { .setup-section {
margin-bottom: 2rem; margin-bottom: 2rem;
padding-bottom: 2rem; padding-bottom: 2rem;
@ -247,9 +269,43 @@ if (!setupRequired) {
const form = document.getElementById('setup-form'); const form = document.getElementById('setup-form');
const submitBtn = document.getElementById('submit-btn'); const submitBtn = document.getElementById('submit-btn');
const authStatus = document.getElementById('auth-status'); const authStatus = document.getElementById('auth-status');
const setupError = document.getElementById('setup-error');
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 initialInstanceName = (document.getElementById('instance_name'));
if (initialInstanceName && !initialInstanceName.value) {
const setupCard = document.querySelector('.setup-card');
const fromServer = setupCard ? setupCard.dataset.setupInstanceName : '';
if (fromServer) {
try {
localStorage.setItem('setup-instance-name', fromServer);
} catch (e) {}
initialInstanceName.value = fromServer;
}
const fromStatus = localStorage.getItem('setup-instance-name');
if (!initialInstanceName.value && fromStatus) initialInstanceName.value = fromStatus;
}
function showError(html) {
if (!setupError) return;
setupError.innerHTML = html;
setupError.style.display = 'block';
}
function clearError() {
if (!setupError) return;
setupError.style.display = 'none';
setupError.innerHTML = '';
}
function showSuccess(html) {
if (!setupSuccess) return;
setupSuccess.innerHTML = html;
setupSuccess.style.display = 'block';
}
// Show/hide single community name field // Show/hide single community name field
platformModeInputs.forEach(input => { platformModeInputs.forEach(input => {
input.addEventListener('change', (e) => { input.addEventListener('change', (e) => {
@ -267,6 +323,9 @@ if (!setupRequired) {
<p>You need to log in first. <a href="/login">Go to login</a></p> <p>You need to log in first. <a href="/login">Go to login</a></p>
<p>If you haven't registered yet, <a href="/register">register first</a> (first user becomes admin).</p> <p>If you haven't registered yet, <a href="/register">register first</a> (first user becomes admin).</p>
`; `;
showError(
'<p><strong>Not signed in.</strong> Create the first user account, then return to this page to complete setup.</p>'
);
return false; return false;
} }
@ -289,11 +348,15 @@ if (!setupRequired) {
if (adminRes.status === 403) { if (adminRes.status === 403) {
authStatus.className = 'auth-status error'; authStatus.className = 'auth-status error';
authStatus.innerHTML = `<p>You are logged in as ${user.username}, but you need admin privileges.</p>`; authStatus.innerHTML = `<p>You are logged in as ${user.username}, but you need admin privileges.</p>`;
showError(
'<p><strong>Admin privileges required.</strong> The first registered user becomes platform admin. If you are not the first user, ask an operator to grant you platform admin.</p>'
);
return false; return false;
} }
authStatus.className = 'auth-status success'; authStatus.className = 'auth-status success';
authStatus.innerHTML = `<p>Logged in as <strong>${user.username}</strong> (admin)</p>`; authStatus.innerHTML = `<p>Logged in as <strong>${user.username}</strong> (admin)</p>`;
clearError();
submitBtn.disabled = false; submitBtn.disabled = false;
return true; return true;
} catch (e) { } catch (e) {
@ -301,6 +364,7 @@ if (!setupRequired) {
authStatus.innerHTML = ` authStatus.innerHTML = `
<p>Session expired. <a href="/login">Please log in again</a></p> <p>Session expired. <a href="/login">Please log in again</a></p>
`; `;
showError('<p><strong>Session expired.</strong> Please log in again and retry setup.</p>');
return false; return false;
} }
} }
@ -310,10 +374,11 @@ if (!setupRequired) {
// Form submission // Form submission
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
clearError();
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) { if (!token) {
alert('Please log in first'); showError('<p><strong>Please log in first.</strong> <a href="/login">Go to login</a></p>');
return; return;
} }
@ -338,14 +403,32 @@ if (!setupRequired) {
}); });
if (!res.ok) { if (!res.ok) {
const error = await res.text(); const errorText = await res.text();
throw new Error(error); if (res.status === 401) {
showError('<p><strong>Not authorized.</strong> Your session expired. <a href="/login">Log in again</a>.</p>');
return;
}
if (res.status === 403) {
showError('<p><strong>Forbidden.</strong> You must be a platform admin to complete setup.</p>');
return;
}
showError(
`<p><strong>Setup failed.</strong></p><p>${(errorText || 'Unknown error')}</p><p>Fix the issue and try again.</p>`
);
return;
} }
// Success - redirect to home showSuccess(
window.location.href = '/'; '<p><strong>Setup complete.</strong></p>' +
'<p>Next steps:</p>' +
'<p><a href="/admin/settings">Go to Admin Settings</a></p>' +
'<p><a href="/communities">Browse or create communities</a></p>'
);
window.likwidUi?.toast?.('success', 'Setup complete');
submitBtn.textContent = 'Setup Complete';
} catch (err) { } catch (err) {
alert('Setup failed: ' + err.message); showError(`<p><strong>Setup failed.</strong></p><p>${err && err.message ? err.message : 'Unknown error'}</p>`);
window.likwidUi?.toast?.('error', 'Setup failed');
submitBtn.disabled = false; submitBtn.disabled = false;
submitBtn.textContent = 'Complete Setup'; submitBtn.textContent = 'Complete Setup';
} }