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