From b9028e8f7a85ed0322dbfb3074b5238c34778e0f Mon Sep 17 00:00:00 2001 From: kristianwind Date: Fri, 23 Oct 2026 14:13:14 +0200 Subject: [PATCH] feat(app): fladen kan tale med en valgfri server, med sessionen som bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tredje trin mod iOS-appen. I en browser deler fladen og API'et origin: hver sti er relativ, og sessionen er en httpOnly-cookie. I appen ligger fladen i appen selv på capacitor://localhost, og serveren er den, ejeren skrev ind. Alt, der er forskelligt, bor i den nye `platform.js`, så browserens svar er præcis, hvad det var før. - `apiURL()` og `authorize()`: relative stier og cookie i en browser; den valgte server og `Authorization: Bearer` i appen, `credentials: 'omit'`. - Login sender `client: app` og gemmer `session_token`; log ud fjerner det fra telefonen, også når serveren ikke svarede. - Live-synk i appen beder om en billet og åbner socket'en på serveren med den. - Første skærm i appen: "Hvilken Verdande vil du logge ind på?". Adressen prøves med /ping, før et adgangskodefelt vises, så en tastefejl, en router eller en server, der er nede, får sin egen sætning. Passkeys og opsætning af første konto vises ikke i appen; begge hører til serverens eget domæne. - Billeder og downloads under /api/v1 hentes med tokenet og vises fra en blob, i appen og kun dér (`nativemedia.js`). Stien bevares i `data-src`, og `richtext.js` læser den først, så en note aldrig gemmer en blob-adresse. - Service worker og Web Push er slået fra i appen: WKWebView har ingen af dem. Serveren: `VERDANDE_APP_ORIGINS` regner flere origins som appens. Den findes for E2E-projektet `app-origin`, der kører fladen fra app.localhost — samme server, anden origin — med en Capacitor-stub og uden cookie. Prøven vælger server, logger ind, ser et projekt fra "en anden enhed" dukke op over socket'en, viser et billede fra en blob og logger ud. Fire plantede fejl, hver fanget af prøven ved sit eget trin: ingen bearer, socket uden billet, billeder ikke hentet, token efterladt ved log ud. Dokumenterer også `VERDANDE_APP_SESSION_TTL` fra trin 1. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/configuration.md | 3 + internal/config/config.go | 11 ++ internal/httpapi/app_cors_test.go | 22 +++ internal/httpapi/app_handlers.go | 6 +- internal/httpapi/middleware.go | 12 +- internal/httpapi/server.go | 2 +- web/e2e/app.spec.js | 219 +++++++++++++++++++++++++++ web/playwright.config.js | 21 ++- web/src/lib/api.js | 39 ++++- web/src/lib/components/SignIn.svelte | 119 ++++++++++++++- web/src/lib/locales/da.js | 9 ++ web/src/lib/locales/en.js | 9 ++ web/src/lib/nativemedia.js | 92 +++++++++++ web/src/lib/platform.js | 115 ++++++++++++++ web/src/lib/push.js | 4 + web/src/lib/richtext.js | 5 +- web/src/lib/stores.svelte.js | 44 +++++- web/src/routes/+layout.svelte | 7 + web/svelte.config.js | 11 ++ 20 files changed, 728 insertions(+), 23 deletions(-) create mode 100644 web/e2e/app.spec.js create mode 100644 web/src/lib/nativemedia.js create mode 100644 web/src/lib/platform.js diff --git a/README.md b/README.md index 6750b7d..6d02f8f 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ to put one that the operator would ever see. | `VERDANDE_ADDR` | `:8080` | Listen address. | | `VERDANDE_DATA_DIR` | `/data` | Database, uploaded files and backups. | | `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. | +| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. | | `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. | | `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. | | `VERDANDE_SMTP_HOST` | — | Blank disables outbound mail; invite links are shown in the app instead. | diff --git a/docs/configuration.md b/docs/configuration.md index d253a3d..af8b018 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,11 +37,14 @@ straight off the internet is keyed on the address it really came from. | Variable | Default | What it does | |---|---|---| | `VERDANDE_SESSION_TTL` | `720h` | How long a login lasts. | +| `VERDANDE_APP_SESSION_TTL` | `4320h` | How long a login from the iOS app may sit unused. It slides: every use moves the end forward, so a phone in daily use stays signed in. | | `VERDANDE_INVITE_TTL` | `168h` | How long an invite link stays valid. | | `VERDANDE_RESET_TTL` | `1h` | How long a password-reset link stays valid. | Any Go duration: `720h`, `30m`, `45s`. +`VERDANDE_APP_ORIGINS` (comma-separated) counts further origins as the iOS app's own, beside `capacitor://localhost`. It exists for the end-to-end suite, which runs the interface from a second host name to test it the way the app does; an instance has no reason to set it. + ## Mail | Variable | Default | What it does | diff --git a/internal/config/config.go b/internal/config/config.go index c1e146c..d315b85 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -69,6 +69,12 @@ type Config struct { // the deal its operator made by self-hosting. UpdateCheck bool + // AppOrigins are origins treated like the iOS app's own, capacitor://localhost, + // which is always included. Set by the end-to-end suite, which serves the + // interface from a second host name to test it the way the app runs it. An + // operator has no reason to set it. + AppOrigins []string + // The Yggdrasil panel this instance runs under, so it can ask to be restarted // from its own interface rather than from another browser tab. // @@ -165,6 +171,11 @@ func Load() (*Config, error) { Edition: env("VERDANDE_EDITION", EditionFull), UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false), } + for _, o := range strings.Split(env("VERDANDE_APP_ORIGINS", ""), ",") { + if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" { + c.AppOrigins = append(c.AppOrigins, o) + } + } var err error if c.SessionTTL, err = envDuration("VERDANDE_SESSION_TTL", 30*24*time.Hour); err != nil { diff --git a/internal/httpapi/app_cors_test.go b/internal/httpapi/app_cors_test.go index 4b5c9b2..3d31f2c 100644 --- a/internal/httpapi/app_cors_test.go +++ b/internal/httpapi/app_cors_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/coder/websocket" + + "github.com/kristianwind/verdande/internal/config" ) func (ts *testServer) corsDo(t *testing.T, method, path, origin string, header map[string]string) *http.Response { @@ -200,3 +202,23 @@ func TestACookieSocketStillChecksTheOrigin(t *testing.T) { t.Errorf("control: the instance's own origin: %v", err) } } + +// The end-to-end suite runs the interface from a second host name and tells the +// server to count it as the app's. That origin is then the app's in every +// respect, and capacitor://localhost stays one too. +func TestConfiguredAppOriginsCountAsTheApp(t *testing.T) { + ts := newTestServerWith(t, func(c *config.Config) { + c.AppOrigins = []string{"http://app.localhost:8097"} + }) + for _, origin := range []string{"http://app.localhost:8097", "capacitor://localhost"} { + resp := ts.corsDo(t, "GET", "/api/v1/ping", origin, nil) + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Errorf("%s: Allow-Origin %q", origin, got) + } + } + // Not a prefix match: a neighbour on another port is nobody. + resp := ts.corsDo(t, "GET", "/api/v1/ping", "http://app.localhost:8098", nil) + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Errorf("a neighbouring origin was answered: %q", got) + } +} diff --git a/internal/httpapi/app_handlers.go b/internal/httpapi/app_handlers.go index f143490..7ac1a6f 100644 --- a/internal/httpapi/app_handlers.go +++ b/internal/httpapi/app_handlers.go @@ -23,10 +23,10 @@ import ( // something this header ever needs to unlock. Every other origin gets no CORS // headers at all, exactly as before — the browser then keeps a foreign page from // reading anything, which is the default this must not loosen. -func appCORS(next http.Handler) http.Handler { +func (s *Server) appCORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") - if !isAppOrigin(origin) { + if !s.isAppOrigin(origin) { next.ServeHTTP(w, r) return } @@ -129,7 +129,7 @@ func (s *Server) handleWebSocketEntry(w http.ResponseWriter, r *http.Request) { // ticket is not the app. Refused here rather than left to the origin // check, which compares host names only: capacitor://localhost and an // instance served at http://localhost have the same one. - if isAppOrigin(r.Header.Get("Origin")) { + if s.isAppOrigin(r.Header.Get("Origin")) { writeError(w, http.StatusUnauthorized, CodeUnauthorized, "the app opens this socket with a ticket") return } diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go index dcaf042..01ff1a1 100644 --- a/internal/httpapi/middleware.go +++ b/internal/httpapi/middleware.go @@ -222,7 +222,7 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler { // this Origin — the browser writes the header, not the page — and an app // login sets no cookie, so there is no session here for a forged request // to ride on. - if isAppOrigin(r.Header.Get("Origin")) { + if s.isAppOrigin(r.Header.Get("Origin")) { next.ServeHTTP(w, r) return } @@ -251,12 +251,20 @@ func (s *Server) requireCSRF(next http.Handler) http.Handler { // makes to a server arrives from here, whichever server it is. var appOrigins = []string{"capacitor://localhost"} -func isAppOrigin(origin string) bool { +func (s *Server) isAppOrigin(origin string) bool { + if origin == "" { + return false + } for _, o := range appOrigins { if origin == o { return true } } + for _, o := range s.cfg.AppOrigins { + if origin == o { + return true + } + } return false } diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index f73ddd9..23c660e 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -279,7 +279,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Route("/api/v1", func(r chi.Router) { // Before the CSRF lock, so a preflight is answered and never reaches it. - r.Use(appCORS) + r.Use(s.appCORS) r.Use(s.requireCSRF) r.Get("/ping", s.handlePing) diff --git a/web/e2e/app.spec.js b/web/e2e/app.spec.js new file mode 100644 index 0000000..f28284b --- /dev/null +++ b/web/e2e/app.spec.js @@ -0,0 +1,219 @@ +import { test, expect } from '@playwright/test'; +import { USER } from './user.js'; + +/** + * The interface the way the iOS app runs it: from a different origin than the + * server, with no cookie, its session as a bearer token. + * + * The real app is served from capacitor://localhost, which a test browser cannot + * open. `app.localhost` stands in for it — Chromium sends every *.localhost to + * the loopback address, so it is this same server, but to the browser it is a + * different origin, and that difference is the whole thing under test. The server + * is told to treat it as the app's origin (VERDANDE_APP_ORIGINS in the config), + * and a stub of Capacitor's bridge makes the interface believe it is in the app. + * + * bypassCSP because the server's policy is written for its own pages + * (`connect-src 'self'`); the app's pages are inside the app and never carry it. + * Without this the browser would block exactly the cross-origin calls the test + * exists to make, and the failure would read as the app not working. + */ + +const PORT = 8097; +const SERVER = `http://localhost:${PORT}`; + +test.use({ + baseURL: `http://app.localhost:${PORT}`, + bypassCSP: true, + storageState: { cookies: [], origins: [] } +}); + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.Capacitor = { isNativePlatform: () => true }; + }); +}); + +/** The app's session, read the way the app reads it. */ +const storedToken = (page) => + page.evaluate(() => localStorage.getItem('verdande.app.session') ?? ''); + +test('appen vælger sin server, logger ind med et token og følger med live', async ({ page }) => { + await page.goto('/'); + + // First the address — and an address with nothing behind it is said to be so, + // rather than leading to a sign-in form that cannot succeed. + await expect(page.getByText('Hvilken Verdande vil du logge ind på?')).toBeVisible(); + await page.getByLabel(/Serveradresse/).fill('http://localhost:1'); + await page.getByRole('button', { name: 'Fortsæt' }).click(); + await expect(page.getByText(/Kunne ikke nå localhost:1/)).toBeVisible(); + + await page.getByLabel(/Serveradresse/).fill(SERVER); + await page.getByRole('button', { name: 'Fortsæt' }).click(); + + // The sign-in form, saying where it signs in to. + await expect(page.getByText(`localhost:${PORT}`)).toBeVisible(); + await page.getByLabel(/E-mail/).fill(USER.email); + await page.getByLabel(/Adgangskode/).fill(USER.password); + await page.getByRole('button', { name: 'Log ind', exact: true }).click(); + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + + // Signed in by token, and by nothing else: no session cookie on either origin. + const token = await storedToken(page); + expect(token).not.toBe(''); + const cookies = await page.context().cookies(); + expect(cookies.filter((c) => c.name.includes('verdande_session'))).toEqual([]); + + // Live sync over a ticketed socket: a project made behind the interface's back + // — straight to the API, as another device would — shows up in the sidebar + // without a reload. Nothing but the socket can have put it there. + const name = `Fra en anden enhed ${Date.now()}`; + const status = await page.evaluate( + async ({ server, token, name }) => { + const res = await fetch(`${server}/api/v1/projects`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + return res.status; + }, + { server: SERVER, token, name } + ); + expect(status).toBe(201); + await expect(page.getByRole('navigation', { name: 'Hovedmenu' }).getByText(name)).toBeVisible(); + + // A picture from the server, which an cannot fetch with a token. It is + // shown from a blob, and the path it came from is kept for the note to save. + const path = await page.evaluate( + async ({ server, token }) => { + const auth = { Authorization: `Bearer ${token}` }; + const note = await fetch(`${server}/api/v1/notes`, { + method: 'POST', + headers: { ...auth, 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: '# Billede fra appen' }) + }).then((r) => r.json()); + // A one-pixel PNG. + const png = Uint8Array.from( + atob( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==' + ), + (c) => c.charCodeAt(0) + ); + const form = new FormData(); + form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png'); + const saved = await fetch(`${server}/api/v1/notes/${note.id}/attachments`, { + method: 'POST', + headers: auth, + body: form + }).then((r) => r.json()); + const img = document.createElement('img'); + img.id = 'appbillede'; + img.setAttribute('src', saved.url); + document.body.append(img); + return saved.url; + }, + { server: SERVER, token } + ); + const img = page.locator('#appbillede'); + await expect(img).toHaveAttribute('data-src', path); + await expect(img).toHaveAttribute('src', /^blob:/); + await expect.poll(() => img.evaluate((el) => el.naturalWidth)).toBe(1); + + // Signing out takes the token off the phone, and keeps the server. + await page.evaluate(() => document.getElementById('appbillede')?.remove()); + // Two taps: the first asks, the second signs out. + await page.getByRole('button', { name: /Log ud/ }).click(); + await page.getByRole('button', { name: /Tryk igen for at logge ud/ }).click(); + await expect(page.getByText(`localhost:${PORT}`)).toBeVisible(); + expect(await storedToken(page)).toBe(''); + + // And the token is dead on the server too, not just forgotten here. + const after = await page.evaluate( + async ({ server, token }) => + (await fetch(`${server}/api/v1/auth/me`, { headers: { Authorization: `Bearer ${token}` } })) + .status, + { server: SERVER, token } + ); + expect(after).toBe(401); +}); + +/** + * Signs in the way the app does, outside the page, and hands the page the server + * and the session as the app would have stored them. + */ +async function signedInApp(page) { + const origin = { Origin: `http://app.localhost:${PORT}` }; + const res = await fetch(`${SERVER}/api/v1/auth/login`, { + method: 'POST', + headers: { ...origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: USER.email, + password: USER.password, + client: 'app', + device_name: 'Prøve-iPhone' + }) + }); + const { session_token: token } = await res.json(); + expect(token).toBeTruthy(); + await page.addInitScript( + ({ server, token }) => { + localStorage.setItem('verdande.app.server', server); + localStorage.setItem('verdande.app.session', token); + }, + { server: SERVER, token } + ); + const call = async (method, path, body) => { + const init = { method, headers: { ...origin, Authorization: `Bearer ${token}` } }; + if (body instanceof FormData) init.body = body; + else if (body !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + const r = await fetch(`${SERVER}/api/v1${path}`, init); + return r.status === 204 ? null : r.json(); + }; + return call; +} + +/** + * The picture in a note is shown from a blob made on the phone. Editing the note + * turns the page back into markdown, and what it must write is the path the + * picture came from — a blob address saved into a note is a picture that is gone + * the next time anybody opens it, on any device. + */ +test('en note med et billede gemmer stien, ikke blob-adressen, når den redigeres i appen', async ({ + page +}) => { + const call = await signedInApp(page); + + const note = await call('POST', '/notes', { body: '# Billednote fra appen' }); + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg==', + 'base64' + ); + const form = new FormData(); + form.append('file', new Blob([png], { type: 'image/png' }), 'prik.png'); + const saved = await call('POST', `/notes/${note.id}/attachments`, form); + await call('PATCH', `/notes/${note.id}`, { + body: `# Billednote fra appen\n\n![](${saved.url})\n\nTekst under billedet` + }); + + await page.goto('/noter?note=' + note.id); + const ed = page.getByRole('textbox', { name: 'Notens tekst' }); + const img = ed.locator('img'); + await expect(img).toHaveAttribute('src', /^blob:/); + await expect(img).toHaveAttribute('data-src', saved.url); + + // Touch the note so it saves, and wait for the save itself. + const patched = page.waitForResponse( + (r) => r.request().method() === 'PATCH' && /\/notes\/[^/]+$/.test(new URL(r.url()).pathname) && r.ok() + ); + await ed.getByText('Tekst under billedet').click(); + await page.keyboard.press('End'); + await page.keyboard.type(' og mere'); + await patched; + + const after = await call('GET', `/notes/${note.id}`); + expect(after.body).toContain('og mere'); + expect(after.body).toContain(`](${saved.url})`); + expect(after.body).not.toContain('blob:'); +}); diff --git a/web/playwright.config.js b/web/playwright.config.js index becdde0..a8b32c9 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -81,7 +81,8 @@ export default defineConfig({ // Alt undtagen noter-udgaven, som har sin egen server på sin egen port. // Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor // den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker. - testIgnore: /edition\.spec\.js/, + // Og app-prøven, der kører fladen fra en anden origin med sin egen login. + testIgnore: /(edition|app)\.spec\.js/, use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' }, dependencies: ['setup'] }, @@ -120,6 +121,19 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` } }, + /** + * Fladen, som iOS-appen kører den: fra en anden origin end serveren, uden + * cookie, med sessionen som bearer. Se app.spec.js. Afhænger af `setup`, + * fordi den logger ind på kontoen, opsætningen lavede — men låner ikke dens + * cookie, for det er netop det, appen ikke har. + */ + { + name: 'app-origin', + testMatch: /app\.spec\.js/, + use: { ...devices['Desktop Chrome'] }, + dependencies: ['setup'] + }, + { name: 'webkit-forms', grep: /@forms/, @@ -164,7 +178,10 @@ export default defineConfig({ VERDANDE_BASE_URL: `http://localhost:${PORT}`, VERDANDE_DATA_DIR: dataDir, // Off: an update check would reach out to GitHub from a test run. - VERDANDE_UPDATE_CHECK: 'false' + VERDANDE_UPDATE_CHECK: 'false', + // Den origin, app-origin-projektet kører fladen fra, regnet som appens + // egen — som capacitor://localhost er det i den rigtige app. + VERDANDE_APP_ORIGINS: `http://app.localhost:${PORT}` } }, diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 7e79115..4076e24 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -24,6 +24,7 @@ export class ApiError extends Error { import { t } from './i18n.svelte.js'; import { connectivity, sleepOrOnline } from './connectivity.svelte.js'; +import { apiURL, appSession, authorize, deviceName, isNativeApp } from './platform.js'; /** * How long a request keeps retrying a network failure before giving up. @@ -152,19 +153,21 @@ async function request(method, path, body, options = {}) { const init = { method, headers: {}, - // The session cookie is httpOnly and same-origin; nothing here handles a token. - credentials: 'same-origin', signal: options.signal }; if (body !== undefined) { init.headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } + // In a browser: the httpOnly session cookie, same-origin, and nothing here + // handles a token. In the iOS app: its session token as a bearer. See + // platform.js. + authorize(init); // Retries a network failure for a few seconds so a short drop does not lose the // call; `withRetry` turns an exhausted window into the same `offline` error the // app already handles. `options.noRetry` opts a call out — used by nothing yet. - const res = await withRetry(() => fetch(`/api/v1${path}`, init), options.noRetry); + const res = await withRetry(() => fetch(apiURL(path), init), options.noRetry); if (res.status === 204) return null; @@ -205,7 +208,7 @@ async function upload(path, file, field = 'file') { form.append(field, file); const res = await withRetry(() => - fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' }) + fetch(apiURL(path), authorize({ method: 'POST', body: form })) ); const text = await res.text(); @@ -232,9 +235,30 @@ export const api = { // --- auth setupState: () => get('/auth/setup'), setup: (data) => post('/auth/setup', data), - login: (email, password) => post('/auth/login', { email, password }), + login: async (email, password) => { + if (!isNativeApp()) return post('/auth/login', { email, password }); + // The app gets its session in the answer instead of a cookie, and keeps + // it — also while a code is still owed, because the second step is sent + // with it. + const result = await post('/auth/login', { + email, + password, + client: 'app', + device_name: deviceName() + }); + if (result?.session_token) appSession.setToken(result.session_token); + return result; + }, loginTOTP: (code) => post('/auth/login/totp', { code }), - logout: () => post('/auth/logout'), + logout: async () => { + try { + return await post('/auth/logout'); + } finally { + // Gone from the phone whether or not the server heard: a sign-out that + // left the token behind because the network was down is not one. + if (isNativeApp()) appSession.signOut(); + } + }, me: () => get('/auth/me'), updateProfile: (data) => patch('/auth/me', data), // Its own route rather than a field on the profile: that one backs a form with @@ -421,6 +445,9 @@ export const api = { post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }), deleteToken: (id) => del(`/tokens/${id}`), + // --- live sync, for the app (see stores.svelte.js) + wsTicket: () => post('/ws-ticket'), + // --- notifications and push listNotifications: () => get('/notifications'), markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'), diff --git a/web/src/lib/components/SignIn.svelte b/web/src/lib/components/SignIn.svelte index 0b9c783..685677b 100644 --- a/web/src/lib/components/SignIn.svelte +++ b/web/src/lib/components/SignIn.svelte @@ -21,9 +21,56 @@ import { focusOnMount } from '$lib/focus.js'; import { t } from '$lib/i18n.svelte.js'; import { available, signIn } from '$lib/passkey.js'; + import { appSession, isNativeApp, normaliseServer } from '$lib/platform.js'; - // loading | login | setup | totp | forgot | sent | invite | reset | done + // loading | server | login | setup | totp | forgot | sent | invite | reset | done let mode = $state('loading'); + + // --- which server (the iOS app only) ------------------------------------------ + + const native = isNativeApp(); + let server = $state(native ? appSession.server() : ''); + let serverHost = $derived(server ? server.replace(/^https?:\/\//, '') : ''); + + /** + * The app has no server of its own. Before a password field is shown for an + * address somebody typed, the address has to be a Verdande: asked with /ping, + * which answers without a login and names the product. A router's login page, + * a typo that lands on somebody's blog, a server that is down — each gets its + * own sentence here instead of a sign-in form that cannot succeed. + */ + async function chooseServer() { + const url = normaliseServer(server); + if (!url) { + error = t('app.serverInvalid'); + return; + } + let answer; + try { + const res = await fetch(`${url}/api/v1/ping`, { credentials: 'omit' }); + answer = res.ok ? await res.json() : null; + } catch { + error = t('app.serverUnreachable', { host: url.replace(/^https?:\/\//, '') }); + return; + } + if (answer?.product !== 'verdande') { + error = t('app.notVerdande'); + return; + } + appSession.setServer(url); + server = url; + await enterServer(); + } + + /** Asks the chosen server what it wants: sign-in, or — in a browser — setup. */ + async function enterServer() { + try { + const state = await api.setupState(); + mode = state.needs_setup ? 'setup' : 'login'; + } catch { + mode = 'login'; + } + } let email = $state(''); let password = $state(''); let name = $state(''); @@ -37,6 +84,9 @@ let passkeyReady = $state(false); $effect(() => { + // Not in the app: a passkey is bound to the server's own domain, and the + // app's pages are not on it. Offered there it would fail every time. + if (native) return; available().then((ok) => (passkeyReady = ok)); }); @@ -94,10 +144,11 @@ mode = linkMode; return; } - api - .setupState() - .then((state) => (mode = state.needs_setup ? 'setup' : 'login')) - .catch(() => (mode = 'login')); + if (native && !appSession.server()) { + mode = 'server'; + return; + } + enterServer(); }); async function run(fn) { @@ -120,6 +171,10 @@ run(async () => { switch (mode) { + case 'server': + await chooseServer(); + break; + case 'setup': await api.setup({ email, @@ -190,6 +245,34 @@ {#if mode === 'loading'}

 

+ {:else if mode === 'server'} +

{t('app.serverPrompt')}

+ + + + + {:else if mode === 'setup' && native} + +

{t('app.setupInBrowser', { host: serverHost })}

+ {:else if mode === 'setup'}

{t('auth.firstAccount')}

@@ -303,6 +386,15 @@ {:else}

{t('auth.signInPrompt')}

+ {#if native} +

+ {serverHost} + +

+ {/if} +