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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
This commit is contained in:
@@ -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",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ from .health import healthz
|
||||
urlpatterns = [
|
||||
path("healthz", healthz, name="healthz"),
|
||||
path("", include("portal.accounts.urls")),
|
||||
path("", include("portal.shell.urls")),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Only the components the shell uses; each import registers its custom element.
|
||||
import "@fluentui/web-components/avatar/define.js";
|
||||
Generated
+557
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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."
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShellConfig(AppConfig):
|
||||
name = "portal.shell"
|
||||
label = "shell"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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")))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -1,13 +1 @@
|
||||
{% load i18n %}<!doctype html>
|
||||
<html lang="{{ LANGUAGE_CODE }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{% trans "Self-service portal" %}{% endblock %}</title>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{% extends "base.html" %}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{% extends "accounts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Welcome" %}{% endblock %}
|
||||
{% block title %}{% trans "Home" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% blocktrans with name=portal_user.display_name %}Signed in as {{ name }}{% endblocktrans %}</h1>
|
||||
<p>{{ portal_user.user.sam }}</p>
|
||||
<form method="post" action="{% url 'logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit">{% trans "Sign out" %}</button>
|
||||
</form>
|
||||
<h1>{% blocktrans with name=portal_user.display_name %}Hello, {{ name }}{% endblocktrans %}</h1>
|
||||
<p class="lead">{% trans "You are signed in. More pages will follow here." %}</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{% load i18n static %}<!doctype html>
|
||||
<html lang="{{ html_lang }}" data-theme="{{ preferences.theme }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{% block title %}ping-mee Portal{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% static 'portal/shell.css' %}">
|
||||
<script src="{% static 'vendor/fluent.js' %}" defer></script>
|
||||
<script src="{% static 'portal/shell.js' %}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#content">{% trans "Skip to content" %}</a>
|
||||
<header class="top-bar">
|
||||
{% if portal_user %}
|
||||
<button type="button" class="menu-button" aria-controls="menu" aria-expanded="false">
|
||||
<span class="burger" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">{% trans "Menu" %}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a class="portal-name" href="{% url 'home' %}">ping-mee Portal</a>
|
||||
{% if portal_user %}
|
||||
<details class="profile">
|
||||
<summary aria-label="{% trans 'Profile menu' %}">
|
||||
<fluent-avatar name="{{ portal_user.display_name }}" color="brand" size="32"></fluent-avatar>
|
||||
<span class="avatar-fallback" aria-hidden="true">{{ initials }}</span>
|
||||
</summary>
|
||||
<div class="profile-menu">
|
||||
<p class="profile-name">{{ portal_user.display_name }}</p>
|
||||
<p class="profile-user">{{ portal_user.user.sam }}</p>
|
||||
<form method="post" action="{% url 'preferences' %}" class="prefs">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
{% 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 %}
|
||||
</form>
|
||||
<form method="post" action="{% url 'logout' %}" class="sign-out">
|
||||
{% csrf_token %}
|
||||
<button type="submit">{% trans "Sign out" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</header>
|
||||
<div class="layout">
|
||||
{% if portal_user %}
|
||||
<nav id="menu" aria-label="{% trans 'Main menu' %}">
|
||||
{% for group in menu_groups %}
|
||||
<div class="menu-group">
|
||||
{% if group.title %}<p class="menu-heading">{{ group.title }}</p>{% endif %}
|
||||
{% for entry in group.entries %}
|
||||
<a href="{{ entry.url }}"{% if entry.current %} class="current" aria-current="page"{% endif %}>{{ entry.label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
<main id="content" tabindex="-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ title }}</h1>
|
||||
<p class="lead">{% trans "This page is not available yet." %}</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
<fieldset>
|
||||
<legend>{{ legend }}</legend>
|
||||
<div class="choices">
|
||||
{% for value, label in options %}
|
||||
<button type="submit" name="{{ field }}" value="{{ value }}" data-pref="{{ field }}" aria-pressed="{% if value == current %}true{% else %}false{% endif %}">{{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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", "<script>", "DARK", "x" * 500])
|
||||
def test_forged_cookie_values_fall_back_to_the_defaults(value):
|
||||
cookies = {"pm_theme": value, "pm_lang": value, "pm_form": value}
|
||||
prefs = preferences.from_request(request_with(cookies))
|
||||
assert prefs == Preferences(theme="system", language="en", form="sie")
|
||||
|
||||
|
||||
def test_the_informal_form_only_changes_german():
|
||||
assert Preferences(language="de", form="du").language_code == "de-du"
|
||||
assert Preferences(language="de", form="sie").language_code == "de"
|
||||
assert Preferences(language="en", form="du").language_code == "en"
|
||||
|
||||
|
||||
def test_only_valid_choices_are_taken_from_a_submission():
|
||||
assert preferences.chosen({"theme": "dark", "language": "xx", "form": "du"}) == {
|
||||
"pm_theme": "dark",
|
||||
"pm_form": "du",
|
||||
}
|
||||
assert preferences.chosen({}) == {}
|
||||
|
||||
|
||||
# -- the preferences address ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_choosing_sets_the_cookies_and_returns_to_the_page():
|
||||
response = Client().post(
|
||||
"/preferences", {"theme": "dark", "language": "de", "form": "du", "next": "/x?y=1"}
|
||||
)
|
||||
assert response.status_code == 302 and response["Location"] == "/x?y=1"
|
||||
for name, value in (("pm_theme", "dark"), ("pm_lang", "de"), ("pm_form", "du")):
|
||||
cookie = response.cookies[name]
|
||||
assert cookie.value == value
|
||||
assert cookie["samesite"] == "Lax" and cookie["secure"] and not cookie["httponly"]
|
||||
assert cookie["max-age"] == preferences.COOKIE_MAX_AGE
|
||||
|
||||
|
||||
def test_an_invalid_value_sets_no_cookie():
|
||||
response = Client().post("/preferences", {"theme": "neon", "language": "fr"})
|
||||
assert not response.cookies
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["https://evil.example.test/", "//evil.example.test", "x", ""])
|
||||
def test_the_return_address_cannot_leave_the_site(target):
|
||||
response = Client().post("/preferences", {"theme": "dark", "next": target})
|
||||
assert response["Location"] == "/"
|
||||
|
||||
|
||||
def test_choosing_needs_post():
|
||||
assert Client().get("/preferences").status_code == 405
|
||||
|
||||
|
||||
def test_choosing_needs_a_csrf_token():
|
||||
strict = Client(enforce_csrf_checks=True)
|
||||
assert strict.post("/preferences", {"theme": "dark"}).status_code == 403
|
||||
@@ -0,0 +1,225 @@
|
||||
"""The page shell: layout, role-based menu, preferences applied to pages (AD-15, AD-16)."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.utils import translation
|
||||
|
||||
from portal.tests.test_signin import sign_in
|
||||
|
||||
pytestmark = pytest.mark.django_db(transaction=True)
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "static" / "portal"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def people(adapters):
|
||||
d = adapters.directory
|
||||
d.add_user("alice", display_name="Alice Anders")
|
||||
d.add_user("pub", display_name="Pat Publisher", is_publisher=True)
|
||||
d.add_user("boss", display_name="Bo Boss", is_admin=True, is_publisher=True)
|
||||
d.add_user("admin", display_name="Ann Admin", is_admin=True)
|
||||
return d
|
||||
|
||||
|
||||
def page(adapters, username="alice", path="/", **cookies):
|
||||
client, _ = sign_in(adapters, username)
|
||||
for name, value in cookies.items():
|
||||
client.cookies[name] = value
|
||||
return client.get(path), client
|
||||
|
||||
|
||||
def menu_of(response):
|
||||
html = response.content.decode()
|
||||
nav = re.search(r'<nav id="menu".*?</nav>', html, re.S)
|
||||
return nav.group(0) if nav else ""
|
||||
|
||||
|
||||
# -- the layout ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_signed_in_page_has_the_shell_and_the_users_name(people, adapters):
|
||||
response, _ = page(adapters)
|
||||
html = response.content.decode()
|
||||
assert response.status_code == 200
|
||||
assert 'class="top-bar"' in html and '<nav id="menu"' in html and '<main id="content"' in html
|
||||
assert html.count("Alice Anders") >= 2 # heading and profile menu
|
||||
assert ">AA<" in html # initials until the avatar component is defined
|
||||
|
||||
|
||||
def test_the_skip_link_is_the_first_thing_in_the_page(people, adapters):
|
||||
html = page(adapters)[0].content.decode()
|
||||
body = html[html.index("<body>") :]
|
||||
assert body.index('class="skip-link"') < body.index("<header")
|
||||
assert 'href="#content"' in html
|
||||
|
||||
|
||||
def test_the_page_loads_its_scripts_and_styles_from_its_own_origin(people, adapters):
|
||||
html = page(adapters)[0].content.decode()
|
||||
sources = re.findall(r'(?:src|href)="([^"]+)"', html)
|
||||
assert all(not s.startswith(("http:", "https:", "//")) for s in sources)
|
||||
assert "/static/vendor/fluent.js" in sources and "/static/portal/shell.js" in sources
|
||||
|
||||
|
||||
def test_nothing_is_inline_so_the_strict_policy_holds(people, adapters):
|
||||
for username in ("alice", "boss"):
|
||||
html = page(adapters, username)[0].content.decode()
|
||||
assert not re.search(r"<script(?![^>]*\bsrc=)", html)
|
||||
assert not re.search(r"<style\b", html)
|
||||
assert not re.search(r"\sstyle=", html)
|
||||
assert not re.search(r"\son[a-z]+=", html)
|
||||
|
||||
|
||||
def test_the_policy_allows_scripts_only_from_the_site(people, adapters):
|
||||
csp = page(adapters)[0]["Content-Security-Policy"]
|
||||
assert "script-src 'self'" in csp and "unsafe-inline" not in csp and "unsafe-eval" not in csp
|
||||
|
||||
|
||||
def test_the_current_page_is_marked_in_the_menu(people, adapters):
|
||||
nav = menu_of(page(adapters)[0])
|
||||
assert re.search(r'<a href="/" class="current" aria-current="page">', nav)
|
||||
|
||||
|
||||
def test_signed_out_pages_use_the_layout_without_menu_or_avatar():
|
||||
html = Client().get("/auth/signed-out").content.decode()
|
||||
assert 'class="top-bar"' in html and "ping-mee Portal" in html
|
||||
assert '<nav id="menu"' not in html and 'class="profile"' not in html
|
||||
|
||||
|
||||
def test_the_wide_and_phone_layouts_are_in_the_stylesheet():
|
||||
css = (STATIC / "shell.css").read_text(encoding="utf-8")
|
||||
assert "@media (width <= 720px)" in css
|
||||
assert "--touch: 44px" in css and "--nav-width: 220px" in css
|
||||
narrow = css[css.index("@media (width <= 720px)") :]
|
||||
assert ".js #menu" in narrow and "display: none" in narrow
|
||||
assert "flex-direction: column" in narrow
|
||||
|
||||
|
||||
# -- the menu by role -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_normal_user_sees_only_the_home_entry(people, adapters):
|
||||
nav = menu_of(page(adapters, "alice")[0])
|
||||
assert "Home" in nav and "Publishing" not in nav and "Administration" not in nav
|
||||
|
||||
|
||||
def test_a_publisher_sees_publishing_but_not_administration(people, adapters):
|
||||
nav = menu_of(page(adapters, "pub")[0])
|
||||
assert "Publishing" in nav and "Manage announcements" in nav
|
||||
assert "Administration" not in nav
|
||||
|
||||
|
||||
def test_an_admin_sees_administration_but_not_publishing(people, adapters):
|
||||
nav = menu_of(page(adapters, "admin")[0])
|
||||
assert "Administration" in nav and "Audit log" in nav
|
||||
assert "Publishing" not in nav
|
||||
|
||||
|
||||
def test_someone_who_is_both_sees_both_groups(people, adapters):
|
||||
nav = menu_of(page(adapters, "boss")[0])
|
||||
assert "Publishing" in nav and "Administration" in nav
|
||||
|
||||
|
||||
PAGES = {
|
||||
"/publish/announcements": "publisher",
|
||||
"/administration/appearance": "admin",
|
||||
"/administration/settings": "admin",
|
||||
"/administration/audit": "admin",
|
||||
"/administration/status": "admin",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", PAGES)
|
||||
def test_hidden_pages_refuse_a_direct_request(people, adapters, path):
|
||||
allowed = {"publisher": {"pub", "boss"}, "admin": {"admin", "boss"}}[PAGES[path]]
|
||||
for username in ("alice", "pub", "admin", "boss"):
|
||||
response, _ = page(adapters, username, path)
|
||||
assert response.status_code == (200 if username in allowed else 403), username
|
||||
|
||||
|
||||
def test_a_refused_page_says_so_in_the_shell(people, adapters):
|
||||
response, _ = page(adapters, "alice", "/administration/audit")
|
||||
html = response.content.decode()
|
||||
assert response.status_code == 403 and "do not have permission" in html
|
||||
assert 'class="top-bar"' in html and "Audit log" not in menu_of(response)
|
||||
|
||||
|
||||
def test_pages_for_roles_need_a_sign_in():
|
||||
response = Client().get("/administration/audit")
|
||||
assert response.status_code == 302 and response["Location"].startswith("/auth/login")
|
||||
|
||||
|
||||
# -- preferences applied to the page ----------------------------------------------------
|
||||
|
||||
|
||||
def test_the_theme_is_an_attribute_on_the_root_element(people, adapters):
|
||||
assert 'data-theme="system"' in page(adapters)[0].content.decode()
|
||||
assert 'data-theme="dark"' in page(adapters, pm_theme="dark")[0].content.decode()
|
||||
assert 'data-theme="light"' in page(adapters, pm_theme="light")[0].content.decode()
|
||||
|
||||
|
||||
def test_an_unknown_theme_cookie_is_not_echoed(people, adapters):
|
||||
html = page(adapters, pm_theme='"><script>x</script>')[0].content.decode()
|
||||
assert "<script>x" not in html and 'data-theme="system"' in html
|
||||
|
||||
|
||||
def test_the_browser_language_is_used_when_nothing_is_chosen(people, adapters):
|
||||
client, _ = sign_in(adapters, "alice")
|
||||
html = client.get("/", HTTP_ACCEPT_LANGUAGE="de-DE,de;q=0.9").content.decode()
|
||||
assert '<html lang="de"' in html and "Guten Tag, Alice Anders" in html
|
||||
|
||||
|
||||
def test_german_formal_is_the_default_german(people, adapters):
|
||||
html = page(adapters, pm_lang="de")[0].content.decode()
|
||||
assert "Guten Tag, Alice Anders" in html and "Sie sind angemeldet" in html
|
||||
assert "Startseite" in menu_of(page(adapters, pm_lang="de")[0])
|
||||
|
||||
|
||||
def test_german_informal_uses_du(people, adapters):
|
||||
html = page(adapters, pm_lang="de", pm_form="du")[0].content.decode()
|
||||
assert "Hallo, Alice Anders" in html and "Du bist angemeldet" in html
|
||||
|
||||
|
||||
def test_the_form_of_address_does_not_change_english(people, adapters):
|
||||
html = page(adapters, pm_lang="en", pm_form="du")[0].content.decode()
|
||||
assert "Hello, Alice Anders" in html and "Hallo" not in html
|
||||
|
||||
|
||||
def test_the_form_control_is_shown_only_in_german(people, adapters):
|
||||
assert 'value="du"' in page(adapters, pm_lang="de")[0].content.decode()
|
||||
assert 'value="du"' not in page(adapters, pm_lang="en")[0].content.decode()
|
||||
|
||||
|
||||
def test_the_chosen_options_are_marked_pressed(people, adapters):
|
||||
html = page(adapters, pm_theme="dark")[0].content.decode()
|
||||
assert re.search(r'value="dark"[^>]*aria-pressed="true"', html)
|
||||
assert re.search(r'value="system"[^>]*aria-pressed="false"', html)
|
||||
|
||||
|
||||
def test_the_profile_menu_holds_sign_out_and_posts_back_to_the_page(people, adapters):
|
||||
response, _ = page(adapters, path="/")
|
||||
html = response.content.decode()
|
||||
assert 'action="/auth/logout"' in html and 'action="/preferences"' in html
|
||||
assert 'name="next" value="/"' in html
|
||||
|
||||
|
||||
def test_responses_are_marked_as_varying_with_the_preferences(people, adapters):
|
||||
response, _ = page(adapters, pm_lang="de")
|
||||
assert response["Content-Language"] == "de"
|
||||
assert {"Cookie", "Accept-Language"} <= {v.strip() for v in response["Vary"].split(",")}
|
||||
|
||||
|
||||
def test_the_language_does_not_leak_into_the_next_request(people, adapters):
|
||||
client, _ = sign_in(adapters, "alice")
|
||||
client.cookies["pm_lang"] = "de"
|
||||
assert "Startseite" in menu_of(client.get("/"))
|
||||
del client.cookies["pm_lang"]
|
||||
assert "Home" in menu_of(client.get("/"))
|
||||
|
||||
|
||||
def test_a_german_string_with_no_informal_entry_falls_back_to_the_formal_one():
|
||||
with translation.override("de-du"):
|
||||
assert translation.gettext("Home") == "Startseite"
|
||||
assert translation.gettext("Sign out") == "Abmelden"
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Every user-visible string has German text (AD-15); the informal catalogue only differs."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import polib
|
||||
import pytest
|
||||
|
||||
PORTAL = Path(__file__).resolve().parent.parent
|
||||
LOCALE = PORTAL / "locale"
|
||||
|
||||
TRANS_TAG = re.compile(r"""\{%\s*trans\s+(?:"([^"]+)"|'([^']+)')""")
|
||||
BLOCKTRANS = re.compile(r"\{%\s*blocktrans[^%]*%\}(.*?)\{%\s*endblocktrans\s*%\}", re.S)
|
||||
VARIABLE = re.compile(r"\{\{\s*(\w+)\s*\}\}")
|
||||
PY_CALL = re.compile(r"""(?<![\w.])(?:_|gettext|gettext_lazy)\(\s*(?:"([^"]+)"|'([^']+)')""")
|
||||
|
||||
|
||||
def msgids_in_code() -> set[str]:
|
||||
found = set()
|
||||
for template in (PORTAL / "templates").rglob("*.html"):
|
||||
text = template.read_text(encoding="utf-8")
|
||||
found.update(a or b for a, b in TRANS_TAG.findall(text))
|
||||
found.update(VARIABLE.sub(r"%(\1)s", body).strip() for body in BLOCKTRANS.findall(text))
|
||||
for source in PORTAL.rglob("*.py"):
|
||||
if "tests" in source.parts or "migrations" in source.parts:
|
||||
continue
|
||||
found.update(a or b for a, b in PY_CALL.findall(source.read_text(encoding="utf-8")))
|
||||
return found
|
||||
|
||||
|
||||
def catalogue(name: str) -> polib.POFile:
|
||||
return polib.pofile(str(LOCALE / name / "LC_MESSAGES" / "django.po"))
|
||||
|
||||
|
||||
def test_the_scan_finds_the_strings():
|
||||
assert {"Sign out", "Home", "Hello, %(name)s", "Audit log"} <= msgids_in_code()
|
||||
|
||||
|
||||
def test_every_string_in_the_code_has_german_text():
|
||||
german = {e.msgid: e for e in catalogue("de")}
|
||||
missing = sorted(msgids_in_code() - german.keys())
|
||||
assert not missing, f"no German entry for: {missing}"
|
||||
for entry in german.values():
|
||||
assert entry.msgstr.strip(), f"empty German text for {entry.msgid!r}"
|
||||
assert "fuzzy" not in entry.flags, f"fuzzy entry {entry.msgid!r}"
|
||||
|
||||
|
||||
def test_the_german_catalogue_has_no_stale_entries():
|
||||
stale = sorted({e.msgid for e in catalogue("de")} - msgids_in_code())
|
||||
assert not stale, f"entries no longer used: {stale}"
|
||||
|
||||
|
||||
def test_the_informal_catalogue_only_holds_differences_to_known_strings():
|
||||
german = {e.msgid: e.msgstr for e in catalogue("de")}
|
||||
for entry in catalogue("de_DU"):
|
||||
assert entry.msgid in german, f"unknown string {entry.msgid!r}"
|
||||
assert entry.msgstr.strip() and "fuzzy" not in entry.flags
|
||||
assert entry.msgstr != german[entry.msgid], f"{entry.msgid!r} does not differ"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["de", "de_DU"])
|
||||
def test_placeholders_survive_translation(name):
|
||||
for entry in catalogue(name):
|
||||
assert set(re.findall(r"%\(\w+\)s", entry.msgid)) == set(
|
||||
re.findall(r"%\(\w+\)s", entry.msgstr)
|
||||
), entry.msgid
|
||||
|
||||
|
||||
def test_the_formal_text_does_not_use_du_and_the_informal_does_not_use_sie():
|
||||
formal = " ".join(e.msgstr for e in catalogue("de"))
|
||||
assert not re.search(r"\b(du|dir|dich|dein|deine)\b", formal, re.I)
|
||||
informal = " ".join(e.msgstr for e in catalogue("de_DU"))
|
||||
assert not re.search(r"\b(Sie|Ihnen|Ihr|Ihre)\b", informal)
|
||||
Reference in New Issue
Block a user