From 4ded98a8f903df177fdc59fab33709ce6aac7e74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 23:23:07 +0000 Subject: [PATCH 01/31] :arrow_up:(deps): Bump fast-uri from 3.1.0 to 3.1.2 Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5b531dd5..601a26af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3060,9 +3060,9 @@ fast-safe-stringify@^2.1.1: integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== fast-uri@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" - integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== + version "3.1.2" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec" + integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== fdir@^6.4.4, fdir@^6.5.0: version "6.5.0" From 533c9d2baa3feaff04740fdb7dcc7dce51ec2f87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 09:32:17 +0000 Subject: [PATCH 02/31] :arrow_up:(deps): Bump the minor-and-patch group with 5 updates Bumps the minor-and-patch group with 5 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/view](https://github.com/codemirror/view) | `6.42.1` | `6.43.0` | | [dompurify](https://github.com/cure53/DOMPurify) | `3.4.2` | `3.4.3` | | [yaml](https://github.com/eemeli/yaml) | `2.8.4` | `2.9.0` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.5` | `4.1.6` | | [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) | `3.2.8` | `3.2.9` | Updates `@codemirror/view` from 6.42.1 to 6.43.0 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `dompurify` from 3.4.2 to 3.4.3 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.2...3.4.3) Updates `yaml` from 2.8.4 to 2.9.0 - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.8.4...v2.9.0) Updates `@vitest/ui` from 4.1.5 to 4.1.6 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/ui) Updates `vue-tsc` from 3.2.8 to 3.2.9 - [Release notes](https://github.com/vuejs/language-tools/releases) - [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md) - [Commits](https://github.com/vuejs/language-tools/commits/v3.2.9/packages/tsc) --- updated-dependencies: - dependency-name: "@codemirror/view" dependency-version: 6.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: dompurify dependency-version: 3.4.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: yaml dependency-version: 2.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: "@vitest/ui" dependency-version: 4.1.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: vue-tsc dependency-version: 3.2.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] --- package.json | 10 +++---- yarn.lock | 78 +++++++++++++++++++++++++++++++--------------------- 2 files changed, 52 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 1f70d03a..e37de408 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@codemirror/lint": "^6.9.6", "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.41.1", + "@codemirror/view": "^6.43.0", "@jsonforms/core": "^3.7.0", "@jsonforms/vue": "^3.7.0", "@jsonforms/vue-vanilla": "^3.7.0", @@ -37,7 +37,7 @@ "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "crypto-js": "^4.2.0", - "dompurify": "^3.4.1", + "dompurify": "^3.4.3", "express": "^4.21.0", "express-basic-auth": "^1.2.1", "frappe-charts": "^1.6.2", @@ -51,12 +51,12 @@ "vue-router": "^4.4.0", "vue-select": "4.0.0-beta.6", "vuex": "^4.1.0", - "yaml": "^2.8.3" + "yaml": "^2.9.0" }, "devDependencies": { "@eslint/js": "^10.0.1", "@vitejs/plugin-vue": "^5.0.0", - "@vitest/ui": "^4.1.5", + "@vitest/ui": "^4.1.6", "@vue/compiler-sfc": "^3.5.0", "@vue/test-utils": "^2.4.8", "autoprefixer": "^10.4.27", @@ -73,7 +73,7 @@ "vite-svg-loader": "^5.1.0", "vitest": "^4.1.5", "vue-eslint-parser": "^10.0.0", - "vue-tsc": "^3.2.8" + "vue-tsc": "^3.2.9" }, "engines": { "node": ">=18.0.0" diff --git a/yarn.lock b/yarn.lock index 416c8d4b..06a1d25a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -874,10 +874,10 @@ dependencies: "@marijn/find-cluster-break" "^1.0.0" -"@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.41.1", "@codemirror/view@^6.42.0": - version "6.42.1" - resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.42.1.tgz#2ce83206e0a7e1704b123ea3b1d1b32ad6abe3b0" - integrity sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg== +"@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.42.0", "@codemirror/view@^6.43.0": + version "6.43.0" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.43.0.tgz#a577da65f1d5d8f7cbf08e14849284c12f38365a" + integrity sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA== dependencies: "@codemirror/state" "^6.6.0" crelt "^1.0.6" @@ -1950,6 +1950,13 @@ dependencies: tinyrainbow "^3.1.0" +"@vitest/pretty-format@4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.6.tgz#24a1c03a6b68a8775f8ddfec51d3636315edc3f5" + integrity sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw== + dependencies: + tinyrainbow "^3.1.0" + "@vitest/runner@4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.5.tgz#a14dd2d2f48603f906dd52304a10c7fc623bb1de" @@ -1973,12 +1980,12 @@ resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.5.tgz#fa7858ffab746fa9ac29496e626f5a0caf9a5a7f" integrity sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ== -"@vitest/ui@^4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-4.1.5.tgz#041ac70b0c769182a313574402e1bc15f0454d14" - integrity sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag== +"@vitest/ui@^4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-4.1.6.tgz#9ab55522fcffcb6ebdc1607e8119dc6d08b68591" + integrity sha512-wiu5em68DfGv/2HFvI1Njr7JI2CHcBlQvereSzVG8my53PRxjTNOCsD9VOkRKrsJBDHmyuXvosxWZw7T91a2mw== dependencies: - "@vitest/utils" "4.1.5" + "@vitest/utils" "4.1.6" fflate "^0.8.2" flatted "^3.4.2" pathe "^2.0.3" @@ -1995,6 +2002,15 @@ convert-source-map "^2.0.0" tinyrainbow "^3.1.0" +"@vitest/utils@4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.6.tgz#3f4acf1f60e135ec1ce896f10baa4cd6466d0d38" + integrity sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ== + dependencies: + "@vitest/pretty-format" "4.1.6" + convert-source-map "^2.0.0" + tinyrainbow "^3.1.0" + "@volar/language-core@2.4.28": version "2.4.28" resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.28.tgz#c21f365a91c1dffe8bd7264fd491770c8d74fef3" @@ -2063,15 +2079,15 @@ resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343" integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g== -"@vue/language-core@3.2.8": - version "3.2.8" - resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.2.8.tgz#3bd38c343b89976208b7996bc670df56313047de" - integrity sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g== +"@vue/language-core@3.2.9": + version "3.2.9" + resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.2.9.tgz#903dde38a614f6263ace162c9edc2cd479e6c69b" + integrity sha512-ie0ojt/0fU/GfIogh+zgHbaYRPlt9S+cLOxcWwF7nTSFh897BVgnFKL2byT4kpp1mlqYWZ2psGwSniyE2xsxYw== dependencies: "@volar/language-core" "2.4.28" "@vue/compiler-dom" "^3.5.0" "@vue/shared" "^3.5.0" - alien-signals "^3.1.2" + alien-signals "^3.2.0" muggle-string "^0.4.1" path-browserify "^1.0.1" picomatch "^4.0.4" @@ -2179,10 +2195,10 @@ ajv@^8.0.0, ajv@^8.20.0, ajv@^8.6.0, ajv@^8.6.1: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" -alien-signals@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-3.1.2.tgz#26e623e3ed81e401df1a7c503f726e2288a4fa02" - integrity sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw== +alien-signals@^3.2.0: + version "3.2.1" + resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-3.2.1.tgz#eb66256949bce90b7d30d055e2752e62d6930c7c" + integrity sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g== ansi-regex@^5.0.1: version "5.0.1" @@ -2731,10 +2747,10 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.4.1: - version "3.4.2" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.2.tgz#f0ff81be682c485505097ba8195a058d8f575218" - integrity sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA== +dompurify@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.3.tgz#3ef336e7a757c3bf1efbd3781afb149a3ae5cfa4" + integrity sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A== optionalDependencies: "@types/trusted-types" "^2.0.7" @@ -5303,13 +5319,13 @@ vue-select@4.0.0-beta.6: resolved "https://registry.yarnpkg.com/vue-select/-/vue-select-4.0.0-beta.6.tgz#7c250cb7c01280b54a311cb446629801b3c8df98" integrity sha512-K+zrNBSpwMPhAxYLTCl56gaMrWZGgayoWCLqe5rWwkB8aUbAUh7u6sXjIR7v4ckp2WKC7zEEUY27g6h1MRsIHw== -vue-tsc@^3.2.8: - version "3.2.8" - resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-3.2.8.tgz#92e6190f198b460c92b35f4f66eb791e374f0c01" - integrity sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog== +vue-tsc@^3.2.9: + version "3.2.9" + resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-3.2.9.tgz#94998ec126c37eef1729daa62331713c30b52d95" + integrity sha512-qm8/nbo+9eZc1SCndm9wT+gq23pM+wRIdHY0wjm83B3lIginHTwcdrLUyTrKjDWXbMVNjKegNrnymhpdqnCL3A== dependencies: "@volar/typescript" "2.4.28" - "@vue/language-core" "3.2.8" + "@vue/language-core" "3.2.9" vue@^3.5.34: version "3.5.34" @@ -5622,10 +5638,10 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yaml@^2.8.3: - version "2.8.4" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.4.tgz#4b5f411dd25f9544914d8673d4da7f29248e5e2e" - integrity sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog== +yaml@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== yocto-queue@^0.1.0: version "0.1.0" From 93f7633a7df3270305089302d50ccb037820aec2 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 13:50:56 +0100 Subject: [PATCH 03/31] =?UTF-8?q?=F0=9F=94=90=20Applies=20server-side=20au?= =?UTF-8?q?thentication=20to=20all=20internal=20routes=20when=20OIDC/KC=20?= =?UTF-8?q?enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/authentication.md | 70 ++++++++++++--- docs/troubleshooting.md | 2 +- package.json | 1 + services/app.js | 57 +++++++++--- services/auth-oidc.js | 140 +++++++++++++++++++++++++++++ src/router.js | 8 +- src/utils/auth/Auth.js | 15 ++-- src/utils/auth/KeycloakAuth.js | 24 +++-- src/utils/auth/OidcAuth.js | 36 ++++---- src/utils/auth/getApiAuthHeader.js | 43 +++++++++ src/utils/config/ConfigSchema.json | 10 +++ src/utils/config/defaults.js | 1 + src/utils/request.js | 15 +++- tests/server/cors-proxy.test.js | 8 ++ tests/server/general.test.js | 9 ++ tests/server/status-check.test.js | 6 ++ yarn.lock | 5 ++ 17 files changed, 386 insertions(+), 64 deletions(-) create mode 100644 services/auth-oidc.js create mode 100644 src/utils/auth/getApiAuthHeader.js diff --git a/docs/authentication.md b/docs/authentication.md index b3a56a7f..0bec846c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -30,7 +30,7 @@ > [!IMPORTANT] > Dashy's built-in auth is not intended to protect a publicly hosted instance against unauthorized access. Instead you should use an auth provider compatible with your reverse proxy, or access Dashy via your VPN, or implement your own SSO logic. > -> If Dashy is only accessible within your home network and you just want a login page, then the built-in auth may be sufficient. To also protect server-side endpoints and config files, set `ENABLE_HTTP_AUTH=true` (see [Adding HTTP Auth to Configuration](#adding-http-auth-to-configuration)). +> If Dashy is only accessible within your home network and you just want a login page, then the built-in auth may be sufficient. To also protect server-side endpoints and config files: with built-in auth set `ENABLE_HTTP_AUTH=true` ([details](#adding-http-auth-to-configuration)). (Or, consider setting up[OIDC](#oidc), [Keycloak](#keycloak), or [Header Auth](#header-authentication), where the server-side enforcement is on automatically). ## Built-In Auth @@ -210,11 +210,13 @@ Use the following run command, replacing the attributes (default credentials, po docker run -d \ -p 8081:8080 \ --name auth-server \ - -e KEYCLOAK_USER=admin \ - -e KEYCLOAK_PASSWORD=admin \ - quay.io/keycloak/keycloak:15.0.2 + -e KEYCLOAK_ADMIN=admin \ + -e KEYCLOAK_ADMIN_PASSWORD=admin \ + quay.io/keycloak/keycloak:25.0 start-dev ``` +(The `KEYCLOAK_USER` / `KEYCLOAK_PASSWORD` env vars and the `/auth` URL prefix from Keycloak 16 and older have been replaced. If you are still on 17 or older, set `legacySupport: true` in your Dashy config later on.) + If you need to pull from DockerHub, a non-official image is available [here](https://registry.hub.docker.com/r/jboss/keycloak). Or if you would prefer not to use Docker, you can also directly install Keycloak from source, following [this guide](https://www.keycloak.org/docs/latest/getting_started/index.html). You should now be able to access the Keycloak web interface, using the port specified above (e.g. `http://127.0.0.1:8081`), login with the default credentials, and when prompted create a new password. @@ -230,14 +232,27 @@ Before we can use Keycloak, we must first set it up with some users. Keycloak us You can now create your first user. 1. In the left-hand menu, click 'Users', then 'Add User' -2. Fill in the form, including username and hit 'Save' +2. Fill in the form. On Keycloak 25 and newer, *First name* and *Last name* are required by the default user-profile schema. If you skip them the user can sign in but login will then fail with "Account is not fully set up" 3. Under the 'Credentials' tab, give the new user an initial password. They will be prompted to change this after first login -The last thing we need to do in the Keycloak admin console is to create a new client +Next, create a new client for Dashy. 1. Within your new realm, navigate to 'Clients' on the left-hand side, then click 'Create' in the top-right -2. Choose a 'Client ID', set 'Client Protocol' to 'openid-connect', and for 'Valid Redirect URIs' put a URL pattern to where you're hosting Dashy (if you're just testing locally, then * is fine), and do the same for the 'Web Origins' field -3. Make note of your client-id, and click 'Save' +2. Choose a 'Client ID' (e.g. `dashy`), set 'Client Protocol' to 'openid-connect' +3. Turn *Client authentication* OFF and leave *Standard flow* enabled. Dashy is a SPA, so it acts as an OAuth public client with PKCE. A confidential client requires a client_secret that a browser app can't safely hold +4. For 'Valid Redirect URIs' put the URL where you host Dashy (e.g. `https://dashy.example.com/*`, or just `*` while testing locally). Do the same for the 'Web Origins' field +5. Make note of your client-id, and click 'Save' + +For the `adminRole` check to work, the role must appear in the id_token (Keycloak's default mapper only adds it to the access token): + +1. Open your `dashy` client, go to the *Client scopes* tab, click the dedicated scope row (`dashy-dedicated`) +2. Add a new mapper of type *User Realm Role*, name it (e.g. `realm_roles`), claim name `realm_access.roles`, multivalued ON, *Add to ID token* ON, *Add to access token* ON +3. (Optional, for `adminGroup` instead of `adminRole`) Add a second mapper of type *Group Membership*, claim name `groups` + +To create the admin role itself and grant it to a user: + +1. *Realm roles* in the left-hand menu, *Create role*, name it (e.g. `dashy-admin`) +2. *Users* → pick your admin user → *Role mapping* → *Assign role* → select `dashy-admin` ### 3. Enable Keycloak in Dashy Config File @@ -246,13 +261,15 @@ For example: ```yaml appConfig: - ... + # ... + disableConfigurationForNonAdmin: true auth: enableKeycloak: true keycloak: serverUrl: 'http://localhost:8081' realm: 'alicia-homelab' clientId: 'dashy' + adminRole: 'dashy-admin' # role name that grants admin privileges ``` Note that if you are using Keycloak V 17 or older, you will also need to set `legacySupport: true` (also under `appConfig.auth.keycloak`). This is because the API endpoint was updated in later versions. @@ -281,12 +298,19 @@ sections: groups: ['DevelopmentTeam'] ``` -Depending on how you're hosting Dashy and Keycloak, you may also need to set some HTTP headers, to prevent a CORS error. This would typically be the `Access-Control-Allow-Origin [URL-of Dashy]` on your Keycloak instance. See the [Setting Headers](https://github.com/Lissy93/dashy/blob/master/docs/management.md#setting-headers) guide in the management docs for more info. - Your app is now secured :) When you load Dashy, it will redirect to your Keycloak login page, and any user without valid credentials will be prevented from accessing your dashboard. From within the Keycloak console, you can then configure things like time-outs, password policies, etc. You can also backup your full Keycloak config, and it is recommended to do this, along with your Dashy config. You can spin up both Dashy and Keycloak simultaneously and restore both applications configs using a `docker-compose.yml` file, and this is recommended. +### CORS Headers +If Dashy and Keycloak run on different origins (typical when testing locally on different `localhost:` ports), Keycloak's default `Content-Security-Policy: frame-ancestors 'self'` and `X-Frame-Options: SAMEORIGIN` block the hidden iframe `keycloak-js` uses to check your session. Symptom: a generic "Authentication failed (Keycloak)" toast on first load. To allow the iframe, open *Realm settings → Security defenses → Browser headers*, clear `X-Frame-Options`, and change `Content-Security-Policy` to `frame-src 'self' ; frame-ancestors 'self' ; object-src 'none';`. Same-origin production deployments don't hit this. + +### How server-side enforcement works + +Dashy's server reads `auth.keycloak` from `conf.yml` at boot, lazily fetches your Keycloak realm's OIDC discovery doc + JWKS, then verifies the `id_token` the SPA attaches to every API call as `Authorization: Bearer `. Tokens that fail signature / issuer / audience / expiry verification are rejected with `401`. Write endpoints (`POST /config-manager/save`) additionally require the `adminRole` (or `adminGroup`) to be present in the token claims, and non-admins receive `403`. Note that the `/conf.yml` remains anonymously readable still (so the SPA can bootstrap before login). + +The admin check reads the role / group claim from the id_token, so the client mapper from Step 2 above (the one with *Add to ID token* on) is what makes `adminRole` / `adminGroup` work. Without it the server gets a token with no roles claim and treats everyone as non-admin. + ### Troubleshooting Keycloak If you encounter issues with your Keycloak setup, follow these steps to troubleshoot and resolve common problems. @@ -357,6 +381,7 @@ Dashy also supports using a general [OIDC compatible](https://openid.net/connect ```yaml appConfig: + disableConfigurationForNonAdmin: true # Prevent authenticated non-admins using editor auth: enableOidc: true oidc: @@ -368,6 +393,8 @@ appConfig: Because Dashy is a SPA, a [public client](https://datatracker.ietf.org/doc/html/rfc6749#section-2.1) registration with PKCE is needed. +If you set `adminGroup`, include `groups` in `scope` (e.g. `scope: 'openid profile email groups'`) so your IdP actually returns the claim in the id_token. Same goes for `adminRole` and a `roles` scope if your IdP needs one. + Note, that if your `clientId` is numeric, you must place it in quotes. Otherwise it will be interpreted as a number and truncated to 64 chars! An example for Authelia is shared below, but other OIDC systems can be used: @@ -396,6 +423,12 @@ identity_providers: Groups and roles will be populated and available for controlling display similar to [Keycloak](#Keycloak) above. +### How server-side enforcement works + +Dashy's server reads `auth.oidc` from `conf.yml` at boot, lazily fetches the OIDC discovery doc + JWKS from your `endpoint`, then verifies the `id_token` the SPA attaches to every API call as `Authorization: Bearer `. Tokens that fail signature / issuer / audience / expiry verification are rejected with `401`. Write endpoints (`POST /config-manager/save`) additionally require the `adminGroup` (or `adminRole`) to be present in the token's `groups` / `roles` claims, and non-admins receive `403`. Note that the `/conf.yml` remains anonymously readable still + +Your IdP must include `groups` / `roles` in the id_token, not only the access token, for the admin check to work (most IdPs do this when the `groups` scope is requested). + --- ## authentik @@ -433,7 +466,7 @@ A dialog box will pop-up, select the `OAuth2/OpenID Provider`. Click `Next`. ![image](https://github.com/user-attachments/assets/ea84fe57-b813-404d-8dad-5e221b440bdb) -On the next page of the wizard, set the `Name`, `Authentication flow`, and `Authorization flow`. See example below. Using the `default-provider-authorization-implicit-consent` authorization flow on internal services and `default-provider-authorization-explicit-consent` on external services is a common practice. However, it is fully up to you on how you would like to configure this option. `Implicit` will login directly without user consent, `explicit` will ask if the user approves the service being logged into with their user credentials. +On the next page of the wizard, set the `Name`, `Authentication flow`, `Authorization flow`, and `Invalidation flow`. See example below. Using the `default-provider-authorization-implicit-consent` authorization flow on internal services and `default-provider-authorization-explicit-consent` on external services is a common practice. However, it is fully up to you on how you would like to configure this option. `Implicit` will login directly without user consent, `explicit` will ask if the user approves the service being logged into with their user credentials. For the invalidation flow (required on Authentik 2023.10 and later) the built-in `default-provider-invalidation-flow` is fine. ![image](https://github.com/user-attachments/assets/e600aeaf-08d1-49aa-b304-11e90e5c89cd) @@ -448,7 +481,15 @@ Scroll down to set the `Signing Key`. It is recommended to use the built in `aut ![image](https://github.com/user-attachments/assets/386c0750-9d2b-4482-8938-8b301b489b38) -Expand `Advanced protocol settings` then verify the `Scopes` are set to what is highlighted in `white` below. Set the `Subject mode` to `Based on the Users's Email`. +If you plan to use `adminGroup` in your Dashy config, you need a `groups` scope mapping first. Authentik does not ship one by default. Open *Customisation > Property Mappings* in a new tab, click *Create > Scope Mapping*, set *Name* to `groups`, *Scope name* to `groups`, and *Expression* to: + +```python +return {"groups": [g.name for g in request.user.ak_groups.all()]} +``` + +Save it, then come back to the provider wizard. + +Expand `Advanced protocol settings` then verify the `Scopes` are set to what is highlighted in `white` below (including the `groups` mapping you just created, if you want `adminGroup` to work). Set the `Subject mode` to `Based on the Users's Email`. ![image](https://github.com/user-attachments/assets/ae5e87b8-1ad6-41dd-b6e1-9665623f842a) @@ -507,11 +548,12 @@ Enter the `Client ID` in the `clientId` field and `OpenID Configuration Issuer` Below is how to configure the `auth` section in the yaml syntax. Once this is enabled, when an attempt to access `Dashy` is made it will now redirect you to the `authentik` login page moving forward. -``` +```yaml appConfig: theme: glass layout: auto iconSize: medium + disableConfigurationForNonAdmin: true # Prevent logged-in, non-admins using the view/edit config features auth: enableOidc: true oidc: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b8c6e101..1c67e2f5 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -175,7 +175,7 @@ docker compose up -d --force-recreate ### Intentionally read-only mode -To stop anyone saving from the UI, set `appConfig.preventWriteToDisk: true` in `conf.yml`. This disables the endpoint and the save button. For Docker users, you can harden things further, by mounting the config and all user-data as read-only. +To hide the "Save to disk" UI for everyone, set `appConfig.preventWriteToDisk: true` in `conf.yml`. This is a UI-only flag — the `/config-manager/save` server endpoint itself is gated by the configured auth method (`auth.users` with `ENABLE_HTTP_AUTH`, OIDC/Keycloak admin role, header-auth, etc.), so anyone unauthenticated or non-admin already can't save regardless of this flag. For Docker users, you can harden things further by mounting `user-data` (or just `conf.yml`) as read-only — the kernel will refuse the write even before the server tries. --- diff --git a/package.json b/package.json index 1f70d03a..c9705f87 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "express": "^4.21.0", "express-basic-auth": "^1.2.1", "frappe-charts": "^1.6.2", + "jose": "^5.10.0", "js-yaml": "^4.1.0", "keycloak-js": "^26.0.0", "oidc-client-ts": "^3.0.1", diff --git a/services/app.js b/services/app.js index dd2b1023..da68f39e 100644 --- a/services/app.js +++ b/services/app.js @@ -30,6 +30,7 @@ const systemInfo = require('./system-info'); // Basic system info, for resource const sslServer = require('./ssl-server'); // TLS-enabled web server const corsProxy = require('./cors-proxy'); // Enables API requests to CORS-blocked services const getUser = require('./get-user'); // Enables server side user lookup +const { loadOidcSettings, createOidcMiddleware } = require('./auth-oidc'); // Server-side OIDC/Keycloak token verification /* Service endpoint URL paths (see also serviceEndpoints in src/utils/config/defaults.js) */ const ENDPOINTS = { @@ -78,7 +79,7 @@ process.on('unhandledRejection', (reason) => { /* Load appConfig.auth from config (if present) for authorization purposes */ function loadAuthConfig() { try { - const filePath = path.join(rootDir, process.env.USER_DATA_DIR || 'user-data', 'conf.yml'); + const filePath = path.resolve(rootDir, process.env.USER_DATA_DIR || 'user-data', 'conf.yml'); const fileContents = fs.readFileSync(filePath, 'utf8'); const data = yaml.load(fileContents); return data?.appConfig?.auth || {}; @@ -116,9 +117,9 @@ function customAuthorizer(username, password) { } } -/* If auth is enabled, setup auth for config access, otherwise skip */ -function getBasicAuthMiddleware() { - const authConfig = loadAuthConfig(); +/* Pick an auth strategy based on what's configured in conf.yml + env. + OIDC / Keycloak takes precedence — it's the strongest enforcement we offer. */ +function getAuthMiddleware(authConfig, oidcSettings) { const confUsers = authConfig.users || null; const hasConfUsers = confUsers && confUsers.length > 0; const useConfAuth = process.env.ENABLE_HTTP_AUTH && hasConfUsers; @@ -133,7 +134,9 @@ function getBasicAuthMiddleware() { + ' This will cause auth failures. Set ENABLE_HTTP_AUTH=true, or remove users from conf.yml.'); } - if (useConfAuth) { + if (oidcSettings) { + return createOidcMiddleware(oidcSettings); + } else if (useConfAuth) { return basicAuth({ authorizer: customAuthorizer, challenge: true, @@ -163,11 +166,37 @@ function getBasicAuthMiddleware() { return (req, res, next) => next(); } -const protectConfig = getBasicAuthMiddleware(); +const initialAuthConfig = loadAuthConfig(); +const oidcSettings = loadOidcSettings(initialAuthConfig); +const protectConfig = getAuthMiddleware(initialAuthConfig, oidcSettings); -/* Middleware to restrict write endpoints to admin users only */ +/* True when any auth method is configured. Used to keep zero-auth deployments + open (their original behaviour) while closing the gate for everyone else. */ +const authIsConfigured = Boolean( + oidcSettings + || (process.env.ENABLE_HTTP_AUTH && initialAuthConfig.users?.length) + || (process.env.BASIC_AUTH_USERNAME && process.env.BASIC_AUTH_PASSWORD) + || (initialAuthConfig.enableHeaderAuth && initialAuthConfig.headerAuth), +); + +/* Require an authenticated identity on this request. No-op for zero-auth deploys. */ +function requireAuth(req, res, next) { + if (!authIsConfigured) return next(); + if (req.auth) return next(); + return res.status(401).json({ success: false, message: 'Unauthorized' }); +} + +/* Restrict to admin users. OIDC/Keycloak get isAdmin from token claims; the + conf.yml `users[]` path falls back to looking up user.type === 'admin'. */ function requireAdmin(req, res, next) { - if (!req.auth) return next(); + if (!authIsConfigured) return next(); + if (!req.auth) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } + if (typeof req.auth.isAdmin === 'boolean') { + if (req.auth.isAdmin) return next(); + return res.status(403).json({ success: false, message: 'Forbidden - Admin access required' }); + } const users = loadUserConfig(); if (!users || users.length === 0) return next(); const user = users.find(u => u.user.toLowerCase() === req.auth.user.toLowerCase()); @@ -191,7 +220,7 @@ const app = express() // Load middlewares for parsing JSON, and supporting HTML5 history routing .use(express.json({ limit: '1mb' })) // GET endpoint to run status of a given URL with GET request - .use(ENDPOINTS.statusCheck, protectConfig, method('GET', (req, res) => { + .use(ENDPOINTS.statusCheck, protectConfig, requireAuth, method('GET', (req, res) => { try { statusCheck(req.url, (results) => { if (!res.headersSent) { @@ -223,7 +252,7 @@ const app = express() }); })) // GET endpoint to return system info, for widget - .use(ENDPOINTS.systemInfo, protectConfig, method('GET', (req, res) => { + .use(ENDPOINTS.systemInfo, protectConfig, requireAuth, method('GET', (req, res) => { try { safeEnd(res, JSON.stringify(systemInfo())); } catch (e) { @@ -231,7 +260,7 @@ const app = express() } })) // GET for accessing non-CORS API services - .use(ENDPOINTS.corsProxy, protectConfig, (req, res) => { + .use(ENDPOINTS.corsProxy, protectConfig, requireAuth, (req, res) => { try { corsProxy(req, res); } catch (e) { @@ -239,7 +268,7 @@ const app = express() } }) // GET endpoint to return user info - .use(ENDPOINTS.getUser, protectConfig, method('GET', (req, res) => { + .use(ENDPOINTS.getUser, protectConfig, requireAuth, method('GET', (req, res) => { try { safeEnd(res, JSON.stringify(getUser(config, req))); } catch (e) { @@ -249,13 +278,13 @@ const app = express() // Middleware to serve any .yml files in USER_DATA_DIR with optional protection .get('/*.yml', protectConfig, (req, res) => { const ymlFile = req.path.split('/').pop(); - const filePath = path.join(rootDir, process.env.USER_DATA_DIR || 'user-data', ymlFile); + const filePath = path.resolve(rootDir, process.env.USER_DATA_DIR || 'user-data', ymlFile); res.sendFile(filePath, (err) => { if (err) safeEnd(res, errBody(`Could not read ${ymlFile}`), 404); }); }) // Serves up static files - .use(express.static(path.join(rootDir, process.env.USER_DATA_DIR || 'user-data'))) + .use(express.static(path.resolve(rootDir, process.env.USER_DATA_DIR || 'user-data'))) .use(express.static(path.join(rootDir, 'dist'))) .use(express.static(path.join(rootDir, 'public'), { index: 'initialization.html' })) // If no other route is matched, serve up the index.html with a 404 status diff --git a/services/auth-oidc.js b/services/auth-oidc.js new file mode 100644 index 00000000..c41747af --- /dev/null +++ b/services/auth-oidc.js @@ -0,0 +1,140 @@ +/** + * Server-side OIDC / Keycloak token verification. + * + * Why this exists: Dashy's OIDC + Keycloak flows are otherwise entirely + * client-side, so the server never sees an identity. This module verifies the + * id_token a logged-in browser attaches to API requests, so admin-gated + * endpoints (and authenticated endpoints generally) can enforce auth server-side. + * + * The middleware is permissive on a missing Authorization header — bootstrap + * fetches (e.g. /conf.yml) must succeed before the user is logged in. Endpoints + * that require auth should be paired with `requireAuth` (in app.js). + * A present-but-invalid token is always rejected. + */ + +const { createRemoteJWKSet, jwtVerify } = require('jose'); + +/* Normalise the OIDC / Keycloak block from conf.yml into a unified shape. + Returns null when neither provider is enabled or required fields are missing. */ +function loadOidcSettings(authConfig) { + if (!authConfig || typeof authConfig !== 'object') return null; + + if (authConfig.enableOidc && authConfig.oidc) { + const { endpoint, clientId, adminGroup, adminRole } = authConfig.oidc; + if (!endpoint || !clientId) return null; + return { + kind: 'oidc', + issuer: String(endpoint), + clientId: String(clientId), + adminGroup: adminGroup || null, + adminRole: adminRole || null, + }; + } + + if (authConfig.enableKeycloak && authConfig.keycloak) { + const { + serverUrl, realm, clientId, adminGroup, adminRole, legacySupport, + } = authConfig.keycloak; + if (!serverUrl || !realm || !clientId) return null; + // Mirror the URL keycloak-js builds in the browser. + const base = (legacySupport ? `${serverUrl}/auth` : serverUrl).replace(/\/$/, ''); + return { + kind: 'keycloak', + issuer: `${base}/realms/${realm}`, + clientId: String(clientId), + adminGroup: adminGroup || null, + adminRole: adminRole || null, + }; + } + + return null; +} + +/* Per-issuer cache: discovery metadata + JWKS resolver. createRemoteJWKSet + handles key caching + rotation internally. */ +const issuerCache = new Map(); + +async function fetchDiscovery(issuer) { + const base = issuer.endsWith('/') ? issuer : `${issuer}/`; + const url = new URL('.well-known/openid-configuration', base); + const res = await fetch(url); + if (!res.ok) throw new Error(`OIDC discovery returned ${res.status} for ${url}`); + return res.json(); +} + +async function getIssuerContext(issuer) { + const cached = issuerCache.get(issuer); + if (cached) return cached; + const config = await fetchDiscovery(issuer); + if (!config.issuer || !config.jwks_uri) { + throw new Error('Discovery document is missing `issuer` or `jwks_uri`'); + } + const ctx = { + canonicalIssuer: config.issuer, + jwks: createRemoteJWKSet(new URL(config.jwks_uri)), + }; + issuerCache.set(issuer, ctx); + return ctx; +} + +/* Pure helper: true when the token's claims map to the configured admin group/role. + Handles standard OIDC top-level `groups`/`roles` claims plus Keycloak's nested + realm_access / resource_access role shapes (mirrors what KeycloakAuth.js does + client-side). */ +function deriveIsAdmin(claims, settings) { + if (!claims) return false; + const groups = Array.isArray(claims.groups) ? [...claims.groups] : []; + const roles = Array.isArray(claims.roles) ? [...claims.roles] : []; + + if (settings.kind === 'keycloak') { + const realmRoles = claims.realm_access && claims.realm_access.roles; + if (Array.isArray(realmRoles)) roles.push(...realmRoles); + const clientRoles = claims.resource_access + && claims.resource_access[settings.clientId] + && claims.resource_access[settings.clientId].roles; + if (Array.isArray(clientRoles)) roles.push(...clientRoles); + } + + const { adminGroup, adminRole } = settings; + if (adminGroup && groups.includes(adminGroup)) return true; + if (adminRole && roles.includes(adminRole)) return true; + return false; +} + +/* Connect middleware factory. Verifies Bearer id_token; sets req.auth on success. */ +function createOidcMiddleware(settings) { + return async (req, res, next) => { + const header = req.headers.authorization || ''; + const match = header.match(/^Bearer\s+(.+)$/i); + if (!match) return next(); // Permissive: no token attached, let downstream gates decide + const token = match[1].trim(); + if (!token) return next(); + + try { + const { canonicalIssuer, jwks } = await getIssuerContext(settings.issuer); + const { payload } = await jwtVerify(token, jwks, { + issuer: canonicalIssuer, + audience: settings.clientId, + clockTolerance: '30s', + }); + req.auth = { + user: payload.preferred_username || payload.email || payload.sub || 'unknown', + isAdmin: deriveIsAdmin(payload, settings), + claims: payload, + }; + return next(); + } catch (e) { + console.warn('[auth-oidc] token verification failed:', e.message || e); // eslint-disable-line no-console + return res.status(401).json({ + success: false, + message: 'Unauthorized - Invalid or expired token', + }); + } + }; +} + +module.exports = { + loadOidcSettings, + createOidcMiddleware, + deriveIsAdmin, +}; diff --git a/src/router.js b/src/router.js index c24f243d..2c9ca4e5 100644 --- a/src/router.js +++ b/src/router.js @@ -18,15 +18,21 @@ import Keys from '@/utils/StoreMutations'; import { isAuthEnabled, isLoggedIn, isGuestAccessEnabled } from '@/utils/auth/Auth'; import { isOidcEnabled } from '@/utils/auth/OidcAuth'; import { isKeycloakEnabled } from '@/utils/auth/KeycloakAuth'; +import { isHeaderAuthEnabled } from '@/utils/auth/HeaderAuth'; import { startingView as defaultStartingView, routePaths } from '@/utils/config/defaults'; import { VIEW_META } from '@/utils/config/ConfigHelpers'; import ErrorHandler from '@/utils/logging/ErrorHandler'; const progress = new Progress({ color: 'var(--progress-bar)' }); +/* True for ANY auth (OIDC, KC, HeaderAuth, etc) */ +const isAnyAuthConfigured = () => + isAuthEnabled() || isOidcEnabled() || isKeycloakEnabled() || isHeaderAuthEnabled(); + /* Returns true if user is already authenticated, or if auth is not enabled */ const isAuthenticated = () => { - const authEnabled = isAuthEnabled(); + if (store.state.criticalError) return false; + const authEnabled = isAnyAuthConfigured(); const userLoggedIn = isLoggedIn(); const guestEnabled = isGuestAccessEnabled(); return (!authEnabled || userLoggedIn || guestEnabled); diff --git a/src/utils/auth/Auth.js b/src/utils/auth/Auth.js index 9ab9a348..d517e8b4 100644 --- a/src/utils/auth/Auth.js +++ b/src/utils/auth/Auth.js @@ -33,14 +33,11 @@ const getUsers = () => { // Otherwise, return the users array, if available const users = auth.users ? [...auth.users] : []; - if (isOidcEnabled()) { - if (localStorage[localStorageKeys.USERNAME]) { - const user = { - user: localStorage[localStorageKeys.USERNAME], - type: localStorage[localStorageKeys.ISADMIN] === 'true' ? 'admin' : 'normal', - }; - users.push(user); - } + if ((isOidcEnabled() || isKeycloakEnabled()) && localStorage[localStorageKeys.USERNAME]) { + users.push({ + user: localStorage[localStorageKeys.USERNAME], + type: localStorage[localStorageKeys.ISADMIN] === 'true' ? 'admin' : 'normal', + }); } return users; @@ -91,7 +88,7 @@ export const isLoggedIn = () => { const users = getUsers(); const cookieToken = getCookieToken(); - if (isOidcEnabled()) { + if (isOidcEnabled() || isKeycloakEnabled()) { const username = localStorage[localStorageKeys.USERNAME]; // Get username if (!username) return false; // No username return users.some((user) => ( diff --git a/src/utils/auth/KeycloakAuth.js b/src/utils/auth/KeycloakAuth.js index 2dcfd86f..99492ac8 100644 --- a/src/utils/auth/KeycloakAuth.js +++ b/src/utils/auth/KeycloakAuth.js @@ -17,8 +17,10 @@ class KeycloakAuth { constructor(Keycloak) { const { auth } = getAppConfig(); const { - serverUrl, realm, clientId, idpHint, legacySupport, + serverUrl, realm, clientId, idpHint, legacySupport, adminGroup, adminRole, } = auth.keycloak; + this.adminGroup = adminGroup; + this.adminRole = adminRole; if (typeof clientId === 'number' && !Number.isSafeInteger(clientId)) { ErrorHandler( 'Keycloak clientId appears invalid. ', @@ -31,12 +33,12 @@ class KeycloakAuth { const loginOptions = idpHint ? { idpHint } : {}; this.loginOptions = loginOptions; - this.keycloakClient = Keycloak(initOptions); + this.keycloakClient = new Keycloak(initOptions); } login() { return new Promise((resolve, reject) => { - this.keycloakClient.init({ onLoad: 'check-sso' }) + this.keycloakClient.init({ onLoad: 'check-sso', responseMode: 'query' }) .then((auth) => { if (auth) { this.storeKeycloakInfo(); @@ -55,6 +57,8 @@ class KeycloakAuth { logout() { localStorage.removeItem(localStorageKeys.USERNAME); localStorage.removeItem(localStorageKeys.KEYCLOAK_INFO); + localStorage.removeItem(localStorageKeys.ISADMIN); + localStorage.removeItem(localStorageKeys.ID_TOKEN); this.keycloakClient.logout(); } @@ -68,10 +72,10 @@ class KeycloakAuth { preferred_username: preferredUsername, } = this.keycloakClient.tokenParsed; - const realmRoles = realmAccess.roles || []; + const realmRoles = (realmAccess && realmAccess.roles) || []; let clientRoles = []; - if (Object.hasOwn(resourceAccess, clientId)) { + if (resourceAccess && Object.hasOwn(resourceAccess, clientId)) { clientRoles = resourceAccess[clientId].roles || []; } @@ -82,8 +86,18 @@ class KeycloakAuth { roles, }; + // Compute isAdmin from the configured admin group/role + const isAdmin = (Array.isArray(groups) && this.adminGroup && groups.includes(this.adminGroup)) + || (Array.isArray(roles) && this.adminRole && roles.includes(this.adminRole)) + || false; + localStorage.setItem(localStorageKeys.KEYCLOAK_INFO, JSON.stringify(info)); localStorage.setItem(localStorageKeys.USERNAME, preferredUsername); + localStorage.setItem(localStorageKeys.ISADMIN, isAdmin); + // Save id_token locally, so it can be attached as Bearer for network requests + if (this.keycloakClient.idToken) { + localStorage.setItem(localStorageKeys.ID_TOKEN, this.keycloakClient.idToken); + } } } } diff --git a/src/utils/auth/OidcAuth.js b/src/utils/auth/OidcAuth.js index 77234bcb..48fbe118 100644 --- a/src/utils/auth/OidcAuth.js +++ b/src/utils/auth/OidcAuth.js @@ -53,39 +53,43 @@ class OidcAuth { const code = url.searchParams.get('code'); if (code) { - await this.userManager.signinCallback(window.location.href); + // Populate localStorage before the reload so the post-reload route guard + // sees the user as logged-in and lets them through to /, not /login. + const callbackUser = await this.userManager.signinCallback(window.location.href); + if (callbackUser) this.persistUserInfo(callbackUser); window.location.href = '/'; return; } const user = await this.userManager.getUser(); - if (user === null) { if (!isOidcGuestAccessEnabled()) { await this.userManager.signinRedirect(); } } else { - const { roles = [], groups = [] } = user.profile; - const info = { - groups, - roles, - }; - const isAdmin = (Array.isArray(groups) && groups.includes(this.adminGroup)) - || (Array.isArray(roles) && roles.includes(this.adminRole)) - || false; - - statusMsg(`user: ${user.profile.preferred_username} admin: ${isAdmin}`, JSON.stringify(info)); - - localStorage.setItem(localStorageKeys.KEYCLOAK_INFO, JSON.stringify(info)); - localStorage.setItem(localStorageKeys.USERNAME, user.profile.preferred_username); - localStorage.setItem(localStorageKeys.ISADMIN, isAdmin); + this.persistUserInfo(user); } } + /* Mirror the OIDC user into the localStorage keys other parts of Dashy read */ + persistUserInfo(user) { + const { roles = [], groups = [] } = user.profile; + const info = { groups, roles }; + const isAdmin = (Array.isArray(groups) && groups.includes(this.adminGroup)) + || (Array.isArray(roles) && roles.includes(this.adminRole)) + || false; + statusMsg(`user: ${user.profile.preferred_username} admin: ${isAdmin}`, JSON.stringify(info)); + localStorage.setItem(localStorageKeys.KEYCLOAK_INFO, JSON.stringify(info)); + localStorage.setItem(localStorageKeys.USERNAME, user.profile.preferred_username); + localStorage.setItem(localStorageKeys.ISADMIN, isAdmin); + if (user.id_token) localStorage.setItem(localStorageKeys.ID_TOKEN, user.id_token); + } + async logout() { localStorage.removeItem(localStorageKeys.USERNAME); localStorage.removeItem(localStorageKeys.KEYCLOAK_INFO); localStorage.removeItem(localStorageKeys.ISADMIN); + localStorage.removeItem(localStorageKeys.ID_TOKEN); try { await this.userManager.signoutRedirect(); diff --git a/src/utils/auth/getApiAuthHeader.js b/src/utils/auth/getApiAuthHeader.js new file mode 100644 index 00000000..ddcdc015 --- /dev/null +++ b/src/utils/auth/getApiAuthHeader.js @@ -0,0 +1,43 @@ +/** + * Returns Authorization header for internal API requests when OIDC/KC configured + * Uses the id_token which is already stored locally after successful login + * + * Will return `null`, and cause the caller to fall through, when: + * - no token has been stashed + * - the stashed token can't be parsed + * - the token has already expired + */ + +import { localStorageKeys } from '@/utils/config/defaults'; + +/* Base64URL → utf-8 string decode */ +function decodeBase64Url(input) { + const padded = input.replace(/-/g, '+').replace(/_/g, '/'); + const pad = padded.length % 4; + return atob(pad ? padded + '='.repeat(4 - pad) : padded); +} + +/* Check JWT isn't expired, the server will handle the actual verification */ +function isExpired(token) { + try { + const [, payload] = token.split('.'); + if (!payload) return true; + const claims = JSON.parse(decodeBase64Url(payload)); + if (typeof claims.exp !== 'number') return false; + return claims.exp * 1000 <= Date.now(); + } catch { + return true; + } +} + +/* Returns { Authorization: 'Bearer …' } or null if no usable token is available */ +export default function getApiAuthHeader() { + let token; + try { + token = localStorage.getItem(localStorageKeys.ID_TOKEN); + } catch { + return null; + } + if (!token || isExpired(token)) return null; + return { Authorization: `Bearer ${token}` }; +} diff --git a/src/utils/config/ConfigSchema.json b/src/utils/config/ConfigSchema.json index c00c9f28..8215c20e 100644 --- a/src/utils/config/ConfigSchema.json +++ b/src/utils/config/ConfigSchema.json @@ -694,6 +694,16 @@ "type": "string", "description": "Set to the 'Alias' of an existing Identity Provider in the specified realm to skip the Keycloak login page and redirect straight to the external IdP for authentication" }, + "adminRole": { + "title": "Admin Role", + "type": "string", + "description": "The realm or client role that grants admin privileges (mapped into realm_access.roles in the id_token). If not set, no roles will be considered admin." + }, + "adminGroup": { + "title": "Admin Group", + "type": "string", + "description": "The group claim value that grants admin privileges. Requires a Keycloak mapper that puts a 'groups' claim into the id_token. If not set, no groups will be considered admin." + }, "legacySupport": { "title": "Legacy Support", "type": "boolean", diff --git a/src/utils/config/defaults.js b/src/utils/config/defaults.js index bfee1a15..fe437354 100644 --- a/src/utils/config/defaults.js +++ b/src/utils/config/defaults.js @@ -145,6 +145,7 @@ const defaults = { LAST_USED: 'lastUsed', KEYCLOAK_INFO: 'keycloakInfo', ISADMIN: 'isAdmin', + ID_TOKEN: 'idToken', DISABLE_CRITICAL_WARNING: 'disableCriticalWarning', }, /* Key names for cookie identifiers */ diff --git a/src/utils/request.js b/src/utils/request.js index 550c8380..822aa28a 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -8,6 +8,7 @@ */ import { makeBasicAuthHeaders } from '@/utils/auth/Auth'; +import getApiAuthHeader from '@/utils/auth/getApiAuthHeader'; /** Check if a request URL targets the local Dashy server */ function isLocalRequest(url) { @@ -64,11 +65,17 @@ async function makeRequest(config) { signal: controller.signal, }; - // For local API requests, include basic auth headers when configured + // For local API requests, attach auth headers when configured + // Bearer (OIDC / Keycloak id_token) takes precedence over basic-auth cookie header if (isLocalRequest(fullUrl) && !fetchOptions.headers.Authorization) { - const authConfig = makeBasicAuthHeaders(); - if (authConfig.headers) { - Object.assign(fetchOptions.headers, authConfig.headers); + const bearer = getApiAuthHeader(); + if (bearer) { + Object.assign(fetchOptions.headers, bearer); + } else { + const authConfig = makeBasicAuthHeaders(); + if (authConfig.headers) { + Object.assign(fetchOptions.headers, authConfig.headers); + } } } diff --git a/tests/server/cors-proxy.test.js b/tests/server/cors-proxy.test.js index 19b772e4..d86f3f93 100644 --- a/tests/server/cors-proxy.test.js +++ b/tests/server/cors-proxy.test.js @@ -1,10 +1,18 @@ // @vitest-environment node import http from 'http'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { describe, it, expect, afterEach, beforeAll, afterAll, vi, } from 'vitest'; import request from 'supertest'; +// Isolate from the repo's conf.yml so test behaviour doesn't depend on which +// auth method (if any) the developer has configured locally. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-cors-test-')); +process.env.USER_DATA_DIR = tmpDir; + const app = require('../../services/app'); const { substituteEnv } = require('../../services/cors-proxy'); diff --git a/tests/server/general.test.js b/tests/server/general.test.js index 56f82e93..35adf65b 100644 --- a/tests/server/general.test.js +++ b/tests/server/general.test.js @@ -1,7 +1,16 @@ // @vitest-environment node +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { describe, it, expect } from 'vitest'; import request from 'supertest'; +// Isolate from the repo's conf.yml so test behaviour doesn't depend on which +// auth method (if any) the developer has configured locally. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-general-test-')); +fs.writeFileSync(path.join(tmpDir, 'conf.yml'), 'pageInfo:\n title: Test\nsections: []\n'); +process.env.USER_DATA_DIR = tmpDir; + const app = require('../../services/app'); describe('Healthcheck', () => { diff --git a/tests/server/status-check.test.js b/tests/server/status-check.test.js index 6a18a583..da9ea748 100644 --- a/tests/server/status-check.test.js +++ b/tests/server/status-check.test.js @@ -1,7 +1,13 @@ // @vitest-environment node +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { describe, it, expect } from 'vitest'; import request from 'supertest'; +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashy-status-test-')); +process.env.USER_DATA_DIR = tmpDir; + const app = require('../../services/app'); describe('Status check', () => { diff --git a/yarn.lock b/yarn.lock index 416c8d4b..0830a8a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3803,6 +3803,11 @@ jake@^10.8.5: filelist "^1.0.4" picocolors "^1.1.1" +jose@^5.10.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-5.10.0.tgz#c37346a099d6467c401351a9a0c2161e0f52c4be" + integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg== + js-beautify@^1.14.9: version "1.15.4" resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.4.tgz#f579f977ed4c930cef73af8f98f3f0a608acd51e" From a0c33ae1fc2c9c0232afadc52405bfb982e359be Mon Sep 17 00:00:00 2001 From: Liss-Bot Date: Fri, 15 May 2026 13:08:38 +0000 Subject: [PATCH 04/31] =?UTF-8?q?=F0=9F=94=96=20Bump=20version=20to=204.0.?= =?UTF-8?q?9=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 067f99df..8d8da10a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dashy", - "version": "4.0.8", + "version": "4.0.9", "license": "MIT", "main": "server", "author": "Alicia Sykes (https://aliciasykes.com)", From be7fc8903f59a64712e85c19d321c502805f9132 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 18:10:30 +0100 Subject: [PATCH 05/31] =?UTF-8?q?=F0=9F=91=B7=20Smoother=20releases,=20wit?= =?UTF-8?q?h=20built=20app=20and=20checksum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-release-assets.yml | 87 ----------- .github/workflows/draft-release.yml | 103 ------------- .github/workflows/release.yml | 170 +++++++++++++++++++++ 3 files changed, 170 insertions(+), 190 deletions(-) delete mode 100644 .github/workflows/build-release-assets.yml delete mode 100644 .github/workflows/draft-release.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build-release-assets.yml b/.github/workflows/build-release-assets.yml deleted file mode 100644 index 1736b741..00000000 --- a/.github/workflows/build-release-assets.yml +++ /dev/null @@ -1,87 +0,0 @@ -name: 📦 Build & Upload Release Assets - -# Builds Dashy and uploads a pre-built tarball to the GitHub release. -# This allows non-Docker installs (e.g. Proxmox VE community scripts) to -# download a ready-to-run package without having to build from source. -# -# The tarball contains the compiled frontend (dist/) plus all server-side -# files. Users extract it and run `yarn install --production` + `node server`. -# -# Triggered whenever a new release is created, or when manually dispatched - -on: - release: - types: [created] - workflow_dispatch: - inputs: - tag: - description: 'Tag to build assets for (must already exist as a release)' - required: true - -permissions: - contents: write - -concurrency: - group: ${{ github.workflow }}-${{ github.event.release.tag_name || github.event.inputs.tag }} - cancel-in-progress: true - -jobs: - build-release-assets: - name: Build app & upload tarball - runs-on: ubuntu-latest - env: - TAG: ${{ github.event.release.tag_name || github.event.inputs.tag }} - - steps: - - name: Checkout code 🛎️ - uses: actions/checkout@v6 - with: - ref: refs/tags/${{ env.TAG }} - - - name: Setup Node.js ⚙️ - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'yarn' - - - name: Install dependencies 📥 - run: yarn install --frozen-lockfile --ignore-engines --network-timeout 300000 - - - name: Build app 🏗️ - run: NODE_OPTIONS=--openssl-legacy-provider yarn build --mode production - - - name: Package release artifact 📦 - run: | - STAGING="dashy-release-staging" - mkdir -p "$STAGING" - - # Runtime files - cp -r dist "$STAGING/" - cp -r services "$STAGING/" - cp -r public "$STAGING/" - cp -r user-data "$STAGING/" - cp server.js "$STAGING/" - cp yarn.lock "$STAGING/" - - # src/utils/ files referenced directly by the server at runtime - mkdir -p "$STAGING/src/utils/config" - cp src/utils/config/ConfigSchema.json "$STAGING/src/utils/config/" - - # Strip devDependencies so `yarn install --production` stays lean - node -e " - const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); - delete pkg.devDependencies; - require('fs').writeFileSync('$STAGING/package.json', JSON.stringify(pkg, null, 2)); - " - - TARBALL="dashy-${TAG}.tar.gz" - tar -czf "${TARBALL}" -C "${STAGING}" . - echo "TARBALL=${TARBALL}" >> "$GITHUB_ENV" - echo "Size: $(du -sh ${TARBALL} | cut -f1)" - - - name: Upload tarball to GitHub Release 🚀 - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ env.TAG }} - files: ${{ env.TARBALL }} - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml deleted file mode 100644 index beaf1c37..00000000 --- a/.github/workflows/draft-release.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: 🏗️ Draft New Release - -on: - push: - tags: - - '*.*.*' - workflow_dispatch: - inputs: - tag: - description: 'Tag to draft a release for (must already exist)' - required: true - -permissions: - contents: write - -jobs: - create-draft-release: - runs-on: ubuntu-latest - env: - TAG: ${{ github.event.inputs.tag || github.ref_name }} - steps: - - name: Checkout code 🛎️ - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Check if major or minor version changed 🔍 - id: version_check - env: - CURRENT_TAG: ${{ github.event.inputs.tag || github.ref_name }} - run: | - git fetch --tags --force - CURRENT_MM=$(echo "$CURRENT_TAG" | sed 's/^v//; s/\([0-9]*\.[0-9]*\)\..*/\1/') - - # Find the immediately previous tag (to detect patch-only bumps) - PREVIOUS_TAG=$(git tag --sort=-version:refname \ - | grep -v "^${CURRENT_TAG}$" | head -1) - - if [ -z "$PREVIOUS_TAG" ]; then - echo "No previous tag found, creating release" - echo "should_release=true" >> $GITHUB_OUTPUT - echo "previous_tag=" >> $GITHUB_OUTPUT - exit 0 - fi - - PREVIOUS_MM=$(echo "$PREVIOUS_TAG" | sed 's/^v//; s/\([0-9]*\.[0-9]*\)\..*/\1/') - if [ "$CURRENT_MM" = "$PREVIOUS_MM" ]; then - echo "Patch-only bump ($PREVIOUS_TAG -> $CURRENT_TAG), skipping" - echo "should_release=false" >> $GITHUB_OUTPUT - echo "previous_tag=" >> $GITHUB_OUTPUT - exit 0 - fi - - # Minor/major bump — find the last tag from the previous release - PREV_RELEASE_TAG=$(git tag --sort=-version:refname | while read -r t; do - [ "$t" = "$CURRENT_TAG" ] && continue - t_mm=$(echo "$t" | sed 's/^v//; s/\([0-9]*\.[0-9]*\)\..*/\1/') - if [ "$t_mm" != "$CURRENT_MM" ]; then echo "$t"; break; fi - done) - echo "Minor/major bump, comparing against ${PREV_RELEASE_TAG:-$PREVIOUS_TAG}" - echo "should_release=true" >> $GITHUB_OUTPUT - echo "previous_tag=${PREV_RELEASE_TAG:-$PREVIOUS_TAG}" >> $GITHUB_OUTPUT - - - name: Create draft release 📝 - if: steps.version_check.outputs.should_release == 'true' || github.event_name == 'workflow_dispatch' - id: create_release - uses: softprops/action-gh-release@v3 - with: - tag_name: ${{ env.TAG }} - name: Release ${{ env.TAG }} - draft: true - prerelease: false - generate_release_notes: true - previous_tag: ${{ steps.version_check.outputs.previous_tag }} - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - - - name: Job summary 📋 - if: always() - env: - REPO_URL: ${{ github.server_url }}/${{ github.repository }} - SHOULD_RELEASE: ${{ steps.version_check.outputs.should_release }} - RELEASE_URL: ${{ steps.create_release.outputs.url }} - PREV_TAG: ${{ steps.version_check.outputs.previous_tag }} - run: | - { - echo "## 🏗️ Draft Release" - echo "" - echo "| Step | Result |" - echo "|------|--------|" - echo "| Tag | [\`${TAG}\`](${REPO_URL}/releases/tag/${TAG}) |" - - if [ -n "$PREV_TAG" ]; then - echo "| Compared against | [\`${PREV_TAG}\`](${REPO_URL}/releases/tag/${PREV_TAG}) |" - fi - - if [ -n "$RELEASE_URL" ]; then - echo "| Draft release | ✅ [Review and publish](${RELEASE_URL}) |" - elif [ "$SHOULD_RELEASE" = "false" ]; then - echo "| Draft release | ⏭️ Skipped (patch-only bump) |" - else - echo "| Draft release | ❌ Failed |" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5b1c3f70 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,170 @@ +# Builds Dashy and drafts a GitHub release with the compiled tarball, +# along with SHA256 checksum and SLSA build-provenance attestation +# +# Triggered by: +# - Push of any major/minor (X.Y.0) git tag +# - Manual dispatch with any existing tag (any version) + +name: 🚀 Build & Release + +on: + push: + tags: ['*.*.0'] + workflow_dispatch: + inputs: + tag: + description: 'Existing git tag to release (e.g. 4.2.0)' + required: true + +concurrency: + group: ${{ github.workflow }}-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release: + name: 🚀 Build & Draft Release + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + id-token: write + attestations: write + env: + TAG: ${{ inputs.tag || github.ref_name }} + steps: + - name: 🛎️ Checkout tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ env.TAG }} + fetch-depth: 0 + + - name: 🔧 Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'yarn' + + - name: 📥 Install dependencies + run: yarn install --frozen-lockfile --ignore-engines --network-timeout 300000 + + - name: 🏗️ Build app + run: NODE_OPTIONS=--openssl-legacy-provider yarn build --mode production + + - name: 📦 Package release tarball + id: package + run: | + set -euo pipefail + STAGING="dashy-release-staging" + mkdir -p "$STAGING" + cp -r dist "$STAGING/" + cp -r services "$STAGING/" + cp -r public "$STAGING/" + cp -r user-data "$STAGING/" + cp server.js "$STAGING/" + cp yarn.lock "$STAGING/" + mkdir -p "$STAGING/src/utils/config" + cp src/utils/config/ConfigSchema.json "$STAGING/src/utils/config/" + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + delete pkg.devDependencies; + fs.writeFileSync('$STAGING/package.json', JSON.stringify(pkg, null, 2)); + " + TARBALL="dashy-${TAG}.tar.gz" + tar -czf "$TARBALL" -C "$STAGING" . + echo "tarball=$TARBALL" >> "$GITHUB_OUTPUT" + echo "size=$(du -h "$TARBALL" | cut -f1)" >> "$GITHUB_OUTPUT" + + - name: 🔢 Generate SHA256 checksum + id: checksum + env: + TARBALL: ${{ steps.package.outputs.tarball }} + run: | + set -euo pipefail + CHECKSUM="${TARBALL}.sha256" + sha256sum "$TARBALL" > "$CHECKSUM" + echo "file=$CHECKSUM" >> "$GITHUB_OUTPUT" + + - name: 🪪 Generate build provenance attestation + id: attest + uses: actions/attest-build-provenance@v4 + with: + subject-path: ${{ steps.package.outputs.tarball }} + + - name: 📤 Rename attestation bundle + id: attest_asset + env: + TARBALL: ${{ steps.package.outputs.tarball }} + BUNDLE: ${{ steps.attest.outputs.bundle-path }} + run: | + set -euo pipefail + OUT="${TARBALL}.intoto.jsonl" + cp "$BUNDLE" "$OUT" + echo "file=$OUT" >> "$GITHUB_OUTPUT" + + - name: 🔎 Find previous release tag + id: prev + env: + CURRENT_TAG: ${{ env.TAG }} + run: | + set -euo pipefail + git fetch --tags --force + PREV=$({ echo "$CURRENT_TAG"; git tag | grep -E '^[0-9]+\.[0-9]+\.0$'; } \ + | sort -uV \ + | awk -v cur="$CURRENT_TAG" '$0 == cur { print prev; exit } { prev = $0 }') + echo "tag=$PREV" >> "$GITHUB_OUTPUT" + + - name: 📝 Create draft release + id: release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ env.TAG }} + name: Release ${{ env.TAG }} + draft: true + prerelease: false + generate_release_notes: true + previous_tag: ${{ steps.prev.outputs.tag }} + fail_on_unmatched_files: true + files: | + ${{ steps.package.outputs.tarball }} + ${{ steps.checksum.outputs.file }} + ${{ steps.attest_asset.outputs.file }} + token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + + - name: 📋 Job summary + if: always() + env: + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + PREV_TAG: ${{ steps.prev.outputs.tag }} + RELEASE_URL: ${{ steps.release.outputs.url }} + TARBALL: ${{ steps.package.outputs.tarball }} + SIZE: ${{ steps.package.outputs.size }} + ATTEST_URL: ${{ steps.attest.outputs.attestation-url }} + run: | + set -euo pipefail + { + echo "## 🚀 Release Draft" + echo "" + echo "| Item | Value |" + echo "|------|-------|" + echo "| Tag | [\`${TAG}\`](${REPO_URL}/releases/tag/${TAG}) |" + if [ -n "$PREV_TAG" ]; then + echo "| Notes since | [\`${PREV_TAG}\`](${REPO_URL}/releases/tag/${PREV_TAG}) |" + fi + if [ -n "$TARBALL" ]; then + echo "| Tarball | \`${TARBALL}\` (${SIZE:-?}) |" + fi + if [ -n "$ATTEST_URL" ]; then + echo "| Attestation | ✅ [View](${ATTEST_URL}) |" + else + echo "| Attestation | ❌ Failed |" + fi + if [ -n "$RELEASE_URL" ]; then + echo "| Draft release | ✅ [Review and publish](${RELEASE_URL}) |" + else + echo "| Draft release | ❌ Failed |" + fi + } >> "$GITHUB_STEP_SUMMARY" From 0cabd9be8f184f086544feb6ede1521b7431b1e4 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 18:10:59 +0100 Subject: [PATCH 06/31] =?UTF-8?q?=F0=9F=91=B7=20Streamlined=20auto-tagging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/manual-tag.yml | 66 -------- .../workflows/{bump-and-tag.yml => tag.yml} | 160 +++++++++++++----- 2 files changed, 118 insertions(+), 108 deletions(-) delete mode 100644 .github/workflows/manual-tag.yml rename .github/workflows/{bump-and-tag.yml => tag.yml} (67%) diff --git a/.github/workflows/manual-tag.yml b/.github/workflows/manual-tag.yml deleted file mode 100644 index 85cf5ff0..00000000 --- a/.github/workflows/manual-tag.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Manual fallback for creating a tag with optional version bump. -# The automated flow is handled by bump-and-tag.yml on PR merge. -name: 🏷️ Tag on Version Change - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to tag (e.g. 3.2.0). Leave empty to auto-bump patch.' - required: false - -concurrency: - group: manual-tag-version - cancel-in-progress: false - -jobs: - tag-version: - runs-on: ubuntu-latest - - steps: - - name: Check Out Repository 🛎️ - uses: actions/checkout@v6 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - fetch-depth: 0 - - - name: Configure Git Identity 🤖 - run: | - git config user.name "Liss-Bot" - git config user.email "liss-bot@d0h.co" - - - name: Determine and Apply Version 🔢 - id: version - env: - INPUT_VERSION: ${{ github.event.inputs.version }} - run: | - CURRENT=$(node -p "require('./package.json').version") - if [ -n "$INPUT_VERSION" ]; then - TARGET="${INPUT_VERSION#v}" - else - npm version patch --no-git-tag-version > /dev/null - TARGET=$(node -p "require('./package.json').version") - fi - if [ "$TARGET" != "$CURRENT" ]; then - npm version "$TARGET" --no-git-tag-version --allow-same-version - git add package.json - git commit -m "🔖 Bump version to $TARGET [skip ci]" - git push - echo "Committed version bump to $TARGET" - else - echo "package.json already at $CURRENT, skipping commit" - fi - echo "TARGET=$TARGET" >> $GITHUB_OUTPUT - - - name: Create and Push Tag ⤴️ - env: - TAG: ${{ steps.version.outputs.TARGET }} - run: | - git fetch --tags --force - if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Tag $TAG already exists, skipping" - else - git tag -a "$TAG" -m "Release v$TAG" - git push origin "$TAG" - echo "Created and pushed tag $TAG" - fi diff --git a/.github/workflows/bump-and-tag.yml b/.github/workflows/tag.yml similarity index 67% rename from .github/workflows/bump-and-tag.yml rename to .github/workflows/tag.yml index 4378d6aa..377b63fb 100644 --- a/.github/workflows/bump-and-tag.yml +++ b/.github/workflows/tag.yml @@ -1,17 +1,29 @@ -# Creates a new git tag when a PR is merged +# Creates a new git tag when a PR is merged, or on manual dispatch # -# Here's the flow: +# PR trigger flow: # - Triggered whenever a PR is merged, if that PR made code changes # - If version wasn't bumped in PR, increment patch version and update package.json # - Otherwise (if the PR did bump version) we use the new version from package.json # - Creates and pushes a git tag for the new version # - That git tag then triggers Docker publishing and release drafting in other CI -# - Add tags to issues from newly relesaed features/fixes (if applicable) +# - Add labels and release comments to referenced issues (if applicable) # - Trigger fresh deploy of docs site, so changelog remains up-to-date # - Finally, shows summary of actions taken and new tag published +# +# Manual dispatch flow: +# - If a version is provided, sets package.json to that version +# - If no version is provided, increments patch version automatically +# - Creates and pushes a git tag, then triggers docs site rebuild + name: 🔖 Auto Version & Tag on: + workflow_dispatch: + inputs: + version: + description: 'Version to tag (e.g. 4.1.0). Leave blank to auto-increment patch.' + required: false + type: string pull_request_target: types: [closed] branches: [master] @@ -25,14 +37,37 @@ permissions: pull-requests: read issues: write +env: + IS_MANUAL: ${{ github.event_name == 'workflow_dispatch' }} + jobs: version-and-tag: - if: github.event.pull_request.merged == true + if: >- + github.event_name == 'workflow_dispatch' + || github.event.pull_request.merged == true runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - name: Check PR for code changes and version bump 📂 + - name: 🔢 Validate manual dispatch + if: env.IS_MANUAL == 'true' + env: + INPUT_VERSION: ${{ inputs.version }} + DISPATCH_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [ "$DISPATCH_REF" != "refs/heads/master" ]; then + echo "::error::Manual dispatch only allowed from master (got: $DISPATCH_REF)" + exit 1 + fi + if [ -n "$INPUT_VERSION" ] && ! printf '%s' "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid version '${INPUT_VERSION}'. Must be semver (e.g. 4.1.0)." + exit 1 + fi + + - name: 📂 Check PR for code changes and version bump id: check_pr + if: env.IS_MANUAL != 'true' uses: actions/github-script@v8 with: script: | @@ -43,7 +78,7 @@ jobs: github.rest.pulls.listFiles, { owner, repo, pull_number } ); const codePatterns = [ - /^src\//, /^services\//, /^public\//, /^Dockerfile$/, /^[^/]+\.js$/, + /^src\//, /^services\//, /^public\//, /^Dockerfile$/, /^yarn.lock$/, /^[^/]+\.js$/, ]; const codeChanged = files.some(f => codePatterns.some(p => p.test(f.filename)) @@ -83,21 +118,21 @@ jobs: core.setOutput('needs_bump', needsBump.toString()); core.setOutput('needs_tag', needsTag.toString()); - - name: Checkout repository 🛎️ - if: steps.check_pr.outputs.needs_tag == 'true' + - name: 🛎️ Checkout repository + if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' uses: actions/checkout@v6 with: token: ${{ secrets.BOT_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - - name: Configure git identity 👤 - if: steps.check_pr.outputs.needs_tag == 'true' + - name: 👤 Configure git identity + if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' run: | git config user.name "Liss-Bot" git config user.email "liss-bot@d0h.co" - - name: Extract referenced issues 🔍 + - name: 🔍 Extract referenced issues id: issues - if: steps.check_pr.outputs.needs_tag == 'true' + if: env.IS_MANUAL != 'true' && steps.check_pr.outputs.needs_tag == 'true' uses: actions/github-script@v8 with: script: | @@ -119,47 +154,76 @@ jobs: core.info(`Found issue references: ${unique.join(', ')}`); core.setOutput('numbers', unique.join(',')); - - name: Bump patch version ⬆️ - if: steps.check_pr.outputs.needs_bump == 'true' - run: | - npm version patch --no-git-tag-version - git add package.json - git commit -m "🔖 Bump version to $(node -p "require('./package.json').version")" - git push - - - name: Create and push tag 🏷️ - id: tag - if: steps.check_pr.outputs.needs_tag == 'true' + - name: ⬆️ Bump version + id: bump + if: >- + env.IS_MANUAL == 'true' + || steps.check_pr.outputs.needs_bump == 'true' env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} - PR_AUTHOR: ${{ github.event.pull_request.user.login }} - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ "$IS_MANUAL" = "true" ] && [ -n "$INPUT_VERSION" ]; then + npm version "$INPUT_VERSION" --no-git-tag-version --allow-same-version + else + npm version patch --no-git-tag-version + fi + NEW_VERSION=$(node -p "require('./package.json').version") + git add package.json + if git diff --cached --quiet; then + echo "package.json already at $NEW_VERSION, nothing to commit" + echo "bumped=false" >> "$GITHUB_OUTPUT" + else + git commit -m "🔖 Bump version to $NEW_VERSION" + git push + echo "bumped=true" >> "$GITHUB_OUTPUT" + fi + + - name: 🏷️ Create and push tag + id: tag + if: env.IS_MANUAL == 'true' || steps.check_pr.outputs.needs_tag == 'true' + env: + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + PR_TITLE: ${{ github.event.pull_request.title || '' }} + PR_AUTHOR: ${{ github.event.pull_request.user.login || github.actor }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha || github.sha }} ISSUES: ${{ steps.issues.outputs.numbers }} run: | + set -euo pipefail VERSION=$(node -p "require('./package.json').version") git fetch --tags --force if git rev-parse "refs/tags/$VERSION" >/dev/null 2>&1; then echo "Tag $VERSION already exists, skipping" + echo "result=existed" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" exit 0 fi { printf 'Dashy v%s 🚀\n\n' "$VERSION" - printf 'PR: #%s - %s\n' "$PR_NUMBER" "$PR_TITLE" + if [ -n "$PR_NUMBER" ]; then + printf 'PR: #%s - %s\n' "$PR_NUMBER" "$PR_TITLE" + else + printf 'Manual release by @%s\n' "$PR_AUTHOR" + fi if [ -n "$ISSUES" ]; then printf 'Resolves: %s\n' "$(echo "$ISSUES" | sed 's/,/, #/g; s/^/#/')" fi printf 'Author: @%s\n' "$PR_AUTHOR" - printf 'Merge commit: %s\n' "$MERGE_SHA" + printf 'Commit: %s\n' "$MERGE_SHA" } > tag-message.txt git tag -a "$VERSION" -F tag-message.txt git push origin "$VERSION" + echo "result=created" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - name: Label referenced issues 🛩️ + - name: 🛩️ Label referenced issues id: label - if: steps.check_pr.outputs.needs_tag == 'true' && steps.issues.outputs.numbers != '' + if: >- + env.IS_MANUAL != 'true' + && steps.check_pr.outputs.needs_tag == 'true' + && steps.issues.outputs.numbers != '' continue-on-error: true uses: actions/github-script@v8 env: @@ -234,53 +298,65 @@ jobs: } } - - name: Trigger docs site rebuild 📝 + - name: 📝 Trigger docs site rebuild id: docs - if: steps.tag.outcome == 'success' + if: steps.tag.outputs.result == 'created' continue-on-error: true env: HOOK_URL: ${{ secrets.DOCS_SITE_REBUILD_HOOK }} + VERSION: ${{ steps.tag.outputs.version }} run: | + set -euo pipefail if [ -z "$HOOK_URL" ]; then echo "::warning::DOCS_SITE_REBUILD_HOOK secret is not set, skipping" exit 1 fi - VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") curl -sf -X POST -d '{}' "${HOOK_URL}?trigger_title=v${VERSION}+released" \ --max-time 15 --retry 2 --retry-max-time 30 echo "Triggered docs rebuild for v${VERSION}" - - name: Job summary 📋 + - name: 📋 Job summary if: always() env: - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + PR_TITLE: ${{ github.event.pull_request.title || '' }} REPO_URL: ${{ github.server_url }}/${{ github.repository }} NEEDS_BUMP: ${{ steps.check_pr.outputs.needs_bump }} NEEDS_TAG: ${{ steps.check_pr.outputs.needs_tag }} ISSUES: ${{ steps.issues.outputs.numbers }} + BUMPED: ${{ steps.bump.outputs.bumped }} TAG_OUTCOME: ${{ steps.tag.outcome }} + TAG_RESULT: ${{ steps.tag.outputs.result }} + TAG_VERSION: ${{ steps.tag.outputs.version }} LABEL_OUTCOME: ${{ steps.label.outcome }} DOCS_OUTCOME: ${{ steps.docs.outcome }} run: | - VERSION=$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown") + set -euo pipefail + VERSION="${TAG_VERSION:-$(node -p "require('./package.json').version" 2>/dev/null || echo "unknown")}" { echo "## 🔖 Auto Version & Tag" echo "" echo "| Step | Result |" echo "|------|--------|" - echo "| PR | [#${PR_NUMBER}](${REPO_URL}/pull/${PR_NUMBER}) — ${PR_TITLE} |" - if [ "$NEEDS_BUMP" = "true" ]; then + if [ "$IS_MANUAL" = "true" ]; then + echo "| Trigger | Manual dispatch |" + elif [ -n "$PR_NUMBER" ]; then + echo "| PR | [#${PR_NUMBER}](${REPO_URL}/pull/${PR_NUMBER}) — ${PR_TITLE} |" + fi + + if [ "$BUMPED" = "true" ]; then echo "| Version bump | ✅ \`${VERSION}\` |" else echo "| Version bump | ⏭️ Skipped |" fi - if [ "$NEEDS_TAG" = "true" ] && [ "$TAG_OUTCOME" = "success" ]; then + if [ "$TAG_RESULT" = "created" ]; then echo "| Tag | ✅ [\`${VERSION}\`](${REPO_URL}/releases/tag/${VERSION}) |" - elif [ "$NEEDS_TAG" = "true" ]; then + elif [ "$TAG_RESULT" = "existed" ]; then + echo "| Tag | ⏭️ Already exists: \`${VERSION}\` |" + elif [ "$TAG_OUTCOME" = "failure" ]; then echo "| Tag | ❌ Failed |" else echo "| Tag | ⏭️ Skipped |" From b3455afb4984f493ac630f8eaf6919e711e5ce2e Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 18:11:37 +0100 Subject: [PATCH 07/31] =?UTF-8?q?=F0=9F=91=B7=20Faster=20cross-arch=20Dock?= =?UTF-8?q?er=20compilation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-build-publish.yml | 84 ------ .github/workflows/docker.yml | 305 +++++++++++++++++++++ 2 files changed, 305 insertions(+), 84 deletions(-) delete mode 100644 .github/workflows/docker-build-publish.yml create mode 100644 .github/workflows/docker.yml diff --git a/.github/workflows/docker-build-publish.yml b/.github/workflows/docker-build-publish.yml deleted file mode 100644 index 22757c22..00000000 --- a/.github/workflows/docker-build-publish.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: 🐳 Build + Publish Multi-Platform Image - -on: - workflow_dispatch: - push: - tags: ['*.*.*'] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - packages: write - -env: - DH_IMAGE: ${{ secrets.DOCKER_REPO }} - GH_IMAGE: ${{ github.repository_owner }}/${{ github.event.repository.name }} - -jobs: - docker: - runs-on: ubuntu-latest - permissions: { contents: read, packages: write } - - steps: - - name: 🛎️ Checkout Repo - uses: actions/checkout@v6 - - - name: 🗂️ Make Docker Meta - id: meta - uses: docker/metadata-action@v6 - with: - images: | - ${{ env.DH_IMAGE }} - ghcr.io/${{ env.GH_IMAGE }} - tags: | - type=ref,event=tag - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}}.x - type=raw,value=latest - flavor: | - latest=false - - - name: ⏱️ Capture Build Timestamp - id: timestamp - run: echo "iso=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" - - - name: 🔧 Set up QEMU - uses: docker/setup-qemu-action@v4 - with: - platforms: linux/amd64,linux/arm64,linux/arm/v7 - - - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: 🔑 Login to DockerHub - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - - - name: 🔑 Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: 🚦 Check Registry Status - uses: crazy-max/ghaction-docker-status@v4 - - - name: ⚒️ Build and Push - uses: docker/build-push-action@v7 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64,linux/arm/v7 - tags: ${{ steps.meta.outputs.tags }} - build-args: | - VERSION=${{ steps.meta.outputs.version }} - REVISION=${{ github.sha }} - CREATED=${{ steps.timestamp.outputs.iso }} - cache-from: type=gha - cache-to: type=gha,mode=max - push: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..66d9c080 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,305 @@ +# Builds and publishes the multi-arch Docker image +# +# Triggered by: +# - On git tag push, publishes to :X.Y.Z, :X.Y, and :latest +# - On manual dispatch from master, rebuilds and updates :latest +# - On weekly cron, rebuilds :latest from master for upstream patches +# +# The workflow will: +# - Builds multi-arch (amd64, arm64, armv7) in parallel on native runners +# - Trivy scans + reports security issues, and fails on CRITICAL CVEs +# - Publishes to GHCR, and to Docker Hub if creds are configured +# - Attests both the build provenance and SBOM and publishes to GHCR +# - Uploads digest, SBOM and outputs as artifact, and shows MD summary + +name: 🐳 Build + Publish Multi-Platform Image + +on: + workflow_dispatch: + inputs: + tag: + description: 'Existing git tag to build. Empty = build current ref as :latest.' + required: false + default: '' + push: + tags: ['*.*.*'] + schedule: + - cron: '0 4 * * 0' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.tag }} + cancel-in-progress: false + +permissions: + contents: read + +env: + DH_IMAGE: ${{ secrets.DOCKER_REPO }} + GH_IMAGE: ghcr.io/${{ github.repository }} + +jobs: + build: + name: 🔨 Build (${{ matrix.arch }}) + timeout-minutes: 30 + permissions: + contents: read + packages: write + security-events: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + - platform: linux/arm/v7 + runner: ubuntu-latest + arch: armv7 + runs-on: ${{ matrix.runner }} + steps: + - name: 🛎️ Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: 🏷️ Resolve build version + id: version + env: + INPUT_TAG: ${{ inputs.tag }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + if [ -n "$INPUT_TAG" ]; then + v="$INPUT_TAG" + elif [ "$EVENT_NAME" = "push" ]; then + v="$REF_NAME" + else + v="latest" + fi + echo "value=$v" >> "$GITHUB_OUTPUT" + + - name: 🔧 Set up QEMU + if: matrix.arch == 'armv7' + uses: docker/setup-qemu-action@v4 + with: + platforms: linux/arm/v7 + + - name: 🔧 Set up Buildx + uses: docker/setup-buildx-action@v4 + + - name: 🔑 Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: ⏱️ Capture build timestamp + id: timestamp + run: echo "iso=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + + - name: 🔨 Build image (load for scan) + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,scope=${{ matrix.arch }},mode=max + load: true + tags: dashy-scan:${{ matrix.arch }} + provenance: false + build-args: | + VERSION=${{ steps.version.outputs.value }} + REVISION=${{ github.sha }} + CREATED=${{ steps.timestamp.outputs.iso }} + + - name: 🛡️ Trivy vulnerability scan + uses: aquasecurity/trivy-action@0.36.0 + env: + TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 + TRIVY_JAVA_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-java-db:1 + with: + image-ref: dashy-scan:${{ matrix.arch }} + severity: CRITICAL + ignore-unfixed: true + exit-code: ${{ github.event_name == 'schedule' && '1' || '0' }} + vuln-type: 'os,library' + format: 'sarif' + output: 'trivy-${{ matrix.arch }}.sarif' + timeout: '10m' + + - name: 📤 Upload Trivy SARIF + if: always() && hashFiles(format('trivy-{0}.sarif', matrix.arch)) != '' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-${{ matrix.arch }}.sarif + category: trivy-${{ matrix.arch }} + + - name: 🚀 Push by digest + id: push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: ${{ matrix.platform }} + cache-from: type=gha,scope=${{ matrix.arch }} + outputs: type=image,name=${{ env.GH_IMAGE }},push-by-digest=true,name-canonical=true,push=true + provenance: false + build-args: | + VERSION=${{ steps.version.outputs.value }} + REVISION=${{ github.sha }} + CREATED=${{ steps.timestamp.outputs.iso }} + + - name: 🧬 Write digest + run: | + mkdir -p "${{ runner.temp }}/digests" + echo "${{ steps.push.outputs.digest }}" > "${{ runner.temp }}/digests/${{ matrix.arch }}" + + - name: 📤 Upload digest + uses: actions/upload-artifact@v7 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/${{ matrix.arch }} + if-no-files-found: error + retention-days: 1 + + merge: + name: 🧩 Merge & Push Manifests + needs: build + timeout-minutes: 30 + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + env: + HAS_DH: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' && secrets.DOCKER_REPO != '' }} + SEMVER_VALUE: ${{ inputs.tag || github.ref_name }} + SEMVER_ENABLE: ${{ github.event_name == 'push' || inputs.tag != '' }} + LATEST_ENABLE: ${{ inputs.tag == '' }} + steps: + - name: 📥 Download digests + uses: actions/download-artifact@v7 + with: + path: ${{ runner.temp }}/digests + pattern: digest-* + merge-multiple: true + + - name: 🔧 Set up Buildx + uses: docker/setup-buildx-action@v4 + + - name: 🔑 Login to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🔑 Login to Docker Hub + if: env.HAS_DH == 'true' + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: 🗂️ Generate tags + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.GH_IMAGE }} + ${{ env.HAS_DH == 'true' && env.DH_IMAGE || '' }} + tags: | + type=raw,value=latest,enable=${{ env.LATEST_ENABLE }} + type=semver,pattern={{version}},value=${{ env.SEMVER_VALUE }},enable=${{ env.SEMVER_ENABLE }} + type=semver,pattern={{major}}.{{minor}},value=${{ env.SEMVER_VALUE }},enable=${{ env.SEMVER_ENABLE }} + type=semver,pattern={{major}}.x,value=${{ env.SEMVER_VALUE }},enable=${{ env.SEMVER_ENABLE }} + flavor: | + latest=false + + - name: 🧩 Create & push manifest + id: manifest + working-directory: ${{ runner.temp }}/digests + run: | + set -euo pipefail + TAGS=() + while IFS= read -r tag; do TAGS+=(-t "$tag"); done \ + < <(jq -r '.tags[]' <<< "$DOCKER_METADATA_OUTPUT_JSON") + SOURCES=() + for f in *; do SOURCES+=("${GH_IMAGE}@$(cat "$f")"); done + docker buildx imagetools create "${TAGS[@]}" "${SOURCES[@]}" + PRIMARY=$(jq -r --arg img "$GH_IMAGE" \ + '.tags[] | select(startswith($img + ":"))' \ + <<< "$DOCKER_METADATA_OUTPUT_JSON" | head -1) + DIGEST=$(docker buildx imagetools inspect "$PRIMARY" --format '{{.Manifest.Digest}}') + echo "primary_tag=$PRIMARY" >> "$GITHUB_OUTPUT" + echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" + + - name: 🔐 Generate SBOM (SPDX) + uses: anchore/sbom-action@v0.24.0 + with: + image: ${{ steps.manifest.outputs.primary_tag }} + format: spdx-json + output-file: sbom.spdx.json + upload-artifact: false + + - name: 🪪 Attest SBOM + id: attest_sbom + uses: actions/attest-sbom@v4 + continue-on-error: true + with: + subject-name: ${{ env.GH_IMAGE }} + subject-digest: ${{ steps.manifest.outputs.digest }} + sbom-path: sbom.spdx.json + push-to-registry: true + + - name: 🛡️ Attest build provenance + id: attest_provenance + uses: actions/attest-build-provenance@v4 + continue-on-error: true + with: + subject-name: ${{ env.GH_IMAGE }} + subject-digest: ${{ steps.manifest.outputs.digest }} + push-to-registry: true + + - name: 📋 Summary + if: always() + env: + SBOM_OUTCOME: ${{ steps.attest_sbom.outcome }} + SBOM_URL: ${{ steps.attest_sbom.outputs.attestation-url }} + PROV_OUTCOME: ${{ steps.attest_provenance.outcome }} + PROV_URL: ${{ steps.attest_provenance.outputs.attestation-url }} + DIGEST: ${{ steps.manifest.outputs.digest }} + TAGS_JSON: ${{ steps.meta.outputs.json }} + run: | + set -euo pipefail + attest() { + case "$2" in + success) + if [ -n "$3" ]; then + echo "- ✅ $1 attested ([view]($3))" + else + echo "- ✅ $1 attested" + fi ;; + failure) echo "- ⚠️ $1 attestation failed (image pushed without attest)" ;; + *) echo "- ⏭️ $1 attestation \`$2\`" ;; + esac + } + { + if [ -n "$DIGEST" ]; then + echo "## 🐳 Docker Image" + echo "**Manifest:** \`$DIGEST\`" + echo '```bash' + jq -r '.tags[] | "docker pull \(.)"' <<< "$TAGS_JSON" + echo '```' + fi + echo "## 🪪 Attestations" + attest "SBOM" "$SBOM_OUTCOME" "$SBOM_URL" + attest "Build provenance" "$PROV_OUTCOME" "$PROV_URL" + } >> "$GITHUB_STEP_SUMMARY" From 159307f1a3ec4aa14032c6f86653b10fe95f27b8 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 18:12:06 +0100 Subject: [PATCH 08/31] =?UTF-8?q?=F0=9F=91=B7=20More=20complete=20PR=20CI?= =?UTF-8?q?=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{pr-quality-check.yml => ci.yml} | 330 ++++++++++-------- .github/workflows/release.yml | 2 +- 2 files changed, 180 insertions(+), 152 deletions(-) rename .github/workflows/{pr-quality-check.yml => ci.yml} (52%) diff --git a/.github/workflows/pr-quality-check.yml b/.github/workflows/ci.yml similarity index 52% rename from .github/workflows/pr-quality-check.yml rename to .github/workflows/ci.yml index 60fd2490..ef384b6e 100644 --- a/.github/workflows/pr-quality-check.yml +++ b/.github/workflows/ci.yml @@ -1,151 +1,179 @@ -name: 🔍 PR Quality Check - -on: - pull_request: - branches: ['master', 'develop'] - paths-ignore: - - '**.md' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - changes: - name: 🔎 Detect Changes - runs-on: ubuntu-latest - outputs: - lockfile: ${{ steps.filter.outputs.lockfile }} - steps: - - name: 🛎️ Checkout Code - uses: actions/checkout@v6 - - - name: 🔎 Filter Paths - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - lockfile: - - 'yarn.lock' - - lint: - name: 📝 Lint Code - runs-on: ubuntu-latest - steps: - - name: 🛎️ Checkout Code - uses: actions/checkout@v6 - - - name: 🔧 Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - cache: 'yarn' - - - name: 📦 Install Dependencies - run: yarn install --frozen-lockfile - - - name: 🔍 Run ESLint - run: yarn lint - - typecheck: - name: 🧷 Type Check - runs-on: ubuntu-latest - steps: - - name: 🛎️ Checkout Code - uses: actions/checkout@v6 - - - name: 🔧 Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - cache: 'yarn' - - - name: 📦 Install Dependencies - run: yarn install --frozen-lockfile - - - name: 🧷 Run vue-tsc - run: yarn typecheck - - test: - name: 🧪 Run Tests - runs-on: ubuntu-latest - steps: - - name: 🛎️ Checkout Code - uses: actions/checkout@v6 - - - name: 🔧 Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - cache: 'yarn' - - - name: 📦 Install Dependencies - run: yarn install --frozen-lockfile - - - name: 🧪 Run Tests - run: yarn test - - build: - name: 🏗️ Build Application - runs-on: ubuntu-latest - steps: - - name: 🛎️ Checkout Code - uses: actions/checkout@v6 - - - name: 🔧 Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - cache: 'yarn' - - - name: 📦 Install Dependencies - run: yarn install --frozen-lockfile - - - name: 🏗️ Build Project - run: yarn build - - - name: ✅ Verify Build Output - run: | - if [ ! -d "dist" ]; then - echo "❌ Build failed: dist directory not created" - exit 1 - fi - if [ ! -f "dist/index.html" ]; then - echo "❌ Build failed: index.html not found" - exit 1 - fi - echo "✅ Build successful" - - docker-smoke: - name: 🐳 Docker Smoke Test - runs-on: ubuntu-latest - continue-on-error: true - steps: - - name: 🛎️ Checkout Code - uses: actions/checkout@v6 - - - name: 🐳 Build & Test Docker Image - run: sh tests/docker-smoke-test.sh - timeout-minutes: 10 - - security: - name: 🔒 Security Audit - runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.lockfile == 'true' - continue-on-error: true - steps: - - name: 🛎️ Checkout Code - uses: actions/checkout@v6 - - - name: 🔧 Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - cache: 'yarn' - - - name: 📦 Install Dependencies - run: yarn install --frozen-lockfile - - - name: 🔒 Run Security Audit - run: yarn audit --level high +# CI checks to run when PR is opened +name: 🚦 PR Check + +on: + pull_request: + branches: ['master', 'develop'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + changes: + name: 🔎 Detect Changes + runs-on: ubuntu-latest + outputs: + lockfile: ${{ steps.filter.outputs.lockfile }} + workflows: ${{ steps.filter.outputs.workflows }} + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Filter Paths + uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + lockfile: + - 'yarn.lock' + workflows: + - '.github/workflows/**' + + lint: + name: 🛡️ Lint + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'yarn' + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Run ESLint + run: yarn lint + + typecheck: + name: 🦴 Typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'yarn' + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Run vue-tsc + run: yarn typecheck + + test: + name: 🧪 Test + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'yarn' + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Run Tests + run: yarn test + + build: + name: 🏗️ Build Check + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'yarn' + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Build Project + run: yarn build + + - name: Verify Build Output + run: | + if [ ! -d "dist" ]; then + echo "❌ Build failed: dist directory not created" + exit 1 + fi + if [ ! -f "dist/index.html" ]; then + echo "❌ Build failed: index.html not found" + exit 1 + fi + echo "✅ Build successful" + + docker-smoke: + name: 🐳 Docker Smoke Test + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Build & Test Docker Image + run: sh tests/docker-smoke-test.sh + timeout-minutes: 10 + + dependency-review: + name: 🔒 Dependency Audit + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.lockfile == 'true' + permissions: + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Review Dependencies + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: moderate + + secret-scan: + name: 🔑 Secret Scanning + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Scan PR Diff for Secrets + uses: trufflesecurity/trufflehog@v3.95.3 + with: + base: ${{ github.event.pull_request.base.sha }} + head: ${{ github.event.pull_request.head.sha }} + extra_args: --only-verified --fail + + actionlint: + name: 🛠️ Lint Actions + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.workflows == 'true' + steps: + - name: Checkout Code + uses: actions/checkout@v6 + + - name: Run Actionlint + uses: raven-actions/actionlint@v2 + with: + fail-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b1c3f70..2c89f521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,7 +112,7 @@ jobs: run: | set -euo pipefail git fetch --tags --force - PREV=$({ echo "$CURRENT_TAG"; git tag | grep -E '^[0-9]+\.[0-9]+\.0$'; } \ + PREV=$({ echo "$CURRENT_TAG"; git tag | grep -E '^[0-9]+\.[0-9]+\.0$' || true; } \ | sort -uV \ | awk -v cur="$CURRENT_TAG" '$0 == cur { print prev; exit } { prev = $0 }') echo "tag=$PREV" >> "$GITHUB_OUTPUT" From 5aa1dc268775c0cd578c047477f7ce9414a3dd6a Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 18:17:30 +0100 Subject: [PATCH 09/31] =?UTF-8?q?=F0=9F=92=9A=20Fixes=20secret=20scanning?= =?UTF-8?q?=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef384b6e..f49eb095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,7 @@ jobs: with: base: ${{ github.event.pull_request.base.sha }} head: ${{ github.event.pull_request.head.sha }} - extra_args: --only-verified --fail + extra_args: --only-verified actionlint: name: 🛠️ Lint Actions From 9dc19587fc70363abe8346d3adecbe60c4cdb225 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 18:24:39 +0100 Subject: [PATCH 10/31] =?UTF-8?q?=F0=9F=92=9A=20Fixes=20trivy=20versioning?= =?UTF-8?q?=20for=20Docker=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 66d9c080..cad595d5 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,7 +12,7 @@ # - Attests both the build provenance and SBOM and publishes to GHCR # - Uploads digest, SBOM and outputs as artifact, and shows MD summary -name: 🐳 Build + Publish Multi-Platform Image +name: 🐳 Docker on: workflow_dispatch: @@ -119,7 +119,7 @@ jobs: CREATED=${{ steps.timestamp.outputs.iso }} - name: 🛡️ Trivy vulnerability scan - uses: aquasecurity/trivy-action@0.36.0 + uses: aquasecurity/trivy-action@v0.36.0 env: TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2 TRIVY_JAVA_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-java-db:1 From b61687c30d756baa1d6332f7fc8b90278b2b25ab Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 21:19:19 +0100 Subject: [PATCH 11/31] =?UTF-8?q?=F0=9F=9B=82=20OIDC=20max=20failed=20atte?= =?UTF-8?q?mpts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/auth/OidcAuth.js | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/utils/auth/OidcAuth.js b/src/utils/auth/OidcAuth.js index 48fbe118..257c7d26 100644 --- a/src/utils/auth/OidcAuth.js +++ b/src/utils/auth/OidcAuth.js @@ -3,6 +3,10 @@ import { localStorageKeys } from '@/utils/config/defaults'; import ErrorHandler from '@/utils/logging/ErrorHandler'; import { statusMsg, statusErrorMsg } from '@/utils/logging/CoolConsole'; +// Session storage config for storing last sign-in attempt +const SIGNIN_GUARD_KEY = 'dashy.oidc.signin-attempt'; +const SIGNIN_GUARD_THRESHOLD_MS = 5 * 1000; + const getAppConfig = () => { const Accumulator = new ConfigAccumulator(); const config = Accumulator.config(); @@ -51,6 +55,13 @@ class OidcAuth { async login() { const url = new URL(window.location.href); const code = url.searchParams.get('code'); + const providerError = url.searchParams.get('error'); + + // Provider redirected back with an error + if (providerError && !code) { + const desc = url.searchParams.get('error_description') || ''; + throw new Error(`OIDC provider returned ${providerError}: ${desc}`); + } if (code) { // Populate localStorage before the reload so the post-reload route guard @@ -64,7 +75,20 @@ class OidcAuth { const user = await this.userManager.getUser(); if (user === null) { if (!isOidcGuestAccessEnabled()) { + // Bail with error, if we've literally just redirected. Prevents loop + const lastAttempt = Number(sessionStorage.getItem(SIGNIN_GUARD_KEY)) || 0; + if (Date.now() - lastAttempt < SIGNIN_GUARD_THRESHOLD_MS) { + sessionStorage.removeItem(SIGNIN_GUARD_KEY); + throw new Error( + 'OIDC sign-in redirect loop detected. Check provider redirect URIs ' + + 'and that id_token claims include a username.', + ); + } await this.userManager.signinRedirect(); + // Mark the attempt only once signinRedirect has resolved (i.e. the + // navigation actually fired). If it threw — e.g. discovery fetch failed + // — we leave the guard untouched so a manual retry isn't blocked. + sessionStorage.setItem(SIGNIN_GUARD_KEY, String(Date.now())); } } else { this.persistUserInfo(user); @@ -78,11 +102,17 @@ class OidcAuth { const isAdmin = (Array.isArray(groups) && groups.includes(this.adminGroup)) || (Array.isArray(roles) && roles.includes(this.adminRole)) || false; - statusMsg(`user: ${user.profile.preferred_username} admin: ${isAdmin}`, JSON.stringify(info)); + // Fall back through username candidates so USERNAME is always a non-empty + const username = user.profile.preferred_username + || user.profile.email + || user.profile.sub + || 'oidc-user'; + statusMsg(`Authenticated as ${username} ${isAdmin ? '(admin)' : '(non-admin)'}`, JSON.stringify(info)); localStorage.setItem(localStorageKeys.KEYCLOAK_INFO, JSON.stringify(info)); - localStorage.setItem(localStorageKeys.USERNAME, user.profile.preferred_username); + localStorage.setItem(localStorageKeys.USERNAME, username); localStorage.setItem(localStorageKeys.ISADMIN, isAdmin); if (user.id_token) localStorage.setItem(localStorageKeys.ID_TOKEN, user.id_token); + sessionStorage.removeItem(SIGNIN_GUARD_KEY); } async logout() { From 567517b3e41fedbf1f633839b5509afc6775fa0f Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 21:33:01 +0100 Subject: [PATCH 12/31] =?UTF-8?q?=F0=9F=90=8B=20Updated=20Docker=20workflo?= =?UTF-8?q?w=20actions,=20improves=20summary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker.yml | 75 ++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index cad595d5..01837bc4 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -34,7 +34,7 @@ permissions: contents: read env: - DH_IMAGE: ${{ secrets.DOCKER_REPO }} + DH_IMAGE: ${{ vars.DOCKER_REPO || 'lissy93/dashy' }} GH_IMAGE: ghcr.io/${{ github.repository }} jobs: @@ -45,6 +45,9 @@ jobs: contents: read packages: write security-events: write + env: + DOCKER_BUILD_SUMMARY: "false" + DOCKER_BUILD_RECORD_UPLOAD: "false" strategy: fail-fast: false matrix: @@ -135,7 +138,7 @@ jobs: - name: 📤 Upload Trivy SARIF if: always() && hashFiles(format('trivy-{0}.sarif', matrix.arch)) != '' - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: trivy-${{ matrix.arch }}.sarif category: trivy-${{ matrix.arch }} @@ -179,7 +182,7 @@ jobs: id-token: write attestations: write env: - HAS_DH: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' && secrets.DOCKER_REPO != '' }} + HAS_DH: ${{ secrets.DOCKER_USERNAME != '' && secrets.DOCKER_PASSWORD != '' }} SEMVER_VALUE: ${{ inputs.tag || github.ref_name }} SEMVER_ENABLE: ${{ github.event_name == 'push' || inputs.tag != '' }} LATEST_ENABLE: ${{ inputs.tag == '' }} @@ -235,8 +238,8 @@ jobs: for f in *; do SOURCES+=("${GH_IMAGE}@$(cat "$f")"); done docker buildx imagetools create "${TAGS[@]}" "${SOURCES[@]}" PRIMARY=$(jq -r --arg img "$GH_IMAGE" \ - '.tags[] | select(startswith($img + ":"))' \ - <<< "$DOCKER_METADATA_OUTPUT_JSON" | head -1) + '[.tags[] | select(startswith($img + ":"))] | first // empty' \ + <<< "$DOCKER_METADATA_OUTPUT_JSON") DIGEST=$(docker buildx imagetools inspect "$PRIMARY" --format '{{.Manifest.Digest}}') echo "primary_tag=$PRIMARY" >> "$GITHUB_OUTPUT" echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" @@ -251,7 +254,7 @@ jobs: - name: 🪪 Attest SBOM id: attest_sbom - uses: actions/attest-sbom@v4 + uses: actions/attest@v4 continue-on-error: true with: subject-name: ${{ env.GH_IMAGE }} @@ -276,30 +279,68 @@ jobs: PROV_OUTCOME: ${{ steps.attest_provenance.outcome }} PROV_URL: ${{ steps.attest_provenance.outputs.attestation-url }} DIGEST: ${{ steps.manifest.outputs.digest }} + PRIMARY: ${{ steps.manifest.outputs.primary_tag }} TAGS_JSON: ${{ steps.meta.outputs.json }} + DIGESTS_DIR: ${{ runner.temp }}/digests run: | + # Behold, some ugly bash, to produce a pretty output (don't read it, just trust) set -euo pipefail - attest() { - case "$2" in + + attest_line() { + local label="$1" outcome="$2" url="$3" + case "$outcome" in success) - if [ -n "$3" ]; then - echo "- ✅ $1 attested ([view]($3))" + if [ -n "$url" ]; then + echo "- ✅ $label attested ([view]($url))" else - echo "- ✅ $1 attested" + echo "- ✅ $label attested" fi ;; - failure) echo "- ⚠️ $1 attestation failed (image pushed without attest)" ;; - *) echo "- ⏭️ $1 attestation \`$2\`" ;; + failure) echo "- ⚠️ $label attestation failed (image pushed without attest)" ;; + *) echo "- ⏭️ $label attestation \`$outcome\`" ;; esac } + + arch_section() { + local arch="$1" file="$DIGESTS_DIR/$arch" + [ -f "$file" ] || return 0 + local digest manifest size count + digest=$(cat "$file") + manifest=$(docker buildx imagetools inspect "${PRIMARY%%:*}@$digest" --raw 2>/dev/null || echo '{}') + size=$(jq '[.layers[]?.size // 0] | add // 0' <<< "$manifest") + count=$(jq '.layers // [] | length' <<< "$manifest") + echo "#### Dashy \`$arch\`" + echo "" + echo "- **Digest:** \`$digest\`" + [ "$size" != "0" ] && echo "- **Size:** $(numfmt --to=iec --suffix=B "$size" 2>/dev/null || echo "$size B")" + [ "$count" != "0" ] && echo "- **Layers:** $count" + echo "" + } + + # Clear auto-generated "Attestation Created" blocks from attest actions. + : > "$GITHUB_STEP_SUMMARY" { if [ -n "$DIGEST" ]; then - echo "## 🐳 Docker Image" + echo "## Docker Image" + echo "" echo "**Manifest:** \`$DIGEST\`" + echo "" echo '```bash' jq -r '.tags[] | "docker pull \(.)"' <<< "$TAGS_JSON" echo '```' + echo "" + echo "---" + echo "" fi - echo "## 🪪 Attestations" - attest "SBOM" "$SBOM_OUTCOME" "$SBOM_URL" - attest "Build provenance" "$PROV_OUTCOME" "$PROV_URL" + echo "## Attestations" + echo "" + attest_line "SBOM" "$SBOM_OUTCOME" "$SBOM_URL" + attest_line "Build provenance" "$PROV_OUTCOME" "$PROV_URL" + echo "" + echo "---" + echo "" + echo "## Build Info" + echo "" + for arch in amd64 arm64 armv7; do + arch_section "$arch" + done } >> "$GITHUB_STEP_SUMMARY" From e0084b70eb6e69546928f0f661e468a2d626bb3a Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Fri, 15 May 2026 21:33:34 +0100 Subject: [PATCH 13/31] =?UTF-8?q?=F0=9F=91=B7=20CI=20workflow=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/mirror.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/tag.yml | 2 +- .github/workflows/update-docs-site.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index 65f2072a..55bdc512 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -1,7 +1,7 @@ # Syncs the full source of the Dashy repo over to our Codeberg mirror # For all you non-Microsoft babes! # This is then accessible over at https://codeberg.org/alicia/dashy -name: 🪞 Mirror to Codeberg +name: 🪞 Mirror on: workflow_dispatch: schedule: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c89f521..7548c52a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ # - Push of any major/minor (X.Y.0) git tag # - Manual dispatch with any existing tag (any version) -name: 🚀 Build & Release +name: 🚀 Release on: push: diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 377b63fb..2b286fba 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -15,7 +15,7 @@ # - If no version is provided, increments patch version automatically # - Creates and pushes a git tag, then triggers docs site rebuild -name: 🔖 Auto Version & Tag +name: 🔖 Tag on: workflow_dispatch: diff --git a/.github/workflows/update-docs-site.yml b/.github/workflows/update-docs-site.yml index de1f627c..acb6454b 100644 --- a/.github/workflows/update-docs-site.yml +++ b/.github/workflows/update-docs-site.yml @@ -1,4 +1,4 @@ -name: 📝 Update Documentation +name: 📝 Sync Docs # This will run whenever the /docs directory in master branch is updated, # or if the workflow is manually dispatched, plus a sync check on Sun at 03:30 UTC From 8f1dc1d51eea0ca9f38b633c06ce01bba7e34873 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sat, 16 May 2026 11:23:29 +0100 Subject: [PATCH 14/31] =?UTF-8?q?=E2=AC=86=EF=B8=8F=20Dependencies=20updat?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 11 ++-- yarn.lock | 155 ++++++++++++++++++++++----------------------------- 2 files changed, 72 insertions(+), 94 deletions(-) diff --git a/package.json b/package.json index 8d8da10a..a5838695 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "ajv-formats": "^3.0.1", "crypto-js": "^4.2.0", "dompurify": "^3.4.3", - "express": "^4.21.0", + "express": "^4.22.2", "express-basic-auth": "^1.2.1", "frappe-charts": "^1.6.2", "jose": "^5.10.0", @@ -61,7 +61,7 @@ "@vue/compiler-sfc": "^3.5.0", "@vue/test-utils": "^2.4.8", "autoprefixer": "^10.4.27", - "eslint": "^10.2.1", + "eslint": "^10.4.0", "eslint-plugin-import-x": "^4.16.2", "eslint-plugin-vue": "^10.9.1", "globals": "^17.5.0", @@ -72,7 +72,7 @@ "vite": "^6.2.0", "vite-plugin-pwa": "^1.3.0", "vite-svg-loader": "^5.1.0", - "vitest": "^4.1.5", + "vitest": "^4.1.6", "vue-eslint-parser": "^10.0.0", "vue-tsc": "^3.2.9" }, @@ -91,9 +91,10 @@ "resolutions": { "braces": "^3.0.3", "micromatch": "^4.0.8", - "postcss": "^8.4.31", + "postcss": "^8.5.14", "serialize-javascript": "^7.0.3", - "vite": "^6.2.0" + "vite": "^6.2.0", + "@babel/plugin-transform-modules-systemjs": "^7.29.4" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/yarn.lock b/yarn.lock index 135c7820..178689b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -483,10 +483,10 @@ "@babel/helper-module-transforms" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-modules-systemjs@^7.29.0": - version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz#e458a95a17807c415924106a3ff188a3b8dee964" - integrity sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ== +"@babel/plugin-transform-modules-systemjs@^7.29.0", "@babel/plugin-transform-modules-systemjs@^7.29.4": + version "7.29.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20" + integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w== dependencies: "@babel/helper-module-transforms" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6" @@ -1057,10 +1057,10 @@ debug "^4.3.1" minimatch "^10.2.4" -"@eslint/config-helpers@^0.5.5": - version "0.5.5" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.5.tgz#ae16134e4792ac5fbdc533548a24ac1ea9f7f3ae" - integrity sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w== +"@eslint/config-helpers@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03" + integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== dependencies: "@eslint/core" "^1.2.1" @@ -1922,34 +1922,27 @@ resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz#9e8a512eb174bfc2a333ba959bbf9de428d89ad8" integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== -"@vitest/expect@4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.5.tgz#5caab19535cfb04fbc37087c5608d46e74dc9292" - integrity sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw== +"@vitest/expect@4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.6.tgz#b50c9390aae6957ab4d9e20722cebb17d5bf169a" + integrity sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg== dependencies: "@standard-schema/spec" "^1.1.0" "@types/chai" "^5.2.2" - "@vitest/spy" "4.1.5" - "@vitest/utils" "4.1.5" + "@vitest/spy" "4.1.6" + "@vitest/utils" "4.1.6" chai "^6.2.2" tinyrainbow "^3.1.0" -"@vitest/mocker@4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.5.tgz#9d5791733e4866cfb8af2d48ca371b127e7d2e93" - integrity sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw== +"@vitest/mocker@4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.6.tgz#6b624045745236b02aca879a02aef68b72d9d4cd" + integrity sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ== dependencies: - "@vitest/spy" "4.1.5" + "@vitest/spy" "4.1.6" estree-walker "^3.0.3" magic-string "^0.30.21" -"@vitest/pretty-format@4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.5.tgz#4c13d77a77e2931e44db95522ed5700bcf0570d4" - integrity sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g== - dependencies: - tinyrainbow "^3.1.0" - "@vitest/pretty-format@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.6.tgz#24a1c03a6b68a8775f8ddfec51d3636315edc3f5" @@ -1957,28 +1950,28 @@ dependencies: tinyrainbow "^3.1.0" -"@vitest/runner@4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.5.tgz#a14dd2d2f48603f906dd52304a10c7fc623bb1de" - integrity sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ== +"@vitest/runner@4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.6.tgz#b6d189e68bd9927c4f111ad089ff96e4757591b1" + integrity sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA== dependencies: - "@vitest/utils" "4.1.5" + "@vitest/utils" "4.1.6" pathe "^2.0.3" -"@vitest/snapshot@4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.5.tgz#d07970d1448190ee5a258db6ab79c65b8018c13b" - integrity sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ== +"@vitest/snapshot@4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.6.tgz#14fdfc8baf6b4b3e4e35763431dbea3aaa8aa0eb" + integrity sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw== dependencies: - "@vitest/pretty-format" "4.1.5" - "@vitest/utils" "4.1.5" + "@vitest/pretty-format" "4.1.6" + "@vitest/utils" "4.1.6" magic-string "^0.30.21" pathe "^2.0.3" -"@vitest/spy@4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.5.tgz#fa7858ffab746fa9ac29496e626f5a0caf9a5a7f" - integrity sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ== +"@vitest/spy@4.1.6": + version "4.1.6" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.6.tgz#0a316893630f47fa545e33026cfc91575070d165" + integrity sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg== "@vitest/ui@^4.1.6": version "4.1.6" @@ -1993,15 +1986,6 @@ tinyglobby "^0.2.15" tinyrainbow "^3.1.0" -"@vitest/utils@4.1.5": - version "4.1.5" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.5.tgz#20d6a6ae651a0dd33f945548921698d49701fa43" - integrity sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug== - dependencies: - "@vitest/pretty-format" "4.1.5" - convert-source-map "^2.0.0" - tinyrainbow "^3.1.0" - "@vitest/utils@4.1.6": version "4.1.6" resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.6.tgz#3f4acf1f60e135ec1ce896f10baa4cd6466d0d38" @@ -2347,10 +2331,10 @@ basic-auth@^2.0.1: dependencies: safe-buffer "5.1.2" -body-parser@~1.20.3: - version "1.20.4" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.4.tgz#f8e20f4d06ca8a50a71ed329c15dccad1cdc547f" - integrity sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA== +body-parser@~1.20.5: + version "1.20.5" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.5.tgz#303c8c34423d1d6fa799bc764e93c1e4dc6ebf64" + integrity sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA== dependencies: bytes "~3.1.2" content-type "~1.0.5" @@ -2360,7 +2344,7 @@ body-parser@~1.20.3: http-errors "~2.0.1" iconv-lite "~0.4.24" on-finished "~2.4.1" - qs "~6.14.0" + qs "~6.15.1" raw-body "~2.5.3" type-is "~1.6.18" unpipe "~1.0.0" @@ -3033,15 +3017,15 @@ eslint-visitor-keys@^3.4.3: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -eslint@^10.2.1: - version "10.3.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.3.0.tgz#ed5b810eb8e0191bf24bddcf9cdb45b974e0a16d" - integrity sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw== +eslint@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.4.0.tgz#d86b6c405de0f19f3318c47139b8cb6771b3f592" + integrity sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ== dependencies: "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.2" "@eslint/config-array" "^0.23.5" - "@eslint/config-helpers" "^0.5.5" + "@eslint/config-helpers" "^0.6.0" "@eslint/core" "^1.2.1" "@eslint/plugin-kit" "^0.7.1" "@humanfs/node" "^0.16.6" @@ -3136,14 +3120,14 @@ express-basic-auth@^1.2.1: dependencies: basic-auth "^2.0.1" -express@^4.21.0: - version "4.22.1" - resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" - integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g== +express@^4.22.2: + version "4.22.2" + resolved "https://registry.yarnpkg.com/express/-/express-4.22.2.tgz#c17ae0981e5efc24b22272f0e041c4662503b700" + integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q== dependencies: accepts "~1.3.8" array-flatten "1.1.1" - body-parser "~1.20.3" + body-parser "~1.20.5" content-disposition "~0.5.4" content-type "~1.0.4" cookie "~0.7.1" @@ -3162,7 +3146,7 @@ express@^4.21.0: parseurl "~1.3.3" path-to-regexp "~0.1.12" proxy-addr "~2.0.7" - qs "~6.14.0" + qs "~6.15.1" range-parser "~1.2.1" safe-buffer "5.2.1" send "~0.19.0" @@ -4294,10 +4278,10 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.31, postcss@^8.5.14, postcss@^8.5.3: - version "8.5.13" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.13.tgz#6cfaf647f2e7ef69850208eccd849e0d3f65d420" - integrity sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag== +postcss@^8.5.14, postcss@^8.5.3: + version "8.5.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c" + integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" @@ -4336,20 +4320,13 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -qs@^6.14.1: +qs@^6.14.1, qs@~6.15.1: version "6.15.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.1.tgz#bdb55aed06bfac257a90c44a446a73fba5575c8f" integrity sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== dependencies: side-channel "^1.1.0" -qs@~6.14.0: - version "6.14.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c" - integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== - dependencies: - side-channel "^1.1.0" - range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -5255,18 +5232,18 @@ vite-svg-loader@^5.1.0: optionalDependencies: fsevents "~2.3.3" -vitest@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.5.tgz#cda189c0cd9dd1c920be477c0f371b64ec14782a" - integrity sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg== +vitest@^4.1.6: + version "4.1.6" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.6.tgz#754875c9a09c5a3e8ca7d07d440659d92c19787f" + integrity sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ== dependencies: - "@vitest/expect" "4.1.5" - "@vitest/mocker" "4.1.5" - "@vitest/pretty-format" "4.1.5" - "@vitest/runner" "4.1.5" - "@vitest/snapshot" "4.1.5" - "@vitest/spy" "4.1.5" - "@vitest/utils" "4.1.5" + "@vitest/expect" "4.1.6" + "@vitest/mocker" "4.1.6" + "@vitest/pretty-format" "4.1.6" + "@vitest/runner" "4.1.6" + "@vitest/snapshot" "4.1.6" + "@vitest/spy" "4.1.6" + "@vitest/utils" "4.1.6" es-module-lexer "^2.0.0" expect-type "^1.3.0" magic-string "^0.30.21" From 38f623792fa7e272db66d4526af35c105d17f9a4 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sat, 16 May 2026 11:24:08 +0100 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=90=9B=20Fixes=20contentMaxWidth=20?= =?UTF-8?q?on=20ver/hor=20layout=20(#2134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Home.vue | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/views/Home.vue b/src/views/Home.vue index 2f17e404..7e8099e7 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -220,13 +220,12 @@ export default { } } &.orientation-horizontal, &.orientation-vertical, &.single-section-view { - @include phone { --content-max-width: 100%; } - @include tablet { --content-max-width: 98%; } - @include laptop { --content-max-width: 90%; } - @include monitor { --content-max-width: 85%; } - @include big-screen { --content-max-width: 80%; } - @include big-screen-up { --content-max-width: 60%; } - max-width: var(--content-max-width, 90%); + @include phone { max-width: var(--content-max-width, 100%); } + @include tablet { max-width: var(--content-max-width, 98%); } + @include laptop { max-width: var(--content-max-width, 90%); } + @include monitor { max-width: var(--content-max-width, 85%); } + @include big-screen { max-width: var(--content-max-width, 80%); } + @include big-screen-up { max-width: var(--content-max-width, 60%); } } /* Specify number of columns, based on screen size or user preference */ From 04743de881dea72e9678d980c0125b272f5cdba5 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sat, 16 May 2026 11:24:31 +0100 Subject: [PATCH 16/31] =?UTF-8?q?=F0=9F=A5=85=20Better=20error=20catching?= =?UTF-8?q?=20for=20OICD=20fails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.js | 2 +- src/utils/auth/OidcAuth.js | 27 +++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/main.js b/src/main.js index d7ec6347..81fd90f7 100644 --- a/src/main.js +++ b/src/main.js @@ -61,7 +61,7 @@ const mount = () => app.mount('#app'); /* Handle failures of third-party auth initialization */ const handleAuthFailure = (provider, err) => { ErrorHandler(`Failed to authenticate with ${provider}`, err); - store.commit(Keys.CRITICAL_ERROR_MSG, `Authentication failed (${provider}). See console for details.`); + store.commit(Keys.CRITICAL_ERROR_MSG, `Authentication failed (${provider}).`); router.replace({ name: 'login' }).catch(() => {}).finally(mount); }; diff --git a/src/utils/auth/OidcAuth.js b/src/utils/auth/OidcAuth.js index 257c7d26..aa99485a 100644 --- a/src/utils/auth/OidcAuth.js +++ b/src/utils/auth/OidcAuth.js @@ -50,6 +50,14 @@ class OidcAuth { this.adminGroup = adminGroup; this.adminRole = adminRole; this.userManager = new UserManager(settings); + + // Surface OIDC errors that fire outside the init promise chain + this.userManager.events.addSilentRenewError((err) => { + ErrorHandler('OIDC silent token renew failed', err); + }); + this.userManager.events.addUserSignedOut(() => { + statusMsg('OIDC', 'User signed out at provider'); + }); } async login() { @@ -67,7 +75,13 @@ class OidcAuth { // Populate localStorage before the reload so the post-reload route guard // sees the user as logged-in and lets them through to /, not /login. const callbackUser = await this.userManager.signinCallback(window.location.href); - if (callbackUser) this.persistUserInfo(callbackUser); + if (!callbackUser) { + throw new Error( + 'OIDC signinCallback returned no user. Check userinfo CORS, ' + + 'requested scopes, and that id_token claims include a username.', + ); + } + this.persistUserInfo(callbackUser); window.location.href = '/'; return; } @@ -84,11 +98,8 @@ class OidcAuth { + 'and that id_token claims include a username.', ); } - await this.userManager.signinRedirect(); - // Mark the attempt only once signinRedirect has resolved (i.e. the - // navigation actually fired). If it threw — e.g. discovery fetch failed - // — we leave the guard untouched so a manual retry isn't blocked. sessionStorage.setItem(SIGNIN_GUARD_KEY, String(Date.now())); + await this.userManager.signinRedirect(); } } else { this.persistUserInfo(user); @@ -139,7 +150,11 @@ export const isOidcEnabled = () => { let oidc; export const initOidcAuth = async () => { - const { UserManager, WebStorageStateStore } = await import('oidc-client-ts'); + const { UserManager, WebStorageStateStore, Log } = await import('oidc-client-ts'); + if (import.meta.env.DEV) { + Log.setLogger(console); + Log.setLevel(Log.INFO); + } oidc = new OidcAuth(UserManager, WebStorageStateStore); return oidc.login(); }; From 30b094caf70d25e386f4ce4197af42461ba1ea52 Mon Sep 17 00:00:00 2001 From: Alicia Sykes Date: Sat, 16 May 2026 11:45:23 +0100 Subject: [PATCH 17/31] =?UTF-8?q?=F0=9F=8C=90Improved=20languuage=20detect?= =?UTF-8?q?ion=20and=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 26 +++++++++++--------- src/components/Settings/LanguageSwitcher.vue | 6 +++-- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/App.vue b/src/App.vue index 7ec62a4d..4f6bdb8a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -183,17 +183,21 @@ export default { } }, + /* Case-insensitive lookup that returns the canonical locale code, or undefined */ + resolveLocale(availibleLocales, userLang) { + if (!userLang) return undefined; + const target = userLang.toLowerCase(); + return availibleLocales.find((lang) => lang.toLowerCase() === target); + }, + /* Auto-detects users language from browser/ os, when not specified */ autoDetectLanguage(availibleLocales) { - const isLangSupported = (languageList, userLang) => languageList - .map(lang => lang.toLowerCase()).find((lang) => lang === userLang.toLowerCase()); - const usersBorwserLang1 = window.navigator.language || ''; // e.g. en-GB or or '' const usersBorwserLang2 = usersBorwserLang1.split('-')[0]; // e.g. en or undefined - const usersSpairLangs = window.navigator.languages; // e.g [en, en-GB, en-US] - return isLangSupported(availibleLocales, usersBorwserLang1) - || isLangSupported(availibleLocales, usersBorwserLang2) - || usersSpairLangs.find((spair) => isLangSupported(availibleLocales, spair)) + const usersSpairLangs = window.navigator.languages || []; // e.g [en, en-GB, en-US] + return this.resolveLocale(availibleLocales, usersBorwserLang1) + || this.resolveLocale(availibleLocales, usersBorwserLang2) + || usersSpairLangs.map((spair) => this.resolveLocale(availibleLocales, spair)).find(Boolean) || defaultLanguage; }, @@ -202,11 +206,9 @@ export default { const availibleLocales = this.$i18n.availableLocales; // All available locales const usersLang = localStorage[localStorageKeys.LANGUAGE] || this.appConfig.language; if (usersLang) { - if (availibleLocales.includes(usersLang)) { - return usersLang; - } else { - ErrorHandler(`Unsupported Language: '${usersLang}'`); - } + const resolved = this.resolveLocale(availibleLocales, usersLang); + if (resolved) return resolved; + ErrorHandler(`Unsupported Language: '${usersLang}'`); } return this.autoDetectLanguage(availibleLocales); }, diff --git a/src/components/Settings/LanguageSwitcher.vue b/src/components/Settings/LanguageSwitcher.vue index 40dd73a0..b8ff6da9 100644 --- a/src/components/Settings/LanguageSwitcher.vue +++ b/src/components/Settings/LanguageSwitcher.vue @@ -127,11 +127,13 @@ export default {