Setup: utente admin via web, ruoli e run-context con security-group, onboarding install/uninstall script
Nightly Build / build (push) Failing after 6m12s
Nightly Build / build (push) Failing after 6m12s
This commit is contained in:
@@ -238,6 +238,13 @@ pub enum ServerEvent {
|
||||
ClientSelected {
|
||||
client: String,
|
||||
},
|
||||
/// The session security-group (permission group) changed. Broadcast to every
|
||||
/// client of the source so the chat picker stays in sync — the twin of
|
||||
/// `ClientSelected` for the model. `group` is the effective group id
|
||||
/// (`"default"` when cleared). The backend is the single source of truth.
|
||||
SecurityGroupSelected {
|
||||
group: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ServerEvent {
|
||||
@@ -275,6 +282,7 @@ impl ServerEvent {
|
||||
Self::UserMessage { .. } => "user_message",
|
||||
Self::TurnRunning { .. } => "turn_running",
|
||||
Self::ClientSelected { .. } => "client_selected",
|
||||
Self::SecurityGroupSelected { .. } => "security_group_selected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +343,19 @@ impl ChatHub {
|
||||
Some(sid) => sid,
|
||||
None => return Ok(()), // no prior session, nothing to resume
|
||||
};
|
||||
// Guard against double-driving. A client sends `resume` on connect whenever
|
||||
// history shows a pending/interrupted tool — including when the turn is still
|
||||
// live and merely awaiting an approval. Without this check `resume_turn` would
|
||||
// block on the `processing` lock and, once the approval unblocks the original
|
||||
// turn and it finishes, run a spurious *second* turn on the just-completed
|
||||
// conversation. If a turn is already in flight it owns the session and emits
|
||||
// its own events, so there is nothing to resume — skip.
|
||||
if let Ok(handler) = self.session_handler(source_id).await {
|
||||
if handler.is_processing() {
|
||||
info!(source_id, "ChatHub::resume: turn already in flight — skipping resume");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.resume_session(session_id).await
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Result, bail};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// The built-in admin role — immutable from the API.
|
||||
@@ -20,6 +20,81 @@ fn from_raw((id, label, permission_group, attrs, created_at): RawRow) -> Role {
|
||||
Role { id, label, permission_group, attrs, created_at }
|
||||
}
|
||||
|
||||
// ── Typed view over `roles.attrs` (§0.1: role attributes live in free-form JSON,
|
||||
// never per-attribute columns) ────────────────────────────────────────────────
|
||||
|
||||
/// Interface mode a role opts into. `full` unless the role explicitly chooses the
|
||||
/// simplified UI; `admin` is resolved to `full` upstream. Values other than the two
|
||||
/// known ones fall back to `full` (tolerant parse).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UiMode {
|
||||
#[default]
|
||||
Full,
|
||||
Simple,
|
||||
}
|
||||
|
||||
impl UiMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
UiMode::Full => "full",
|
||||
UiMode::Simple => "simple",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed parse of `roles.attrs`. The **single** place that reads the attrs JSON, so
|
||||
/// scattered `serde_json::Value.get(...)` calls don't drift. Tolerant: any parse
|
||||
/// error or missing key yields defaults.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RoleAttrs {
|
||||
pub ui_mode: UiMode,
|
||||
/// Security-groups (`tool_permission_groups` ids) this role may use **in addition**
|
||||
/// to its default `permission_group`. The default is always implicitly allowed; the
|
||||
/// effective set is `unique({permission_group} ∪ permission_groups)`.
|
||||
pub permission_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl RoleAttrs {
|
||||
pub fn from_opt(attrs: &Option<String>) -> RoleAttrs {
|
||||
attrs
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn attrs_parsed(&self) -> RoleAttrs {
|
||||
RoleAttrs::from_opt(&self.attrs)
|
||||
}
|
||||
|
||||
/// The security-groups this role may select: its default first, then any extras
|
||||
/// from `attrs.permission_groups`, deduped.
|
||||
pub fn effective_groups(&self) -> Vec<String> {
|
||||
let mut out = vec![self.permission_group.clone()];
|
||||
for g in self.attrs_parsed().permission_groups {
|
||||
if !out.contains(&g) {
|
||||
out.push(g);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a role may use `group_id` as its session security-group. `admin` holds
|
||||
/// every group by construction; a missing role allows nothing.
|
||||
pub async fn role_allows_group(pool: &SqlitePool, role_id: &str, group_id: &str) -> Result<bool> {
|
||||
if role_id == ADMIN_ROLE_ID {
|
||||
return Ok(true);
|
||||
}
|
||||
match get(pool, role_id).await? {
|
||||
Some(role) => Ok(role.effective_groups().iter().any(|g| g == group_id)),
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<Role>> {
|
||||
@@ -122,3 +197,72 @@ pub async fn seed_admin(pool: &SqlitePool) -> Result<()> {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp_db(tag: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("skald-roles-{tag}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join("system.db").to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
fn role(permission_group: &str, attrs: Option<&str>) -> Role {
|
||||
Role {
|
||||
id: "member".into(),
|
||||
label: "Member".into(),
|
||||
permission_group: permission_group.into(),
|
||||
attrs: attrs.map(str::to_string),
|
||||
created_at: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_attrs_are_tolerant() {
|
||||
// Missing → defaults.
|
||||
let a = RoleAttrs::from_opt(&None);
|
||||
assert_eq!(a.ui_mode, UiMode::Full);
|
||||
assert!(a.permission_groups.is_empty());
|
||||
|
||||
// Populated.
|
||||
let a = RoleAttrs::from_opt(&Some(
|
||||
r#"{"ui_mode":"simple","permission_groups":["ops","research"]}"#.into(),
|
||||
));
|
||||
assert_eq!(a.ui_mode, UiMode::Simple);
|
||||
assert_eq!(a.permission_groups, vec!["ops", "research"]);
|
||||
|
||||
// Malformed JSON → defaults, never an error.
|
||||
let a = RoleAttrs::from_opt(&Some("not json".into()));
|
||||
assert_eq!(a.ui_mode, UiMode::Full);
|
||||
assert!(a.permission_groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_groups_prepends_default_and_dedups() {
|
||||
let r = role("default", Some(r#"{"permission_groups":["ops","default","research"]}"#));
|
||||
assert_eq!(r.effective_groups(), vec!["default", "ops", "research"]);
|
||||
|
||||
// No extras → just the default.
|
||||
let r = role("kids", None);
|
||||
assert_eq!(r.effective_groups(), vec!["kids"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn role_allows_group_admin_member_and_unknown() {
|
||||
let pool = crate::db::init_system_pool(&tmp_db("allows")).await.unwrap();
|
||||
|
||||
// admin is seeded by init and allows any group by construction.
|
||||
assert!(role_allows_group(&pool, ADMIN_ROLE_ID, "anything").await.unwrap());
|
||||
|
||||
insert(&pool, "member", "Member", "default", Some(r#"{"permission_groups":["ops"]}"#))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(role_allows_group(&pool, "member", "default").await.unwrap());
|
||||
assert!(role_allows_group(&pool, "member", "ops").await.unwrap());
|
||||
assert!(!role_allows_group(&pool, "member", "research").await.unwrap());
|
||||
|
||||
// An unknown role allows nothing.
|
||||
assert!(!role_allows_group(&pool, "ghost", "default").await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ pub mod run_context;
|
||||
pub mod secrets;
|
||||
pub mod service_manager;
|
||||
pub mod session;
|
||||
pub mod setup;
|
||||
pub mod tic;
|
||||
pub mod tool_catalog;
|
||||
pub mod tool_discovery;
|
||||
|
||||
@@ -100,6 +100,53 @@ impl RunContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of validating a client-supplied [`RunContext`] against the caller's role.
|
||||
pub enum RunContextDecision {
|
||||
/// Apply this (possibly sanitized) run-context to the session.
|
||||
Apply(Option<RunContext>),
|
||||
/// The requested security-group is not in the role's allowed set (→ 403); the
|
||||
/// string is the offending group id.
|
||||
Forbidden(String),
|
||||
}
|
||||
|
||||
/// Gate a client-supplied run-context by the caller's role, closing two holes at
|
||||
/// once (§0.1 — enforce server-side, never trust the client):
|
||||
///
|
||||
/// - **Group governance**: a non-admin may only select a security-group in its
|
||||
/// role's effective set ([`crate::db::roles::role_allows_group`]); anything else
|
||||
/// is [`RunContextDecision::Forbidden`].
|
||||
/// - **fs escalation**: for a non-admin every other `RunContext` field
|
||||
/// (`system_prompt`, `allow_fs_writes`/`allow_fs_reads`, `working_directory`) is
|
||||
/// **discarded** — the client can set the permission group, nothing more. A rich
|
||||
/// run-context (a project's) is resolved server-side, never through this path.
|
||||
///
|
||||
/// `admin` is trusted and passes through unchanged. `None` (clear) is always
|
||||
/// allowed and falls back to the role's default group at session build.
|
||||
pub async fn validate_run_context_for_role(
|
||||
registry_pool: &SqlitePool,
|
||||
role_id: &str,
|
||||
incoming: Option<RunContext>,
|
||||
) -> Result<RunContextDecision> {
|
||||
if role_id == crate::db::roles::ADMIN_ROLE_ID {
|
||||
return Ok(RunContextDecision::Apply(incoming));
|
||||
}
|
||||
let Some(rc) = incoming else {
|
||||
return Ok(RunContextDecision::Apply(None));
|
||||
};
|
||||
match rc.tool_group_id() {
|
||||
// A non-admin that names no group is treated as a clear (→ default group).
|
||||
None => Ok(RunContextDecision::Apply(None)),
|
||||
Some(group) => {
|
||||
if crate::db::roles::role_allows_group(registry_pool, role_id, group).await? {
|
||||
let group = group.to_string();
|
||||
Ok(RunContextDecision::Apply(Some(RunContext::with_security_group(Some(group)))))
|
||||
} else {
|
||||
Ok(RunContextDecision::Forbidden(group.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RunContextManager {
|
||||
db: Arc<SqlitePool>,
|
||||
approval: Arc<ApprovalManager>,
|
||||
@@ -373,4 +420,61 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(&wd).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_admin_passes_through_untouched() {
|
||||
let path = unique_tmp().join("system.db");
|
||||
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
let rc = RunContext {
|
||||
security_group: Some("ops".into()),
|
||||
allow_fs_writes: vec!["/etc".into()],
|
||||
..Default::default()
|
||||
};
|
||||
match validate_run_context_for_role(&pool, "admin", Some(rc)).await.unwrap() {
|
||||
RunContextDecision::Apply(Some(got)) => {
|
||||
assert_eq!(got.tool_group_id(), Some("ops"));
|
||||
assert_eq!(got.allow_fs_writes, vec!["/etc".to_string()]);
|
||||
}
|
||||
_ => panic!("admin must pass through unchanged"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_non_admin_gates_group_and_strips_fs() {
|
||||
let path = unique_tmp().join("system.db");
|
||||
let pool = crate::db::init_system_pool(path.to_str().unwrap()).await.unwrap();
|
||||
crate::db::roles::insert(&pool, "member", "Member", "default",
|
||||
Some(r#"{"permission_groups":["ops"]}"#)).await.unwrap();
|
||||
|
||||
// Allowed group: kept, but every other field is discarded (fs hardening).
|
||||
let rc = RunContext {
|
||||
security_group: Some("ops".into()),
|
||||
allow_fs_writes: vec!["/etc".into()],
|
||||
system_prompt: vec!["ignore me".into()],
|
||||
..Default::default()
|
||||
};
|
||||
match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() {
|
||||
RunContextDecision::Apply(Some(got)) => {
|
||||
assert_eq!(got.tool_group_id(), Some("ops"));
|
||||
assert!(got.allow_fs_writes.is_empty());
|
||||
assert!(got.system_prompt.is_empty());
|
||||
}
|
||||
_ => panic!("an allowed group must apply, sanitized"),
|
||||
}
|
||||
|
||||
// A group outside the role's set is refused.
|
||||
let rc = RunContext { security_group: Some("secret".into()), ..Default::default() };
|
||||
match validate_run_context_for_role(&pool, "member", Some(rc)).await.unwrap() {
|
||||
RunContextDecision::Forbidden(g) => assert_eq!(g, "secret"),
|
||||
_ => panic!("a group outside the set must be forbidden"),
|
||||
}
|
||||
|
||||
// Clearing is always allowed (falls back to the role default at build time).
|
||||
match validate_run_context_for_role(&pool, "member", None).await.unwrap() {
|
||||
RunContextDecision::Apply(None) => {}
|
||||
_ => panic!("clear must be allowed"),
|
||||
}
|
||||
|
||||
std::fs::remove_dir_all(path.parent().unwrap()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! First-run instance initialization — the seam both setup shells share.
|
||||
//!
|
||||
//! `skald-setup` (the terminal wizard) and the web setup endpoint both need to do
|
||||
//! the same thing exactly once: seed the instance's roles from a chosen **seed
|
||||
//! profile** and create the first admin. Keeping that here — rather than duplicated
|
||||
//! in each shell — is what stops the two paths from drifting apart.
|
||||
//!
|
||||
//! A [`SeedProfile`] is the neutral primitive (§0.1); the domain flavour ("Family",
|
||||
//! "Office", …) lives only in the profile's seed data — labels and role presets,
|
||||
//! never in the engine. One profile ships today; adding another is data, not code.
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::{self, roles::ADMIN_ROLE_ID};
|
||||
use crate::users::UserManager;
|
||||
|
||||
/// One role a profile seeds. `attrs` is the `roles.attrs` JSON (§0.1) — `ui_mode`,
|
||||
/// allowed security-groups, and future role attributes.
|
||||
pub struct RoleSeed {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub permission_group: &'static str,
|
||||
pub attrs: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// A named preset of roles the admin picks at first-run. Neutral mechanism; the
|
||||
/// domain lives in the data.
|
||||
pub struct SeedProfile {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub roles: Vec<RoleSeed>,
|
||||
}
|
||||
|
||||
/// The profiles offered by the setup picker. `admin` is seeded universally at
|
||||
/// table-creation (an FK invariant, `db::roles::seed_admin`), so a profile only
|
||||
/// adds its **domain** roles. Ship one now; `office` / `family-no-kids` are just
|
||||
/// more entries here — no engine change.
|
||||
pub fn seed_profiles() -> Vec<SeedProfile> {
|
||||
vec![SeedProfile {
|
||||
id: "family",
|
||||
label: "Family",
|
||||
roles: vec![
|
||||
RoleSeed {
|
||||
id: "member",
|
||||
label: "Member",
|
||||
permission_group: "default",
|
||||
attrs: Some(r#"{"ui_mode":"full"}"#),
|
||||
},
|
||||
RoleSeed {
|
||||
id: "children",
|
||||
label: "Children",
|
||||
permission_group: "default",
|
||||
attrs: Some(r#"{"ui_mode":"simple"}"#),
|
||||
},
|
||||
],
|
||||
}]
|
||||
}
|
||||
|
||||
/// Look up a profile by id.
|
||||
pub fn seed_profile(id: &str) -> Option<SeedProfile> {
|
||||
seed_profiles().into_iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// Seed a profile's roles (+ their default self-service capabilities) into the
|
||||
/// registry. Idempotent: an existing role id is left untouched, so a re-run never
|
||||
/// clobbers an admin-edited role. Runs at first-run, after every registry table
|
||||
/// exists — so `role_capabilities` is present (no ordering hazard).
|
||||
pub async fn apply_seed_profile(pool: &SqlitePool, profile_id: &str) -> Result<()> {
|
||||
let profile =
|
||||
seed_profile(profile_id).ok_or_else(|| anyhow!("unknown seed profile: {profile_id}"))?;
|
||||
for role in &profile.roles {
|
||||
if db::roles::get(pool, role.id).await?.is_some() {
|
||||
continue; // already present — leave it as the admin left it
|
||||
}
|
||||
db::roles::insert(pool, role.id, role.label, role.permission_group, role.attrs).await?;
|
||||
// The standard self-service capabilities, exactly as `roles::create` grants
|
||||
// them through the API (§14).
|
||||
db::role_capabilities::seed_defaults(pool, role.id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Everything a shell needs to know about the first admin.
|
||||
pub struct FirstAdmin<'a> {
|
||||
pub username: &'a str,
|
||||
pub display_name: Option<&'a str>,
|
||||
pub password: Option<&'a str>,
|
||||
pub encrypted: bool,
|
||||
/// Interface language → the instance default (`ui_locale`). `None` leaves the
|
||||
/// registry default (English) in place.
|
||||
pub locale: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// First-run initialization, shared by both setup shells: apply the chosen seed
|
||||
/// profile, create the admin, set the instance default locale. Returns the new
|
||||
/// admin's user id.
|
||||
///
|
||||
/// The default-locale write goes straight to `db::config` (no system bus): at
|
||||
/// first-run nothing is listening, so both shells converge on the same path.
|
||||
pub async fn initialize_instance(
|
||||
users: &UserManager,
|
||||
pool: &SqlitePool,
|
||||
profile_id: &str,
|
||||
admin: FirstAdmin<'_>,
|
||||
) -> Result<String> {
|
||||
apply_seed_profile(pool, profile_id).await?;
|
||||
|
||||
let id = users
|
||||
.register_user(
|
||||
admin.username,
|
||||
admin.display_name,
|
||||
ADMIN_ROLE_ID,
|
||||
admin.password,
|
||||
admin.encrypted,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(locale) = admin.locale {
|
||||
crate::i18n::set_default_locale(pool, locale).await?;
|
||||
}
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::roles::UiMode;
|
||||
|
||||
fn tmp_db(tag: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("skald-setup-{tag}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join("system.db").to_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn family_profile_seeds_roles_and_caps_idempotently() {
|
||||
let pool = crate::db::init_system_pool(&tmp_db("family")).await.unwrap();
|
||||
apply_seed_profile(&pool, "family").await.unwrap();
|
||||
apply_seed_profile(&pool, "family").await.unwrap(); // idempotent
|
||||
|
||||
let member = db::roles::get(&pool, "member").await.unwrap().unwrap();
|
||||
assert_eq!(member.attrs_parsed().ui_mode, UiMode::Full);
|
||||
let children = db::roles::get(&pool, "children").await.unwrap().unwrap();
|
||||
assert_eq!(children.attrs_parsed().ui_mode, UiMode::Simple);
|
||||
|
||||
// The standard self-service capabilities were granted to a seeded role.
|
||||
assert!(db::role_capabilities::has(
|
||||
&pool, "member", db::role_capabilities::REGISTER_REMOTE,
|
||||
).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_profile_is_an_error() {
|
||||
let pool = crate::db::init_system_pool(&tmp_db("unknown")).await.unwrap();
|
||||
assert!(apply_seed_profile(&pool, "does-not-exist").await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -25,13 +25,9 @@ use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use skald_core::db::{self, SYSTEM_DB_PATH};
|
||||
use skald_core::setup::{self, FirstAdmin};
|
||||
use skald_core::users::UserManager;
|
||||
|
||||
/// The role id given to the first user. There is no `roles` table yet, and
|
||||
/// `users.role_id` has no foreign key, so this is a plain string for now — the
|
||||
/// seeded `admin` preset (blueprint §12) will adopt it later without a migration.
|
||||
const ADMIN_ROLE: &str = "admin";
|
||||
|
||||
/// SQLCipher's per-user privacy is only as strong as the password's entropy
|
||||
/// times the KDF cost (§5.1). The KDF is fixed; this is the floor we put under
|
||||
/// the entropy. Not a substitute for a real strength meter — a deliberate,
|
||||
@@ -139,6 +135,7 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin
|
||||
|
||||
println!("\nWelcome to Skald. Let's create the first user (the admin).\n");
|
||||
|
||||
let profile = prompt_profile()?;
|
||||
let username = prompt_username()?;
|
||||
let display_name = prompt_line("Display name (optional): ")?;
|
||||
let display_name = display_name.trim();
|
||||
@@ -148,16 +145,23 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin
|
||||
let encrypt = prompt_encrypt()?;
|
||||
let password = prompt_new_password()?;
|
||||
|
||||
let id = users
|
||||
.register_user(&username, display_name, ADMIN_ROLE, Some(&password), encrypt)
|
||||
.await
|
||||
.context("creating the admin user")?;
|
||||
|
||||
// The first-run language choice is instance-wide: the registry config
|
||||
// default every user follows until they override it on their profile.
|
||||
skald_core::i18n::set_default_locale(pool, &locale)
|
||||
.await
|
||||
.context("saving the default language")?;
|
||||
// The shared seam both setup shells call: seed the chosen profile's roles,
|
||||
// create the admin, set the instance default language — one implementation, so
|
||||
// the terminal and web wizards can never drift apart.
|
||||
let id = setup::initialize_instance(
|
||||
users,
|
||||
pool,
|
||||
&profile,
|
||||
FirstAdmin {
|
||||
username: &username,
|
||||
display_name,
|
||||
password: Some(&password),
|
||||
encrypted: encrypt,
|
||||
locale: Some(&locale),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("initializing the instance")?;
|
||||
|
||||
println!("\n✓ Admin user '{username}' created (id {id}).");
|
||||
if encrypt {
|
||||
@@ -169,6 +173,34 @@ async fn step_first_user(users: &UserManager, pool: &sqlx::SqlitePool, has_admin
|
||||
|
||||
// ── Prompts ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Which seed profile to provision. The profile decides which domain roles the
|
||||
/// instance starts with (`family` → member + children). With a single profile
|
||||
/// there is nothing to choose, so it is selected silently.
|
||||
fn prompt_profile() -> Result<String> {
|
||||
let profiles = setup::seed_profiles();
|
||||
if profiles.len() <= 1 {
|
||||
return Ok(profiles
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|p| p.id.to_string())
|
||||
.unwrap_or_else(|| "family".to_string()));
|
||||
}
|
||||
println!("What kind of instance is this?");
|
||||
for (i, p) in profiles.iter().enumerate() {
|
||||
println!(" {}) {}", i + 1, p.label);
|
||||
}
|
||||
loop {
|
||||
let line = prompt_line("Choose [1]: ")?;
|
||||
let line = line.trim();
|
||||
let choice = if line.is_empty() { 1 } else { line.parse::<usize>().unwrap_or(0) };
|
||||
if (1..=profiles.len()).contains(&choice) {
|
||||
println!();
|
||||
return Ok(profiles[choice - 1].id.to_string());
|
||||
}
|
||||
println!(" Please enter a number between 1 and {}.", profiles.len());
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_username() -> Result<String> {
|
||||
loop {
|
||||
let name = prompt_line("Username: ")?;
|
||||
|
||||
Reference in New Issue
Block a user