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 {