commit 881c00414cf060c4c4c51980bc6da2bbb70faaf9
parent 39f3f9bb31b98ffc77bc1df31a81d23116561f21
Author: triesap <tyson@radroots.org>
Date: Fri, 6 Feb 2026 15:54:40 +0000
app: add config status gating
- add config status and gate helpers for flow control
- wire config status into context and setup navigation
- add config placeholder page and gating before app routes
- show setup and config status on settings status page
Diffstat:
5 files changed, 263 insertions(+), 3 deletions(-)
diff --git a/app/src/app.rs b/app/src/app.rs
@@ -48,7 +48,9 @@ use crate::{
app_log_debug_emit,
app_log_error_emit,
app_log_error_store,
+ app_config_gate_from_status,
app_config_default,
+ app_config_status,
app_datastore_clear_setup_draft,
app_datastore_write_profile_seed,
app_datastore_write_setup_draft,
@@ -65,6 +67,7 @@ use crate::{
app_setup_gate_from_status,
app_setup_step_default,
RadrootsAppBackends,
+ RadrootsAppConfigStatus,
RadrootsAppInitError,
RadrootsAppInitStage,
RadrootsAppNotifications,
@@ -1514,6 +1517,22 @@ fn RecoveryPage() -> impl IntoView {
}
#[component]
+fn ConfigPage() -> impl IntoView {
+ view! {
+ <main id="app-config" class="app-page app-page-fixed">
+ <section id="app-config-body" class="flex flex-col gap-2 px-4 pt-6">
+ <h1 id="app-config-title" class="text-xl font-semibold">
+ {"Configure your profile"}
+ </h1>
+ <p id="app-config-subtitle" class="text-sm text-[var(--text-secondary)]">
+ {"Complete your configuration to continue."}
+ </p>
+ </section>
+ </main>
+ }
+}
+
+#[component]
fn HomePage() -> impl IntoView {
let current_view = RwSignal::new_local(HomeView::Activity);
let is_activity = move || matches!(current_view.get(), HomeView::Activity);
@@ -1579,11 +1598,13 @@ fn AppShell() -> impl IntoView {
let init_error = RwSignal::new_local(None::<RadrootsAppInitError>);
let init_state = RwSignal::new_local(app_init_state_default());
let setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown);
+ let config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown);
let navigate = use_navigate();
provide_context(backends);
provide_context(init_error);
provide_context(init_state);
provide_context(setup_status);
+ provide_context(config_status);
provide_context(app_i18n_init());
Effect::new(move || {
let navigate = navigate.clone();
@@ -1639,6 +1660,7 @@ fn AppShell() -> impl IntoView {
log_init_stage(stage);
let navigate = navigate.clone();
let setup_status = setup_status.clone();
+ let config_status = config_status.clone();
spawn_local(async move {
let keystore = radroots_app_core::keystore::RadrootsClientWebKeystoreNostr::new(
Some(keystore_config),
@@ -1648,17 +1670,30 @@ fn AppShell() -> impl IntoView {
setup_status.set(status);
match status {
RadrootsAppSetupStatus::Required | RadrootsAppSetupStatus::Locked => {
+ config_status.set(RadrootsAppConfigStatus::Unknown);
navigate("/setup", Default::default());
}
RadrootsAppSetupStatus::Corrupt => {
+ config_status.set(RadrootsAppConfigStatus::Unknown);
navigate("/recovery", Default::default());
}
- _ => {}
+ RadrootsAppSetupStatus::Configured => {
+ let config_state = app_config_status(datastore.as_ref(), &key_maps).await;
+ config_status.set(config_state);
+ if matches!(
+ config_state,
+ RadrootsAppConfigStatus::Required | RadrootsAppConfigStatus::Corrupt
+ ) {
+ navigate("/setup/config", Default::default());
+ }
+ }
+ RadrootsAppSetupStatus::Unknown => {}
}
}
Err(err) => {
let _ = app_log_error_emit(&err);
setup_status.set(RadrootsAppSetupStatus::Corrupt);
+ config_status.set(RadrootsAppConfigStatus::Unknown);
navigate("/recovery", Default::default());
}
}
@@ -1688,11 +1723,21 @@ fn AppShell() -> impl IntoView {
})
});
let setup_gate = move || app_setup_gate_from_status(setup_status.get());
+ let config_gate = move || app_config_gate_from_status(config_status.get());
+ let config_ready = move || {
+ let status = setup_status.get();
+ if matches!(status, RadrootsAppSetupStatus::Configured) {
+ !matches!(config_status.get(), RadrootsAppConfigStatus::Unknown)
+ } else {
+ true
+ }
+ };
view! {
<Show
when=move || {
init_state.get().stage == RadrootsAppInitStage::Ready
&& !matches!(setup_status.get(), RadrootsAppSetupStatus::Unknown)
+ && config_ready()
}
fallback=|| view! { <SplashPage /> }
>
@@ -1705,6 +1750,10 @@ fn AppShell() -> impl IntoView {
return view! { <SetupPage /> }.into_any();
}
if gate.show_app {
+ let config_gate = config_gate();
+ if config_gate.show_config {
+ return view! { <ConfigPage /> }.into_any();
+ }
return view! {
<div id="app-shell">
<Routes
diff --git a/app/src/configuration.rs b/app/src/configuration.rs
@@ -122,6 +122,35 @@ impl Default for RadrootsAppConfigData {
pub const APP_CONFIG_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RadrootsAppConfigStatus {
+ Unknown,
+ Required,
+ Configured,
+ Corrupt,
+}
+
+impl Default for RadrootsAppConfigStatus {
+ fn default() -> Self {
+ RadrootsAppConfigStatus::Unknown
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RadrootsAppConfigGate {
+ pub show_config: bool,
+ pub show_app: bool,
+}
+
+impl RadrootsAppConfigGate {
+ pub const fn splash() -> Self {
+ Self {
+ show_config: false,
+ show_app: false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RadrootsAppConfigRecordError {
Missing,
Corrupt,
@@ -236,6 +265,44 @@ impl std::error::Error for RadrootsAppConfigStoreError {}
pub type RadrootsAppConfigStoreResult<T> = Result<T, RadrootsAppConfigStoreError>;
+pub async fn app_config_status<T: RadrootsClientDatastore>(
+ datastore: &T,
+ key_maps: &RadrootsAppKeyMapConfig,
+) -> RadrootsAppConfigStatus {
+ match app_datastore_read_config_record(datastore, key_maps).await {
+ Ok(_) => RadrootsAppConfigStatus::Configured,
+ Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::Missing)) => {
+ RadrootsAppConfigStatus::Required
+ }
+ Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::InvalidChecksum))
+ | Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::UnsupportedVersion(_)))
+ | Err(RadrootsAppConfigStoreError::Datastore(_)) => RadrootsAppConfigStatus::Corrupt,
+ Err(RadrootsAppConfigStoreError::Config(_))
+ | Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::Corrupt))
+ | Err(RadrootsAppConfigStoreError::Record(RadrootsAppConfigRecordError::AlreadyExists)) => {
+ RadrootsAppConfigStatus::Corrupt
+ }
+ }
+}
+
+pub const fn app_config_gate_from_status(
+ status: RadrootsAppConfigStatus,
+) -> RadrootsAppConfigGate {
+ match status {
+ RadrootsAppConfigStatus::Unknown => RadrootsAppConfigGate::splash(),
+ RadrootsAppConfigStatus::Required | RadrootsAppConfigStatus::Corrupt => {
+ RadrootsAppConfigGate {
+ show_config: true,
+ show_app: false,
+ }
+ }
+ RadrootsAppConfigStatus::Configured => RadrootsAppConfigGate {
+ show_config: false,
+ show_app: true,
+ },
+ }
+}
+
pub async fn app_datastore_write_config_record<T: RadrootsClientDatastore>(
datastore: &T,
key_maps: &RadrootsAppKeyMapConfig,
@@ -333,10 +400,13 @@ pub async fn app_datastore_clear_config<T: RadrootsClientDatastore>(
#[cfg(test)]
mod tests {
use super::{
+ app_config_gate_from_status,
app_config_record_new,
app_config_record_validate,
+ RadrootsAppConfigGate,
RadrootsAppConfigData,
RadrootsAppConfigRecordError,
+ RadrootsAppConfigStatus,
APP_CONFIG_SCHEMA_VERSION,
};
@@ -359,4 +429,18 @@ mod tests {
let err = app_config_record_validate(&record).expect_err("checksum");
assert_eq!(err, RadrootsAppConfigRecordError::InvalidChecksum);
}
+
+ #[test]
+ fn config_gate_maps_status() {
+ assert_eq!(
+ app_config_gate_from_status(RadrootsAppConfigStatus::Unknown),
+ RadrootsAppConfigGate::splash()
+ );
+ let required = app_config_gate_from_status(RadrootsAppConfigStatus::Required);
+ assert!(required.show_config);
+ assert!(!required.show_app);
+ let configured = app_config_gate_from_status(RadrootsAppConfigStatus::Configured);
+ assert!(configured.show_app);
+ assert!(!configured.show_config);
+ }
}
diff --git a/app/src/context.rs b/app/src/context.rs
@@ -2,7 +2,13 @@
use leptos::prelude::{use_context, LocalStorage, RwSignal};
-use crate::{RadrootsAppBackends, RadrootsAppInitError, RadrootsAppInitState, RadrootsAppSetupStatus};
+use crate::{
+ RadrootsAppBackends,
+ RadrootsAppConfigStatus,
+ RadrootsAppInitError,
+ RadrootsAppInitState,
+ RadrootsAppSetupStatus,
+};
#[derive(Clone)]
pub struct RadrootsAppContext {
@@ -10,6 +16,7 @@ pub struct RadrootsAppContext {
pub init_error: RwSignal<Option<RadrootsAppInitError>, LocalStorage>,
pub init_state: RwSignal<RadrootsAppInitState, LocalStorage>,
pub setup_status: RwSignal<RadrootsAppSetupStatus, LocalStorage>,
+ pub config_status: RwSignal<RadrootsAppConfigStatus, LocalStorage>,
}
pub fn app_context() -> Option<RadrootsAppContext> {
@@ -18,6 +25,7 @@ pub fn app_context() -> Option<RadrootsAppContext> {
init_error: use_context::<RwSignal<Option<RadrootsAppInitError>, LocalStorage>>()?,
init_state: use_context::<RwSignal<RadrootsAppInitState, LocalStorage>>()?,
setup_status: use_context::<RwSignal<RadrootsAppSetupStatus, LocalStorage>>()?,
+ config_status: use_context::<RwSignal<RadrootsAppConfigStatus, LocalStorage>>()?,
})
}
@@ -29,6 +37,7 @@ mod tests {
RadrootsAppBackends,
RadrootsAppInitError,
RadrootsAppInitStage,
+ RadrootsAppConfigStatus,
RadrootsAppSetupStatus,
};
use leptos::prelude::{provide_context, Owner, RwSignal, WithUntracked};
@@ -48,10 +57,12 @@ mod tests {
let init_error = RwSignal::new_local(None::<RadrootsAppInitError>);
let init_state = RwSignal::new_local(app_init_state_default());
let setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown);
+ let config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown);
provide_context(backends);
provide_context(init_error);
provide_context(init_state);
provide_context(setup_status);
+ provide_context(config_status);
let context = app_context().expect("context");
assert!(context.backends.with_untracked(|value| value.is_none()));
assert!(context.init_error.with_untracked(|value| value.is_none()));
@@ -65,5 +76,11 @@ mod tests {
.with_untracked(|value| *value),
RadrootsAppSetupStatus::Unknown
);
+ assert_eq!(
+ context
+ .config_status
+ .with_untracked(|value| *value),
+ RadrootsAppConfigStatus::Unknown
+ );
}
}
diff --git a/app/src/lib.rs b/app/src/lib.rs
@@ -60,6 +60,8 @@ pub use data::{
pub use configuration::{
app_config_record_new,
app_config_record_validate,
+ app_config_gate_from_status,
+ app_config_status,
app_datastore_clear_config,
app_datastore_create_config,
app_datastore_has_config,
@@ -71,10 +73,12 @@ pub use configuration::{
RadrootsAppConfigData,
RadrootsAppConfigFarmer,
RadrootsAppConfigIndividual,
+ RadrootsAppConfigGate,
RadrootsAppConfigPreferences,
RadrootsAppConfigProfile,
RadrootsAppConfigRecord,
RadrootsAppConfigRecordError,
+ RadrootsAppConfigStatus,
RadrootsAppConfigStoreError,
RadrootsAppConfigStoreResult,
APP_CONFIG_SCHEMA_VERSION,
diff --git a/app/src/settings_status.rs b/app/src/settings_status.rs
@@ -14,6 +14,7 @@ use crate::{
spawn_health_checks,
t,
RadrootsAppBackends,
+ RadrootsAppConfigStatus,
RadrootsAppHealthCheckResult,
RadrootsAppHealthReport,
RadrootsAppSetupStatus,
@@ -54,6 +55,43 @@ fn status_text(value: String) -> RadrootsAppUiListLabelValue {
}
}
+fn config_status_label(status: RadrootsAppConfigStatus) -> String {
+ match status {
+ RadrootsAppConfigStatus::Unknown => t!("app.common.unknown"),
+ RadrootsAppConfigStatus::Required => String::from("required"),
+ RadrootsAppConfigStatus::Configured => String::from("configured"),
+ RadrootsAppConfigStatus::Corrupt => String::from("corrupt"),
+ }
+}
+
+fn config_status_class(status: RadrootsAppConfigStatus) -> &'static str {
+ match status {
+ RadrootsAppConfigStatus::Configured => "status-good",
+ RadrootsAppConfigStatus::Required => "status-warn",
+ RadrootsAppConfigStatus::Corrupt => "status-error",
+ RadrootsAppConfigStatus::Unknown => "status-neutral",
+ }
+}
+
+fn setup_status_label(status: RadrootsAppSetupStatus) -> String {
+ match status {
+ RadrootsAppSetupStatus::Unknown => t!("app.common.unknown"),
+ RadrootsAppSetupStatus::Required => String::from("required"),
+ RadrootsAppSetupStatus::Configured => String::from("configured"),
+ RadrootsAppSetupStatus::Corrupt => String::from("corrupt"),
+ RadrootsAppSetupStatus::Locked => String::from("locked"),
+ }
+}
+
+fn setup_status_class(status: RadrootsAppSetupStatus) -> &'static str {
+ match status {
+ RadrootsAppSetupStatus::Configured => "status-good",
+ RadrootsAppSetupStatus::Required | RadrootsAppSetupStatus::Locked => "status-warn",
+ RadrootsAppSetupStatus::Corrupt => "status-error",
+ RadrootsAppSetupStatus::Unknown => "status-neutral",
+ }
+}
+
fn status_row(label: String, result: RadrootsAppHealthCheckResult) -> RadrootsAppUiListItem {
let status_label = health_result_label(&result);
let status_class = health_status_class(result.status);
@@ -97,6 +135,7 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView {
let context = app_context();
let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>);
let fallback_setup_status = RwSignal::new_local(RadrootsAppSetupStatus::Unknown);
+ let fallback_config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown);
let backends = context
.as_ref()
.map(|value| value.backends)
@@ -105,6 +144,10 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView {
.as_ref()
.map(|value| value.setup_status)
.unwrap_or(fallback_setup_status);
+ let config_status = context
+ .as_ref()
+ .map(|value| value.config_status)
+ .unwrap_or(fallback_config_status);
let health_report = RwSignal::new_local(RadrootsAppHealthReport::empty());
let health_running = RwSignal::new_local(false);
let health_autorun = RwSignal::new_local(false);
@@ -189,6 +232,63 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView {
{move || {
let report = health_report.get();
let active = active_key_label(active_key.get());
+ let setup_value = setup_status.get();
+ let config_value = config_status.get();
+ let config_list = RadrootsAppUiList {
+ id: Some("settings-config-status-list".to_string()),
+ view: Some("settings-config-status".to_string()),
+ classes: None,
+ title: Some(RadrootsAppUiListTitle {
+ value: RadrootsAppUiListTitleValue::Text(String::from("Configuration")),
+ classes: None,
+ mod_value: None,
+ link: None,
+ on_click: None,
+ }),
+ default_state: None,
+ list: Some(vec![
+ Some(RadrootsAppUiListItem {
+ kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch {
+ label: RadrootsAppUiListLabel {
+ left: vec![status_text(String::from("setup status"))],
+ right: vec![
+ status_text(setup_status_label(setup_value)),
+ status_dot(setup_status_class(setup_value)),
+ ],
+ },
+ display: None,
+ end: None,
+ on_click: None,
+ }),
+ loading: false,
+ hide_active: true,
+ hide_field: false,
+ full_rounded: false,
+ offset: None,
+ }),
+ Some(RadrootsAppUiListItem {
+ kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch {
+ label: RadrootsAppUiListLabel {
+ left: vec![status_text(String::from("config status"))],
+ right: vec![
+ status_text(config_status_label(config_value)),
+ status_dot(config_status_class(config_value)),
+ ],
+ },
+ display: None,
+ end: None,
+ on_click: None,
+ }),
+ loading: false,
+ hide_active: true,
+ hide_field: false,
+ full_rounded: false,
+ offset: None,
+ }),
+ ]),
+ hide_offset: false,
+ styles: None,
+ };
let list = RadrootsAppUiList {
id: Some("settings-status-list".to_string()),
view: Some("settings-status".to_string()),
@@ -241,7 +341,13 @@ pub fn RadrootsAppSettingsStatusPage() -> impl IntoView {
hide_offset: false,
styles: None,
};
- view! { <RadrootsAppUiListView basis=list /> }.into_any()
+ view! {
+ <div class="flex flex-col gap-4">
+ <RadrootsAppUiListView basis=config_list />
+ <RadrootsAppUiListView basis=list />
+ </div>
+ }
+ .into_any()
}}
</section>
</AppPageChrome>