feat: add password change from account page

This commit is contained in:
limes
2026-08-29 19:59:16 +02:00
parent 85d858612f
commit d793cf9726
9 changed files with 257 additions and 13 deletions

8
package-lock.json generated
View File

@@ -19,7 +19,7 @@
"@nuxtjs/i18n": "^10.6.0",
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/error-codes": "^1.2.2",
"@pretendonetwork/grpc": "^2.5.10",
"@pretendonetwork/grpc": "^2.6.1",
"@pretendonetwork/mii-js": "^1.0.11",
"@vueuse/core": "^14.4.0",
"better-sqlite3": "^12.11.1",
@@ -5347,9 +5347,9 @@
}
},
"node_modules/@pretendonetwork/grpc": {
"version": "2.5.10",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.5.10.tgz",
"integrity": "sha512-1nd/nsRU+tdi08O6fHFuYvUJqByJh/qToQxvsDgWonwZLR/RsBFi3o2MU1/4ketbeGu2KmStLN7vBLDcQ+c44w==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@pretendonetwork/grpc/-/grpc-2.6.1.tgz",
"integrity": "sha512-+HKJ8cTV4AV8PJgUqg2Tocz4EJTajqErY9r/Oul8WIZM2lO+0gRPSf3Mlxi9Ghvjq88ZS9pWWccAKtn77ePrew==",
"license": "AGPL-3.0-only",
"dependencies": {
"@bufbuild/protobuf": "^2.2.2",

View File

@@ -25,7 +25,7 @@
"@nuxtjs/i18n": "^10.6.0",
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/error-codes": "^1.2.2",
"@pretendonetwork/grpc": "^2.5.10",
"@pretendonetwork/grpc": "^2.6.1",
"@pretendonetwork/mii-js": "^1.0.11",
"@vueuse/core": "^14.4.0",
"better-sqlite3": "^12.11.1",

View File

@@ -0,0 +1,43 @@
import { ClientError } from 'nice-grpc';
import { PasswordUpdateSchema } from '~~/shared/api-types';
import type { ApiErrorCodes } from '~~/shared/errors';
const bucket = createRatelimitBucket({
id: 'update-password',
points: 15,
durationSec: 5 * 60, // 5 minutes
blockDurationSec: 1 * 60 * 60 // 1 hour
});
const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: Password must be between 6 and 16 characters long': 'INVALID_PASSWORD_LENGTH',
'INVALID_ARGUMENT: Password cannot be the same as username': 'INVALID_PASSWORD_USERNAME',
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'INVALID_PASSWORD_COMBOS',
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'INVALID_PASSWORD_REPEATING',
'INVALID_ARGUMENT: Passwords do not match': 'INVALID_PASSWORD_NO_MATCH',
'INVALID_ARGUMENT: Password is incorrect': 'INVALID_PASSWORD'
};
export default defineEventHandler(async (event): Promise<void> => {
await enforceRatelimit(event, bucket);
const body = await readZodBody(event, PasswordUpdateSchema);
const auth = enforceLoggedIn(event);
const grpc = useApiGrpcWithToken(event, auth.token);
try {
await grpc.updatePassword({
oldPassword: body.oldPassword,
newPassword: body.newPassword,
newPasswordConfirm: body.newPasswordConfirm
});
} catch (error: unknown) {
if (error instanceof ClientError) {
const errorCode = errors[error.details];
if (errorCode) {
throw createApiError(errorCode);
}
}
throw error;
}
});

View File

@@ -21,10 +21,10 @@ const errors: Record<string, ApiErrorCodes> = {
'INVALID_ARGUMENT: Two or more punctuation characters cannot be used in a row': 'USERNAME_INVALID_CHARS',
'INVALID_ARGUMENT: PNID already in use': 'USERNAME_IN_USE',
'INVALID_ARGUMENT: Mii name too long': 'MIINAME_TOO_LONG',
'INVALID_ARGUMENT: Password must be between 6 and 16 characters long': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password cannot be the same as username': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'INVALID_PASSWORD_INPUT',
'INVALID_ARGUMENT: Password must be between 6 and 16 characters long': 'INVALID_PASSWORD_LENGTH',
'INVALID_ARGUMENT: Password cannot be the same as username': 'INVALID_PASSWORD_USERNAME',
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'INVALID_PASSWORD_COMBOS',
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'INVALID_PASSWORD_REPEATING',
'INVALID_ARGUMENT: Passwords do not match': 'INVALID_PASSWORD_NO_MATCH'
};

View File

@@ -123,6 +123,13 @@ export const EmailUpdateSchema = z.object({
});
export type ApiAccountEmailUpdateRequest = z.infer<typeof EmailUpdateSchema>;
export const PasswordUpdateSchema = z.object({
oldPassword: z.string(),
newPassword: z.string(),
newPasswordConfirm: z.string()
});
export type ApiAccountPasswordUpdateRequest = z.infer<typeof PasswordUpdateSchema>;
export const EmailVerifySchema = z.object({
token: z.string()
});

View File

@@ -16,7 +16,10 @@ const apiErrorCodes = {
USERNAME_INVALID_CHARS: 'Username contains invalid characters',
USERNAME_IN_USE: 'PNID already in use',
MIINAME_TOO_LONG: 'Mii name too long',
INVALID_PASSWORD_INPUT: 'Password must be between 6 and 16 characters long',
INVALID_PASSWORD_LENGTH: 'Password must be between 6 and 16 characters long',
INVALID_PASSWORD_USERNAME: 'Password cannot be the same as username',
INVALID_PASSWORD_COMBOS: 'Password must have combination of letters, numbers, and/or punctuation characters',
INVALID_PASSWORD_REPEATING: 'Password may not have 3 repeating characters',
INVALID_PASSWORD_NO_MATCH: 'Passwords do not match',
ACCOUNT_DELETED: 'Account has been deleted',
INVALID_ACCESS_LEVEL: 'Invalid access level',
@@ -48,7 +51,6 @@ export const apiErrorCodeStatus: Record<ApiErrorCodes, number> = {
UNDER_THIRTEEN: 400,
ACCOUNT_DELETED: 400,
INVALID_EMAIL: 400,
INVALID_PASSWORD_INPUT: 400,
INVALID_PASSWORD_NO_MATCH: 400,
MIINAME_TOO_LONG: 400,
USERNAME_IN_USE: 400,
@@ -65,7 +67,11 @@ export const apiErrorCodeStatus: Record<ApiErrorCodes, number> = {
INVALID_MII_DATA: 400,
EMAIL_UNCHANGED: 400,
INVALID_EMAIL_TOKEN: 400,
MISSING_EMAIL_TOKEN: 400
MISSING_EMAIL_TOKEN: 400,
INVALID_PASSWORD_LENGTH: 400,
INVALID_PASSWORD_USERNAME: 400,
INVALID_PASSWORD_COMBOS: 400,
INVALID_PASSWORD_REPEATING: 400
};
export function getTextForApiErrorCode(code: ApiErrorCodes): string {

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { Dialog } from 'reka-ui/namespaced';
import type { ApiAccountPasswordUpdateRequest } from '~~/shared/api-types';
const toasts = useToasts();
const authUtils = useAuthUtils();
const { t } = useI18n();
const { dialogContainer } = defineProps<{
dialogContainer: HTMLElement | null;
}>();
const open = defineModel<boolean>();
const oldPassword = ref('');
const newPassword = ref('');
const newPasswordConfirm = ref('');
const hasRequiredInfo = computed(() => {
return oldPassword.value && newPassword.value && newPasswordConfirm.value;
});
const emit = defineEmits<{
change: [];
}>();
const {
execute: executeUpdatePassword,
isLoading: isLoadingUpdatePassword
} = useAsync({
async handler() {
await apiFetch('/api/account/update-password', {
method: 'PATCH',
body: {
oldPassword: oldPassword.value,
newPassword: newPassword.value,
newPasswordConfirm: newPasswordConfirm.value
} satisfies ApiAccountPasswordUpdateRequest
});
open.value = false;
toasts.publish({
type: 'success',
text: t('account.settings.passwordModal.successNotice')
});
emit('change');
authUtils.logout();
},
onError(error) {
const err = getApiError(error);
toasts.publish({
type: 'error',
text: err.message
});
}
});
</script>
<template>
<Dialog.Root
v-model:open="open"
>
<Dialog.Portal :to="dialogContainer ?? undefined">
<Dialog.Overlay />
<Dialog.Content
class="modal"
@interact-outside="(e) => {
if (isLoadingUpdatePassword) {
return e.preventDefault();
}
}"
>
<Dialog.Title>
{{ $t("account.settings.passwordModal.title") }}.
</Dialog.Title>
<Dialog.Description class="modal-caption">
<p>
{{
$t("account.settings.settingCards.passwordResetNotice")
}}
</p>
</Dialog.Description>
<div class="fieldsets-container">
<fieldset class="account-edit">
<Label for="old-password">{{ $t("account.settings.passwordModal.oldPassword") }}</Label>
<input
id="old-password"
v-model="oldPassword"
name="old-password"
type="password"
autocomplete="current-password"
required
>
</fieldset>
<fieldset class="account-edit">
<Label for="new-password">{{ $t("account.settings.passwordModal.newPassword") }}</Label>
<input
id="new-password"
v-model="newPassword"
name="new-password"
type="password"
autocomplete="new-password"
passwordrules="minlength: 6; maxlength: 16; max-consecutive: 2; allowed: [-!-~];"
pattern="[-!-~]{6,16}"
required
>
</fieldset>
<fieldset class="account-edit">
<Label for="new-password-confirm">{{ $t("account.settings.passwordModal.newPasswordConfirm") }}</Label>
<input
id="new-password-confirm"
v-model="newPasswordConfirm"
name="new-password-confirm"
type="password"
autocomplete="new-password"
passwordrules="minlength: 6; maxlength: 16; max-consecutive: 2; allowed: [-!-~];"
pattern="[-!-~]{6,16}"
required
>
</fieldset>
</div>
<div class="modal-button-wrapper">
<Dialog.Close
class="cancel"
:disabled="isLoadingUpdatePassword"
>
{{ $t("modals.cancel") }}
</Dialog.Close>
<button
:class="{action: true, disabled: isLoadingUpdatePassword || !hasRequiredInfo }"
:disabled="isLoadingUpdatePassword || !hasRequiredInfo"
@click="executeUpdatePassword"
>
<Loader v-if="isLoadingUpdatePassword" />
<span v-else>{{
$t("modals.confirm")
}}</span>
</button>
</div>
</Dialog.Content>
</Dialog.Portal><Dialog.Trigger class="update-trigger">
{{ $t('account.settings.update') }}
</Dialog.Trigger>
</Dialog.Root>
</template>
<style scoped>
.fieldsets-container {
display: grid;
grid-auto-flow: row;
grid-auto-rows: 1fr;
gap: 1rem
}
.update-trigger {
all: unset;
color: var(--accent-shade-1);
font-weight: bold;
cursor: pointer;
}
.update-trigger:hover {
background: none;
text-decoration: underline;
}
fieldset {
position: relative;
display: flex;
flex-flow: column;
height: min-content;
padding: 0;
gap: .25rem;
border: none;
}
</style>

View File

@@ -271,6 +271,14 @@
"caption": "To verify your email address, click on the link in the verification email.",
"lostEmail": "Can't find it?",
"resend": "Resend verification email"
},
"passwordModal": {
"title": "Change password",
"caption": "Input your new password.",
"oldPassword": "Current password",
"newPassword": "New password",
"newPasswordConfirm": "Confirm new password",
"successNotice": "Password updated successfully"
}
},
"accountLevel": [

View File

@@ -66,6 +66,7 @@ const dialogContainer = ref(null);
const deleteModalOpen = ref(false);
const profileEditModalOpen = ref(false);
const emailEditModalOpen = ref(false);
const passwordEditModalOpen = ref(false);
const selectedServerEnv = ref<'dev' | 'test' | 'prod' | undefined>(
profile.value?.serverAccessLevel
);
@@ -467,6 +468,10 @@ useHead({
<p class="value">
●●●●●●●●
</p>
<UpdatePasswordModal
v-model="passwordEditModalOpen"
:dialog-container="dialogContainer"
/>
</li>
</ul>
</div>
@@ -544,7 +549,8 @@ useHead({
hidden: !(
deleteModalOpen ||
profileEditModalOpen ||
emailEditModalOpen
emailEditModalOpen ||
passwordEditModalOpen
),
}"
>