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:
parent
0a0c35c531
commit
659e678522
|
|
@ -142,6 +142,18 @@ attaches `authorization: Basic …` to every request through a tonic
|
||||||
interceptor; without configured credentials it sends no header, which
|
interceptor; without configured credentials it sends no header, which
|
||||||
keeps today's zero-config local setup working against an open server.
|
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
|
## Structure
|
||||||
|
|
||||||
```d2
|
```d2
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ keys -> ui
|
||||||
| all playback + volume/mute/shuffle/repeat | transport bar & keys |
|
| all playback + volume/mute/shuffle/repeat | transport bar & keys |
|
||||||
| skipped tracks red | same, via `Track.is_skipped` |
|
| skipped tracks red | same, via `Track.is_skipped` |
|
||||||
| update stream reconnect | same, backoff + disconnect banner |
|
| 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 |
|
| `?` help modal | `?` help overlay listing keys |
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,13 @@ fn apply_theme(theme: &str) {
|
||||||
struct Store {
|
struct Store {
|
||||||
connected: RwSignal<bool>,
|
connected: RwSignal<bool>,
|
||||||
needs_login: 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: RwSignal<Vec<Track>>,
|
||||||
queue_pos: RwSignal<u32>,
|
queue_pos: RwSignal<u32>,
|
||||||
resolving: RwSignal<bool>,
|
resolving: RwSignal<bool>,
|
||||||
|
|
@ -101,6 +108,8 @@ impl Store {
|
||||||
Self {
|
Self {
|
||||||
connected: RwSignal::new(false),
|
connected: RwSignal::new(false),
|
||||||
needs_login: RwSignal::new(false),
|
needs_login: RwSignal::new(false),
|
||||||
|
guest_ok: RwSignal::new(false),
|
||||||
|
login_prompted: RwSignal::new(false),
|
||||||
queue: RwSignal::new(Vec::new()),
|
queue: RwSignal::new(Vec::new()),
|
||||||
queue_pos: RwSignal::new(0),
|
queue_pos: RwSignal::new(0),
|
||||||
resolving: RwSignal::new(false),
|
resolving: RwSignal::new(false),
|
||||||
|
|
@ -132,6 +141,7 @@ impl Store {
|
||||||
fn fail(&self, status: tonic::Status) {
|
fn fail(&self, status: tonic::Status) {
|
||||||
if status.code() == tonic::Code::Unauthenticated {
|
if status.code() == tonic::Code::Unauthenticated {
|
||||||
self.needs_login.set(true);
|
self.needs_login.set(true);
|
||||||
|
self.guest_ok.set(false);
|
||||||
self.dialog.set(Some(Dialog::Login));
|
self.dialog.set(Some(Dialog::Login));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -519,6 +529,20 @@ fn run_stream(store: Store) {
|
||||||
if let Some(position) = init.position {
|
if let Some(position) = init.position {
|
||||||
store.apply(StreamUpdate::Position(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),
|
Err(status) => store.fail(status),
|
||||||
}
|
}
|
||||||
|
|
@ -543,6 +567,7 @@ fn run_stream(store: Store) {
|
||||||
}
|
}
|
||||||
Err(status) if status.code() == tonic::Code::Unauthenticated => {
|
Err(status) if status.code() == tonic::Code::Unauthenticated => {
|
||||||
store.needs_login.set(true);
|
store.needs_login.set(true);
|
||||||
|
store.guest_ok.set(false);
|
||||||
store.dialog.set(Some(Dialog::Login));
|
store.dialog.set(Some(Dialog::Login));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1102,8 +1127,6 @@ fn ConfirmDialog(store: Store, path: String, title: String) -> impl IntoView {
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn LoginDialog(store: Store) -> impl IntoView {
|
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 user = RwSignal::new(load_pref("user").unwrap_or_default());
|
||||||
let password = RwSignal::new(String::new());
|
let password = RwSignal::new(String::new());
|
||||||
let submit = move |ev: leptos::ev::SubmitEvent| {
|
let submit = move |ev: leptos::ev::SubmitEvent| {
|
||||||
|
|
@ -1115,10 +1138,22 @@ fn LoginDialog(store: Store) -> impl IntoView {
|
||||||
let _ = window.location().reload();
|
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! {
|
view! {
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<form class="dialog" on:submit=submit>
|
<form class="dialog" on:submit=submit>
|
||||||
<label>"This server requires credentials (architecture/roles-auth.md)"</label>
|
<label>{label}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="role: owner | queue-owner | queue-appender"
|
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))
|
on:input=move |ev| password.set(event_target_value(&ev))
|
||||||
/>
|
/>
|
||||||
<div class="dialog-actions">
|
<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>
|
<button type="submit">"connect"</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,9 @@ pub enum Dialog {
|
||||||
},
|
},
|
||||||
/// The capture-delete confirmation (architecture/capture-deletion.md).
|
/// The capture-delete confirmation (architecture/capture-deletion.md).
|
||||||
ConfirmDelete { path: String, title: String },
|
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,
|
Login,
|
||||||
/// The `?` key binding overlay.
|
/// The `?` key binding overlay.
|
||||||
Help,
|
Help,
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,11 @@ message InitResponse {
|
||||||
float volume = 5;
|
float volume = 5;
|
||||||
bool mute = 6;
|
bool mute = 6;
|
||||||
TrackPosition position = 7;
|
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
|
// Library
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ pub async fn serve(
|
||||||
update_tx,
|
update_tx,
|
||||||
playback.playback_tx.clone(),
|
playback.playback_tx.clone(),
|
||||||
orchestrator.provider_tx.clone(),
|
orchestrator.provider_tx.clone(),
|
||||||
|
authenticator.enabled(),
|
||||||
);
|
);
|
||||||
orchestrator.run();
|
orchestrator.run();
|
||||||
info!("provider orchestrator started");
|
info!("provider orchestrator started");
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,9 @@ impl Playback {
|
||||||
repeat: queue.repeat,
|
repeat: queue.repeat,
|
||||||
shuffle: queue.shuffle,
|
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");
|
trace!(?response, "sending init response");
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,10 @@ pub struct RpcService {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
playback_tx: flume::Sender<PlaybackMessage>,
|
playback_tx: flume::Sender<PlaybackMessage>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
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 {
|
impl RpcService {
|
||||||
|
|
@ -53,11 +57,13 @@ impl RpcService {
|
||||||
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
update_tx: tokio::sync::broadcast::Sender<StreamUpdate>,
|
||||||
playback_tx: flume::Sender<PlaybackMessage>,
|
playback_tx: flume::Sender<PlaybackMessage>,
|
||||||
provider_tx: flume::Sender<ProviderMessage>,
|
provider_tx: flume::Sender<ProviderMessage>,
|
||||||
|
auth_enabled: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
update_tx,
|
update_tx,
|
||||||
playback_tx,
|
playback_tx,
|
||||||
provider_tx,
|
provider_tx,
|
||||||
|
auth_enabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,10 +92,14 @@ impl CrabidyService for RpcService {
|
||||||
let (result_tx, result_rx) = flume::bounded(1);
|
let (result_tx, result_rx) = flume::bounded(1);
|
||||||
self.send_playback(PlaybackCommand::Init { result_tx })
|
self.send_playback(PlaybackCommand::Init { result_tx })
|
||||||
.await?;
|
.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}");
|
error!("no reply from playback loop: {err}");
|
||||||
Status::internal("playback loop did not reply")
|
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))
|
Ok(Response::new(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ fn service() -> RpcService {
|
||||||
let (update_tx, _) = tokio::sync::broadcast::channel(4);
|
let (update_tx, _) = tokio::sync::broadcast::channel(4);
|
||||||
let (playback_tx, _playback_rx) = flume::unbounded();
|
let (playback_tx, _playback_rx) = flume::unbounded();
|
||||||
let (provider_tx, _provider_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 {
|
fn hash(password: &str) -> String {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue