diff --git a/.gitignore b/.gitignore index 439547d..d4e6adc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules/ # local scratch / generated artifacts /graphify-out/ PLAN_*.md +_* test-results/ .claude/ .opencode/ diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 20710b6..df486a0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -59,7 +59,8 @@ environment: | `SECRET_KEY` | — | **yes**¹ | Cookie signing key | | `DATABASE_ARGS` | `{}` | no | Extra args for the psycopg3 driver | | `SESSION_LIFETIME` | `1` | no | Session duration in days | -| `LANGUAGE_FILE` | `i18n/en.json` | no | UI translation file | +| `LANGUAGES` | `["en","de","fr","es","pl"]` | no | Locale codes offered in the picker (ships all five; narrow via this list) | +| `DEFAULT_LANGUAGE` | `en` | no | Fallback language (NULL user pref + no cookie). Must be listed in `LANGUAGES` | | `THEME_FILE` | `theme.css` | no | Colour theme stylesheet (`static/`-relative name, or absolute path/URL) | | `BASE_PATH` | *(empty)* | no | URL prefix WARP is mounted under, e.g. `/warp` (see [Mounting under a URL prefix](#mounting-under-a-url-prefix)) | | `WEEKS_IN_ADVANCE` | `1` | no | Weeks after current week available for booking | @@ -163,22 +164,36 @@ python -c 'from subprocess import run; print(run(["openssl","rand","16"],capture ## Language -The UI language is set globally for the instance — all users see the same language. +The UI language is a **per-user** choice: a language picker on the login +screen and in **Preferences** lets each user pick their own, stored in +`user_prefs.language` and carried across login/logout by the `warp_lang` +cookie. A deployment configures which languages are offered and the fallback: -| Language | `LANGUAGE_FILE` value | -| ----------------- | --------------------- | -| English (default) | `i18n/en.json` | -| German | `i18n/de.json` | -| French | `i18n/fr.json` | -| Spanish | `i18n/es.json` | -| Polish | `i18n/pl.json` | +| Setting | Default | Meaning | +| ------------------- | ------------ | ------- | +| `WARP_LANGUAGES` | `["en","de","fr","es","pl"]` | JSON array of locale codes offered in the picker (ships all five). Renders only when more than one is listed. | +| `WARP_DEFAULT_LANGUAGE` | `en` | Fallback language for users with no pref and no cookie. Must be listed in `WARP_LANGUAGES`. | ``` -WARP_LANGUAGE_FILE=i18n/de.json +WARP_LANGUAGES='["en","de","pl"]' +WARP_DEFAULT_LANGUAGE=en ``` -The iCal feed and action pages use the same language file for event summaries and -button labels. +Resolution precedence: **logged-in** users — `user_prefs.language` → `warp_lang` +cookie → `DEFAULT_LANGUAGE` (a stale cookie left by another user on a shared +device does not override your pref). **Login screen** (not logged in) — +`warp_lang` cookie → `DEFAULT_LANGUAGE`. Preferences lists each offered language +by name; there is no `Default` entry — a user with no stored preference follows +`DEFAULT_LANGUAGE` (shown applied, not selectable), so a later `DEFAULT_LANGUAGE` +change still reaches them. Picking any language pins it. + +> **Breaking change:** the former `WARP_LANGUAGE_FILE` (single deployment-wide +> file) is removed. If still set, it is **silently ignored** (a startup warning +> on stderr only) and the UI falls back to `DEFAULT_LANGUAGE` (`en`). Migrate by +> setting `WARP_LANGUAGES` (a JSON array) and `WARP_DEFAULT_LANGUAGE` instead. + +The iCal feed and action pages render in the owner's resolved language (a NULL +pref falls back to `DEFAULT_LANGUAGE`). --- diff --git a/FEATURES.md b/FEATURES.md index 8a4963f..efc85da 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -644,7 +644,7 @@ When a user clicks a link from their calendar, they are taken to a simple WARP p 2. On confirm: shows **"Seat released"** with the seat name. 3. On cancel: shows **"Action cancelled"**. -All text on these pages is translated according to the deployment-wide language setting. +All text on these pages is translated in the owner’s language (a user with no language preference falls back to the deployment default). --- @@ -668,9 +668,10 @@ All text on these pages is translated according to the deployment-wide language ## 20. Multi-Language Support - WARP supports **English, German, French, Spanish, and Polish**. -- The language is configured globally per instance via `LANGUAGE_FILE` (e.g., `i18n/de.json`). +- The language is a **per-user choice**: a flag picker on the login screen and a **Language** row in **Preferences** let each user pick their own. The choice is stored in `user_prefs.language` and carried across login/logout by the `warp_lang` cookie. A user with no stored preference follows the deployment's `DEFAULT_LANGUAGE` (shown applied in Preferences, not selectable); picking any language pins it. +- Which languages a deployment offers is configured via `WARP_LANGUAGES` (JSON array); the fallback via `WARP_DEFAULT_LANGUAGE`. - All UI strings (buttons, labels, error messages, modal text) are translated. -- The iCal feed uses the same language for event summaries and action page text. +- The iCal feed and action pages render in the owner's resolved language. - Date pickers adapt to the locale (first day of week, month names, etc.). --- @@ -830,7 +831,8 @@ A zone admin can **release another user's booking** from the plan map by clickin | `MAX_MAP_SIZE` | 2 MB | Maximum zone map image size | | `MAX_CONTENT_LENGTH` | 5 MB | Maximum request body size | | `TIMEZONE` | auto-detect | Timezone label for iCal DTSTART/DTEND | -| `LANGUAGE_FILE` | `i18n/en.json` | UI language file | +| `LANGUAGES` | `["en","de","fr","es","pl"]` | JSON array of locale codes offered in the picker (renders by default) | +| `DEFAULT_LANGUAGE` | `en` | Fallback language (NULL user pref / no cookie) | | `SECRET_KEY` | — (required) | Key for signing session cookies | | `DATABASE_ADDRESS` | — (required) | Database host or `host:port` (port defaults to 5432) | | `DATABASE_NAME` | — (required) | Database name | diff --git a/README.md b/README.md index 961786f..9ce73d6 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,11 @@ for writing new tests. # Other +## Third-party assets + +- **Material Icons** (Google) — `warp/static/material_icons/`, Apache 2.0. +- **Flag SVGs** — `warp/static/images/flags/`, from [lipis/flag-icons](https://github.com/lipis/flag-icons) (MIT). Used for the per-user language picker. + ## How can I support you Oh.. I was not expecting that, but you can send a beer via PayPal: https://paypal.me/sebo271 diff --git a/containers/README.md b/containers/README.md index b905067..eb12f90 100644 --- a/containers/README.md +++ b/containers/README.md @@ -165,7 +165,8 @@ LDAP, …) or any other feature, add the relevant `WARP_*` variables under | `warp_secret_key` secret | `mysecretkey` | A random secret — see [CONFIGURATION.md](../CONFIGURATION.md#secret-key) | | `warp_db_password` secret | `postgres_password` | A strong database password (used by both the DB and the app) | | `warp-app` image tag | `:latest` | A pinned version, e.g. `:v1.2.3` | -| `WARP_LANGUAGE_FILE` | `i18n/en.json` | Your preferred language (`de`/`fr`/`es`/`pl`) | +| `WARP_LANGUAGES` | `["en","de","fr","es","pl"]` | JSON array of locale codes offered in the picker (`en`/`de`/`fr`/`es`/`pl`) | +| `WARP_DEFAULT_LANGUAGE` | `en` | Fallback language (must be listed in `WARP_LANGUAGES`) | --- @@ -261,7 +262,8 @@ install the unit files under `~/.config/containers/systemd/` instead. 4. **Review the unit files** and adjust paths if you changed any of the directories above (`Volume=` lines in `warp-db.container` and `warp-revproxy.container`), the image reference in `warp-app.container`, and - `WARP_LANGUAGE_FILE` (`i18n/en.json`, also `de`/`fr`/`es`/`pl`). + `WARP_LANGUAGES` / `WARP_DEFAULT_LANGUAGE` (per-user language picker; see + CONFIGURATION.md). 5. **Install the unit files** into the Quadlet drop-in directory and reload: diff --git a/containers/compose/compose.yaml b/containers/compose/compose.yaml index 58f7a2e..053a68f 100644 --- a/containers/compose/compose.yaml +++ b/containers/compose/compose.yaml @@ -40,7 +40,6 @@ services: WARP_DATABASE_USER: "postgres" WARP_DATABASE_PASSWORD_FILE: /run/secrets/warp_db_password WARP_SECRET_KEY_FILE: /run/secrets/warp_secret_key - WARP_LANGUAGE_FILE: "i18n/en.json" # uWSGI endpoints, mirroring the Quadlet setup: disable the binary uWSGI # socket (empty value) so only the HTTP unix socket that Caddy proxies to # over the shared /run/warp volume remains. The HTTP socket already defaults diff --git a/containers/quadlet/warp-app.container b/containers/quadlet/warp-app.container index bdcaa99..98e413e 100644 --- a/containers/quadlet/warp-app.container +++ b/containers/quadlet/warp-app.container @@ -33,9 +33,6 @@ Environment=WARP_DATABASE_PASSWORD_FILE=/run/secrets/warp-db-password Secret=warp-secret-key Environment=WARP_SECRET_KEY_FILE=/run/secrets/warp-secret-key -# UI language. Available: en, de, fr, es, pl. -Environment=WARP_LANGUAGE_FILE=i18n/en.json - # uWSGI endpoints. The in-pod Caddy proxies over the HTTP unix socket on the # shared /run/warp volume, so the binary uWSGI socket is disabled (empty value). # The HTTP socket already defaults to /run/warp/uwsgi-http.sock in the app, so it diff --git a/e2e/README.md b/e2e/README.md index 5a9236f..48c477c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -77,6 +77,11 @@ npm run report # open last HTML report test, so no manual cleanup is needed. Mind that jumping forward by a day or more expires every login session (`SESSION_LIFETIME`): log in again after advancing the clock, or `/xhr/*` calls silently redirect to `/login`. +- **Language**: the e2e container is started with + `WARP_LANGUAGES='["en","de"]'` and `WARP_DEFAULT_LANGUAGE=en`, so the + per-user language picker renders with two entries. `setLanguage(page, lang)` + (debug only) switches the deployment fallback `DEFAULT_LANGUAGE` at runtime + and clears the iCal feed cache; the fixture resets it to `en` between tests. - Because all tests share one database, the config pins `workers: 1` and `fullyParallel: false`. Do not turn parallelism on without giving each worker its own database. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 4739140..4acf467 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -20,15 +20,16 @@ async function resetServerClock(baseURL: string): Promise { } /** - * Reset the deployment language to English (see helpers/debug.ts setLanguage). - * LANGUAGE_FILE is process-global state; a test that switches language would - * otherwise poison every test after it. Tolerates 404 for a non-debug server. + * Reset the deployment fallback language to English (see helpers/debug.ts + * setLanguage). DEFAULT_LANGUAGE is process-global state; a test that switches + * it would otherwise poison every test after it. Tolerates 404 for a non-debug + * server. */ async function resetServerLanguage(baseURL: string): Promise { const resp = await fetch(`${baseURL}/debug/set_language`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ language_file: 'i18n/en.json' }), + body: JSON.stringify({ language: 'en' }), }); if (!resp.ok && resp.status !== 404) { throw new Error(`resetting language failed: HTTP ${resp.status}`); diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index fc3e384..7dcc074 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -89,6 +89,10 @@ export default async function globalSetup() { // The suite resets the database directly over TCP, so Postgres must bind // all interfaces inside the container (off by default — see Dockerfile_debug). '-e', 'EXPOSE_POSTGRES=1', + // Per-user language picker needs >1 configured language to render the + // dropdown; the ical-language spec also switches DEFAULT_LANGUAGE to 'de'. + '-e', "WARP_LANGUAGES=[\"en\",\"de\"]", + '-e', 'WARP_DEFAULT_LANGUAGE=en', IMAGE_TAG, ]); diff --git a/e2e/helpers/debug.ts b/e2e/helpers/debug.ts index 54d93d7..d08eddd 100644 --- a/e2e/helpers/debug.ts +++ b/e2e/helpers/debug.ts @@ -33,15 +33,14 @@ export async function advanceDays(page: Page, days: number): Promise { } /** - * Switch the deployment language (debug only). Sets LANGUAGE_FILE and clears - * the iCal feed cache so the feed regenerates in the new language. - * `lang` is a short code ('de','en',...) resolved to `i18n/.json`, - * or pass a full path via the second arg. Reset between tests with 'en'. + * Switch the deployment fallback language (debug only). Sets DEFAULT_LANGUAGE + * (the per-user resolver falls back to it when a user has no pref) and clears + * the iCal feed cache so the feed regenerates in the new language. `lang` is a + * short code ('de','en',...); reset between tests with 'en'. */ -export async function setLanguage(page: Page, lang: string, fullPath?: string): Promise { - const languageFile = fullPath ?? `i18n/${lang}.json`; +export async function setLanguage(page: Page, lang: string): Promise { const resp = await page.request.post('/debug/set_language', { - data: { language_file: languageFile }, + data: { language: lang }, headers: { 'Content-Type': 'application/json' }, }); if (!resp.ok()) { diff --git a/e2e/tests/settings/ical-language.spec.ts b/e2e/tests/settings/ical-language.spec.ts index 4963617..782f3f1 100644 --- a/e2e/tests/settings/ical-language.spec.ts +++ b/e2e/tests/settings/ical-language.spec.ts @@ -3,9 +3,10 @@ * * Feed event summaries (phrases: booking / missing / release) and the * action-page titles + button labels (Release seat? / Seat released / - * Confirm / Cancel …) are rendered from the deployment language file - * (LANGUAGE_FILE). Switched at runtime via the debug-only /debug/set_language - * endpoint; the per-test fixture resets LANGUAGE_FILE to English afterwards. + * Confirm / Cancel ...) are rendered from the resolved language. For a user + * with no language pref, that falls back to the deployment DEFAULT_LANGUAGE, + * switched at runtime via the debug-only /debug/set_language endpoint; the + * per-test fixture resets DEFAULT_LANGUAGE to English afterwards. */ import { test, expect } from '../../fixtures'; @@ -37,7 +38,7 @@ async function fetchIcal(page: Page, token: string): Promise { return parseIcal(await resp.text()); } -// ─── Feed summaries follow LANGUAGE_FILE ──────────────────────────────────── +// ─── Feed summaries follow the resolved language ──────────────────────── test.describe('iCal feed text follows the deployment language', () => { test('German: booking summary uses "Platz {name}" and reminders "Platz in … buchen"', async ({ page }) => { @@ -76,7 +77,7 @@ test.describe('iCal feed text follows the deployment language', () => { }); }); -// ─── Action-page text follows LANGUAGE_FILE ───────────────────────────────── +// ─── Action-page text follows the resolved language ─────────────────────── test.describe('iCal action-page text follows the deployment language', () => { test('German: release confirm page shows "Platz freigeben?" and Bestätigen/Abbrechen', async ({ page }) => { diff --git a/e2e/tests/settings/language.spec.ts b/e2e/tests/settings/language.spec.ts new file mode 100644 index 0000000..b5f4565 --- /dev/null +++ b/e2e/tests/settings/language.spec.ts @@ -0,0 +1,217 @@ +/** + * Per-user language selection (see PLAN_language_selection.md). + * + * Coverage: + * - login screen flag dropdown: switching reloads + renders in the chosen + * language (assert a translated string); the warp_lang cookie is set; + * clicking the already-active flag does NOT reload (bounded negative). + * - Preferences: a Language row listing each offered language (no Default + * entry — a NULL pref shows the deployment default applied, not selectable); + * selecting a language and saving reloads + persists (DB backchannel); + * picking the deployment-default language pins it; saving with no language + * change keeps the pref NULL (the default keeps applying). + * - precedence (shared device): a seeded user pref beats a stale cookie, and + * bootstrap resets the cookie to the pref. + * - default: no cookie + NULL pref renders the deployment default (en). + * + * The container runs with WARP_LANGUAGES='["en","de"]' (global-setup.ts). + */ + +import { test, expect } from '../../fixtures'; +import { logIn, expectLoggedIn } from '../../helpers/auth'; +import { USER1 } from '../../helpers/users'; +import { querySql } from '../../helpers/db'; +import { openUserMenu } from '../../helpers/settings'; +import { waitForViewReady } from '../../helpers/spa'; +import { getRuntimeInfo } from '../../helpers/runtime'; + +type Page = import('@playwright/test').Page; + +async function clearUserPrefs(login: string): Promise { + await querySql('DELETE FROM user_prefs WHERE login = $1', [login]); +} + +async function seedUserPref(login: string, language: string | null): Promise { + await querySql( + 'INSERT INTO user_prefs(login, language) VALUES($1,$2) ON CONFLICT(login) DO UPDATE SET language = $2', + [login, language], + ); +} + +async function prefLanguage(login: string): Promise { + const rows = await querySql('SELECT language FROM user_prefs WHERE login = $1', [login]); + return rows.rowCount ? rows.rows[0].language : null; +} + +// ─── Login screen dropdown ──────────────────────────────────────────────── + +test.describe('login screen language dropdown', () => { + + test('switching language reloads and renders the login button translated', async ({ page, context }) => { + await clearUserPrefs(USER1.login); + await page.goto('/login'); + await expect(page.locator('.lang-trigger')).toBeVisible(); + + // Default is English: the submit button reads "Login". + await expect(page.locator('button[type=submit]')).toHaveText('Login'); + + // Pick German from the dropdown. + await page.locator('.lang-trigger').click(); + await page.locator('.lang-dropdown a[data-lang="de"]').click(); + + // The click sets the cookie and reloads; after reload the button is German. + await expect(page.locator('button[type=submit]')).toHaveText('Anmelden'); + const cookies = await context.cookies(); + expect(cookies.find(c => c.name === 'warp_lang')?.value).toBe('de'); + }); + + test('clicking the already-active flag does not reload', async ({ page }) => { + await page.goto('/login'); + await page.locator('.lang-trigger').click(); + // English is active by default; clicking it must not navigate. + const navigations: number[] = []; + page.on('framenavigated', () => navigations.push(Date.now())); + await page.locator('.lang-dropdown a[data-lang="en"]').click(); + // Bounded wait: if no navigation fires within 1s, the negative holds. + await page.waitForTimeout(1000); + expect(navigations.length, 'no reload expected when choosing the active language').toBe(0); + }); +}); + +// ─── Preferences modal ──────────────────────────────────────────────────── + +async function openPrefs(page: Page): Promise { + // Await THIS page's /xhr/prefs GET (the prefs modal JS resolves its Promise: + // - loads `loadedPrefs`, unblocking postPrefs()'s loadedPrefs===null guard + // (without this, save is a no-op → 'de' never POSTed → DB stays null); + // - runs applyPrefsToUI()/setLangUI(), which OVERWRITES an in-progress + // selection — clicking 'de' before the GET resolves is reverted to null. + // Waiting on .pref-lang-name alone is NOT enough: setLangUI(null) fills it + // from the default option at modal open, before the GET resolves. + // The listener MUST be registered BEFORE page.goto returns settling — under + // load, the GET completes while page.goto() is still awaiting 'load', so an + // after-goto listener misses it (logged in only this test, when it runs + // right after ical-language.spec.ts). Registered before navigation, it + // captures this boot's GET; the prior page's in-flight GET was aborted by + // the navigation and never fires 'response'. + const prefsLoaded = page.waitForResponse( + r => r.url().includes('/xhr/prefs') && r.request().method() === 'GET', + ); + await page.goto('/'); + await openUserMenu(page); + // Open by href, not translated text: this spec seeds/switches non-English + // languages, so the link reads e.g. "Einstellungen", not "Preferences". + await page.locator('#user_menu_dropdown a[href="#pref_modal"]').click(); + await expect(page.locator('#pref_modal')).toBeVisible(); + await prefsLoaded; +} + +// The prefs Language control is an M.Dropdown (flag+name list), not a +// native diff --git a/warp/view.py b/warp/view.py index b77727f..be70064 100644 --- a/warp/view.py +++ b/warp/view.py @@ -230,7 +230,7 @@ def manifest(): 'start_url': scope, 'scope': scope, 'display': 'standalone', - 'lang': 'en', + 'lang': flask.current_app.config['DEFAULT_LANGUAGE'], 'background_color': '#2C3E50', 'theme_color': '#2C3E50', 'icons': [ diff --git a/warp/xhr/bootstrap.py b/warp/xhr/bootstrap.py index 270b862..072e0d0 100644 --- a/warp/xhr/bootstrap.py +++ b/warp/xhr/bootstrap.py @@ -2,12 +2,19 @@ from warp.db import * from warp.xhr.prefs import get_user_prefs +from warp import i18n bp = flask.Blueprint('bootstrap', __name__) @bp.route("/bootstrap", methods=["GET"]) def bootstrap(): + # NOTE: this GET intentionally has DB-write + Set-Cookie side effects. It + # fires once per shell boot (cached by bootstrap.js), so it is idempotent + # and bounded. It is the single place that reconciles the warp_lang cookie + # with user_prefs.language for every authenticated session, regardless of + # which auth backend established it. + # Left nav: plans the user has access to (has accessible seats in at least # one zone). Mirrors the old headerDataInit context processor. accessible_zone_rows = Zone.select(Zone.id, Zone.name) \ @@ -27,9 +34,10 @@ def bootstrap(): .order_by(Plan.name) plans = [{"id": p['id'], "name": p['name']} for p in plan_rows] - default_plan = get_user_prefs(flask.g.login).get('default_plan') + prefs = get_user_prefs(flask.g.login) + default_plan = prefs.get('default_plan') - return { + payload = { "plans": plans, "zones": zones, "defaultPlan": default_plan, @@ -37,3 +45,45 @@ def bootstrap(): "login": flask.g.login, "name": flask.g.name, } + + # --- cookie / prefs language sync (decision 1) --- + # Prefs are authoritative while logged in; the cookie is only the + # carry-across-logout transport. The active language itself is resolved + # at render time (context processor); this block only reconciles the cookie + # with prefs, never returns the active value. + configured = set(i18n.configured_languages()) + cookie = flask.request.cookies.get('warp_lang') + cookie_valid = cookie in configured + + pref = prefs.get('language') + if pref not in configured: + # A stored code the deployment later removed must not be echoed into + # the cookie (the render path's resolve already ignores it). + pref = None + + resp = flask.jsonify(payload) + if pref is not None: + # Prefs win. Correct a stale/differing/absent cookie to match. + if cookie != pref: + resp.set_cookie('warp_lang', pref, max_age=31536000, samesite='lax', path='/') + elif cookie_valid: + # No pref yet: persist the cookie choice so it sticks across logout. + # Upsert (not a plain UPDATE): get_user_prefs synthesizes defaults + # when no row exists, so an UPDATE would match zero rows and silently + # no-op for exactly the never-saved users most likely to hit this. + UserPrefs.insert({ + UserPrefs.login: flask.g.login, + UserPrefs.language: cookie, + }).on_conflict( + conflict_target=[UserPrefs.login], + update={UserPrefs.language: cookie} + ).execute() + # The persisted language changes this user's calendar feed text. + from warp.ical import invalidate_calendar_cache + invalidate_calendar_cache(flask.g.login) + elif cookie is not None: + # Invalid stale cookie: delete it so it stops shadowing the default. + resp.delete_cookie('warp_lang', path='/') + # else: no pref, no cookie -> nothing to do (render falls back to default). + + return resp diff --git a/warp/xhr/prefs.py b/warp/xhr/prefs.py index d803e96..fd9abb4 100644 --- a/warp/xhr/prefs.py +++ b/warp/xhr/prefs.py @@ -24,7 +24,10 @@ "zone_show_seat_names": {"type": "boolean"}, "zone_show_booking_preview": {"type": "boolean"}, "zone_show_assigned_names": {"type": "boolean"}, + "language": {"type": ["string", "null"]}, }, + # `language` is optional: a client that never loaded prefs omits it (see + # prefs_set) rather than POST a snapshot that would wipe the stored pref. "required": ["default_day", "default_time", "zone_show_seat_names", "zone_show_booking_preview", "zone_show_assigned_names"], "additionalProperties": False } @@ -38,6 +41,7 @@ def _row_to_prefs(row): "zone_show_seat_names": row['zone_show_seat_names'], "zone_show_booking_preview": row['zone_show_booking_preview'], "zone_show_assigned_names": row['zone_show_assigned_names'], + "language": row['language'], } @@ -59,10 +63,19 @@ def get_user_prefs(login): UserPrefs.zone_show_seat_names, UserPrefs.zone_show_booking_preview, UserPrefs.zone_show_assigned_names, + UserPrefs.language, ).where(UserPrefs.login == login).first() if row: - return _row_to_prefs(row) + prefs = _row_to_prefs(row) + # Coerce a stored language the deployment later removed: returning it + # raw would let every prefs save POST it back and hit the runtime 400 + # gate (the user could never save again — and on a single-language + # deployment there's no UI recovery). Same coercion bootstrap applies. + lang = prefs['language'] + if lang is not None and lang not in flask.current_app.config['LANGUAGES']: + prefs['language'] = None + return prefs return { "default_plan": None, @@ -71,6 +84,7 @@ def get_user_prefs(login): "zone_show_seat_names": False, "zone_show_booking_preview": False, "zone_show_assigned_names": False, + "language": None, } @@ -87,6 +101,20 @@ def prefs_set(): if time_from >= time_to: return {"msg": "Data error", "code": 13}, 400 + # `language` is OPTIONAL in the payload: when the client never loaded + # prefs (stale tab, a failed /xhr/prefs GET, or a slow GET overwriting an + # in-flight selection) it omits the key rather than POST a boot-time + # snapshot that would wipe the stored pref + cookie with no reload to + # reveal it. When present, null means "no pinned language" (the client + # sends null, never an empty string, which 400s at schema validation). + # Runtime in-LANGUAGES gate: a code the deployment does not offer must not + # be stored (the resolver would silently ignore it) — the module-level + # schema can't see LANGUAGES, so this is the real gate. + language_present = 'language' in jsonData + language = jsonData.get('language') + if language_present and language is not None and language not in flask.current_app.config['LANGUAGES']: + return {"msg": "Data error", "code": 13}, 400 + values = { UserPrefs.login: flask.g.login, UserPrefs.default_plan: _coerce_default_plan(jsonData.get('default_plan')), @@ -97,6 +125,8 @@ def prefs_set(): UserPrefs.zone_show_booking_preview: jsonData['zone_show_booking_preview'], UserPrefs.zone_show_assigned_names: jsonData['zone_show_assigned_names'], } + if language_present: + values[UserPrefs.language] = language update = { UserPrefs.default_plan: values[UserPrefs.default_plan], @@ -107,10 +137,28 @@ def prefs_set(): UserPrefs.zone_show_booking_preview: values[UserPrefs.zone_show_booking_preview], UserPrefs.zone_show_assigned_names: values[UserPrefs.zone_show_assigned_names], } + if language_present: + update[UserPrefs.language] = language UserPrefs.insert(values).on_conflict( conflict_target=[UserPrefs.login], update=update ).execute() - return get_user_prefs(flask.g.login) + # A language change invalidates this user's calendar feed cache (feed text + # is language-specific). Import here to avoid a circular import at module load. + from warp.ical import invalidate_calendar_cache + invalidate_calendar_cache(flask.g.login) + + # Mirror the choice into the warp_lang cookie so the next render (and the + # post-logout login page) use it. null deletes the cookie so the deployment + # default takes over. Only when the client sent a language (a change); an + # omitted key leaves the stored language + cookie untouched. (The client + # also sets/deletes it so the reload on change happens immediately.) + resp = flask.jsonify(get_user_prefs(flask.g.login)) + if language_present: + if language is not None: + resp.set_cookie('warp_lang', language, max_age=31536000, samesite='lax', path='/') + else: + resp.delete_cookie('warp_lang', path='/') + return resp