diff --git a/ui/style.css b/ui/style.css
index 6c19ba6d4e..55c1905e1e 100644
--- a/ui/style.css
+++ b/ui/style.css
@@ -4088,6 +4088,20 @@ a:focus-visible,
color: #fff;
}
+.qs-text-input {
+ padding: var(--space-6) var(--space-8);
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--color-border);
+ background: var(--color-secondary);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+}
+
+.qs-text-input:focus {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 1px;
+}
+
/* --- Screenshot Flash --- */
.screenshot-flash {
diff --git a/ui/utils/quick-settings.js b/ui/utils/quick-settings.js
index 358592b824..5112ee9a4f 100644
--- a/ui/utils/quick-settings.js
+++ b/ui/utils/quick-settings.js
@@ -1,6 +1,10 @@
// Quick Settings Panel - Centralized configuration for all UI features
// Accessible via gear icon in header
+import { apiService } from '../services/api.service.js';
+
+const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
+
export class QuickSettings {
constructor(app) {
this.app = app;
@@ -10,10 +14,21 @@ export class QuickSettings {
}
init() {
+ this.applyStoredApiToken();
this.createButton();
this.createPanel();
}
+ // Apply a previously-saved bearer token to apiService as early as
+ // possible, before any tab's REST calls fire. The server only ever
+ // checks the `Authorization: Bearer` header (see bearer_auth.rs) — this
+ // intentionally never puts the token in a URL query string.
+ applyStoredApiToken() {
+ let token = null;
+ try { token = localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
+ if (token) apiService.setAuthToken(token);
+ }
+
createButton() {
this.button = document.createElement('button');
this.button.className = 'settings-gear';
@@ -70,6 +85,18 @@ export class QuickSettings {
+
Data
@@ -112,6 +139,30 @@ export class QuickSettings {
}
});
+ this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
+ const input = this.panel.querySelector('#qs-api-token');
+ const status = this.panel.querySelector('#qs-api-token-status');
+ const token = input.value.trim();
+ if (!token) {
+ status.textContent = 'Enter a token first, or use Clear to remove one.';
+ return;
+ }
+ try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
+ apiService.setAuthToken(token);
+ status.textContent = 'Token saved and applied. Reloading...';
+ setTimeout(() => window.location.reload(), 600);
+ });
+
+ this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
+ const input = this.panel.querySelector('#qs-api-token');
+ const status = this.panel.querySelector('#qs-api-token-status');
+ try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
+ apiService.setAuthToken(null);
+ input.value = '';
+ status.textContent = 'Token cleared. Reloading...';
+ setTimeout(() => window.location.reload(), 600);
+ });
+
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
try {
localStorage.clear();
@@ -154,6 +205,10 @@ export class QuickSettings {
if (this.getSetting('compact')) {
document.body.classList.add('compact-mode');
}
+ const status = this.panel.querySelector('#qs-api-token-status');
+ let hasToken = false;
+ try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
+ if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
}
prefersReducedMotion() {
diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs
index d929e857d2..011343ace4 100644
--- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs
+++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs
@@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
/// Path prefix the middleware protects when auth is enabled.
pub const PROTECTED_PREFIX: &str = "/api/v1/";
+/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from
+/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor
+/// cannot attach an `Authorization` header to the handshake request, so this
+/// path can never carry a bearer token from a stock browser client — the
+/// same reasoning that already exempts `/ws/sensing` (see module docs).
+/// Exempted here rather than moved out of `/api/v1/*` to avoid an API
+/// surface change for existing clients.
+const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"];
+
/// Cheap, cloneable handle to the configured token (or `None`).
#[derive(Debug, Clone, Default)]
pub struct AuthState {
@@ -93,7 +102,8 @@ pub async fn require_bearer(
let Some(expected) = auth.token.clone() else {
return next.run(request).await;
};
- if !request.uri().path().starts_with(PROTECTED_PREFIX) {
+ let path = request.uri().path();
+ if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) {
return next.run(request).await;
}
let supplied = request
@@ -141,6 +151,7 @@ mod tests {
.route("/health", get(|| async { "ok" }))
.route("/api/v1/info", get(|| async { "ok" }))
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
+ .route("/api/v1/stream/pose", get(|| async { "ok" }))
.route("/ui/index.html", get(|| async { "
" }))
}
@@ -361,6 +372,26 @@ mod tests {
);
}
+ /// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket`
+ /// constructor drives directly — it cannot attach an `Authorization`
+ /// header, so this path must stay reachable even with auth ON (mirrors
+ /// the existing `/ws/sensing` exemption, just inside the `/api/v1/*`
+ /// prefix this time).
+ #[tokio::test]
+ async fn enabled_exempts_pose_stream_websocket() {
+ let r = wrap(AuthState::from_token("s3cr3t"));
+ assert_eq!(
+ status(r.clone(), "GET", "/api/v1/stream/pose", None).await,
+ StatusCode::OK,
+ "pose stream WS must stay reachable without a bearer token"
+ );
+ // The exemption is narrow: it must not leak to other /api/v1/* paths.
+ assert_eq!(
+ status(r, "GET", "/api/v1/info", None).await,
+ StatusCode::UNAUTHORIZED
+ );
+ }
+
#[test]
fn ct_eq_basics() {
assert!(ct_eq(b"abc", b"abc"));