Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
93e457d
user/edit/password: check haveibeenpwned.com
Aug 5, 2026
96e14a5
lint
Aug 5, 2026
866ce50
lint
Aug 5, 2026
c8fc03f
lint
Aug 5, 2026
df913cd
lint
Aug 5, 2026
bb3ad0c
lineline html
Aug 5, 2026
eec7ea1
Merge branch 'master' into set-password-check-pwned
alxndrsn Aug 5, 2026
ee681d1
layout
Aug 5, 2026
226180d
e2e test?
Aug 5, 2026
46fe608
revert change
Aug 5, 2026
bc76850
wip
Aug 6, 2026
edcff1b
Merge branch 'master' into set-password-check-pwned
Aug 11, 2026
7528a42
working
Aug 12, 2026
791bc69
wortking
Aug 12, 2026
fc03a69
transiton error in/out
Aug 12, 2026
02c613f
fix test
Aug 12, 2026
e20053c
revert logging and length
Aug 12, 2026
c1775bb
reorder, rename
Aug 12, 2026
398e3af
remove .only
Aug 12, 2026
876c462
revert test contenxt
Aug 12, 2026
dc023cc
revert karma config
Aug 12, 2026
4722948
lint
Aug 12, 2026
e7880c2
lint
Aug 12, 2026
7443aa1
fix a test
Aug 12, 2026
49f8009
fix more tests
Aug 12, 2026
1c705a5
fix another test
Aug 12, 2026
bf37431
lint
Aug 12, 2026
715514d
remove .only
Aug 12, 2026
01d73fd
Merge branch 'master' into set-password-check-pwned
alxndrsn Aug 14, 2026
b54fe8f
don't alert
Aug 17, 2026
9e1c738
use danger colour
Aug 17, 2026
ebeb632
i18n
Aug 17, 2026
41d6f30
18n clickHere
Aug 17, 2026
aff5e42
transifix
Aug 17, 2026
248ff5f
new tests
Aug 17, 2026
5735388
Merge branch 'master' into set-password-check-pwned
alxndrsn Aug 20, 2026
fe8f4d0
s/if/it
Aug 27, 2026
e2844af
ci: run less stuff
Aug 27, 2026
eaa65a8
respondNever()
Aug 27, 2026
7d2f6e4
fix test hash
Aug 27, 2026
a3d035a
Merge branch 'master' into set-password-check-pwned
Aug 27, 2026
c6e7413
promises?
Aug 27, 2026
0060c22
promise resolution?
Aug 27, 2026
d70d8b3
lint
Aug 27, 2026
1996282
Merge branch 'master' into set-password-check-pwned
Aug 27, 2026
34c9950
Merge branch 'master' into set-password-check-pwned
alxndrsn Aug 31, 2026
2fb8123
Merge branch 'master' into set-password-check-pwned
Sep 22, 2026
cad294e
disable buttons
Sep 22, 2026
d9e0ede
ci: disable irrelevants
Sep 22, 2026
89499a6
reintro for unit tests
Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 79 additions & 79 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,86 +54,86 @@ jobs:
mode: restore
- run: npm run test -w=@getodk/central-frontend

test-forms-app:
name: 'Test forms app'
needs:
- check-and-build
timeout-minutes: 2
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/node-cache
with:
mode: restore
- run: npx playwright install chromium --with-deps
- run: npm run test -w=@getodk/forms
#test-forms-app:
# name: 'Test forms app'
# needs:
# - check-and-build
# timeout-minutes: 2
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v6
# - uses: ./.github/actions/node-cache
# with:
# mode: restore
# - run: npx playwright install chromium --with-deps
# - run: npm run test -w=@getodk/forms

e2e-tests:
name: 'Test apps e2e'
needs:
- test-central-app
- test-forms-app
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
#e2e-tests:
# name: 'Test apps e2e'
# needs:
# - test-central-app
# - test-forms-app
# timeout-minutes: 20
# runs-on: ubuntu-latest
# steps:

# This one weird trick speeds up every build!
# see: https://github.com/getodk/central-backend/pull/1642
# see: https://github.com/actions/runner/issues/4030
- run: sudo apt-get remove --purge man-db
- uses: actions/checkout@v6
with:
path: client
fetch-depth: 0
- name: Clone getodk/central repo
run: |
git clone -b next https://github.com/getodk/central.git
cd central
git submodule set-branch -b master server
git submodule update --init --remote server
mv ../client .
- name: Modify files
working-directory: central
run: |
yq e '.services.enketo.extra_hosts += ["${DOMAIN}:host-gateway"]' -i docker-compose.yml
sed -i 's|\${BASE_URL}|http://${DOMAIN}|g' files/enketo/config.json.template
sed -i 's|\${BASE_URL}|http://${DOMAIN}|g' files/service/config.json.template
sed -i 's/\$scheme/https/g' files/nginx/odk.conf.template
sed -Ei 's/https:([ ;]|$)/http:\1/g' files/nginx/odk.conf.template
sed 's/your.domain.com/central-test.localhost/; s/^SSL_TYPE=letsencrypt/SSL_TYPE=upstream/' .env.template > .env
- name: Add domain
run: echo '127.0.0.1 central-test.localhost' | sudo tee --append /etc/hosts
- name: Start services
working-directory: central
run: touch ./files/allow-postgres14-upgrade && docker compose build --build-arg FRONTEND_BUILD_MODE=source && docker compose up -d
- name: Set node version
uses: actions/setup-node@v6
with:
node-version-file: central/client/package.json
cache: 'npm'
cache-dependency-path: 'central/client/package-lock.json'
- name: Run tests
working-directory: central
run: client/e2e-tests/run-tests.sh --domain=central-test.localhost --port=80
- name: Archive playwright result
if: failure()
uses: actions/upload-artifact@v7
with:
name: Playwright Artifacts
path: central/client/test-results
- if: always()
name: Docker Container Logs
working-directory: central
run: docker compose logs || true
# # This one weird trick speeds up every build!
# # see: https://github.com/getodk/central-backend/pull/1642
# # see: https://github.com/actions/runner/issues/4030
# - run: sudo apt-get remove --purge man-db
# - uses: actions/checkout@v6
# with:
# path: client
# fetch-depth: 0
# - name: Clone getodk/central repo
# run: |
# git clone -b next https://github.com/getodk/central.git
# cd central
# git submodule set-branch -b master server
# git submodule update --init --remote server
# mv ../client .
# - name: Modify files
# working-directory: central
# run: |
# yq e '.services.enketo.extra_hosts += ["${DOMAIN}:host-gateway"]' -i docker-compose.yml
# sed -i 's|\${BASE_URL}|http://${DOMAIN}|g' files/enketo/config.json.template
# sed -i 's|\${BASE_URL}|http://${DOMAIN}|g' files/service/config.json.template
# sed -i 's/\$scheme/https/g' files/nginx/odk.conf.template
# sed -Ei 's/https:([ ;]|$)/http:\1/g' files/nginx/odk.conf.template
# sed 's/your.domain.com/central-test.localhost/; s/^SSL_TYPE=letsencrypt/SSL_TYPE=upstream/' .env.template > .env
# - name: Add domain
# run: echo '127.0.0.1 central-test.localhost' | sudo tee --append /etc/hosts
# - name: Start services
# working-directory: central
# run: touch ./files/allow-postgres14-upgrade && docker compose build --build-arg FRONTEND_BUILD_MODE=source && docker compose up -d
# - name: Set node version
# uses: actions/setup-node@v6
# with:
# node-version-file: central/client/package.json
# cache: 'npm'
# cache-dependency-path: 'central/client/package-lock.json'
# - name: Run tests
# working-directory: central
# run: client/e2e-tests/run-tests.sh --domain=central-test.localhost --port=80
# - name: Archive playwright result
# if: failure()
# uses: actions/upload-artifact@v7
# with:
# name: Playwright Artifacts
# path: central/client/test-results
# - if: always()
# name: Docker Container Logs
# working-directory: central
# run: docker compose logs || true

wf-tests:
name: 'Test web-forms packages'
needs:
- check-and-build
uses: ./.github/workflows/wf-ci.yml
#wf-tests:
# name: 'Test web-forms packages'
# needs:
# - check-and-build
# uses: ./.github/workflows/wf-ci.yml

wf-e2e-tests:
name: 'Test web-forms e2e'
needs:
- wf-tests
uses: ./.github/workflows/wf-ci-e2e.yml
#wf-e2e-tests:
# name: 'Test web-forms e2e'
# needs:
# - wf-tests
# uses: ./.github/workflows/wf-ci-e2e.yml
2 changes: 1 addition & 1 deletion apps/central/src/components/form-group.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ except according to the terms contained in the LICENSE file.
<input ref="input" v-model="modelValue" v-bind="$attrs" class="form-control"
:placeholder="requiredLabel(placeholder, required)" :required="required"
v-tooltip.aria-describedby="tooltip" :autocomplete="autocomplete">
<span class="form-label">{{ requiredLabel(placeholder, required) }}</span>
<password-strength v-if="autocomplete === 'new-password'"
:password="modelValue"/>
<span class="form-label">{{ requiredLabel(placeholder, required) }}</span>
<slot name="after"></slot>
</label>
</template>
Expand Down
16 changes: 11 additions & 5 deletions apps/central/src/components/password-strength.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ vue-password-strength-meter 1.7.2, which uses the MIT license.
https://github.com/apertureless/vue-password-strength-meter -->
<template>
<div class="password-strength">
<div :data-score="score"></div>
<div class="inner">
<div :data-score="score"></div>
</div>
</div>
</template>

Expand Down Expand Up @@ -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),
Expand Down
106 changes: 80 additions & 26 deletions apps/central/src/components/user/edit/password.vue
Comment thread
alxndrsn marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,37 @@ except according to the terms contained in the LICENSE file.
<p v-if="config.oidcEnabled">{{ $t('oidcBody') }}</p>
<form v-else-if="user.dataExists && user.id === currentUser.id"
@submit.prevent="submit">
<input :value="currentUser.email" autocomplete="username">
<form-group id="user-edit-password-old-password" v-model="oldPassword"
type="password" :placeholder="$t('field.oldPassword')" required
autocomplete="current-password"/>
<form-group id="user-edit-password-new-password" v-model="newPassword"
type="password" :placeholder="$t('field.newPassword')" required
:has-error="tooShort || mismatch" autocomplete="new-password"/>
<form-group id="user-edit-password-confirm" v-model="confirm"
type="password" :placeholder="$t('field.passwordConfirm')" required
:has-error="mismatch" autocomplete="new-password"/>
<button type="submit" class="btn btn-primary"
:aria-disabled="awaitingResponse">
{{ $t('action.change') }} <spinner :state="awaitingResponse"/>
</button>
<fieldset :disabled="submitInProgress">
<input :value="currentUser.email" autocomplete="username">
<form-group id="user-edit-password-old-password" v-model="oldPassword"
type="password" :placeholder="$t('field.oldPassword')" required
autocomplete="current-password"/>
<form-group id="user-edit-password-new-password" v-model="newPassword"
type="password" :placeholder="$t('field.newPassword')" required
:has-error="tooShort || mismatch || pwned" autocomplete="new-password">
<template #after>
<transition name="collapse">
<div v-if="pwned" class="collapsible-error">
<div class="collapsible-inner">
<p>{{ $t('alert.includedInBreach') }}</p>
<i18n-t keypath="moreInfo.clickHere.full">
<template #clickHere>
<a href="https://haveibeenpwned.com/Passwords" target="_blank" rel="noopener noreferrer">{{ $t('moreInfo.clickHere.clickHere') }}</a>
</template>
</i18n-t>
</div>
</div>
</transition>
</template>
</form-group>
<form-group id="user-edit-password-confirm" v-model="confirm"
type="password" :placeholder="$t('field.passwordConfirm')" required
:has-error="mismatch" autocomplete="new-password"/>
<button type="submit" class="btn btn-primary"
:aria-disabled="awaitingResponse">

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now functionally pointless. It should be removed, but this will require changes to the test for "normal button" behaviour.

{{ $t('action.change') }} <spinner :state="submitInProgress"/>
</button>
</fieldset>
</form>
<p v-else>{{ $t('cannotChange') }}</p>
</div>
Expand All @@ -46,6 +63,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',
Expand All @@ -62,13 +80,21 @@ export default {
newPassword: '',
tooShort: false,
confirm: '',
mismatch: false
mismatch: false,
pwned: false,
submitInProgress: false,
};
},
watch: {
newPassword() {
this.pwned = false;

@matthew-white matthew-white Aug 12, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds reasonable to me to reset this.pwned as soon as this.newPassword changes. At the same time, we don't do that for this.tooShort or this.mismatch: we only reset those in the validate() method. I think it'd be good to be consistent between the three. How about:

  1. Removing this watcher for now and only resetting this.pwned in validate()
  2. Maybe a follow-up PR adding a watcher that resets this.pwned along with this.tooShort and this.mismatch

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the same time, we don't do that for this.tooShort or this.mismatch

I'd like this to change. Would that be OK? If so, would you prefer a prior to watch these values?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing that sounds good to me. 👍 I'm happy for that to happen in a prior PR or a follow-up PR or this PR. Mostly I just want this.pwned to behave in a similar way as this.tooShort and this.mismatch.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would you prefer a prior to watch these values?

I'm thinking we only need one watcher, on newPassword, where the watcher can reset all of this.pwned, this.tooShort, and this.mismatch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, looks like there's a word missing. Should have read:

would you prefer a prior PR to watch these values?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A prior PR sounds good to me. 👍 But I'm also happy for it to happen in this PR or in a follow-up PR. Basically whatever's easiest as long as it's done in time for the release.

},
},
methods: {
validate() {
this.tooShort = false;
this.mismatch = false;
this.pwned = false;

if (this.newPassword.length < 10) {
this.alert.danger(this.$t('alert.passwordTooShort'));
Expand All @@ -86,26 +112,53 @@ 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?
this.submitInProgress = true;

checkPasswordPwnage(this.request, this.newPassword)
.then(isPwned => {
if (isPwned) {
this.pwned = true;
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);
.catch(noop)
.finally(() => {
this.submitInProgress = false;
});
}
}
};
</script>

<style lang="scss">
@import '../../../assets/scss/variables';

#user-edit-password input[autocomplete="username"] { display: none; }
.collapsible-error {
display: grid;
grid-template-rows: 1fr;
color: $color-danger;
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 }
</style>

<i18n lang="json5">
Expand All @@ -119,6 +172,7 @@ export default {
},
"cannotChange": "Only the owner of the account may directly set their own password.",
"alert": {
"includedInBreach": "This password has previously been included in a breach.",
"mismatch": "Please check that your new passwords match.",
"success": "Success! Your password has been updated."
}
Expand Down
33 changes: 33 additions & 0 deletions apps/central/src/util/password.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export async function checkPasswordPwnage(request, password) { // eslint-disable-line import/prefer-default-export
const hash = await sha1hash(password); // eslint-disable-line no-use-before-define

const hashPrefix = hash.substring(0, 5);
const hashSuffix = hash.substring(5);

const suffixes = await getSuffixesFor(request, hashPrefix); // eslint-disable-line no-use-before-define

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 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
.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, 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
// if we can't check, just let them use it
return [];
}
}
Loading
Loading