diff --git a/admin/index.html b/admin/index.html
index b86dfbf..dd1921f 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -231,7 +231,7 @@
// ── tiny utils ──────────────────────────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const fmtDate = (d) => d ? new Date(d).toLocaleString() : '—';
-const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>');
+const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
const api = {
async request(method, path, body) {
@@ -240,13 +240,24 @@
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
- const r = await fetch(path, opts);
- if (!r.ok) {
- const txt = await r.text().catch(() => '');
- throw new Error(`${method} ${path} → ${r.status}: ${txt}`);
+ try {
+ const r = await fetch(path, opts);
+ if (r.status === 401) {
+ window.location.href = '/';
+ return;
+ }
+ if (!r.ok) {
+ const txt = await r.text().catch(() => '');
+ throw new Error(`${method} ${path} → ${r.status}: ${txt}`);
+ }
+ if (r.status === 204) return null;
+ return r.json();
+ } catch (err) {
+ if (err.name === 'TypeError') {
+ throw new Error('Network error — check your connection or server availability');
+ }
+ throw err;
}
- if (r.status === 204) return null;
- return r.json();
},
get(path) { return this.request('GET', path); },
patch(path, body) { return this.request('PATCH', path, body); },
@@ -588,7 +599,7 @@
function renderProfiles(profiles) {
if (!profiles.length) return '
No profiles.
';
let html = '';
- ['ID','Name','Description','Admin Only','Actions'].forEach(h => html += `| ${h} | `);
+ ['ID','Name','Description','Admin Only'].forEach(h => html += `${h} | `);
html += '
';
for (const p of profiles) {
html += `
@@ -601,7 +612,6 @@
- | ${p.is_admin_only ? 'Yes' : 'No'} |
`;
}
html += '
';
diff --git a/docs/android-client.md b/docs/android-client.md
index b5f8d66..d939f29 100644
--- a/docs/android-client.md
+++ b/docs/android-client.md
@@ -74,6 +74,57 @@
Use this for any future UI differences between the web client and the Android app (hiding elements, adjusting layout, etc.).
+## OAuth / login flow
+
+Android WebView **does not share cookies** with the system browser. When the user clicks **Login**, the app opens gnexus-auth in the user's default browser (Chrome, DuckDuckGo, etc.) so SSO works. After successful authentication, the backend redirects the browser to `/auth/mobile-done?sid=` instead of setting a cookie.
+
+`/auth/mobile-done` is a bridge page styled with the gnexus UI kit. It:
+1. Immediately attempts `window.location.href = "intent://auth/callback?sid=xxx#Intent;scheme=navi;package=com.navi.client;end"` — Chrome understands this and opens the app automatically.
+2. If the browser blocks the redirect (DuckDuckGo, Firefox), a styled fallback button **"Open Navi App"** appears after 1.5s. Tapping it triggers the same Intent URL with a user gesture, which the browser cannot block.
+
+### Deep-link intent filter
+
+`AndroidManifest.xml` declares an intent filter for `navi://auth/callback`:
+
+```xml
+
+
+
+
+
+
+```
+
+The activity uses `launchMode="singleTask"` so `onNewIntent` is called even when the app is already running.
+
+### Cookie injection
+
+`MainActivity` handles the incoming intent in both `onCreate` (cold start) and `onNewIntent` (app already running):
+
+```kotlin
+override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ intent.data?.let { uri ->
+ if (uri.scheme == "navi" && uri.host == "auth" && uri.path == "/callback") {
+ val sid = uri.getQueryParameter("sid")
+ val prefs = getSharedPreferences("navi", Context.MODE_PRIVATE)
+ val serverUrl = prefs.getString("server_url", null)
+ if (sid != null && serverUrl != null) {
+ CookieManager.getInstance().setCookie(
+ serverUrl,
+ "navi_auth_session=$sid; Path=/"
+ )
+ webView.loadUrl(serverUrl)
+ }
+ }
+ }
+}
+```
+
+After the cookie is set, the WebView reloads the server URL and the user is authenticated.
+
+---
+
## Back navigation
Hardware back button navigates WebView history if available (`webView.canGoBack()`), otherwise falls through to the default Android behaviour (closes the activity).
diff --git a/docs/api.md b/docs/api.md
index 69e3d72..fb64dc1 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -33,19 +33,32 @@
#### `GET /auth/callback`
-OAuth callback. Validates state, exchanges code for tokens, creates DB session, sets cookie.
+OAuth callback. Validates state, exchanges code for tokens, creates DB session.
**Query params**
- `code` — authorization code from gnexus-auth
- `state` — state parameter
-**Response `302`** → Location: `/`
+**Response `302`**
+- **Browser** → Location: `/` (with `Set-Cookie`)
+- **Android** → Location: `/auth/mobile-done?sid=`
**Errors**
- `400` — invalid state, PKCE failure, or token exchange failed
---
+#### `GET /auth/mobile-done`
+
+Bridge page for Android OAuth. Renders HTML that attempts an automatic deep-link back into the native app via Chrome Intent URL (`intent://...`), and falls back to a manual button for browsers that block automatic navigation to custom schemes (e.g. DuckDuckGo). Styled with the gnexus UI kit design system.
+
+**Query params**
+- `sid` — session id that will become the `navi_auth_session` cookie
+
+**Response `200`** — HTML page
+
+---
+
#### `POST /auth/logout`
Logout current user. Deletes DB session and clears cookie.
diff --git a/docs/auth.md b/docs/auth.md
index bf5e37a..69d9dfc 100644
--- a/docs/auth.md
+++ b/docs/auth.md
@@ -101,7 +101,7 @@
## OAuth login flow
-### 1. Initiate login
+### Browser flow (default)
```
GET /auth/login
@@ -112,6 +112,44 @@
2. Stores PKCE verifier and state in `InMemoryPkceStore` / `InMemoryStateStore` (from `gnexus-auth-client-py`)
3. Returns `302` redirect to gnexus-auth `/oauth/authorize`
+After the user logs in at gnexus-auth, they are redirected back to `/auth/callback`, where the backend exchanges the code, creates a DB session, sets an `httpOnly` cookie, and redirects to `/`.
+
+### Android flow (WebView bridge)
+
+Android WebView **cannot share cookies** with the system browser (Chrome, DuckDuckGo, etc.). Therefore the Android app uses a **bridge-page flow** that avoids cookies entirely:
+
+```
+GET /auth/login ← Backend detects Android via User-Agent (NaviAndroid/1.0)
+ ↓ 302
+gnexus-auth /oauth/authorize ← Opens in external browser (SSO works)
+ ↓
+GET /auth/callback?code=...&state=...
+ ↓ 302
+/auth/mobile-done?sid=
+ ↓ JS redirect
+intent://auth/callback?sid=...#Intent;scheme=navi;package=com.navi.client;end
+ ↓
+Android intercepts intent → CookieManager.setCookie() → reload WebView
+```
+
+Key points:
+- Backend detects Android from the HTTP `User-Agent` header (`NaviAndroid/1.0`) rather than a query parameter.
+- For Android, `/auth/callback` **does not set a cookie** — instead it redirects to `/auth/mobile-done`.
+- `/auth/mobile-done` renders a bridge page styled with the gnexus UI kit (dark theme, success accent border). It immediately attempts a Chrome Intent URL auto-redirect; if the browser blocks it, a fallback button appears after 1.5s.
+- The `intent://` URL is intercepted by Android's `intent-filter` for `navi://auth/callback`, which delivers the `sid` to `MainActivity.onNewIntent`. The app then sets the `navi_auth_session` cookie via `CookieManager` and reloads the WebView.
+
+### 1. Initiate login
+
+```
+GET /auth/login
+```
+
+Navi backend:
+1. Calls `client.build_authorization_request(return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"])`
+2. Stores PKCE verifier and state in `InMemoryPkceStore` / `InMemoryStateStore` (from `gnexus-auth-client-py`)
+3. **For Android**: stores `platform=android` alongside the state metadata so callback knows to skip the cookie
+4. Returns `302` redirect to gnexus-auth `/oauth/authorize`
+
### 2. User logs in at gnexus-auth
User authenticates with gnexus-auth, approves scopes, and is redirected back to:
@@ -129,8 +167,8 @@
4. Determines `role` from `role_ids` and collects `permissions` from `permission_ids`
5. **Upserts** `navi_users` row (id, email, display_name, role, permissions)
6. **Creates** `user_auth_sessions` row with encrypted tokens
-7. Sets `navi_auth_session` cookie
-8. Redirects to `/`
+7. **Browser**: sets `navi_auth_session` cookie and redirects to `/`
+8. **Android**: redirects to `/auth/mobile-done?sid=`
### 4. Subsequent requests
diff --git a/webclient/src/api/index.js b/webclient/src/api/index.js
index 36a49da..904c607 100644
--- a/webclient/src/api/index.js
+++ b/webclient/src/api/index.js
@@ -105,14 +105,3 @@
return res.json()
}
-// ─── Admin ─────────────────────────────────────────────────────────────────
-export function getAdminUsers() { return request('GET', '/admin/users') }
-export function getAdminSessions() { return request('GET', '/admin/sessions') }
-export function getAdminMemory() { return request('GET', '/admin/memory') }
-export function getAdminProfiles() { return request('GET', '/admin/profiles') }
-export function getAdminSession(id) { return request('GET', `/admin/sessions/${id}`) }
-export function deleteAdminSession(id) { return request('DELETE', `/admin/sessions/${id}`) }
-export function getAdminUser(id) { return request('GET', `/admin/users/${id}`) }
-export function getAdminUserSessions(id) { return request('GET', `/admin/users/${id}/sessions`) }
-export function updateUserRole(id, role) { return request('PATCH', `/admin/users/${id}/role`, { role }) }
-export function updateProfileAvailability(id, isAdminOnly) { return request('PATCH', `/admin/profiles/${id}/availability`, { is_admin_only: isAdminOnly }) }