From 659e67852286817b38911864ed2899680f98b691 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 23 Jul 2026 17:51:55 +0200 Subject: [PATCH] Web client: prompt for login on visit when auth is enabled The browser client already sent stored credentials and showed a login form, but only when the server answered UNAUTHENTICATED -- i.e. only when every role was guarded. With a fallback role configured, an anonymous browser silently connected as that role and was never offered a way to log in as a higher one. The server now reports its auth on/off switch on the InitResponse (auth_enabled, field 8), which is reachable anonymously. The RPC handler stamps it from Authenticator::enabled(); the playback loop, which owns queue state and not the auth config, leaves it false. On first connect with no stored credentials against an auth-enabled server, the web client raises the login dialog. It is dismissible -- "continue as guest" keeps the unauthenticated fallback role -- and is shown once per session so stream reconnects do not nag. When the server denies anonymous access outright (UNAUTHENTICATED), the same dialog appears without the guest option, because credentials are then the only way in. Docs: architecture/roles-auth.md and web-client.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/roles-auth.md | 12 +++++++ architecture/web-client.md | 2 +- cbd-web/src/app.rs | 52 +++++++++++++++++++++++++-- cbd-web/src/state.rs | 4 ++- crabidy-core/crabidy/v1/crabidy.proto | 5 +++ crabidy-server/src/lib.rs | 1 + crabidy-server/src/playback.rs | 3 ++ crabidy-server/src/rpc.rs | 12 ++++++- crabidy-server/tests/web_server.rs | 2 +- 9 files changed, 86 insertions(+), 7 deletions(-) diff --git a/architecture/roles-auth.md b/architecture/roles-auth.md index 331b854..7490e55 100644 --- a/architecture/roles-auth.md +++ b/architecture/roles-auth.md @@ -142,6 +142,18 @@ attaches `authorization: Basic …` to every request through a tonic interceptor; without configured credentials it sends no header, which keeps today's zero-config local setup working against an open server. +The web client stores its credentials in `localStorage` and attaches +them the same way. Because an anonymous browser silently connects as the +fallback role, it would otherwise never learn a login is possible, so +the `Init` response (reachable anonymously) carries an `auth_enabled` +flag: on first connect with no stored credentials against an +auth-enabled server, the client raises a login dialog. That dialog is +**dismissible** — "continue as guest" keeps the fallback role — so the +zero-typing browse path is preserved. When the server denies anonymous +access outright (all roles guarded → `UNAUTHENTICATED`), the same dialog +appears without the guest option, because credentials are then the only +way in. + ## Structure ```d2 diff --git a/architecture/web-client.md b/architecture/web-client.md index 058aae7..c76c123 100644 --- a/architecture/web-client.md +++ b/architecture/web-client.md @@ -161,7 +161,7 @@ keys -> ui | all playback + volume/mute/shuffle/repeat | transport bar & keys | | skipped tracks red | same, via `Track.is_skipped` | | update stream reconnect | same, backoff + disconnect banner | -| auth via config file | login form on `UNAUTHENTICATED`, localStorage | +| auth via config file | login form (proactive + forced), localStorage | | `?` help modal | `?` help overlay listing keys | ## Styling diff --git a/cbd-web/src/app.rs b/cbd-web/src/app.rs index 1f0bff7..61c6ff9 100644 --- a/cbd-web/src/app.rs +++ b/cbd-web/src/app.rs @@ -73,6 +73,13 @@ fn apply_theme(theme: &str) { struct Store { connected: RwSignal, needs_login: RwSignal, + /// Whether the open login dialog may be dismissed to keep the + /// unauthenticated fallback role (true) or credentials are required + /// because the server denied anonymous access (false). + guest_ok: RwSignal, + /// Set once the proactive "auth is enabled" prompt has been shown, so + /// stream reconnects do not re-open it after the user dismissed it. + login_prompted: RwSignal, queue: RwSignal>, queue_pos: RwSignal, resolving: RwSignal, @@ -101,6 +108,8 @@ impl Store { Self { connected: RwSignal::new(false), needs_login: RwSignal::new(false), + guest_ok: RwSignal::new(false), + login_prompted: RwSignal::new(false), queue: RwSignal::new(Vec::new()), queue_pos: RwSignal::new(0), resolving: RwSignal::new(false), @@ -132,6 +141,7 @@ impl Store { fn fail(&self, status: tonic::Status) { if status.code() == tonic::Code::Unauthenticated { self.needs_login.set(true); + self.guest_ok.set(false); self.dialog.set(Some(Dialog::Login)); return; } @@ -519,6 +529,20 @@ fn run_stream(store: Store) { if let Some(position) = init.position { store.apply(StreamUpdate::Position(position)); } + // We connected — as the unauthenticated fallback + // role if we sent no credentials. When the server + // has auth configured and the user has none stored, + // offer a login once, but let them dismiss it to + // stay on the fallback role. + let no_stored_creds = load_pref("user").unwrap_or_default().is_empty(); + if init.auth_enabled + && no_stored_creds + && !store.login_prompted.get_untracked() + { + store.login_prompted.set(true); + store.guest_ok.set(true); + store.dialog.set(Some(Dialog::Login)); + } } Err(status) => store.fail(status), } @@ -543,6 +567,7 @@ fn run_stream(store: Store) { } Err(status) if status.code() == tonic::Code::Unauthenticated => { store.needs_login.set(true); + store.guest_ok.set(false); store.dialog.set(Some(Dialog::Login)); return; } @@ -1102,8 +1127,6 @@ fn ConfirmDialog(store: Store, path: String, title: String) -> impl IntoView { #[component] fn LoginDialog(store: Store) -> impl IntoView { - // The dialog needs no store access: submitting reloads the page. - let _ = store; let user = RwSignal::new(load_pref("user").unwrap_or_default()); let password = RwSignal::new(String::new()); let submit = move |ev: leptos::ev::SubmitEvent| { @@ -1115,10 +1138,22 @@ fn LoginDialog(store: Store) -> impl IntoView { let _ = window.location().reload(); } }; + // When the server granted an unauthenticated fallback role we are + // already connected under it, so the prompt is skippable; when it + // denied anonymous access, credentials are the only way in. + let guest_ok = store.guest_ok; + let dismiss = move |_| store.dialog.set(None); + let label = move || { + if guest_ok.get() { + "Log in for more access, or continue as guest (architecture/roles-auth.md)" + } else { + "This server requires credentials (architecture/roles-auth.md)" + } + }; view! {
- + impl IntoView { on:input=move |ev| password.set(event_target_value(&ev)) />
+ {move || { + guest_ok + .get() + .then(|| { + view! { + + } + }) + }}
diff --git a/cbd-web/src/state.rs b/cbd-web/src/state.rs index d8269fc..1d89920 100644 --- a/cbd-web/src/state.rs +++ b/cbd-web/src/state.rs @@ -54,7 +54,9 @@ pub enum Dialog { }, /// The capture-delete confirmation (architecture/capture-deletion.md). ConfirmDelete { path: String, title: String }, - /// Credentials form, shown on `UNAUTHENTICATED` responses. + /// Credentials form. Shown on `UNAUTHENTICATED` responses (creds + /// required), and proactively — but dismissible — on first connect + /// when the server reports auth is enabled and we hold none. Login, /// The `?` key binding overlay. Help, diff --git a/crabidy-core/crabidy/v1/crabidy.proto b/crabidy-core/crabidy/v1/crabidy.proto index 8e95ab1..f1b739d 100644 --- a/crabidy-core/crabidy/v1/crabidy.proto +++ b/crabidy-core/crabidy/v1/crabidy.proto @@ -67,6 +67,11 @@ message InitResponse { float volume = 5; bool mute = 6; TrackPosition position = 7; + // Whether the server has any credentials configured (the auth on/off + // switch). Reachable anonymously, so a client that connected as the + // unauthenticated fallback role can learn a higher role is available + // and offer a login (architecture/roles-auth.md). + bool auth_enabled = 8; } // Library diff --git a/crabidy-server/src/lib.rs b/crabidy-server/src/lib.rs index be67384..87955b0 100644 --- a/crabidy-server/src/lib.rs +++ b/crabidy-server/src/lib.rs @@ -94,6 +94,7 @@ pub async fn serve( update_tx, playback.playback_tx.clone(), orchestrator.provider_tx.clone(), + authenticator.enabled(), ); orchestrator.run(); info!("provider orchestrator started"); diff --git a/crabidy-server/src/playback.rs b/crabidy-server/src/playback.rs index b0b211a..5f67186 100644 --- a/crabidy-server/src/playback.rs +++ b/crabidy-server/src/playback.rs @@ -148,6 +148,9 @@ impl Playback { repeat: queue.repeat, shuffle: queue.shuffle, }), + // Stamped by the RPC handler, which owns the auth + // switch; the playback loop only knows queue state. + auth_enabled: false, } }; trace!(?response, "sending init response"); diff --git a/crabidy-server/src/rpc.rs b/crabidy-server/src/rpc.rs index 75e4ac0..3d433ce 100644 --- a/crabidy-server/src/rpc.rs +++ b/crabidy-server/src/rpc.rs @@ -46,6 +46,10 @@ pub struct RpcService { update_tx: tokio::sync::broadcast::Sender, playback_tx: flume::Sender, provider_tx: flume::Sender, + /// Whether role authorization is configured. Surfaced verbatim on the + /// anonymously-reachable `Init` response so the web client can offer a + /// login even when it connected as the unauthenticated fallback role. + auth_enabled: bool, } impl RpcService { @@ -53,11 +57,13 @@ impl RpcService { update_tx: tokio::sync::broadcast::Sender, playback_tx: flume::Sender, provider_tx: flume::Sender, + auth_enabled: bool, ) -> Self { Self { update_tx, playback_tx, provider_tx, + auth_enabled, } } @@ -86,10 +92,14 @@ impl CrabidyService for RpcService { let (result_tx, result_rx) = flume::bounded(1); self.send_playback(PlaybackCommand::Init { result_tx }) .await?; - let response = result_rx.recv_async().await.map_err(|err| { + let mut response = result_rx.recv_async().await.map_err(|err| { error!("no reply from playback loop: {err}"); Status::internal("playback loop did not reply") })?; + // The playback loop owns the queue/player state; the auth switch + // is the server's, so it is stamped here rather than threaded + // through the playback command. + response.auth_enabled = self.auth_enabled; Ok(Response::new(response)) } diff --git a/crabidy-server/tests/web_server.rs b/crabidy-server/tests/web_server.rs index 51830af..049a409 100644 --- a/crabidy-server/tests/web_server.rs +++ b/crabidy-server/tests/web_server.rs @@ -23,7 +23,7 @@ fn service() -> RpcService { let (update_tx, _) = tokio::sync::broadcast::channel(4); let (playback_tx, _playback_rx) = flume::unbounded(); let (provider_tx, _provider_rx) = flume::unbounded(); - RpcService::new(update_tx, playback_tx, provider_tx) + RpcService::new(update_tx, playback_tx, provider_tx, false) } fn hash(password: &str) -> String {