From 93e457da7b42ff55c366f6ccd8d14dc6a444e3ba Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 05:41:59 +0000 Subject: [PATCH 01/42] user/edit/password: check haveibeenpwned.com --- .../src/components/user/edit/password.vue | 46 ++++++++++++------ apps/central/src/util/password.js | 47 +++++++++++++++++++ 2 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 apps/central/src/util/password.js diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 7da343aa6..5a30d9529 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -24,7 +24,11 @@ except according to the terms contained in the LICENSE file. autocomplete="current-password"/> + :has-error="tooShort || mismatch || !!strError" autocomplete="new-password"> + + @@ -46,6 +50,7 @@ import useRequest from '../../../composables/request'; import { apiPaths } from '../../../util/request'; import { noop } from '../../../util/util'; import { useRequestData } from '../../../request-data'; +import { checkPasswordPwnage } from '../../../util/password'; export default { name: 'UserEditPassword', @@ -62,13 +67,15 @@ export default { newPassword: '', tooShort: false, confirm: '', - mismatch: false + mismatch: false, + strError: '', }; }, methods: { validate() { this.tooShort = false; this.mismatch = false; + this.strError = ''; if (this.newPassword.length < 10) { this.alert.danger(this.$t('alert.passwordTooShort')); @@ -86,19 +93,30 @@ export default { }, submit() { if (!this.validate()) return; - const data = { old: this.oldPassword, new: this.newPassword }; - this.request({ - method: 'PUT', - url: apiPaths.password(this.user.id), - data - }) - .then(() => { - this.alert.success(this.$t('alert.success')); - // The Chrome password manager does not realize that the form was - // submitted. Should we navigate to a different page so that it does? - }) - .catch(noop); + (async () => { + const isPwned = await checkPasswordPwnage(this.newPassword); + if (isPwned) { + this.strError = ` +

This password has previously been included in a breach.

+

For more information, see here.

+ `; + } else { + const data = { old: this.oldPassword, new: this.newPassword }; + this.request({ + method: 'PUT', + url: apiPaths.password(this.user.id), + data + }) + .then(() => { + this.alert.success(this.$t('alert.success')); + + // The Chrome password manager does not realize that the form was + // submitted. Should we navigate to a different page so that it does? + }) + .catch(noop); + } + })(); } } }; diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js new file mode 100644 index 000000000..fbc5af19c --- /dev/null +++ b/apps/central/src/util/password.js @@ -0,0 +1,47 @@ +const maxCacheLength = 10; +const hashCache = []; + +async function getSuffixesFor(prefix) { + const cachedHashes = hashCache.find(cached => cached.prefix === prefix); + if(cachedHashes) return cachedHashes.suffixes; + + try { + const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); + if(!res.ok) throw new Error(`Bad response: ${res.status}`); + + const body = await res.text(); + const suffixes = body.split('\n').map(line => line.split(':')[0]); + + if(hashCache.length === maxCacheLength) hashCache.shift(); + + hashCache.push({ prefix, suffixes }); + + return suffixes; + } catch(err) { + console.log('pwned check failed:', err); // eslint-disable-line no-console + // if we can't check, just let them use it + return []; + } +} + +export async function checkPasswordPwnage(password) { + const hash = await digestMessage(password); + + const hashPrefix = hash.substring(0, 5); + const hashSuffix = hash.substring(5); + + const suffixes = await getSuffixesFor(hashPrefix); + + return suffixes.includes(hashSuffix); +} + +// from: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string +async function digestMessage(message) { + const msgUint8 = new TextEncoder().encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray + .map(b => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase(); +} From 96e14a5cc199ed057324eb3391f0418575827565 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:02:22 +0000 Subject: [PATCH 02/42] lint --- apps/central/src/components/user/edit/password.vue | 2 +- apps/central/src/util/password.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 5a30d9529..7c0aaf62f 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -102,7 +102,7 @@ export default {

For more information, see here.

`; } else { - const data = { old: this.oldPassword, new: this.newPassword }; + const data = { old: this.oldPassword, new: this.newPassword }; this.request({ method: 'PUT', url: apiPaths.password(this.user.id), diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index fbc5af19c..08d8358b6 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -3,16 +3,16 @@ const hashCache = []; async function getSuffixesFor(prefix) { const cachedHashes = hashCache.find(cached => cached.prefix === prefix); - if(cachedHashes) return cachedHashes.suffixes; + if (cachedHashes) return cachedHashes.suffixes; try { const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); - if(!res.ok) throw new Error(`Bad response: ${res.status}`); + if (!res.ok) throw new Error(`Bad response: ${res.status}`); const body = await res.text(); const suffixes = body.split('\n').map(line => line.split(':')[0]); - if(hashCache.length === maxCacheLength) hashCache.shift(); + if (hashCache.length === maxCacheLength) hashCache.shift(); hashCache.push({ prefix, suffixes }); From 866ce502ffcac020b450bad366de02c9a621b9ed Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:03:25 +0000 Subject: [PATCH 03/42] lint --- apps/central/src/util/password.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index 08d8358b6..5cb4f6476 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -17,7 +17,7 @@ async function getSuffixesFor(prefix) { hashCache.push({ prefix, suffixes }); return suffixes; - } catch(err) { + } catch (err) { console.log('pwned check failed:', err); // eslint-disable-line no-console // if we can't check, just let them use it return []; @@ -25,14 +25,14 @@ async function getSuffixesFor(prefix) { } export async function checkPasswordPwnage(password) { - const hash = await digestMessage(password); + const hash = await digestMessage(password); // eslint-disable-line no-use-before-define const hashPrefix = hash.substring(0, 5); const hashSuffix = hash.substring(5); const suffixes = await getSuffixesFor(hashPrefix); - return suffixes.includes(hashSuffix); + return suffixes.includes(hashSuffix); } // from: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string From c8fc03f8eb636eba83bf23f5fd7fc234b34ca26f Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:03:46 +0000 Subject: [PATCH 04/42] lint --- apps/central/src/components/user/edit/password.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 7c0aaf62f..e6bdc5381 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -25,7 +25,7 @@ except according to the terms contained in the LICENSE file. - diff --git a/apps/central/src/components/password-strength.vue b/apps/central/src/components/password-strength.vue index d6c3dfd7d..1b45e15c5 100644 --- a/apps/central/src/components/password-strength.vue +++ b/apps/central/src/components/password-strength.vue @@ -15,7 +15,9 @@ vue-password-strength-meter 1.7.2, which uses the MIT license. https://github.com/apertureless/vue-password-strength-meter --> @@ -46,12 +48,16 @@ const score = computed(() => { @import '../assets/scss/mixins'; .password-strength { + position: relative; + height: 2px; +} + +.inner { background-color: #ddd; - float: right; height: 2px; - margin-bottom: 20px; - margin-top: 10px; - position: relative; + position: absolute; + right: 0; + top: 10px; width: 50%; // Use the borders of two pseduo-elements to create 4 blank spaces (gaps), diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index dc640750d..fbda317f3 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -124,7 +124,7 @@ export default { From 226180d268cdef664bb775056df56fba748501ee Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:55:53 +0000 Subject: [PATCH 08/42] e2e test? --- vite.config.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vite.config.js b/vite.config.js index 0e68550fd..17fc9f762 100644 --- a/vite.config.js +++ b/vite.config.js @@ -118,8 +118,9 @@ export default defineConfig(({ mode }) => ({ }, // Not sure why this is needed in addition to build.target above and why it's // only an issue in development. `npm run dev` doesn't work without this. - optimizeDeps: mode === 'development' - ? { esbuildOptions: { target: buildTarget } } - : {}, + optimizeDeps: { + include: ['zxcvbn'], + ...(mode === 'development' ? { esbuildOptions: { target: buildTarget } } : {}), + }, server: devServer })); From 46fe608c53cb8b0c10bc8f8c1c29bb661f75f227 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 5 Aug 2026 09:56:09 +0000 Subject: [PATCH 09/42] revert change --- vite.config.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/vite.config.js b/vite.config.js index 17fc9f762..0e68550fd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -118,9 +118,8 @@ export default defineConfig(({ mode }) => ({ }, // Not sure why this is needed in addition to build.target above and why it's // only an issue in development. `npm run dev` doesn't work without this. - optimizeDeps: { - include: ['zxcvbn'], - ...(mode === 'development' ? { esbuildOptions: { target: buildTarget } } : {}), - }, + optimizeDeps: mode === 'development' + ? { esbuildOptions: { target: buildTarget } } + : {}, server: devServer })); From bc768507f5d72eaf9425d5a41148eb10d6220dce Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Thu, 6 Aug 2026 15:58:50 +0000 Subject: [PATCH 10/42] wip --- apps/central/test/components/user/edit/password.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index 5ea8dee50..be748dd22 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -28,7 +28,7 @@ const submit = return component.get('#user-edit-password form').trigger('submit'); }; -describe('UserEditPassword', () => { +describe.only('UserEditPassword', () => { beforeEach(mockLogin); it('resets the form if the route changes', () => { @@ -145,7 +145,7 @@ describe('UserEditPassword', () => { .respondWithSuccess()); }); - it('sends the correct request', () => + it.only('sends the correct request', () => mockHttp() .mount(UserEditPassword, mountOptions()) .request(submit) From 7528a420694b4b3340162bb6e7e0dd047dba6365 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:20:11 +0000 Subject: [PATCH 11/42] working --- apps/central/karma.conf.js | 2 +- .../src/components/user/edit/password.vue | 17 +++++++++++-- apps/central/src/util/password.js | 25 ++++--------------- apps/central/test/index.js | 4 ++- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/central/karma.conf.js b/apps/central/karma.conf.js index 94cb4a5d3..512f67f92 100644 --- a/apps/central/karma.conf.js +++ b/apps/central/karma.conf.js @@ -74,7 +74,7 @@ module.exports = (config) => { browserDisconnectTimeout: 300_000, browserDisconnectTolerance: 3, reporters: ['spec'], - singleRun: true, + singleRun: false, client: { mocha: { grep: process.env.TEST_PATTERN || '.', diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index fbda317f3..86a7151c9 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -98,7 +98,15 @@ export default { if (!this.validate()) return; (async () => { - const isPwned = await checkPasswordPwnage(this.newPassword); + console.log('calling checkPasswordPwnage()', 'calling...'); + let isPwned; + try { + isPwned = await checkPasswordPwnage(this.request, this.newPassword); + } catch(err) { + console.log('caught:', err); + throw err; + } + console.log('calling checkPasswordPwnage()', 'returned:', isPwned); if (isPwned) { this.pwned = true; } else { @@ -118,7 +126,12 @@ export default { } })(); } - } + }, + watch: { + newPassword() { + this.pwned = false; + }, + }, }; diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index deaf66c0d..e8ebaf8ad 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -1,22 +1,7 @@ -const maxCacheLength = 10; -const hashCache = []; - -async function getSuffixesFor(prefix) { - const cachedHashes = hashCache.find(cached => cached.prefix === prefix); - if (cachedHashes) return cachedHashes.suffixes; - +async function getSuffixesFor(request, prefix) { try { - const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`); - if (!res.ok) throw new Error(`Bad response: ${res.status}`); - - const body = await res.text(); - const suffixes = body.split('\n').map(line => line.split(':')[0]); - - if (hashCache.length === maxCacheLength) hashCache.shift(); - - hashCache.push({ prefix, suffixes }); - - return suffixes; + const res = await request({ url:`https://api.pwnedpasswords.com/range/${prefix}` }); + return res.data.split('\n').map(line => line.split(':')[0]); } catch (err) { console.log('pwned check failed:', err); // eslint-disable-line no-console // if we can't check, just let them use it @@ -24,13 +9,13 @@ async function getSuffixesFor(prefix) { } } -export async function checkPasswordPwnage(password) { // eslint-disable-line import/prefer-default-export +export async function checkPasswordPwnage(request, password) { // eslint-disable-line import/prefer-default-export const hash = await digestMessage(password); // eslint-disable-line no-use-before-define const hashPrefix = hash.substring(0, 5); const hashSuffix = hash.substring(5); - const suffixes = await getSuffixesFor(hashPrefix); + const suffixes = await getSuffixesFor(request, hashPrefix); return suffixes.includes(hashSuffix); } diff --git a/apps/central/test/index.js b/apps/central/test/index.js index 9eb0b2b6c..165747306 100644 --- a/apps/central/test/index.js +++ b/apps/central/test/index.js @@ -58,5 +58,7 @@ setupLanguages(afterEach); // here rather than in karma.conf.js, because doing so is more performant. When // I tried specifying the tests in karma.conf.js, I encountered an out-of-memory // error. -const testsContext = require.context('.', true, /\.spec\.js$/); +console.log(Date.now(), 'loading tests context...'); +const testsContext = require.context('.', true, /password.spec.js/); +console.log(Date.now(), 'tests context loaded.'); testsContext.keys().forEach(testsContext); From 791bc6922f7ecc2d698b2f17a0513dac62133488 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:20:18 +0000 Subject: [PATCH 12/42] wortking --- apps/central/src/components/user/edit/password.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 86a7151c9..232c22ca2 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -80,7 +80,7 @@ export default { this.mismatch = false; this.pwned = false; - if (this.newPassword.length < 10) { + if (this.newPassword.length < 1) { this.alert.danger(this.$t('alert.passwordTooShort')); this.tooShort = true; return false; From fc03a692100184a8ec302a374d579a77d6c5c6bd Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:28:51 +0000 Subject: [PATCH 13/42] transiton error in/out --- .../src/components/user/edit/password.vue | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 232c22ca2..a54ff106c 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -26,10 +26,14 @@ except according to the terms contained in the LICENSE file. type="password" :placeholder="$t('field.newPassword')" required :has-error="tooShort || mismatch || pwned" autocomplete="new-password"> #user-edit-password input[autocomplete="username"] { display: none; } -.error { color:#de2a11; font-size:11px; margin:25px 12px -25px; } +.collapsible-error { + display: grid; + grid-template-rows: 1fr; + color: #de2a11; + font-size: 11px; + margin: 25px 12px -25px; + + .collapsible-inner { overflow:hidden } +} +.collapse-enter-active, .collapse-leave-active { transition:grid-template-rows 0.3s ease, opacity 0.3s ease } +.collapse-enter-from, .collapse-leave-to { grid-template-rows:0fr; opacity:0 } From 02c613f30ace2fe518add2ee8e081432998b9bf0 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:37:24 +0000 Subject: [PATCH 14/42] fix test --- apps/central/src/util/password.js | 3 ++- .../components/user/edit/password.spec.js | 23 +++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index e8ebaf8ad..8ae7a9dd0 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -1,6 +1,7 @@ async function getSuffixesFor(request, prefix) { try { - const res = await request({ url:`https://api.pwnedpasswords.com/range/${prefix}` }); + const url = `https://api.pwnedpasswords.com/range/${prefix}`; + const res = await request({ url }); return res.data.split('\n').map(line => line.split(':')[0]); } catch (err) { console.log('pwned check failed:', err); // eslint-disable-line no-console diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index be748dd22..eb7f98ee0 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -149,12 +149,25 @@ describe.only('UserEditPassword', () => { mockHttp() .mount(UserEditPassword, mountOptions()) .request(submit) + .respondWithData(() => [ + '005E8325869AFF00C6E09BB59964923BE14:1', + '009F3803299EF825B220707AE492B801B8C:9', + '00E4600320A4F051A36B6087D2D1D4933E5:502', + '01010F6D71D3277A8E9767BB7C695A3904E:3', + '0113AE28B46F0D0ABCE49F128E2D218BA23:4', + ].join('\r\n')) .respondWithSuccess() - .testRequests([{ - method: 'PUT', - url: '/v1/users/1/password', - data: { old: 'testPasswordX', new: 'testPasswordY' } - }])); + .testRequests([ + { + method: 'GET', + url: 'https://api.pwnedpasswords.com/range/036EA', + }, + { + method: 'PUT', + url: '/v1/users/1/password', + data: { old: 'testPasswordX', new: 'testPasswordY' } + }, + ])); it('implements some standard button things', () => mockHttp() From e20053ccf4a45d24fba35a3f2d4c2767ff313fba Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:38:36 +0000 Subject: [PATCH 15/42] revert logging and length --- apps/central/src/components/user/edit/password.vue | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index a54ff106c..6087abcca 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -84,7 +84,7 @@ export default { this.mismatch = false; this.pwned = false; - if (this.newPassword.length < 1) { + if (this.newPassword.length < 10) { this.alert.danger(this.$t('alert.passwordTooShort')); this.tooShort = true; return false; @@ -102,15 +102,7 @@ export default { if (!this.validate()) return; (async () => { - console.log('calling checkPasswordPwnage()', 'calling...'); - let isPwned; - try { - isPwned = await checkPasswordPwnage(this.request, this.newPassword); - } catch(err) { - console.log('caught:', err); - throw err; - } - console.log('calling checkPasswordPwnage()', 'returned:', isPwned); + const isPwned = await checkPasswordPwnage(this.request, this.newPassword); if (isPwned) { this.pwned = true; } else { From c1775bb4e42064b2c5edb2492c946dc3496a356f Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:42:18 +0000 Subject: [PATCH 16/42] reorder, rename --- apps/central/src/util/password.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index 8ae7a9dd0..931979d82 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -1,17 +1,5 @@ -async function getSuffixesFor(request, prefix) { - try { - const url = `https://api.pwnedpasswords.com/range/${prefix}`; - const res = await request({ url }); - return res.data.split('\n').map(line => line.split(':')[0]); - } catch (err) { - console.log('pwned check failed:', err); // eslint-disable-line no-console - // if we can't check, just let them use it - return []; - } -} - export async function checkPasswordPwnage(request, password) { // eslint-disable-line import/prefer-default-export - const hash = await digestMessage(password); // eslint-disable-line no-use-before-define + const hash = await sha1hash(password); // eslint-disable-line no-use-before-define const hashPrefix = hash.substring(0, 5); const hashSuffix = hash.substring(5); @@ -22,12 +10,24 @@ export async function checkPasswordPwnage(request, password) { // eslint-disable } // from: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#converting_a_digest_to_a_hex_string -async function digestMessage(message) { +async function sha1hash(message) { const msgUint8 = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); const hashArray = Array.from(new Uint8Array(hashBuffer)); - return hashArray + const hash = hashArray .map(b => b.toString(16).padStart(2, '0')) .join('') .toUpperCase(); } + +async function getSuffixesFor(request, prefix) { + try { + const url = `https://api.pwnedpasswords.com/range/${prefix}`; + const res = await request({ url }); + return res.data.split('\n').map(line => line.split(':')[0]); + } catch (err) { + console.log('pwned check failed:', err); // eslint-disable-line no-console + // if we can't check, just let them use it + return []; + } +} From 398e3af21c3cf3c1a30026da02a7329415015300 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:42:36 +0000 Subject: [PATCH 17/42] remove .only --- apps/central/test/components/user/edit/password.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index eb7f98ee0..4a57da9c8 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -28,7 +28,7 @@ const submit = return component.get('#user-edit-password form').trigger('submit'); }; -describe.only('UserEditPassword', () => { +describe('UserEditPassword', () => { beforeEach(mockLogin); it('resets the form if the route changes', () => { @@ -145,7 +145,7 @@ describe.only('UserEditPassword', () => { .respondWithSuccess()); }); - it.only('sends the correct request', () => + it('sends the correct request', () => mockHttp() .mount(UserEditPassword, mountOptions()) .request(submit) From 876c462b64401d1fb6e55be0d12a63f2bad9db15 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:42:51 +0000 Subject: [PATCH 18/42] revert test contenxt --- apps/central/test/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/central/test/index.js b/apps/central/test/index.js index 165747306..9eb0b2b6c 100644 --- a/apps/central/test/index.js +++ b/apps/central/test/index.js @@ -58,7 +58,5 @@ setupLanguages(afterEach); // here rather than in karma.conf.js, because doing so is more performant. When // I tried specifying the tests in karma.conf.js, I encountered an out-of-memory // error. -console.log(Date.now(), 'loading tests context...'); -const testsContext = require.context('.', true, /password.spec.js/); -console.log(Date.now(), 'tests context loaded.'); +const testsContext = require.context('.', true, /\.spec\.js$/); testsContext.keys().forEach(testsContext); From dc023ccf76ca2af17ce8d6afdfc200e8559b4a8d Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:43:03 +0000 Subject: [PATCH 19/42] revert karma config --- apps/central/karma.conf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/central/karma.conf.js b/apps/central/karma.conf.js index 512f67f92..94cb4a5d3 100644 --- a/apps/central/karma.conf.js +++ b/apps/central/karma.conf.js @@ -74,7 +74,7 @@ module.exports = (config) => { browserDisconnectTimeout: 300_000, browserDisconnectTolerance: 3, reporters: ['spec'], - singleRun: false, + singleRun: true, client: { mocha: { grep: process.env.TEST_PATTERN || '.', From 472294848bcab0c433285107dffc7ebeee74c9df Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:48:36 +0000 Subject: [PATCH 20/42] lint --- apps/central/src/util/password.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index 931979d82..7aaa4c372 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -4,7 +4,7 @@ export async function checkPasswordPwnage(request, password) { // eslint-disable const hashPrefix = hash.substring(0, 5); const hashSuffix = hash.substring(5); - const suffixes = await getSuffixesFor(request, hashPrefix); + const suffixes = await getSuffixesFor(request, hashPrefix); // eslint-disable-line no-use-before-define return suffixes.includes(hashSuffix); } From e7880c2bd09a472a01c3b89c63217d5e326cebf8 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 07:49:57 +0000 Subject: [PATCH 21/42] lint --- apps/central/src/components/user/edit/password.vue | 12 ++++++------ apps/central/src/util/password.js | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index 6087abcca..a9e42ae6b 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -78,6 +78,11 @@ export default { pwned: false, }; }, + watch: { + newPassword() { + this.pwned = false; + }, + }, methods: { validate() { this.tooShort = false; @@ -122,12 +127,7 @@ export default { } })(); } - }, - watch: { - newPassword() { - this.pwned = false; - }, - }, + } }; diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index 7aaa4c372..b3de2755a 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -14,7 +14,7 @@ async function sha1hash(message) { const msgUint8 = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8); const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hash = hashArray + return hashArray .map(b => b.toString(16).padStart(2, '0')) .join('') .toUpperCase(); From 7443aa1b38503f4e81e7a6ec720dccbacd262fc5 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 08:05:24 +0000 Subject: [PATCH 22/42] fix a test --- .../components/user/edit/password.spec.js | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index 4a57da9c8..ca2ca64fc 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -149,19 +149,10 @@ describe('UserEditPassword', () => { mockHttp() .mount(UserEditPassword, mountOptions()) .request(submit) - .respondWithData(() => [ - '005E8325869AFF00C6E09BB59964923BE14:1', - '009F3803299EF825B220707AE492B801B8C:9', - '00E4600320A4F051A36B6087D2D1D4933E5:502', - '01010F6D71D3277A8E9767BB7C695A3904E:3', - '0113AE28B46F0D0ABCE49F128E2D218BA23:4', - ].join('\r\n')) + .respondWithData(haveIbeenPwnedResponse('testPasswordY')) .respondWithSuccess() .testRequests([ - { - method: 'GET', - url: 'https://api.pwnedpasswords.com/range/036EA', - }, + haveIbeenPwnedRequest('testPasswordY'), { method: 'PUT', url: '/v1/users/1/password', @@ -181,8 +172,41 @@ describe('UserEditPassword', () => { mockHttp() .mount(UserEditPassword, mountOptions()) .request(submit) + .respondWithData(haveIbeenPwnedResponse('testPasswordY')) .respondWithSuccess() .afterResponse(component => { component.should.alert('success'); })); }); + +function haveIbeenPwnedRequest(password) { + const hashPrefix = (() => { + switch(password) { + case 'testPasswordY': return '036EA'; + default: throw new Error(`No haveibeenpwned API request defined for password '${password}'`); + } + })(); + + return { + method: 'GET', + url: `https://api.pwnedpasswords.com/range/${hashPrefix}`, + }; +} + +function haveIbeenPwnedResponse(password) { + const hashes = (() => { + switch(password) { + case 'testPasswordY': + return [ + '005E8325869AFF00C6E09BB59964923BE14:1', + '009F3803299EF825B220707AE492B801B8C:9', + '00E4600320A4F051A36B6087D2D1D4933E5:502', + '01010F6D71D3277A8E9767BB7C695A3904E:3', + '0113AE28B46F0D0ABCE49F128E2D218BA23:4', + ]; + default: throw new Error(`No haveibeenpwned API response defined for password '${password}'`); + } + })(); + + return () => hashes.join('\r\n'); +} From 49f8009b7725e5c237aa4a9e1167c6d2c836e9b7 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 08:06:57 +0000 Subject: [PATCH 23/42] fix more tests --- apps/central/test/components/user/edit/password.spec.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index ca2ca64fc..0b3839d0d 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -110,6 +110,7 @@ describe('UserEditPassword', () => { formGroups[1].props().hasError.should.be.false; formGroups[2].props().hasError.should.be.false; }) + .respondWithData(haveIbeenPwnedResponse('testPasswordY')) .respondWithSuccess()); }); @@ -142,6 +143,7 @@ describe('UserEditPassword', () => { formGroups.length.should.equal(3); formGroups[1].props().hasError.should.be.false; }) + .respondWithData(haveIbeenPwnedResponse('testPasswordY')) .respondWithSuccess()); }); From 1c705a58b4a082c4122662ed568e44136c267637 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 08:08:45 +0000 Subject: [PATCH 24/42] fix another test --- apps/central/test/components/user/edit/password.spec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index 0b3839d0d..a446512d5 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -165,6 +165,7 @@ describe('UserEditPassword', () => { it('implements some standard button things', () => mockHttp() .mount(UserEditPassword, mountOptions()) + .respondWithData(haveIbeenPwnedResponse('testPasswordY')) .testStandardButton({ button: '.btn-primary', request: submit From bf37431d6c7960acc224deaee0a96652a066c7ad Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 08:11:02 +0000 Subject: [PATCH 25/42] lint --- .../components/user/edit/password.spec.js | 64 +++++++++---------- 1 file changed, 31 insertions(+), 33 deletions(-) diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index a446512d5..d4c9e3bd0 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -27,8 +27,38 @@ const submit = : (!tooShort ? 'testPasswordZ' : 'z')); return component.get('#user-edit-password form').trigger('submit'); }; +const haveIbeenPwnedRequest = password => { + const hashPrefix = (() => { + switch (password) { + case 'testPasswordY': return '036EA'; + default: throw new Error(`No haveibeenpwned API request defined for password '${password}'`); + } + })(); -describe('UserEditPassword', () => { + return { + method: 'GET', + url: `https://api.pwnedpasswords.com/range/${hashPrefix}`, + }; +}; +const haveIbeenPwnedResponse = password => { + const hashes = (() => { + switch (password) { + case 'testPasswordY': + return [ + '005E8325869AFF00C6E09BB59964923BE14:1', + '009F3803299EF825B220707AE492B801B8C:9', + '00E4600320A4F051A36B6087D2D1D4933E5:502', + '01010F6D71D3277A8E9767BB7C695A3904E:3', + '0113AE28B46F0D0ABCE49F128E2D218BA23:4', + ]; + default: throw new Error(`No haveibeenpwned API response defined for password '${password}'`); + } + })(); + + return () => hashes.join('\r\n'); +}; + +describe.only('UserEditPassword', () => { beforeEach(mockLogin); it('resets the form if the route changes', () => { @@ -181,35 +211,3 @@ describe('UserEditPassword', () => { component.should.alert('success'); })); }); - -function haveIbeenPwnedRequest(password) { - const hashPrefix = (() => { - switch(password) { - case 'testPasswordY': return '036EA'; - default: throw new Error(`No haveibeenpwned API request defined for password '${password}'`); - } - })(); - - return { - method: 'GET', - url: `https://api.pwnedpasswords.com/range/${hashPrefix}`, - }; -} - -function haveIbeenPwnedResponse(password) { - const hashes = (() => { - switch(password) { - case 'testPasswordY': - return [ - '005E8325869AFF00C6E09BB59964923BE14:1', - '009F3803299EF825B220707AE492B801B8C:9', - '00E4600320A4F051A36B6087D2D1D4933E5:502', - '01010F6D71D3277A8E9767BB7C695A3904E:3', - '0113AE28B46F0D0ABCE49F128E2D218BA23:4', - ]; - default: throw new Error(`No haveibeenpwned API response defined for password '${password}'`); - } - })(); - - return () => hashes.join('\r\n'); -} From 715514de6575c78a1d7218582272f05f6667ab03 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Wed, 12 Aug 2026 08:12:40 +0000 Subject: [PATCH 26/42] remove .only --- apps/central/test/components/user/edit/password.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/central/test/components/user/edit/password.spec.js b/apps/central/test/components/user/edit/password.spec.js index d4c9e3bd0..ee5fedb66 100644 --- a/apps/central/test/components/user/edit/password.spec.js +++ b/apps/central/test/components/user/edit/password.spec.js @@ -58,7 +58,7 @@ const haveIbeenPwnedResponse = password => { return () => hashes.join('\r\n'); }; -describe.only('UserEditPassword', () => { +describe('UserEditPassword', () => { beforeEach(mockLogin); it('resets the form if the route changes', () => { From b54fe8fc63315d3a80790f26a13bfaef35f035e1 Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 17 Aug 2026 09:34:54 +0000 Subject: [PATCH 27/42] don't alert --- apps/central/src/util/password.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/central/src/util/password.js b/apps/central/src/util/password.js index b3de2755a..0fda6e612 100644 --- a/apps/central/src/util/password.js +++ b/apps/central/src/util/password.js @@ -23,7 +23,7 @@ async function sha1hash(message) { async function getSuffixesFor(request, prefix) { try { const url = `https://api.pwnedpasswords.com/range/${prefix}`; - const res = await request({ url }); + const res = await request({ url, alert: false }); return res.data.split('\n').map(line => line.split(':')[0]); } catch (err) { console.log('pwned check failed:', err); // eslint-disable-line no-console From 9e1c7385d5701811fb0299234770d861839aae6d Mon Sep 17 00:00:00 2001 From: alxndrsn Date: Mon, 17 Aug 2026 09:39:03 +0000 Subject: [PATCH 28/42] use danger colour --- apps/central/src/components/user/edit/password.vue | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/central/src/components/user/edit/password.vue b/apps/central/src/components/user/edit/password.vue index a9e42ae6b..95e8785e4 100644 --- a/apps/central/src/components/user/edit/password.vue +++ b/apps/central/src/components/user/edit/password.vue @@ -89,7 +89,7 @@ export default { this.mismatch = false; this.pwned = false; - if (this.newPassword.length < 10) { + if (this.newPassword.length < 1) { this.alert.danger(this.$t('alert.passwordTooShort')); this.tooShort = true; return false; @@ -132,11 +132,13 @@ export default {