Skip to content

Commit

Permalink
feat!: password reset
Browse files Browse the repository at this point in the history
  • Loading branch information
hywax committed Aug 10, 2024
1 parent 64d2432 commit b828223
Show file tree
Hide file tree
Showing 19 changed files with 600 additions and 25 deletions.
17 changes: 16 additions & 1 deletion apps/web/app/components/auth/ForgotForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@
{{ $t('auth.form.action.forgot') }}
</UButton>
</UForm>

<div class="mt-4 text-sm text-gray-600 dark:text-gray-500 text-center">
<I18nT keypath="auth.links.login" scope="global">
<template #link>
<ULink to="/auth/login" class="text-primary hover:underline">
{{ $t('auth.login.title') }}
</ULink>
</template>
</I18nT>
</div>
</div>
</template>

Expand All @@ -31,14 +41,19 @@ import { type AuthForgotSchema, authForgotSchema } from '#schema'
const form = ref<Form<AuthForgotSchema>>()
const state = reactive<AuthForgotSchema>({
email: 'test@ta.ru',
email: '',
})
const { status, execute: onSubmit } = useAPI('/api/auth/forgot', {
method: 'post',
body: state,
immediate: false,
watch: false,
onResponse: async ({ response }) => {
if (response.ok) {
await navigateTo('/auth/reset')
}
},
})
const { onChangeLocale } = useI18nUtils()
Expand Down
62 changes: 62 additions & 0 deletions apps/web/app/components/auth/ResetForm.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<template>
<div>
<h1 class="text-3xl font-semibold mb-6 text-black dark:text-white text-center">
{{ $t('auth.reset.title') }}
</h1>
<p class="text-sm mb-6 text-gray-500 dark:text-gray-400 text-center">
{{ $t('auth.reset.description') }}
</p>

<UForm
ref="form"
class="space-y-4"
:state="state"
:schema="authResetSchema"
@submit="onSubmit"
>
<UFormGroup :label="$t('auth.form.token.label')" name="token" required>
<UInput v-model="state.token" type="text" size="md" :placeholder="$t('auth.form.token.placeholder')" />
</UFormGroup>

<UFormGroup :label="$t('auth.form.password.label')" name="password" required>
<UInput v-model="state.password" type="password" size="md" :placeholder="$t('auth.form.password.placeholder')" />
</UFormGroup>

<UButton type="submit" size="md" :loading="status === 'pending'" block>
{{ $t('auth.form.action.reset') }}
</UButton>
</UForm>
</div>
</template>

<script setup lang="ts">
import type { Form } from '#ui/types'
import { type AuthResetSchema, authResetSchema } from '#schema'
const route = useRoute()
const form = ref<Form<AuthResetSchema>>()
const state = reactive<AuthResetSchema>({
token: (route.query?.token as string) || '',
password: '',
})
const { fetch: refreshSession } = useUserSession()
const { status, execute: onSubmit } = useAPI('/api/auth/reset', {
method: 'post',
body: state,
immediate: false,
watch: false,
onResponse: async ({ response }) => {
if (response.ok) {
await refreshSession()
await navigateTo('/')
}
},
})
const { onChangeLocale } = useI18nUtils()
onChangeLocale(() => {
form.value?.clear()
})
</script>
13 changes: 12 additions & 1 deletion apps/web/app/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
"400004": "Passwords do not match",
"400005": "Invalid account general data",
"400006": "Invalid account delete data",
"400007": "Token expired",
"400008": "Invalid token",
"401000": "Unauthorized",
"403000": "Forbidden",
"403001": "Access only for unauthorized users",
Expand Down Expand Up @@ -166,6 +168,10 @@
"title": "Forgot Password",
"description": "Enter your email and we’ll send you a code you can use to update your password."
},
"reset": {
"title": "Reset Password",
"description": "Enter the token you received via email and the new password."
},
"form": {
"name": {
"label": "Name",
Expand All @@ -179,10 +185,15 @@
"label": "Password",
"placeholder": "Enter your password"
},
"token": {
"label": "Token Code",
"placeholder": "Enter your token code"
},
"action": {
"login": "Log In",
"register": "Sign Up",
"forgot": "Send Code"
"forgot": "Send Code",
"reset": "Reset Password"
}
},
"errors": {
Expand Down
13 changes: 12 additions & 1 deletion apps/web/app/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
"400004": "Пароли не совпадают",
"400005": "Неверные данные аккаунта",
"400006": "Неверные данные для удаления аккаунта",
"400007": "Время действия токена истекло",
"400008": "Неверный токен",
"401000": "Не авторизован",
"403000": "Доступ запрещен",
"403001": "Доступ только для неавторизованных пользователей",
Expand Down Expand Up @@ -166,6 +168,10 @@
"title": "Восстановление пароля",
"description": "Введите свой адрес электронной почты, и мы отправим вам код, который вы можете использовать для обновления пароля."
},
"reset": {
"title": "Сброс пароля",
"description": "Введите код, который вы получили по электронной почте и новый пароль."
},
"form": {
"name": {
"label": "Имя",
Expand All @@ -179,10 +185,15 @@
"label": "Пароль",
"placeholder": "Введите ваш пароль"
},
"token": {
"label": "Код",
"placeholder": "Введите код"
},
"action": {
"login": "Войти",
"register": "Зарегистрироваться",
"forgot": "Отправить код"
"forgot": "Отправить код",
"reset": "Сбросить пароль"
}
},
"errors": {
Expand Down
14 changes: 14 additions & 0 deletions apps/web/app/pages/auth/reset.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<AuthResetForm />
</template>

<script setup lang="ts">
definePageMeta({
layout: 'auth',
middleware: ['guest'],
})
useHead({
title: () => $t('auth.reset.title'),
})
</script>
6 changes: 6 additions & 0 deletions apps/web/app/schema/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ export const authForgotSchema = z.object({
email: z.string().email(),
})

export const authResetSchema = z.object({
password: z.string().min(6),
token: z.string(),
})

export type AuthLoginSchema = z.input<typeof authLoginSchema>
export type AuthRegisterSchema = z.input<typeof authRegisterSchema>
export type AuthForgotSchema = z.input<typeof authForgotSchema>
export type AuthResetSchema = z.input<typeof authResetSchema>
48 changes: 28 additions & 20 deletions apps/web/server/api/auth/forgot.post.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { ERROR_EMAIL_CREDENTIALS, ERROR_NOT_IMPLEMENTED } from '#constants/errors'
import { ERROR_EMAIL_CREDENTIALS, ERROR_USER_INVALID_DATA, ERROR_USER_NOT_FOUND } from '#constants/errors'
import { createPasswordReset, findUserByEmail } from '#core/services/user'
import { useEmail } from '#core/email'

export default defineEventHandler(() => {
/**
* This route should email the user's email with a link to the password reset page.
* After clicking the link, the user should enter a new password.
*/
throw errorResolver({}, {
DEFAULT: ERROR_NOT_IMPLEMENTED,
EMAIL_BAD_CREDENTIALS: ERROR_EMAIL_CREDENTIALS,
})
export default defineEventHandler(async (event) => {
try {
const config = useRuntimeConfig(event)
const { send } = useEmail()

// const { send } = useEmail()
// send({
// to: '',
// subject: 'Change password',
// template: 'change-password',
// params: {
// resetUrl: '',
// emailTo: '',
// },
// })
const data = await readBody(event)
const user = await findUserByEmail(data.email)
const passwordReset = await createPasswordReset(user.id)

await send({
to: user.email,
subject: 'Change password',
template: 'change-password',
params: {
resetUrl: `${config.baseUrl}/auth/reset?token=${passwordReset.token}`,
emailTo: user.email,
token: passwordReset.token,
},
})
} catch (e) {
throw errorResolver(e, {
ERROR_USER_NOT_FOUND,
ZOD: ERROR_USER_INVALID_DATA,
EMAIL_BAD_CREDENTIALS: ERROR_EMAIL_CREDENTIALS,
})
}
})
19 changes: 19 additions & 0 deletions apps/web/server/api/auth/reset.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ERROR_TOKEN_EXPIRED, ERROR_TOKEN_INVALID_DATA } from '#constants/errors'
import { resetPassword, validateUserPasswordResetToken } from '#core/services/user'
import { getProjectsAvailableList } from '#core/services/project'

export default defineEventHandler(async (event) => {
try {
const data = await readBody(event)
const user = await validateUserPasswordResetToken(data.token)

await resetPassword(user.id, data.password)
const projects = await getProjectsAvailableList(user.id)
await setUserSession(event, { user, projects })
} catch (e) {
throw errorResolver(e, {
ERROR_TOKEN_EXPIRED,
ZOD: ERROR_TOKEN_INVALID_DATA,
})
}
})
2 changes: 2 additions & 0 deletions apps/web/server/constants/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const ERROR_ACCOUNT_PASSWORD_INVALID_DATA = '400003: Invalid password dat
export const ERROR_ACCOUNT_PASSWORD_NOT_MATCH = '400004: Passwords do not match'
export const ERROR_ACCOUNT_GENERAL_INVALID_DATA = '400005: Invalid account general data'
export const ERROR_ACCOUNT_DELETE_INVALID_DATA = '400006: Invalid account delete data'
export const ERROR_TOKEN_EXPIRED = '400007: Token expired'
export const ERROR_TOKEN_INVALID_DATA = '400008: Invalid token data'
export const ERROR_UNAUTHORIZED = '401000: Unauthorized'
export const ERROR_FORBIDDEN = '403000: Forbidden'
export const ERROR_ACCESS_ONLY_GUEST = '403001: Access only for guest'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE `password_resets` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`token` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch()),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE cascade ON DELETE cascade
);
Loading

0 comments on commit b828223

Please sign in to comment.