mirror of
https://github.com/PretendoNetwork/website.git
synced 2026-09-13 19:37:37 -05:00
Merge pull request #457 from PretendoNetwork/feat/password-editing
Feat: password editing
This commit is contained in:
@@ -67,7 +67,7 @@ services:
|
||||
volumes:
|
||||
- "./assets/garage-init.sh:/etc/init.sh"
|
||||
account:
|
||||
image: ghcr.io/pretendonetwork/account:sha-34dc075
|
||||
image: ghcr.io/pretendonetwork/account:sha-b857d73
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- net
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
43
server/api/account/update-password.patch.ts
Normal file
43
server/api/account/update-password.patch.ts
Normal 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': 'PASSWORD_INVALID_LENGTH',
|
||||
'INVALID_ARGUMENT: Password cannot be the same as username': 'PASSWORD_NOT_USERNAME',
|
||||
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'PASSWORD_NEEDS_CHARS',
|
||||
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'PASSWORD_REPEATED_CHARS',
|
||||
'INVALID_ARGUMENT: Passwords do not match': 'PASSWORDS_DO_NOT_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;
|
||||
}
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const errors: Record<string, ApiErrorCodes> = {
|
||||
'INVALID_ARGUMENT: Password must have combination of letters, numbers, and/or punctuation characters': 'PASSWORD_NEEDS_CHARS',
|
||||
'INVALID_ARGUMENT: Password may not have 3 repeating characters': 'PASSWORD_REPEATED_CHARS',
|
||||
'INVALID_ARGUMENT: Passwords do not match': 'PASSWORDS_DO_NOT_MATCH'
|
||||
|
||||
};
|
||||
|
||||
function getCutoffDateForAge(today: Date, age: number) {
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
176
src/components/UpdatePasswordModal.vue
Normal file
176
src/components/UpdatePasswordModal.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<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')
|
||||
});
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
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>
|
||||
@@ -272,6 +272,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"
|
||||
}
|
||||
},
|
||||
"emailVerification": {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -477,6 +478,10 @@ customSeoMeta({ subsection: 'account' });
|
||||
<p class="value">
|
||||
●●●●●●●●
|
||||
</p>
|
||||
<UpdatePasswordModal
|
||||
v-model="passwordEditModalOpen"
|
||||
:dialog-container="dialogContainer"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -554,7 +559,8 @@ customSeoMeta({ subsection: 'account' });
|
||||
hidden: !(
|
||||
deleteModalOpen ||
|
||||
profileEditModalOpen ||
|
||||
emailEditModalOpen
|
||||
emailEditModalOpen ||
|
||||
passwordEditModalOpen
|
||||
),
|
||||
}"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user