Skip to content

Commit

Permalink
chore: create account change password form
Browse files Browse the repository at this point in the history
  • Loading branch information
hywax committed Aug 5, 2024
1 parent 1a4710d commit a27ae4f
Show file tree
Hide file tree
Showing 15 changed files with 222 additions and 3 deletions.
85 changes: 85 additions & 0 deletions apps/web/app/components/account/ChangePasswordForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<template>
<UForm
ref="form"
:state="state"
:schema="accountChangePasswordSchema"
@submit="onSubmit"
>
<UPageCard
:title="$t('account.changePassword.title')"
:description="$t('account.changePassword.description')"
>
<div class="space-y-4">
<UFormGroup
:label="$t('account.changePassword.form.currentPassword.label')"
name="currentPassword"
required
>
<UInput
v-model="state.currentPassword"
size="md"
type="password"
:placeholder="$t('account.changePassword.form.currentPassword.placeholder')"
/>
</UFormGroup>
<UFormGroup
:label="$t('account.changePassword.form.newPassword.label')"
name="newPassword"
required
>
<UInput
v-model="state.newPassword"
size="md"
type="password"
:placeholder="$t('account.changePassword.form.newPassword.placeholder')"
/>
</UFormGroup>
<UFormGroup
:label="$t('account.changePassword.form.confirmPassword.label')"
name="confirmPassword"
required
>
<UInput
v-model="state.confirmPassword"
size="md"
type="password"
:placeholder="$t('account.changePassword.form.confirmPassword.placeholder')"
/>
</UFormGroup>
</div>
<template #footer-left>
<UButton size="lg" type="submit" :loading="status === 'pending'">
{{ $t('account.changePassword.form.action.change') }}
</UButton>
</template>
</UPageCard>
</UForm>
</template>

<script setup lang="ts">
import type { Form } from '#ui/types'
import { type AccountChangePasswordSchema, accountChangePasswordSchema } from '#schema'
const form = ref<Form<AccountChangePasswordSchema>>()
const state = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: '',
})
const toast = useToast()
const { status, execute: onSubmit } = useAPI('/api/account/change-password', {
method: 'PUT',
body: state,
immediate: false,
watch: false,
onResponse: async ({ response }) => {
if (response.ok) {
toast.add({
color: 'green',
description: $t('account.changePassword.form.action.changed'),
})
}
},
})
</script>
2 changes: 1 addition & 1 deletion apps/web/app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<main class="flex-1 relative">
<NuxtErrorBoundary>
<template #error="{ error }">
<UPageError :error="error" class="min-h-full" />
<UPageError :error="error" class="min-h-[calc(100vh-129px)]" />
</template>

<slot />
Expand Down
25 changes: 25 additions & 0 deletions apps/web/app/locales/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ export default {
400000: 'Bad request',
400001: 'Invalid user data',
400002: 'Invalid email or password',
400003: 'Invalid password data for change',
400004: 'Passwords do not match',
401000: 'Unauthorized',
403000: 'Forbidden',
403001: 'Access only for unauthorized users',
403002: 'Access only for authorized users',
403003: 'Access only for admin',
403004: 'Registration is disabled',
404000: 'Not found',
404001: 'User not found',
409001: 'User with this email already exists',
500000: 'Internal server error',
501000: 'Not implemented',
Expand All @@ -46,6 +49,28 @@ export default {
settings: 'Settings',
signOut: 'Sign Out',
},
changePassword: {
title: 'Change Password',
description: 'Update your password to keep your account secure.',
form: {
currentPassword: {
label: 'Current Password',
placeholder: 'Enter your current password',
},
newPassword: {
label: 'New Password',
placeholder: 'Enter your new password',
},
confirmPassword: {
label: 'Confirm Password',
placeholder: 'Enter your new password again',
},
action: {
change: 'Change Password',
changed: 'Password changed successfully',
},
},
},
},
auth: {
login: {
Expand Down
25 changes: 25 additions & 0 deletions apps/web/app/locales/ru-RU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ export default {
400000: 'Неверный запрос',
400001: 'Неверные данные пользователя',
400002: 'Неверный email или пароль',
400003: 'Неверные данные для смены пароля',
400004: 'Пароли не совпадают',
401000: 'Не авторизован',
403000: 'Доступ запрещен',
403001: 'Доступ только для неавторизованных пользователей',
403002: 'Доступ только для авторизованных пользователей',
403003: 'Доступ только для администраторов',
403004: 'Регистрация отключена',
404000: 'Страница не найдена',
404001: 'Пользователь не найден',
409001: 'Пользователь с таким email уже существует',
500000: 'Внутренняя ошибка сервера',
501000: 'Не реализовано',
Expand All @@ -46,6 +49,28 @@ export default {
settings: 'Настройки',
signOut: 'Выйти',
},
changePassword: {
title: 'Изменить пароль',
description: 'Обновите свой пароль, чтобы обеспечить безопасность вашего аккаунта.',
form: {
currentPassword: {
label: 'Текущий пароль',
placeholder: 'Введите ваш текущий пароль',
},
newPassword: {
label: 'Новый пароль',
placeholder: 'Введите ваш новый пароль',
},
confirmPassword: {
label: 'Подтвердите пароль',
placeholder: 'Введите ваш новый пароль еще раз',
},
action: {
change: 'Изменить пароль',
changed: 'Пароль успешно изменен',
},
},
},
},
auth: {
login: {
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/pages/account/security.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div>
Security not implemented
<div class="grid gap-5">
<AccountChangePasswordForm />
</div>
</template>

Expand Down
11 changes: 11 additions & 0 deletions apps/web/app/schema/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { z } from 'zod'

export const accountChangePasswordSchema = z.object({
currentPassword: z.string().min(6),
newPassword: z.string().min(6),
confirmPassword: z.string().min(6),
}).refine((data) => data.newPassword === data.confirmPassword, {
path: ['confirmPassword'],
})

export type AccountChangePasswordSchema = z.input<typeof accountChangePasswordSchema>
1 change: 1 addition & 0 deletions apps/web/app/schema/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './auth'
export * from './account'
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"nanoid": "^5.0.7",
"nuxt-auth-utils": "^0.3.2",
"nuxt-zod-i18n": "^1.9.0",
"scule": "^1.3.0",
"vue": "^3.4.33",
"zod": "^3.23.8"
},
Expand Down
20 changes: 20 additions & 0 deletions apps/web/server/api/account/change-password.put.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ERROR_ACCOUNT_PASSWORD_INVALID_DATA, ERROR_ACCOUNT_PASSWORD_NOT_MATCH } from '#constants/errors'
import { accountChangePassword } from '#services/account'

export default defineEventHandler(async (event) => {
try {
const data = await readBody(event)
const session = await getUserSession(event)

await accountChangePassword({
userId: session.user!.id,
currentPassword: data.currentPassword,
newPassword: data.newPassword,
})
} catch (e) {
throw errorResolver(e, {
ZOD: ERROR_ACCOUNT_PASSWORD_INVALID_DATA,
ERROR_INVALID_PASSWORD: ERROR_ACCOUNT_PASSWORD_NOT_MATCH,
})
}
})
3 changes: 3 additions & 0 deletions apps/web/server/constants/errors.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
export const ERROR_BAD_REQUEST = '400000: Bad request'
export const ERROR_USER_INVALID_DATA = '400001: Invalid user data'
export const ERROR_USER_INVALID_CREDENTIALS = '400002: Invalid email or password'
export const ERROR_ACCOUNT_PASSWORD_INVALID_DATA = '400003: Invalid password data'
export const ERROR_ACCOUNT_PASSWORD_NOT_MATCH = '400004: Passwords do not match'
export const ERROR_UNAUTHORIZED = '401000: Unauthorized'
export const ERROR_FORBIDDEN = '403000: Forbidden'
export const ERROR_ACCESS_ONLY_GUEST = '403001: Access only for guest'
export const ERROR_ACCESS_ONLY_AUTHORIZED = '403002: Access only for authorized users'
export const ERROR_ACCESS_ONLY_ADMIN = '403003: Access only for admin'
export const ERROR_REGISTRATION_DISABLED = '403004: Registration is disabled'
export const ERROR_NOT_FOUND = '404000: Not found'
export const ERROR_USER_NOT_FOUND = '404001: User not found'
export const ERROR_USER_ALL_READY_EXISTS = '409001: User with this email already exists'
export const ERROR_INTERNAL_ERROR = '500000: Internal server error'
export const ERROR_NOT_IMPLEMENTED = '501000: Not implemented'
1 change: 1 addition & 0 deletions apps/web/server/middleware/01.access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default defineEventHandler(async (event) => {
]
const protectedRoutes: string[] = [
'/api/users*',
'/api/account*',
]

const session = await getUserSession(event)
Expand Down
39 changes: 39 additions & 0 deletions apps/web/server/services/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { z } from 'zod'
import bcrypt from 'bcrypt'
import { eq } from 'drizzle-orm'
import { tables, useDatabase } from '#db'
import type { User } from '#db'

interface AccountChangePasswordData {
userId: User['id']
currentPassword: string
newPassword: string
}

export async function accountChangePassword(data: AccountChangePasswordData): Promise<void> {
const db = useDatabase()
const changePasswordSchema = z.object({
userId: z.string(),
currentPassword: z.string().min(6),
newPassword: z.string().min(6),
})

const { userId, currentPassword, newPassword } = changePasswordSchema.parse(data)
const user = await db.query.users.findFirst({
where: (user, { eq }) => eq(user.id, userId),
})

if (!user) {
throw new Error('User not found')
}

const isMatch = await bcrypt.compare(currentPassword, user.password)

if (!isMatch) {
throw new Error('Invalid password')
}

await db.update(tables.users)
.set({ password: await bcrypt.hash(newPassword, 10) })
.where(eq(tables.users.id, userId))
}
5 changes: 5 additions & 0 deletions apps/web/server/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ function getErrorCode(exception: unknown, codes: ErrorMapCodes): string {
errorString = codesMap.ZOD
} else if (exception instanceof SqliteError) {
errorString = Object.hasOwn(codesMap, exception.code) ? codesMap[exception.code]! : codesMap.SQLITE
} else if (exception instanceof Error) {
const normalizedMessage = exception.message.trim().replaceAll(' ', '_').toUpperCase()
const errorMaybeCode = `ERROR_${normalizedMessage}`

errorString = Object.hasOwn(codesMap, errorMaybeCode) ? codesMap[errorMaybeCode]! : codesMap.DEFAULT
} else {
errorString = codesMap.DEFAULT
}
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit a27ae4f

Please sign in to comment.