feat(setup): improve bootstrap reliability

This commit is contained in:
Marco Allegretti 2026-02-15 22:43:29 +01:00
parent 1d3780d7fd
commit e94520f9f7
4 changed files with 160 additions and 7 deletions

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

@ -87,6 +87,8 @@ 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)> {
if theme_id.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "Theme cannot be empty".to_string()));
@ -238,6 +240,34 @@ async fn complete_setup(
// Check platform admin permission
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
let existing = sqlx::query!("SELECT setup_completed FROM instance_settings LIMIT 1")
.fetch_optional(&pool)
@ -300,6 +330,15 @@ async fn complete_setup(
.await
.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)

View file

@ -126,6 +126,22 @@ server {
## 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
- [Database](database.md) - Database management
- [Security](security.md) - Hardening your instance

View file

@ -28,13 +28,15 @@ if (!setupRequired) {
<Layout title="Setup - Likwid">
<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">
<h1>Welcome to Likwid</h1>
<p>Let's configure your governance platform</p>
</div>
<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 -->
<section class="setup-section" data-step="1">
<h2>Instance Identity</h2>
@ -151,6 +153,26 @@ if (!setupRequired) {
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 {
margin-bottom: 2rem;
padding-bottom: 2rem;
@ -247,9 +269,43 @@ if (!setupRequired) {
const form = document.getElementById('setup-form');
const submitBtn = document.getElementById('submit-btn');
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 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
platformModeInputs.forEach(input => {
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>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;
}
@ -289,11 +348,15 @@ if (!setupRequired) {
if (adminRes.status === 403) {
authStatus.className = 'auth-status error';
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;
}
authStatus.className = 'auth-status success';
authStatus.innerHTML = `<p>Logged in as <strong>${user.username}</strong> (admin)</p>`;
clearError();
submitBtn.disabled = false;
return true;
} catch (e) {
@ -301,6 +364,7 @@ if (!setupRequired) {
authStatus.innerHTML = `
<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;
}
}
@ -310,10 +374,11 @@ if (!setupRequired) {
// Form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
const token = localStorage.getItem('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;
}
@ -338,14 +403,32 @@ if (!setupRequired) {
});
if (!res.ok) {
const error = await res.text();
throw new Error(error);
const errorText = await res.text();
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
window.location.href = '/';
showSuccess(
'<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) {
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.textContent = 'Complete Setup';
}