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} +