diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js
index 7892d4e6..9683bcce 100644
--- a/src/channels/web/static/app.js
+++ b/src/channels/web/static/app.js
@@ -2213,7 +2213,6 @@ function switchTab(tab) {
if (tab === 'memory') loadMemoryTree();
if (tab === 'jobs') loadJobs();
if (tab === 'routines') loadRoutines();
- if (tab === 'users') loadUsers();
if (tab === 'logs') applyLogFilters();
if (tab === 'settings') {
loadSettingsSubtab(currentSettingsSubtab);
@@ -5044,7 +5043,7 @@ document.addEventListener('keydown', (e) => {
// Mod+1-5: switch tabs
if (mod && e.key >= '1' && e.key <= '5') {
e.preventDefault();
- const tabs = ['chat', 'memory', 'jobs', 'routines', 'users', 'settings'];
+ const tabs = ['chat', 'memory', 'jobs', 'routines', 'settings'];
const idx = parseInt(e.key) - 1;
if (tabs[idx]) switchTab(tabs[idx]);
return;
@@ -5139,6 +5138,7 @@ function loadSettingsSubtab(subtab) {
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
else if (subtab === 'mcp') loadMcpServers();
else if (subtab === 'skills') loadSkills();
+ else if (subtab === 'users') loadUsers();
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
}
diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html
index 4192ccc9..3a1370cb 100644
--- a/src/channels/web/static/index.html
+++ b/src/channels/web/static/index.html
@@ -97,7 +97,6 @@
-
@@ -283,31 +282,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- | ID | Display Name | Email | Role | Status | Created | Actions |
-
-
-
-
No users found. Create the first user to get started.
-
-
-
@@ -319,6 +293,7 @@
+
@@ -416,6 +391,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID | Display Name | Email | Role | Status | Created | Actions |
+
+
+
+
No users found. Create the first user to get started.
+
+
diff --git a/src/main.rs b/src/main.rs
index 8dfbfd48..d5ddc8ef 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -631,6 +631,50 @@ async fn async_main() -> anyhow::Result<()> {
if let Some(ref d) = components.db {
gw = gw.with_store(Arc::clone(d));
gw = gw.with_db_auth(Arc::clone(d));
+
+ // Bootstrap: create the first admin user from single-user config
+ // so the owner appears in the Users admin panel immediately.
+ if let Ok(false) = d.has_any_users().await {
+ let now = chrono::Utc::now();
+ let user = ironclaw::db::UserRecord {
+ id: gw_config.user_id.clone(),
+ email: None,
+ display_name: gw_config.user_id.clone(),
+ status: "active".to_string(),
+ role: "admin".to_string(),
+ created_at: now,
+ updated_at: now,
+ last_login_at: None,
+ created_by: None,
+ metadata: serde_json::json!({"source": "bootstrap"}),
+ };
+ if let Err(e) = d.create_user(&user).await {
+ tracing::warn!("Failed to bootstrap admin user: {}", e);
+ } else {
+ // Also create an API token from the gateway auth token so
+ // DB-backed auth works for the bootstrapped user.
+ let auth_token = gw.auth_token();
+ if !auth_token.is_empty() {
+ use ironclaw::channels::web::auth::hash_token;
+ let hash = hash_token(auth_token);
+ let prefix = if auth_token.len() >= 8 {
+ &auth_token[..8]
+ } else {
+ auth_token
+ };
+ if let Err(e) = d
+ .create_api_token(&gw_config.user_id, "bootstrap", &hash, prefix, None)
+ .await
+ {
+ tracing::warn!("Failed to create bootstrap token: {}", e);
+ }
+ }
+ tracing::info!(
+ user_id = gw_config.user_id,
+ "Bootstrapped admin user from gateway config"
+ );
+ }
+ }
}
if let Some(ref jm) = container_job_manager {
gw = gw.with_job_manager(Arc::clone(jm));