From 0dc079a8ac1341b9b96bf061e0dc170bab4260a3 Mon Sep 17 00:00:00 2001 From: Ebbe Date: Sat, 17 Oct 2026 21:05:40 +0200 Subject: [PATCH] feat: add page shell, preferences and translations Base layout with top bar, role-based menu, profile menu and phone layout; theme, language and German form of address kept in cookies; German formal, German informal and English text; Fluent avatar bundled in the image by a Node build stage; placeholder pages for the Publisher and Admin groups. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC --- .dockerignore | 4 + .gitignore | 8 + Dockerfile | 15 +- README.md | 8 + ...shell-preferences-and-translations-plan.md | 104 ++++ portal/config/settings.py | 4 +- portal/config/urls.py | 1 + portal/frontend/build.mjs | 12 + portal/frontend/entry.js | 2 + portal/frontend/package-lock.json | 557 ++++++++++++++++++ portal/frontend/package.json | 16 + portal/locale/de/LC_MESSAGES/django.po | 109 ++++ portal/locale/de_DU/LC_MESSAGES/django.po | 28 + portal/shell/__init__.py | 0 portal/shell/apps.py | 6 + portal/shell/context.py | 37 ++ portal/shell/menu.py | 61 ++ portal/shell/middleware.py | 24 + portal/shell/preferences.py | 82 +++ portal/shell/urls.py | 12 + portal/shell/views.py | 37 ++ portal/static/portal/shell.css | 432 ++++++++++++++ portal/static/portal/shell.js | 73 +++ portal/templates/accounts/base.html | 14 +- portal/templates/accounts/home.html | 10 +- portal/templates/base.html | 69 +++ portal/templates/shell/placeholder.html | 7 + portal/templates/shell/preference_group.html | 8 + portal/tests/conftest.py | 18 + portal/tests/test_preferences.py | 88 +++ portal/tests/test_shell.py | 225 +++++++ portal/tests/test_translations.py | 73 +++ pyproject.toml | 1 + uv.lock | 11 + 34 files changed, 2132 insertions(+), 24 deletions(-) create mode 100644 _bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-page-shell-preferences-and-translations-plan.md create mode 100644 portal/frontend/build.mjs create mode 100644 portal/frontend/entry.js create mode 100644 portal/frontend/package-lock.json create mode 100644 portal/frontend/package.json create mode 100644 portal/locale/de/LC_MESSAGES/django.po create mode 100644 portal/locale/de_DU/LC_MESSAGES/django.po create mode 100644 portal/shell/__init__.py create mode 100644 portal/shell/apps.py create mode 100644 portal/shell/context.py create mode 100644 portal/shell/menu.py create mode 100644 portal/shell/middleware.py create mode 100644 portal/shell/preferences.py create mode 100644 portal/shell/urls.py create mode 100644 portal/shell/views.py create mode 100644 portal/static/portal/shell.css create mode 100644 portal/static/portal/shell.js create mode 100644 portal/templates/base.html create mode 100644 portal/templates/shell/placeholder.html create mode 100644 portal/templates/shell/preference_group.html create mode 100644 portal/tests/test_preferences.py create mode 100644 portal/tests/test_shell.py create mode 100644 portal/tests/test_translations.py diff --git a/.dockerignore b/.dockerignore index 022597f..5b94ea5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,3 +16,7 @@ context-resources staticfiles *.sqlite3* skills-lock.json +**/node_modules +portal/frontend/dist +portal/static/vendor +**/*.mo diff --git a/.gitignore b/.gitignore index 3d07d98..ce97fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,11 @@ staticfiles/ .env .env.* !.env.example + +# Front-end build +node_modules/ +portal/frontend/dist/ +portal/static/vendor/ + +# Compiled message catalogues (built in the image; tests compile their own) +*.mo diff --git a/Dockerfile b/Dockerfile index 6d95c87..1ee8413 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,33 @@ # syntax=docker/dockerfile:1 +# Builds the Fluent web components bundle; Node.js is not part of the runtime image. +FROM node:24-slim AS frontend +WORKDIR /frontend +COPY portal/frontend/package.json portal/frontend/package-lock.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY portal/frontend/entry.js portal/frontend/build.mjs ./ +RUN npm run build + FROM python:3.13-slim AS builder COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /usr/local/bin/uv ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libldap2-dev libsasl2-dev \ + && apt-get install -y --no-install-recommends build-essential libldap2-dev libsasl2-dev gettext \ && rm -rf /var/lib/apt/lists/* COPY pyproject.toml uv.lock .python-version README.md ./ RUN uv sync --frozen --no-dev --no-install-project COPY manage.py ./ COPY portal ./portal -# Build-time only values so collectstatic can load settings; not used at runtime. +COPY --from=frontend /frontend/dist/fluent.js ./portal/static/vendor/fluent.js +# Build-time only values so compilemessages and collectstatic can load settings; not used at runtime. RUN SECRET_KEY=build-time-only-value-not-used-at-runtime-000000 \ ALLOWED_HOSTS=localhost DATA_DIR=/tmp \ LDAP_SERVER=build.invalid LDAP_USER_OU_DN=build LDAP_BIND_DN=build LDAP_BIND_PASSWORD=build \ LDAP_CA_FILE=/app/README.md LDAP_PUBLISHER_GROUP_DN=build LDAP_ADMIN_GROUP_DN=build \ OIDC_ISSUER=https://build.invalid/ OIDC_CLIENT_ID=build OIDC_CLIENT_SECRET=build \ PUBLIC_BASE_URL=https://build.invalid \ - /app/.venv/bin/python manage.py collectstatic --noinput + sh -c '/app/.venv/bin/python manage.py compilemessages && /app/.venv/bin/python manage.py collectstatic --noinput' # Not part of the runtime image: `docker compose --profile test run --rm live-test` FROM builder AS test diff --git a/README.md b/README.md index 90cfdde..e321135 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ uv run ruff check . `pytest` sets its own test environment (see `pyproject.toml`). +### Page shell, front end and translations + +The Fluent web components are bundled by a Node build stage in the `Dockerfile` (`portal/frontend`, pinned by `package-lock.json`) and served from the portal's own origin; the runtime image has no Node.js. To rebuild the bundle locally: `cd portal/frontend && npm ci && npm run build`, then copy `dist/fluent.js` to `portal/static/vendor/`. Pages work without it (the avatar falls back to initials). + +Text is German (formal and informal) and English. The English text is the message id. Add the German text to `portal/locale/de/LC_MESSAGES/django.po`, and an informal variant to `portal/locale/de_DU/...` only where it differs. `test_translations.py` fails if a string has no German text. The Docker build compiles the catalogues with `gettext`; the tests compile their own copy. + +Theme, language and form of address are stored per device in the cookies `pm_theme`, `pm_lang` and `pm_form`; nothing is stored on the server. + ### Directory adapter against the test OU `uv sync` builds `python-ldap`, which needs the OpenLDAP and SASL development libraries (the Docker image installs them). The automated tests use an in-memory fake and a stand-in for the `ldap` module, so they run anywhere. diff --git a/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-page-shell-preferences-and-translations-plan.md b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-page-shell-preferences-and-translations-plan.md new file mode 100644 index 0000000..d8d53a0 --- /dev/null +++ b/_bmad-output/initiative-ping-mee-portal/epic-platform-and-sign-in/story-page-shell-preferences-and-translations-plan.md @@ -0,0 +1,104 @@ +--- +title: 'Page shell, preferences and translations' +type: 'feature' +ticket: '6' +created: '2026-10-17' +status: 'built' +baseline_revision: '3c69c2a' +route: 'full' +route_source: 'auto' +risk: 'medium' +review: 'quick' +review_source: 'pinned' +context: + - '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md' + - '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/DESIGN.md' + - '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md' +--- + + + +## Intent + +**Problem:** After sign-in the user sees an unstyled English page. Every later epic needs one shared layout, the three per-device preferences and German/English text before it can add a page. + +**Approach:** One base layout (top bar, left menu with role-based groups, profile menu, skip link) in plain server-rendered HTML styled with the design tokens, with the Fluent web components used for the interactive controls and bundled into the image. Theme, language and German form of address are stored in cookies and read by the server to render the page. All existing text moves to a German formal, German informal and English catalogue. + +## Boundaries & Constraints + +**Always:** AD-15 and AD-16 apply. No inline script, inline style or inline event handler (CSP stays `script-src 'self'`); no network request for fonts, icons or scripts at runtime. The theme is an attribute on ``; `system` is resolved by CSS (`prefers-color-scheme`) only. Preferences are never sent to the server except as cookies. Every user-visible string goes through translation. The menu is declared in one place with the role each entry needs; a hidden entry is also refused by its view (decorators from 1.5). Touch targets are at least 44 px; the break between wide and narrow layout is 720 px. The page works for navigation and sign-out without JavaScript. + +**Never:** No dashboard content (account card, announcements): that is the account epic. No Admin colour or background settings: the accent colour is a CSS variable with the default `#0F6CBD`, set by a later entry. No server-stored preferences. No Node.js or npm in the runtime image. No CDN. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Shell after sign-in | Signed-in user | Top bar with portal name and avatar (initials), left menu, content; their display name is shown in the profile menu | n/a | +| Menu by role | User / Publisher / Admin | User: Startseite. Publisher adds group "Veröffentlichen". Admin adds group "Administration". A Publisher who is not Admin sees no Administration group | A direct address to a hidden page shows "Dazu fehlt Ihnen die Berechtigung." (403) | +| First visit | No preference cookies | Theme System, language from `Accept-Language` (de or en, else en), German form formal | n/a | +| Choose preference | Profile menu | Cookie set, page re-renders with the new value; with JavaScript it applies at once without reloading | An unknown value is ignored, the cookie is not set | +| Informal form | Language English, form "du" | Form has no effect on English and the control is hidden | n/a | +| Informal German | Language German, form "du" | Strings use "du" forms; a string with no informal entry falls back to the formal one | n/a | +| Unsafe return address | `next` outside the site | Ignored; the user goes to `/` | n/a | +| Phone width | 360 px | Menu behind a menu button, one column, no horizontal scroll | n/a | +| Signed-out and error pages | Not signed in | Same layout without menu and avatar; preferences still readable from the cookies | n/a | + + + +## Code Map + +- `portal/templates/base.html` -- new shared layout; `accounts/base.html` becomes a thin extension of it. All 1.5 pages move onto it. +- `portal/shell/` -- new app: `menu.py` (declaration of entries: label, URL name, required role, group), `preferences.py` (read and validate the three cookies, cookie names and lifetime), `middleware.py` (activates the language, sets `request.preferences`), `context.py` (context processor: menu filtered by role, preferences), `views.py` (`POST /preferences`), `urls.py`. +- `portal/frontend/` -- new: `package.json`, `package-lock.json`, an entry file that imports only the Fluent components used, and a build script. +- `portal/static/portal/` -- `shell.css` (tokens from DESIGN.md, light and dark, phone layout), `shell.js` (menu button, profile menu, apply preference at once; no inline code). +- `portal/locale/{de,de_DU,en}/LC_MESSAGES/django.po` -- catalogues; `de_DU` holds only the informal differences and falls back to `de`. +- `Dockerfile` -- a `frontend` build stage on `node:*-slim` runs `npm ci` and the bundle; the result is copied into `portal/static/vendor/` before `collectstatic`. Message catalogues are compiled in the builder stage. +- `portal/config/settings.py` -- add the app, middleware and context processor, `LANGUAGES` with the informal variant. +- `portal/config/middleware.py` -- CSP gains `style-src 'self'` and `img-src 'self' data:` only if the Fluent bundle needs them (decided at build; no `unsafe-inline`). +- Placeholder pages for the Publisher and Admin groups (one view each, decorated with `publisher_required` / `admin_required`, text "Diese Seite folgt.") so role visibility and the direct-address refusal can be checked now; later epics replace them. +- `portal/tests/` -- `test_preferences.py`, `test_shell.py`, `test_translations.py`. + +## Tasks & Acceptance + +**Execution:** +- [ ] `portal/frontend/` and Dockerfile stage -- build the Fluent bundle into the image, pinned by lockfile -- AD-16 +- [ ] `portal/shell/preferences.py`, `middleware.py` -- cookies in, validated preferences out, language activated per request -- AD-15 +- [ ] `portal/templates/base.html`, `shell.css`, `shell.js` -- layout, themes, profile menu, phone menu, skip link +- [ ] `portal/shell/menu.py`, `context.py` -- role-filtered menu and active-item marker +- [ ] `portal/locale/` -- German formal, German informal and English text for every existing string; compile step in the Docker build +- [ ] Move the 1.5 pages onto the shell; placeholder Publisher and Admin pages +- [ ] Tests for every matrix row + +**Acceptance Criteria:** +- Given a signed-in user, when the home page loads, then the top bar, menu and their display name are in the page and no script, style or event-handler attribute is inline. +- Given a User, a Publisher and an Admin, when the menu renders, then it shows Startseite, plus "Veröffentlichen" only for Publishers and "Administration" only for Admins, and a direct request to a hidden page returns 403. +- Given cookies theme=dark, lang=de, form=du, when a page renders, then `` carries the dark theme and the page text uses the informal form; with lang=en it is English and ignores the form. +- Given no cookies and `Accept-Language: de-DE`, when a page renders, then it is German formal and the theme is System. +- Given an invalid preference value, when it is posted, then no cookie changes. +- Given every `{% trans %}` and `gettext` string in the code, when the catalogue test runs, then each has a German entry and no entry is fuzzy or empty. +- Given a viewport of 360 px, when the page loads, then the menu is behind a button and there is no horizontal scrolling (checked by hand at the done step; the CSS rule is checked by test). + +## Design Notes + +Preferences are cookies named `pm_theme`, `pm_lang`, `pm_form`, `SameSite=Lax`, `Secure`, one year, not `HttpOnly` so `shell.js` can apply a change without a reload; they hold no personal data. The informal German register is a language code `de-du` whose catalogue holds only differing strings; Django's gettext lookup falls back to `de`, which falls back to `msgid`. If that fallback does not behave as expected in the build, the plan changes to a template filter, not to duplicated catalogues. + +`msgfmt` and `xgettext` (GNU gettext) are not available on the Windows development machine, so strings are added to the catalogues by hand and a test (using `polib`) keeps them complete; the compile step runs in the Docker builder stage, which installs `gettext`. Message extraction with `makemessages` can be run in a container later when the string count grows. + +Fluent web components render inside shadow DOM; if a component needs inline styles that the CSP refuses, the control falls back to plain HTML and CSS rather than loosening the policy. + +## Verification + +**Commands:** +- `uv run pytest` -- expected: all tests pass, including the new ones +- `uv run ruff check portal manage.py docker` -- expected: no findings +- `uv run python manage.py makemigrations --check --dry-run` -- expected: no changes detected +- `docker compose up --build` on the Linux host -- expected: image builds with the front-end stage; pages load with no CSP violations in the browser console + +**Manual:** sign in as `ptest-user1`, `ptest-publisher`, `ptest-admin`; check the menu groups, change the three preferences and reload, narrow the window to phone width, and open `/` in a private window to see the defaults. + +## Implementation Notes + +- Built inline; `pytest` 247 passed, 6 skipped (live LDAP), `ruff check` clean, `makemigrations --check` no changes. +- Judgement calls: the Fluent bundle contains only the avatar (the one control the shell needs that works with a fallback); buttons and menus are native HTML so the shell works without JavaScript. The profile menu is a `
` element. Theme applies at once; language and form reload the page because the server renders the text. `LocaleMiddleware` is replaced by `PreferencesMiddleware`. The `de_DU` catalogue fell back to `de` as hoped (tested). No `locale/en` catalogue: the message ids are English. Placeholder pages live at `/publish/announcements` and `/administration/{appearance,settings,audit,status}`. +- Not verified: the Docker build (Node stage, `compilemessages`) and the visual result in a browser, including the Fluent avatar tokens and the phone layout, since neither runs on the Windows machine. diff --git a/portal/config/settings.py b/portal/config/settings.py index f9cac24..502b0d3 100644 --- a/portal/config/settings.py +++ b/portal/config/settings.py @@ -33,6 +33,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "portal.audit", "portal.accounts", + "portal.shell", ] MIDDLEWARE = [ @@ -44,7 +45,7 @@ MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "portal.accounts.middleware.AbsoluteSessionLimitMiddleware", "portal.accounts.middleware.CurrentUserMiddleware", - "django.middleware.locale.LocaleMiddleware", + "portal.shell.middleware.PreferencesMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", @@ -62,6 +63,7 @@ TEMPLATES = [ "context_processors": [ "django.template.context_processors.request", "django.template.context_processors.i18n", + "portal.shell.context.shell", ], }, } diff --git a/portal/config/urls.py b/portal/config/urls.py index fc9b124..6933f6e 100644 --- a/portal/config/urls.py +++ b/portal/config/urls.py @@ -5,4 +5,5 @@ from .health import healthz urlpatterns = [ path("healthz", healthz, name="healthz"), path("", include("portal.accounts.urls")), + path("", include("portal.shell.urls")), ] diff --git a/portal/frontend/build.mjs b/portal/frontend/build.mjs new file mode 100644 index 0000000..6befce3 --- /dev/null +++ b/portal/frontend/build.mjs @@ -0,0 +1,12 @@ +// Bundles entry.js into one minified file the page loads from its own origin (AD-16). +import { build } from "esbuild"; + +await build({ + entryPoints: ["entry.js"], + bundle: true, + minify: true, + format: "iife", + target: ["es2022"], + legalComments: "none", + outfile: "dist/fluent.js", +}); diff --git a/portal/frontend/entry.js b/portal/frontend/entry.js new file mode 100644 index 0000000..978ca95 --- /dev/null +++ b/portal/frontend/entry.js @@ -0,0 +1,2 @@ +// Only the components the shell uses; each import registers its custom element. +import "@fluentui/web-components/avatar/define.js"; diff --git a/portal/frontend/package-lock.json b/portal/frontend/package-lock.json new file mode 100644 index 0000000..9b7e4d0 --- /dev/null +++ b/portal/frontend/package-lock.json @@ -0,0 +1,557 @@ +{ + "name": "portal-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "portal-frontend", + "version": "0.1.0", + "dependencies": { + "@fluentui/web-components": "3.1.3" + }, + "devDependencies": { + "esbuild": "0.28.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fluentui/tokens": { + "version": "1.0.0-alpha.24", + "resolved": "https://registry.npmjs.org/@fluentui/tokens/-/tokens-1.0.0-alpha.24.tgz", + "integrity": "sha512-YrjGQfnxBR+9o8wXyTflON3Ohsv7OrZWYLVRU+7RaiFO1gcu36iDm3WjudlVmYuWRLDO0Gmxotm6RQNhbZuEQQ==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.1" + } + }, + "node_modules/@fluentui/web-components": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@fluentui/web-components/-/web-components-3.1.3.tgz", + "integrity": "sha512-1jl4CHoFFSfevkr8l7F+dKNJzyMWBZqwQfB/khNZtwyY0rv87799mpDWSVu0QeiZAFEbqCtnAOnXWsxJ/kek+Q==", + "license": "MIT", + "dependencies": { + "@fluentui/tokens": "^1.0.0-alpha.24", + "tslib": "^2.1.0" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0" + }, + "peerDependencies": { + "@microsoft/fast-element": "^3.0.0", + "@microsoft/focusgroup-polyfill": "^1.5.0" + } + }, + "node_modules/@microsoft/fast-element": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@microsoft/fast-element/-/fast-element-3.1.2.tgz", + "integrity": "sha512-LmyiGq1zjszwgcqSkiOsZph+9NUZJqnmBhuqPDmqndWfyo0bMXKPTevQX9q3nEKCuIIFDMsGdPNGcQ1xoK7sGw==", + "license": "MIT", + "peer": true + }, + "node_modules/@microsoft/focusgroup-polyfill": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@microsoft/focusgroup-polyfill/-/focusgroup-polyfill-1.6.0.tgz", + "integrity": "sha512-SlJO8FKrdFdSEXtNTNPTlCeqyqbpyLf6suYHTK7G8WYXZf8/BWK0ucERiPgrUIpNewfQjV82BahSOW/p8YNJ1A==", + "license": "MIT", + "peer": true + }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/portal/frontend/package.json b/portal/frontend/package.json new file mode 100644 index 0000000..c3d7060 --- /dev/null +++ b/portal/frontend/package.json @@ -0,0 +1,16 @@ +{ + "name": "portal-frontend", + "version": "0.1.0", + "private": true, + "description": "Builds the Fluent web components bundle that is copied into the image.", + "type": "module", + "scripts": { + "build": "node build.mjs" + }, + "dependencies": { + "@fluentui/web-components": "3.1.3" + }, + "devDependencies": { + "esbuild": "0.28.2" + } +} diff --git a/portal/locale/de/LC_MESSAGES/django.po b/portal/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..cfa938f --- /dev/null +++ b/portal/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,109 @@ +# German, formal address (Sie) +msgid "" +msgstr "" +"Project-Id-Version: self-service-portal\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "No permission" +msgstr "Keine Berechtigung" + +msgid "You do not have permission to open this page." +msgstr "Dazu fehlt Ihnen die Berechtigung." + +msgid "Back to the portal" +msgstr "Zurück zum Portal" + +msgid "Home" +msgstr "Startseite" + +#, python-format +msgid "Hello, %(name)s" +msgstr "Guten Tag, %(name)s" + +msgid "You are signed in. More pages will follow here." +msgstr "Sie sind angemeldet. Weitere Seiten folgen hier." + +msgid "Sign-in not possible" +msgstr "Anmeldung nicht möglich" + +msgid "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator." +msgstr "Mit diesem Konto können Sie sich nicht am Portal anmelden. Wenn Sie glauben, dass das ein Fehler ist, wenden Sie sich an die Administration." + +msgid "Try again" +msgstr "Erneut versuchen" + +msgid "Signed out" +msgstr "Abgemeldet" + +msgid "You have been signed out" +msgstr "Sie wurden abgemeldet." + +msgid "Sign in again" +msgstr "Erneut anmelden" + +msgid "Not available" +msgstr "Nicht verfügbar" + +msgid "This is not possible right now. Please try again later." +msgstr "Das ist gerade nicht möglich. Bitte versuchen Sie es später erneut." + +msgid "Skip to content" +msgstr "Zum Inhalt springen" + +msgid "Menu" +msgstr "Menü" + +msgid "Profile menu" +msgstr "Profilmenü" + +msgid "Main menu" +msgstr "Hauptmenü" + +msgid "Theme" +msgstr "Design" + +msgid "Language" +msgstr "Sprache" + +msgid "Form of address" +msgstr "Anrede" + +msgid "Sign out" +msgstr "Abmelden" + +msgid "This page is not available yet." +msgstr "Diese Seite folgt." + +msgid "Light" +msgstr "Hell" + +msgid "Dark" +msgstr "Dunkel" + +msgid "System" +msgstr "System" + +msgid "Publishing" +msgstr "Veröffentlichen" + +msgid "Manage announcements" +msgstr "Ankündigungen verwalten" + +msgid "Administration" +msgstr "Administration" + +msgid "Appearance" +msgstr "Darstellung" + +msgid "Settings" +msgstr "Einstellungen" + +msgid "Audit log" +msgstr "Protokoll" + +msgid "Status" +msgstr "Status" diff --git a/portal/locale/de_DU/LC_MESSAGES/django.po b/portal/locale/de_DU/LC_MESSAGES/django.po new file mode 100644 index 0000000..d985de7 --- /dev/null +++ b/portal/locale/de_DU/LC_MESSAGES/django.po @@ -0,0 +1,28 @@ +# German, informal address (du); only the differences, the rest falls back to de +msgid "" +msgstr "" +"Project-Id-Version: self-service-portal\n" +"Language: de_DU\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +msgid "You do not have permission to open this page." +msgstr "Dazu fehlt dir die Berechtigung." + +#, python-format +msgid "Hello, %(name)s" +msgstr "Hallo, %(name)s" + +msgid "You are signed in. More pages will follow here." +msgstr "Du bist angemeldet. Weitere Seiten folgen hier." + +msgid "You cannot sign in to the portal with this account. If you think this is a mistake, contact your administrator." +msgstr "Mit diesem Konto kannst du dich nicht am Portal anmelden. Wenn du glaubst, dass das ein Fehler ist, wende dich an die Administration." + +msgid "You have been signed out" +msgstr "Du wurdest abgemeldet." + +msgid "This is not possible right now. Please try again later." +msgstr "Das ist gerade nicht möglich. Bitte versuche es später erneut." diff --git a/portal/shell/__init__.py b/portal/shell/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/portal/shell/apps.py b/portal/shell/apps.py new file mode 100644 index 0000000..b58e21b --- /dev/null +++ b/portal/shell/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShellConfig(AppConfig): + name = "portal.shell" + label = "shell" diff --git a/portal/shell/context.py b/portal/shell/context.py new file mode 100644 index 0000000..c846371 --- /dev/null +++ b/portal/shell/context.py @@ -0,0 +1,37 @@ +from django.utils.translation import gettext_lazy as _ + +from . import menu, preferences + +THEME_OPTIONS = ( + ("light", _("Light")), + ("dark", _("Dark")), + ("system", _("System")), +) +LANGUAGE_OPTIONS = (("de", "Deutsch"), ("en", "English")) +FORM_OPTIONS = (("sie", "Sie"), ("du", "du")) + + +def initials(name: str) -> str: + words = [w for w in name.split() if w] + if not words: + return "?" + letters = words[0][0] + (words[-1][0] if len(words) > 1 else "") + return letters.upper() + + +def shell(request): + """Menu, preferences and the values the base layout needs.""" + prefs = getattr(request, "preferences", preferences.Preferences()) + portal_user = getattr(request, "portal_user", None) + context = { + "preferences": prefs, + "html_lang": prefs.language, + "theme_options": THEME_OPTIONS, + "language_options": LANGUAGE_OPTIONS, + "form_options": FORM_OPTIONS, + "portal_user": portal_user, + } + if portal_user is not None: + context["menu_groups"] = menu.for_user(portal_user, request.path) + context["initials"] = initials(portal_user.display_name) + return context diff --git a/portal/shell/menu.py b/portal/shell/menu.py new file mode 100644 index 0000000..1292f04 --- /dev/null +++ b/portal/shell/menu.py @@ -0,0 +1,61 @@ +"""The left menu, declared once. An entry is shown only to users who may open its page.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + + +@dataclass(frozen=True) +class Entry: + label: object # lazy translated string + url_name: str + + +@dataclass(frozen=True) +class Group: + title: object | None + role: str | None # None: everyone; otherwise "publisher" or "admin" + entries: tuple[Entry, ...] + + +MENU = ( + Group(None, None, (Entry(_("Home"), "home"),)), + Group( + _("Publishing"), + "publisher", + (Entry(_("Manage announcements"), "publish-announcements"),), + ), + Group( + _("Administration"), + "admin", + ( + Entry(_("Appearance"), "admin-appearance"), + Entry(_("Settings"), "admin-settings"), + Entry(_("Audit log"), "admin-audit"), + Entry(_("Status"), "admin-status"), + ), + ), +) + + +def _allowed(group: Group, portal_user) -> bool: + if group.role is None: + return True + return bool(getattr(portal_user, f"is_{group.role}", False)) + + +def for_user(portal_user, current_path: str) -> list[dict]: + """The groups this user sees, each entry with its address and whether it is current.""" + groups = [] + for group in MENU: + if not _allowed(group, portal_user): + continue + entries = [] + for entry in group.entries: + url = reverse(entry.url_name) + entries.append({"label": entry.label, "url": url, "current": url == current_path}) + groups.append({"title": group.title, "entries": entries}) + return groups diff --git a/portal/shell/middleware.py b/portal/shell/middleware.py new file mode 100644 index 0000000..bdf70fb --- /dev/null +++ b/portal/shell/middleware.py @@ -0,0 +1,24 @@ +from django.utils import translation +from django.utils.cache import patch_vary_headers + +from . import preferences + + +class PreferencesMiddleware: + """Read the preference cookies and activate the language for this request (AD-15).""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + prefs = preferences.from_request(request) + request.preferences = prefs + request.LANGUAGE_CODE = prefs.language_code + translation.activate(prefs.language_code) + try: + response = self.get_response(request) + finally: + translation.deactivate() + response.headers.setdefault("Content-Language", prefs.language) + patch_vary_headers(response, ("Cookie", "Accept-Language")) + return response diff --git a/portal/shell/preferences.py b/portal/shell/preferences.py new file mode 100644 index 0000000..0e35f4d --- /dev/null +++ b/portal/shell/preferences.py @@ -0,0 +1,82 @@ +"""Per-device preferences: theme, language and German form of address (AD-15). + +They live in cookies and are never stored on the server. Values are checked against fixed +lists, so a forged cookie can only fall back to the default. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from django.http import HttpRequest, HttpResponse +from django.utils import translation + +THEME_COOKIE = "pm_theme" +LANGUAGE_COOKIE = "pm_lang" +FORM_COOKIE = "pm_form" +COOKIE_MAX_AGE = 365 * 24 * 60 * 60 + +THEMES = ("light", "dark", "system") +LANGUAGES = ("de", "en") +FORMS = ("sie", "du") + +DEFAULT_THEME = "system" +DEFAULT_FORM = "sie" +INFORMAL_GERMAN = "de-du" # catalogue with only the informal differences; falls back to "de" + +_CHOICES = { + THEME_COOKIE: ("theme", THEMES), + LANGUAGE_COOKIE: ("language", LANGUAGES), + FORM_COOKIE: ("form", FORMS), +} + + +@dataclass(frozen=True) +class Preferences: + theme: str = DEFAULT_THEME + language: str = "en" + form: str = DEFAULT_FORM + + @property + def language_code(self) -> str: + """The code the translations are activated with.""" + if self.language == "de" and self.form == "du": + return INFORMAL_GERMAN + return self.language + + +def from_request(request: HttpRequest) -> Preferences: + theme = request.COOKIES.get(THEME_COOKIE) + language = request.COOKIES.get(LANGUAGE_COOKIE) + form = request.COOKIES.get(FORM_COOKIE) + if language not in LANGUAGES: + # Default from Accept-Language: German or English, English otherwise. + language = translation.get_language_from_request(request, check_path=False) + language = language if language in LANGUAGES else "en" + return Preferences( + theme=theme if theme in THEMES else DEFAULT_THEME, + language=language, + form=form if form in FORMS else DEFAULT_FORM, + ) + + +def chosen(values) -> dict[str, str]: + """The cookie name and value for every valid preference in `values` (a POST or a dict).""" + result = {} + for cookie, (field, allowed) in _CHOICES.items(): + value = values.get(field) + if value in allowed: + result[cookie] = value + return result + + +def set_cookies(response: HttpResponse, cookies: dict[str, str], *, secure: bool) -> None: + for name, value in cookies.items(): + response.set_cookie( + name, + value, + max_age=COOKIE_MAX_AGE, + secure=secure, + httponly=False, # shell.js applies a change without a reload + samesite="Lax", + ) diff --git a/portal/shell/urls.py b/portal/shell/urls.py new file mode 100644 index 0000000..d85f940 --- /dev/null +++ b/portal/shell/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("preferences", views.set_preferences, name="preferences"), + path("publish/announcements", views.publish_announcements, name="publish-announcements"), + path("administration/appearance", views.admin_appearance, name="admin-appearance"), + path("administration/settings", views.admin_settings, name="admin-settings"), + path("administration/audit", views.admin_audit, name="admin-audit"), + path("administration/status", views.admin_status, name="admin-status"), +] diff --git a/portal/shell/views.py b/portal/shell/views.py new file mode 100644 index 0000000..5a3ed59 --- /dev/null +++ b/portal/shell/views.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.translation import gettext_lazy as _ +from django.views.decorators.http import require_POST + +from portal.accounts.decorators import admin_required, publisher_required + +from . import preferences + + +@require_POST +def set_preferences(request): + """Store the chosen preferences in cookies (the no-JavaScript path of the profile menu).""" + target = request.POST.get("next", "") + if not (target.startswith("/") and url_has_allowed_host_and_scheme(target, None)): + target = "/" + response = HttpResponseRedirect(target) + preferences.set_cookies( + response, preferences.chosen(request.POST), secure=not settings.DEBUG + ) + return response + + +def _placeholder(title): + def view(request): + return render(request, "shell/placeholder.html", {"title": title}) + + return view + + +publish_announcements = publisher_required(_placeholder(_("Manage announcements"))) +admin_appearance = admin_required(_placeholder(_("Appearance"))) +admin_settings = admin_required(_placeholder(_("Settings"))) +admin_audit = admin_required(_placeholder(_("Audit log"))) +admin_status = admin_required(_placeholder(_("Status"))) diff --git a/portal/static/portal/shell.css b/portal/static/portal/shell.css new file mode 100644 index 0000000..23dcc6f --- /dev/null +++ b/portal/static/portal/shell.css @@ -0,0 +1,432 @@ +/* Page shell: design tokens from DESIGN.md, light and dark, wide and phone layout (AD-15). */ + +:root { + --brand: #0f6cbd; + --on-brand: #fff; + --top-bar: var(--brand); + --on-top-bar: var(--on-brand); + --avatar-bg: #fff; + --avatar-fg: var(--brand); + --bg: #fafafa; + --surface: #fff; + --nav-surface: #f5f5f5; + --text: #242424; + --text-secondary: #616161; + --line: #e0e0e0; + --control-line: #8a8a8a; + --font: "Segoe UI Variable", "Segoe UI", system-ui, sans-serif; + --radius-sm: 4px; + --radius-lg: 8px; + --touch: 44px; + --nav-width: 220px; + --bar-height: 48px; + color-scheme: light; +} + +:root[data-theme="dark"] { + --brand: #479ef5; + --on-brand: #0a0a0a; + --top-bar: #0f2a43; + --on-top-bar: #fff; + --avatar-bg: #479ef5; + --avatar-fg: #0a0a0a; + --bg: #1f1f1f; + --surface: #292929; + --nav-surface: #242424; + --text: #fff; + --text-secondary: #c8c8c8; + --line: #3d3d3d; + --control-line: #9a9a9a; + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root[data-theme="system"] { + --brand: #479ef5; + --on-brand: #0a0a0a; + --top-bar: #0f2a43; + --on-top-bar: #fff; + --avatar-bg: #479ef5; + --avatar-fg: #0a0a0a; + --bg: #1f1f1f; + --surface: #292929; + --nav-surface: #242424; + --text: #fff; + --text-secondary: #c8c8c8; + --line: #3d3d3d; + --control-line: #9a9a9a; + color-scheme: dark; + } +} + +/* The tokens the Fluent avatar reads, mapped to the portal's own. */ +:root { + --colorBrandBackgroundStatic: var(--avatar-bg); + --colorNeutralForegroundStaticInverted: var(--avatar-fg); + --colorNeutralBackground1: var(--surface); + --colorNeutralBackground6: var(--nav-surface); + --colorNeutralForeground3: var(--text-secondary); + --fontFamilyBase: var(--font); + --fontWeightRegular: 400; + --fontWeightSemibold: 600; + --fontSizeBase100: 10px; + --fontSizeBase200: 12px; + --fontSizeBase300: 14px; + --fontSizeBase400: 16px; + --fontSizeBase500: 20px; + --fontSizeBase600: 24px; + --borderRadiusSmall: 2px; + --borderRadiusMedium: 4px; + --borderRadiusLarge: 6px; + --borderRadiusXLarge: 8px; + --borderRadiusCircular: 9999px; + --strokeWidthThin: 1px; + --strokeWidthThick: 2px; + --strokeWidthThicker: 3px; + --strokeWidthThickest: 4px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font: 400 14px/1.45 var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +a { + color: var(--brand); +} + +:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.skip-link { + position: absolute; + left: 8px; + top: -100px; + z-index: 20; + padding: 10px 14px; + background: var(--surface); + color: var(--text); + border: 2px solid var(--brand); + border-radius: var(--radius-sm); +} + +.skip-link:focus { + top: 8px; +} + +/* Top bar */ + +.top-bar { + position: relative; + z-index: 10; + display: flex; + align-items: center; + gap: 12px; + height: var(--bar-height); + padding: 0 16px; + background: var(--top-bar); + color: var(--on-top-bar); +} + +.portal-name { + flex: 1; + color: inherit; + font-weight: 600; + text-decoration: none; +} + +.menu-button { + display: none; + width: var(--touch); + height: var(--touch); + margin-left: -10px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + cursor: pointer; + place-items: center; +} + +.burger, +.burger::before, +.burger::after { + display: block; + width: 18px; + height: 2px; + background: currentcolor; + content: ""; +} + +.burger { + position: relative; +} + +.burger::before { + position: absolute; + top: -6px; +} + +.burger::after { + position: absolute; + top: 6px; +} + +/* Profile menu */ + +.profile { + position: relative; +} + +.profile summary { + display: grid; + place-items: center; + width: var(--touch); + height: var(--touch); + margin-right: -6px; + list-style: none; + cursor: pointer; + border-radius: 50%; +} + +.profile summary::-webkit-details-marker { + display: none; +} + +/* The plain initials show until the Fluent avatar is defined. */ +.profile fluent-avatar:not(:defined) { + display: none; +} + +.profile fluent-avatar:defined + .avatar-fallback { + display: none; +} + +.avatar-fallback { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--avatar-bg); + color: var(--avatar-fg); + font-size: 12px; + font-weight: 700; +} + +.profile-menu { + position: absolute; + right: 0; + top: calc(100% + 2px); + width: 260px; + max-width: calc(100vw - 24px); + padding: 14px; + background: var(--surface); + color: var(--text); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: 0 6px 20px rgb(0 0 0 / 25%); +} + +.profile-name { + margin: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +.profile-user { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.prefs fieldset { + margin: 0; + padding: 8px 0 0; + border: 0; + border-top: 1px solid var(--line); +} + +.prefs legend { + padding: 0; + font-size: 13px; + color: var(--text-secondary); +} + +.choices { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 6px 0 8px; +} + +.profile-menu button { + min-height: var(--touch); + padding: 0 14px; + border: 1px solid var(--control-line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font: inherit; + cursor: pointer; +} + +.profile-menu button[aria-pressed="true"] { + background: var(--brand); + border-color: var(--brand); + color: var(--on-brand); + font-weight: 600; +} + +.sign-out { + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +/* Layout */ + +.layout { + display: flex; + flex: 1; + min-height: 0; +} + +#menu { + width: var(--nav-width); + flex: none; + padding: 12px 8px; + background: var(--nav-surface); + border-right: 1px solid var(--line); +} + +.menu-heading { + margin: 14px 12px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); +} + +#menu a { + display: flex; + align-items: center; + min-height: 38px; + padding: 0 12px; + margin-bottom: 2px; + border-radius: var(--radius-sm); + color: var(--text); + text-decoration: none; +} + +#menu a:hover { + background: var(--surface); +} + +#menu a.current { + background: var(--surface); + font-weight: 600; + box-shadow: inset 3px 0 0 var(--brand); +} + +main { + flex: 1; + min-width: 0; + padding: 24px 28px; +} + +main:focus { + outline: none; +} + +h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 600; + line-height: 1.25; +} + +.lead { + max-width: 640px; + margin: 0 0 18px; + color: var(--text-secondary); +} + +main p, +main li { + max-width: 640px; +} + +main button { + min-height: var(--touch); + padding: 0 16px; + border: 1px solid var(--control-line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font: inherit; + cursor: pointer; +} + +/* Phone: content only, the menu opens from the menu button. */ + +@media (width <= 720px) { + main { + padding: 16px; + } + + h1 { + font-size: 20px; + } + + .layout { + flex-direction: column; + } + + #menu { + width: auto; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + #menu a { + min-height: var(--touch); + } + + /* Without JavaScript the menu stays visible above the content. */ + .js .menu-button { + display: grid; + } + + .js #menu { + display: none; + } + + .js.menu-open #menu { + display: block; + } +} diff --git a/portal/static/portal/shell.js b/portal/static/portal/shell.js new file mode 100644 index 0000000..529020e --- /dev/null +++ b/portal/static/portal/shell.js @@ -0,0 +1,73 @@ +// Page shell behaviour: menu button, profile menu and preferences. Everything also works +// without this script (links, a plain form), so it only makes the page quicker. +(function () { + "use strict"; + + var root = document.documentElement; + root.classList.add("js"); + + var YEAR = 365 * 24 * 60 * 60; + var COOKIES = { theme: "pm_theme", language: "pm_lang", form: "pm_form" }; + var ALLOWED = { + theme: ["light", "dark", "system"], + language: ["de", "en"], + form: ["sie", "du"], + }; + + function writeCookie(field, value) { + var secure = location.protocol === "https:" ? "; Secure" : ""; + document.cookie = + COOKIES[field] + "=" + value + "; Max-Age=" + YEAR + "; Path=/; SameSite=Lax" + secure; + } + + var profile = document.querySelector(".profile"); + var menuButton = document.querySelector(".menu-button"); + + document.addEventListener("click", function (event) { + var button = event.target.closest("button[data-pref]"); + if (button) { + var field = button.getAttribute("data-pref"); + var value = button.value; + if (ALLOWED[field] && ALLOWED[field].indexOf(value) !== -1) { + event.preventDefault(); + writeCookie(field, value); + if (field === "theme") { + root.setAttribute("data-theme", value); + button + .closest(".choices") + .querySelectorAll("button") + .forEach(function (other) { + other.setAttribute("aria-pressed", other === button ? "true" : "false"); + }); + } else { + location.reload(); // language and form change the text, which the server renders + } + } + return; + } + if (profile && profile.open && !profile.contains(event.target)) { + profile.open = false; + } + }); + + if (menuButton) { + menuButton.addEventListener("click", function () { + var open = root.classList.toggle("menu-open"); + menuButton.setAttribute("aria-expanded", open ? "true" : "false"); + }); + } + + document.addEventListener("keydown", function (event) { + if (event.key !== "Escape") { + return; + } + if (profile && profile.open) { + profile.open = false; + profile.querySelector("summary").focus(); + } else if (menuButton && root.classList.contains("menu-open")) { + root.classList.remove("menu-open"); + menuButton.setAttribute("aria-expanded", "false"); + menuButton.focus(); + } + }); +})(); diff --git a/portal/templates/accounts/base.html b/portal/templates/accounts/base.html index 3d6dee6..94d9808 100644 --- a/portal/templates/accounts/base.html +++ b/portal/templates/accounts/base.html @@ -1,13 +1 @@ -{% load i18n %} - - - - -{% block title %}{% trans "Self-service portal" %}{% endblock %} - - -
-{% block content %}{% endblock %} -
- - +{% extends "base.html" %} diff --git a/portal/templates/accounts/home.html b/portal/templates/accounts/home.html index d67325b..6f1dfb5 100644 --- a/portal/templates/accounts/home.html +++ b/portal/templates/accounts/home.html @@ -1,11 +1,7 @@ {% extends "accounts/base.html" %} {% load i18n %} -{% block title %}{% trans "Welcome" %}{% endblock %} +{% block title %}{% trans "Home" %}{% endblock %} {% block content %} -

{% blocktrans with name=portal_user.display_name %}Signed in as {{ name }}{% endblocktrans %}

-

{{ portal_user.user.sam }}

-
- {% csrf_token %} - -
+

{% blocktrans with name=portal_user.display_name %}Hello, {{ name }}{% endblocktrans %}

+

{% trans "You are signed in. More pages will follow here." %}

{% endblock %} diff --git a/portal/templates/base.html b/portal/templates/base.html new file mode 100644 index 0000000..664fb3c --- /dev/null +++ b/portal/templates/base.html @@ -0,0 +1,69 @@ +{% load i18n static %} + + + + + +{% block title %}ping-mee Portal{% endblock %} + + + + + + +
+ {% if portal_user %} + + {% endif %} + ping-mee Portal + {% if portal_user %} +
+ + + + +
+

{{ portal_user.display_name }}

+

{{ portal_user.user.sam }}

+
+ {% csrf_token %} + + {% trans "Theme" as legend %} + {% include "shell/preference_group.html" with legend=legend field="theme" options=theme_options current=preferences.theme %} + {% trans "Language" as legend %} + {% include "shell/preference_group.html" with legend=legend field="language" options=language_options current=preferences.language %} + {% if preferences.language == "de" %} + {% trans "Form of address" as legend %} + {% include "shell/preference_group.html" with legend=legend field="form" options=form_options current=preferences.form %} + {% endif %} +
+
+ {% csrf_token %} + +
+
+
+ {% endif %} +
+
+ {% if portal_user %} + + {% endif %} +
+{% block content %}{% endblock %} +
+
+ + diff --git a/portal/templates/shell/placeholder.html b/portal/templates/shell/placeholder.html new file mode 100644 index 0000000..6da8abf --- /dev/null +++ b/portal/templates/shell/placeholder.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% load i18n %} +{% block title %}{{ title }}{% endblock %} +{% block content %} +

{{ title }}

+

{% trans "This page is not available yet." %}

+{% endblock %} diff --git a/portal/templates/shell/preference_group.html b/portal/templates/shell/preference_group.html new file mode 100644 index 0000000..e398a89 --- /dev/null +++ b/portal/templates/shell/preference_group.html @@ -0,0 +1,8 @@ +
+ {{ legend }} +
+ {% for value, label in options %} + + {% endfor %} +
+
diff --git a/portal/tests/conftest.py b/portal/tests/conftest.py index 700cbb4..c3e8690 100644 --- a/portal/tests/conftest.py +++ b/portal/tests/conftest.py @@ -1,5 +1,7 @@ +from pathlib import Path from types import SimpleNamespace +import polib import pytest from portal.adapters import registry @@ -36,3 +38,19 @@ def adapters(): registry.install(fakes.directory, fakes.idp) yield fakes registry.install() + + +@pytest.fixture(scope="session", autouse=True) +def compiled_messages(): + """The image compiles the catalogues with gettext; tests compile their own copy.""" + for po_file in (Path(__file__).resolve().parent.parent / "locale").glob("*/LC_MESSAGES/*.po"): + polib.pofile(str(po_file)).save_as_mofile(str(po_file.with_suffix(".mo"))) + + +@pytest.fixture(autouse=True) +def plain_static_files(settings): + """No collectstatic in tests, so use storage without a manifest.""" + settings.STORAGES = { + **settings.STORAGES, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, + } diff --git a/portal/tests/test_preferences.py b/portal/tests/test_preferences.py new file mode 100644 index 0000000..eb1d4f0 --- /dev/null +++ b/portal/tests/test_preferences.py @@ -0,0 +1,88 @@ +"""Per-device preferences: cookies in, validated values out (AD-15).""" + +import pytest +from django.test import Client, RequestFactory + +from portal.shell import preferences +from portal.shell.preferences import Preferences + + +def request_with(cookies=None, accept_language=""): + request = RequestFactory().get("/", HTTP_ACCEPT_LANGUAGE=accept_language) + request.COOKIES.update(cookies or {}) + return request + + +def test_without_cookies_the_defaults_apply(): + prefs = preferences.from_request(request_with()) + assert prefs == Preferences(theme="system", language="en", form="sie") + + +@pytest.mark.parametrize( + ("header", "expected"), + [("de-DE,de;q=0.9,en;q=0.5", "de"), ("en-GB", "en"), ("fr-FR", "en"), ("", "en")], +) +def test_the_language_defaults_from_the_browser(header, expected): + assert preferences.from_request(request_with(accept_language=header)).language == expected + + +def test_valid_cookies_are_used_and_beat_the_browser(): + cookies = {"pm_theme": "dark", "pm_lang": "en", "pm_form": "du"} + prefs = preferences.from_request(request_with(cookies, accept_language="de")) + assert prefs == Preferences(theme="dark", language="en", form="du") + + +@pytest.mark.parametrize("value", ["", "blue", "')[0].content.decode() + assert "