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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-23 17:51:55 +02:00
parent 0a0c35c531
commit 659e678522
9 changed files with 86 additions and 7 deletions

View File

@ -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

View File

@ -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

View File

@ -73,6 +73,13 @@ fn apply_theme(theme: &str) {
struct Store {
connected: RwSignal<bool>,
needs_login: RwSignal<bool>,
/// 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<bool>,
/// 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<bool>,
queue: RwSignal<Vec<Track>>,
queue_pos: RwSignal<u32>,
resolving: RwSignal<bool>,
@ -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! {
<div class="overlay">
<form class="dialog" on:submit=submit>
<label>"This server requires credentials (architecture/roles-auth.md)"</label>
<label>{label}</label>
<input
type="text"
placeholder="role: owner | queue-owner | queue-appender"
@ -1133,6 +1168,17 @@ fn LoginDialog(store: Store) -> impl IntoView {
on:input=move |ev| password.set(event_target_value(&ev))
/>
<div class="dialog-actions">
{move || {
guest_ok
.get()
.then(|| {
view! {
<button type="button" on:click=dismiss>
"continue as guest"
</button>
}
})
}}
<button type="submit">"connect"</button>
</div>
</form>

View File

@ -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,

View File

@ -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

View File

@ -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");

View File

@ -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");

View File

@ -46,6 +46,10 @@ pub struct RpcService {
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
playback_tx: flume::Sender<PlaybackMessage>,
provider_tx: flume::Sender<ProviderMessage>,
/// 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<StreamUpdate>,
playback_tx: flume::Sender<PlaybackMessage>,
provider_tx: flume::Sender<ProviderMessage>,
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))
}

View File

@ -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 {