diff --git a/backend/.sqlx/query-18c0fb05da45a3eea514f660bc4ac4d6aca71442645666a9c08db8f2a564ff6c.json b/backend/.sqlx/query-0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f.json similarity index 75% rename from backend/.sqlx/query-18c0fb05da45a3eea514f660bc4ac4d6aca71442645666a9c08db8f2a564ff6c.json rename to backend/.sqlx/query-0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f.json index fe734e3..d58d161 100644 --- a/backend/.sqlx/query-18c0fb05da45a3eea514f660bc4ac4d6aca71442645666a9c08db8f2a564ff6c.json +++ b/backend/.sqlx/query-0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, setup_completed, instance_name, platform_mode,\n 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, platform_mode,\n theme_id, registration_enabled, registration_mode,\n default_community_visibility, allow_private_communities,\n default_plugin_policy, default_moderation_mode\n FROM instance_settings LIMIT 1", "describe": { "columns": [ { @@ -25,31 +25,36 @@ }, { "ordinal": 4, + "name": "theme_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 7, "name": "default_community_visibility", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 8, "name": "allow_private_communities", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 9, "name": "default_plugin_policy", "type_info": "Varchar" }, { - "ordinal": 9, + "ordinal": 10, "name": "default_moderation_mode", "type_info": "Varchar" } @@ -67,8 +72,9 @@ false, false, false, + false, false ] }, - "hash": "18c0fb05da45a3eea514f660bc4ac4d6aca71442645666a9c08db8f2a564ff6c" + "hash": "0620f314de8df0c7990ef63fda55f2ff646d5159c59d8288e4ffdfdb07dc159f" } diff --git a/backend/.sqlx/query-200e864fa5778cf58d36d49f94a4006f7d104eb84e6f166b795df0f222ee93d8.json b/backend/.sqlx/query-593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502.json similarity index 66% rename from backend/.sqlx/query-200e864fa5778cf58d36d49f94a4006f7d104eb84e6f166b795df0f222ee93d8.json rename to backend/.sqlx/query-593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502.json index c1d8507..cdafddb 100644 --- a/backend/.sqlx/query-200e864fa5778cf58d36d49f94a4006f7d104eb84e6f166b795df0f222ee93d8.json +++ b/backend/.sqlx/query-593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT setup_completed, instance_name, 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, platform_mode,\n registration_enabled, registration_mode,\n single_community_id\n FROM instance_settings\n LIMIT 1", "describe": { "columns": [ { @@ -15,21 +15,26 @@ }, { "ordinal": 2, - "name": "platform_mode", + "name": "theme_id", "type_info": "Varchar" }, { "ordinal": 3, + "name": "platform_mode", + "type_info": "Varchar" + }, + { + "ordinal": 4, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 4, + "ordinal": 5, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 6, "name": "single_community_id", "type_info": "Uuid" } @@ -43,8 +48,9 @@ false, false, false, + false, true ] }, - "hash": "200e864fa5778cf58d36d49f94a4006f7d104eb84e6f166b795df0f222ee93d8" + "hash": "593dc329afc129680dd505221df649aa8cb544841fa78a5fe740adfbb4439502" } diff --git a/backend/.sqlx/query-b9586185e84644f0bd936d7bf5e9bec6ebeaba77ab354d0b7096d9334656497f.json b/backend/.sqlx/query-8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb.json similarity index 79% rename from backend/.sqlx/query-b9586185e84644f0bd936d7bf5e9bec6ebeaba77ab354d0b7096d9334656497f.json rename to backend/.sqlx/query-8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb.json index 30a0879..7ca9d9d 100644 --- a/backend/.sqlx/query-b9586185e84644f0bd936d7bf5e9bec6ebeaba77ab354d0b7096d9334656497f.json +++ b/backend/.sqlx/query-8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE instance_settings SET\n setup_completed = true,\n setup_completed_at = NOW(),\n setup_completed_by = $1,\n instance_name = $2,\n platform_mode = $3,\n single_community_id = $4\n RETURNING id, setup_completed, instance_name, platform_mode,\n 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 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", "describe": { "columns": [ { @@ -25,31 +25,36 @@ }, { "ordinal": 4, + "name": "theme_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 7, "name": "default_community_visibility", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 8, "name": "allow_private_communities", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 9, "name": "default_plugin_policy", "type_info": "Varchar" }, { - "ordinal": 9, + "ordinal": 10, "name": "default_moderation_mode", "type_info": "Varchar" } @@ -72,8 +77,9 @@ false, false, false, + false, false ] }, - "hash": "b9586185e84644f0bd936d7bf5e9bec6ebeaba77ab354d0b7096d9334656497f" + "hash": "8dd178663df95d64d72c776e3b8bda63851d2ad0e13a6d80b327610078ecbaeb" } diff --git a/backend/.sqlx/query-c35608b0d7569f739dda24b3da59b7b500ff26f5e79433b3f7e3625d91177d26.json b/backend/.sqlx/query-a903d88370faa52169ffd4ec6a54a789ee4a6173fe84aca0ef8dedaa46b1f93c.json similarity index 65% rename from backend/.sqlx/query-c35608b0d7569f739dda24b3da59b7b500ff26f5e79433b3f7e3625d91177d26.json rename to backend/.sqlx/query-a903d88370faa52169ffd4ec6a54a789ee4a6173fe84aca0ef8dedaa46b1f93c.json index cb0e812..2d2df88 100644 --- a/backend/.sqlx/query-c35608b0d7569f739dda24b3da59b7b500ff26f5e79433b3f7e3625d91177d26.json +++ b/backend/.sqlx/query-a903d88370faa52169ffd4ec6a54a789ee4a6173fe84aca0ef8dedaa46b1f93c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE instance_settings SET\n instance_name = COALESCE($1, instance_name),\n platform_mode = COALESCE($2, platform_mode),\n registration_enabled = COALESCE($3, registration_enabled),\n registration_mode = COALESCE($4, registration_mode)\n RETURNING id, setup_completed, instance_name, platform_mode,\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 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", "describe": { "columns": [ { @@ -25,37 +25,43 @@ }, { "ordinal": 4, + "name": "theme_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "registration_enabled", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "registration_mode", "type_info": "Varchar" }, { - "ordinal": 6, + "ordinal": 7, "name": "default_community_visibility", "type_info": "Varchar" }, { - "ordinal": 7, + "ordinal": 8, "name": "allow_private_communities", "type_info": "Bool" }, { - "ordinal": 8, + "ordinal": 9, "name": "default_plugin_policy", "type_info": "Varchar" }, { - "ordinal": 9, + "ordinal": 10, "name": "default_moderation_mode", "type_info": "Varchar" } ], "parameters": { "Left": [ + "Varchar", "Varchar", "Varchar", "Bool", @@ -72,8 +78,9 @@ false, false, false, + false, false ] }, - "hash": "c35608b0d7569f739dda24b3da59b7b500ff26f5e79433b3f7e3625d91177d26" + "hash": "a903d88370faa52169ffd4ec6a54a789ee4a6173fe84aca0ef8dedaa46b1f93c" } diff --git a/backend/migrations/20260215190000_instance_theme.sql b/backend/migrations/20260215190000_instance_theme.sql new file mode 100644 index 0000000..69eae49 --- /dev/null +++ b/backend/migrations/20260215190000_instance_theme.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_settings +ADD COLUMN IF NOT EXISTS theme_id VARCHAR(50) NOT NULL DEFAULT 'neutral'; diff --git a/backend/src/api/settings.rs b/backend/src/api/settings.rs index 0b0c431..ce8bbd9 100644 --- a/backend/src/api/settings.rs +++ b/backend/src/api/settings.rs @@ -30,6 +30,7 @@ pub struct SetupStatus { pub struct PublicInstanceSettings { pub setup_completed: bool, pub instance_name: String, + pub theme_id: String, pub platform_mode: String, pub registration_enabled: bool, pub registration_mode: String, @@ -42,6 +43,7 @@ pub struct InstanceSettings { pub id: Uuid, pub setup_completed: bool, pub instance_name: String, + pub theme_id: String, pub platform_mode: String, pub registration_enabled: bool, pub registration_mode: String, @@ -64,6 +66,8 @@ pub struct UpdateInstanceRequest { #[serde(default)] pub instance_name: Option, #[serde(default)] + pub theme_id: Option, + #[serde(default)] pub platform_mode: Option, #[serde(default)] pub registration_enabled: Option, @@ -71,6 +75,32 @@ pub struct UpdateInstanceRequest { pub registration_mode: Option, } +const KNOWN_THEME_IDS: [&str; 4] = ["neutral", "breeze-light", "breeze-dark", "opensuse"]; + +fn validate_theme_id(theme_id: &str) -> Result<(), (StatusCode, String)> { + if theme_id.trim().is_empty() { + return Err((StatusCode::BAD_REQUEST, "Theme cannot be empty".to_string())); + } + if theme_id.len() > 50 { + return Err((StatusCode::BAD_REQUEST, "Theme is too long".to_string())); + } + if !theme_id + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(( + StatusCode::BAD_REQUEST, + "Theme contains invalid characters".to_string(), + )); + } + + if !KNOWN_THEME_IDS.iter().any(|t| t == &theme_id) { + return Err((StatusCode::BAD_REQUEST, "Unknown theme".to_string())); + } + + Ok(()) +} + #[derive(Debug, Serialize)] pub struct CommunitySettings { pub community_id: Uuid, @@ -121,7 +151,7 @@ async fn get_public_settings( State(pool): State, ) -> Result, String> { let row = sqlx::query!( - r#"SELECT setup_completed, instance_name, platform_mode, + r#"SELECT setup_completed, instance_name, theme_id, platform_mode, registration_enabled, registration_mode, single_community_id FROM instance_settings @@ -135,6 +165,7 @@ async fn get_public_settings( return Ok(Json(PublicInstanceSettings { setup_completed: false, instance_name: "Likwid".to_string(), + theme_id: "neutral".to_string(), platform_mode: "open".to_string(), registration_enabled: true, registration_mode: "open".to_string(), @@ -161,6 +192,7 @@ async fn get_public_settings( Ok(Json(PublicInstanceSettings { setup_completed: r.setup_completed, instance_name: r.instance_name, + theme_id: r.theme_id, platform_mode: r.platform_mode, registration_enabled: r.registration_enabled, registration_mode: r.registration_mode, @@ -224,6 +256,7 @@ async fn complete_setup( platform_mode = $3, single_community_id = $4 RETURNING id, setup_completed, instance_name, platform_mode, + theme_id, registration_enabled, registration_mode, default_community_visibility, allow_private_communities, default_plugin_policy, default_moderation_mode"#, @@ -240,6 +273,7 @@ async fn complete_setup( id: settings.id, setup_completed: settings.setup_completed, instance_name: settings.instance_name, + theme_id: settings.theme_id, platform_mode: settings.platform_mode, registration_enabled: settings.registration_enabled, registration_mode: settings.registration_mode, @@ -260,7 +294,7 @@ async fn get_instance_settings( let s = sqlx::query!( r#"SELECT id, setup_completed, instance_name, platform_mode, - registration_enabled, registration_mode, + theme_id, registration_enabled, registration_mode, default_community_visibility, allow_private_communities, default_plugin_policy, default_moderation_mode FROM instance_settings LIMIT 1"# @@ -273,6 +307,7 @@ async fn get_instance_settings( id: s.id, setup_completed: s.setup_completed, instance_name: s.instance_name, + theme_id: s.theme_id, platform_mode: s.platform_mode, registration_enabled: s.registration_enabled, registration_mode: s.registration_mode, @@ -293,24 +328,37 @@ async fn update_instance_settings( // Check platform settings permission require_permission(&pool, auth.user_id, perms::PLATFORM_SETTINGS, None).await?; + if let Some(theme_id) = req.theme_id.as_deref() { + validate_theme_id(theme_id)?; + } + if config.is_demo() { - return Err(( - StatusCode::FORBIDDEN, - "Instance settings cannot be modified in demo mode".to_string(), - )); + let allowed = req.theme_id.is_some() + && req.instance_name.is_none() + && req.platform_mode.is_none() + && req.registration_enabled.is_none() + && req.registration_mode.is_none(); + if !allowed { + return Err(( + StatusCode::FORBIDDEN, + "Only theme updates are allowed in demo mode".to_string(), + )); + } } let s = sqlx::query!( r#"UPDATE instance_settings SET instance_name = COALESCE($1, instance_name), - platform_mode = COALESCE($2, platform_mode), - registration_enabled = COALESCE($3, registration_enabled), - registration_mode = COALESCE($4, registration_mode) + theme_id = COALESCE($2, theme_id), + platform_mode = COALESCE($3, platform_mode), + registration_enabled = COALESCE($4, registration_enabled), + registration_mode = COALESCE($5, registration_mode) RETURNING id, setup_completed, instance_name, platform_mode, - registration_enabled, registration_mode, + theme_id, registration_enabled, registration_mode, default_community_visibility, allow_private_communities, default_plugin_policy, default_moderation_mode"#, req.instance_name, + req.theme_id, req.platform_mode, req.registration_enabled, req.registration_mode @@ -323,6 +371,7 @@ async fn update_instance_settings( id: s.id, setup_completed: s.setup_completed, instance_name: s.instance_name, + theme_id: s.theme_id, platform_mode: s.platform_mode, registration_enabled: s.registration_enabled, registration_mode: s.registration_mode, diff --git a/frontend/src/layouts/Layout.astro b/frontend/src/layouts/Layout.astro index e6954b7..a6ec8a0 100644 --- a/frontend/src/layouts/Layout.astro +++ b/frontend/src/layouts/Layout.astro @@ -4,7 +4,7 @@ interface Props { } import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes'; -import { API_BASE as apiBase } from '../lib/api'; +import { API_BASE as apiBase, SERVER_API_BASE } from '../lib/api'; import VotingIcons from '../components/icons/VotingIcons.astro'; import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro'; @@ -22,11 +22,24 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S Object.entries(themeRegistry).map(([id, t]) => [id, { isDark: t.isDark, colors: t.colors }]), ); - const defaultTheme = DEFAULT_THEME; + const settingsApiBase = SERVER_API_BASE || 'http://127.0.0.1:3000'; + + let defaultTheme = DEFAULT_THEME; + try { + const res = await fetch(`${settingsApiBase}/api/settings/public`); + if (res.ok) { + const settings = await res.json(); + if (settings && typeof settings.theme_id === 'string' && themeRegistry[settings.theme_id]) { + defaultTheme = settings.theme_id; + } + } + } catch (_e) {} + + const initialTheme = themeRegistry[defaultTheme] || themeRegistry[DEFAULT_THEME]; --- - + diff --git a/frontend/src/layouts/PublicLayout.astro b/frontend/src/layouts/PublicLayout.astro index fa379b5..3f86ac5 100644 --- a/frontend/src/layouts/PublicLayout.astro +++ b/frontend/src/layouts/PublicLayout.astro @@ -5,6 +5,7 @@ interface Props { } import { DEFAULT_THEME, themes as themeRegistry } from '../lib/themes'; +import { SERVER_API_BASE } from '../lib/api'; import DesignSystemStyles from '../components/ui/DesignSystemStyles.astro'; function isEnabled(v: string | undefined): boolean { @@ -21,11 +22,24 @@ const themes = Object.fromEntries( Object.entries(themeRegistry).map(([id, t]) => [id, { isDark: t.isDark, colors: t.colors }]), ); -const defaultTheme = DEFAULT_THEME; +const settingsApiBase = SERVER_API_BASE || 'http://127.0.0.1:3000'; + +let defaultTheme = DEFAULT_THEME; +try { + const res = await fetch(`${settingsApiBase}/api/settings/public`); + if (res.ok) { + const settings = await res.json(); + if (settings && typeof settings.theme_id === 'string' && themeRegistry[settings.theme_id]) { + defaultTheme = settings.theme_id; + } + } +} catch (_e) {} + +const initialTheme = themeRegistry[defaultTheme] || themeRegistry[DEFAULT_THEME]; --- - + diff --git a/frontend/src/pages/admin/settings.astro b/frontend/src/pages/admin/settings.astro index 5f29642..00ddf31 100644 --- a/frontend/src/pages/admin/settings.astro +++ b/frontend/src/pages/admin/settings.astro @@ -29,6 +29,16 @@ import { API_BASE as apiBase } from '../../lib/api'; + +
+ + +
@@ -182,6 +192,8 @@ import { API_BASE as apiBase } from '../../lib/api'; const saveBtn = document.getElementById('save-btn'); const saveStatus = document.getElementById('save-status'); + let initialSettings = null; + if (!form || !loadingEl || !errorEl || !saveStatus) return; async function loadSettings() { @@ -213,9 +225,11 @@ import { API_BASE as apiBase } from '../../lib/api'; } const settings = await res.json(); + initialSettings = settings; // Populate form (document.getElementById('instance_name')).value = settings.instance_name; + (document.getElementById('theme_id')).value = settings.theme_id || 'neutral'; (document.getElementById('platform_mode')).value = settings.platform_mode; (document.getElementById('registration_enabled')).checked = settings.registration_enabled; (document.getElementById('registration_mode')).value = settings.registration_mode; @@ -240,14 +254,39 @@ import { API_BASE as apiBase } from '../../lib/api'; if (saveBtn) saveBtn.disabled = true; saveStatus.textContent = 'Saving...'; + saveStatus.style.color = ''; - const data = { + const current = { instance_name: (document.getElementById('instance_name')).value, + theme_id: (document.getElementById('theme_id')).value, platform_mode: (document.getElementById('platform_mode')).value, registration_enabled: (document.getElementById('registration_enabled')).checked, - registration_mode: (document.getElementById('registration_mode')).value + registration_mode: (document.getElementById('registration_mode')).value, }; + const data = {}; + if (!initialSettings || current.instance_name !== initialSettings.instance_name) { + data.instance_name = current.instance_name; + } + if (!initialSettings || current.theme_id !== initialSettings.theme_id) { + data.theme_id = current.theme_id; + } + if (!initialSettings || current.platform_mode !== initialSettings.platform_mode) { + data.platform_mode = current.platform_mode; + } + if (!initialSettings || current.registration_enabled !== initialSettings.registration_enabled) { + data.registration_enabled = current.registration_enabled; + } + if (!initialSettings || current.registration_mode !== initialSettings.registration_mode) { + data.registration_mode = current.registration_mode; + } + + if (Object.keys(data).length === 0) { + saveStatus.textContent = 'No changes to save.'; + if (saveBtn) saveBtn.disabled = false; + return; + } + try { const res = await fetch(`${API_BASE}/api/settings/instance`, { method: 'PATCH', @@ -262,8 +301,17 @@ import { API_BASE as apiBase } from '../../lib/api'; throw new Error(await res.text()); } + const updated = await res.json(); + initialSettings = updated; + saveStatus.textContent = 'Saved!'; setTimeout(() => { saveStatus.textContent = ''; }, 3000); + + if (data.theme_id) { + setTimeout(() => { + window.location.reload(); + }, 150); + } } catch (err) { saveStatus.textContent = 'Error: ' + err.message; saveStatus.style.color = '#c62828';