diff --git a/.gitignore b/.gitignore index 7494fa5..8c7f27b 100644 --- a/.gitignore +++ b/.gitignore @@ -112,4 +112,6 @@ fabric.properties .idea/httpRequests # Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser \ No newline at end of file +.idea/caches/build_file_checksums.ser +/prisma/_____migrations___/ +/resources/images/ diff --git a/actions/login.ts b/actions/login.ts index 3473df1..d3795ac 100644 --- a/actions/login.ts +++ b/actions/login.ts @@ -43,12 +43,12 @@ export const login = async (values: zInfer) => { return { error: 'common.something_went_wrong' } } } - + // TODO: logging must be implemented throw error } } -export const SignInProvider = async (provider: 'google' | 'github' | 'facebook') => { +export const SignInProvider = async (provider: 'google' | 'github') => { await signIn(provider, { redirectTo: DEFAULT_LOGIN_REDIRECT, }) diff --git a/actions/new-password.ts b/actions/new-password.ts new file mode 100644 index 0000000..35f4ff5 --- /dev/null +++ b/actions/new-password.ts @@ -0,0 +1,66 @@ +'use server' + +import { NewPasswordSchema } from '@/schemas' +import { infer as zInfer } from 'zod' +import bcrypt from 'bcryptjs' +import { PASSWORD_SALT_LENGTH } from '@/config/validation' + +import { getPasswordResetTokenByToken } from '@/data/password-reset-token' +import { getUserByEmail } from '@/data/user' +import db from '@/lib/db' + +export const newPassword = async (values: zInfer, token?: string | null) => { + if (!token) { + return { error: 'auth.form.error.missing_token' } + } + + const validatedFields = NewPasswordSchema.safeParse(values) + + if (!validatedFields.success) { + return { error: 'auth.form.error.invalid_fields' } + } + + const existingToken = await getPasswordResetTokenByToken(token) + + if (!existingToken) { + return { error: 'auth.form.error.invalid_token' } + } + + const hasExpired = new Date(existingToken.expires) < new Date() + + if (hasExpired) { + return { error: 'auth.form.error.expired_token' } + } + + const existingUser = await getUserByEmail(existingToken.email) + + if (!existingUser) { + return { error: 'auth.form.error.invalid_email' } + } + + const { password } = validatedFields.data + const hashedPassword = await bcrypt.hash(password, PASSWORD_SALT_LENGTH) + + try { + await db.user.update({ + where: { id: existingUser.id }, + data: { password: hashedPassword }, + }) + } catch (err) { + console.error(err) + return { error: 'db.error.update.user_password' } + } + + try { + await db.passwordResetToken.delete({ + where: { id: existingToken.id }, + }) + + return { success: 'db.success.update.password_updated' } + } catch (err) { + //TODO: Implement logging + console.error(err) + } + + return { error: 'db.error.common.something_wrong' } +} \ No newline at end of file diff --git a/actions/register.ts b/actions/register.ts index 89268a4..a2f3a21 100644 --- a/actions/register.ts +++ b/actions/register.ts @@ -1,9 +1,8 @@ 'use server' +import { RegisterSchema } from '@/schemas' import { infer as zInfer } from 'zod' import bcrypt from 'bcryptjs' - -import { RegisterSchema } from '@/schemas' import { PASSWORD_SALT_LENGTH } from '@/config/validation' import db from '@/lib/db' import { getUserByEmail } from '@/data/user' diff --git a/actions/reset.ts b/actions/reset.ts new file mode 100644 index 0000000..a236e33 --- /dev/null +++ b/actions/reset.ts @@ -0,0 +1,24 @@ +'use server' + +import { infer as zInfer } from 'zod' +import { ResetSchema } from '@/schemas' +import { getUserByEmail } from '@/data/user' +import { sendPasswordResetEmail } from '@/actions/send-verification-email' + +export const reset = async (values: zInfer) => { + const validatedFields = ResetSchema.safeParse(values) + + if (!validatedFields.success) { + return { error: 'auth.form.error.invalid_fields' } + } + + const { email } = validatedFields.data + + const existingUser = await getUserByEmail(email) + + if (!existingUser) { + return { error: 'auth.email.success.reset_email_sent' } + } + + return await sendPasswordResetEmail(existingUser.email as string, existingUser.name) +} \ No newline at end of file diff --git a/actions/send-verification-email.ts b/actions/send-verification-email.ts index d2901db..aad876f 100644 --- a/actions/send-verification-email.ts +++ b/actions/send-verification-email.ts @@ -1,18 +1,28 @@ 'use server' import mailer from '@/lib/mailer' -import { AUTH_USER_VERIFICATION_URL } from '@/config/routes' -import { generateVerificationToken } from '@/lib/tokens' +import { AUTH_NEW_PASSWORD_URL, AUTH_USER_VERIFICATION_URL } from '@/config/routes' +import { generatePasswordResetToken, generateVerificationToken } from '@/lib/tokens' import { env } from '@/lib/utils' +import { __ct } from '@/lib/translate' +import { body } from '@/templates/email/send-verification-email' -const sendVerificationEmail = async (email: string, name?: string | null) => { +const sendVerificationEmail = async ( + email: string, + name?: string | null, +) => { const verificationToken = await generateVerificationToken(email) - const confirmLink: string = [env('SITE_URL'), AUTH_USER_VERIFICATION_URL, verificationToken.token].join('') + const confirmLink: string = [env('SITE_URL'), AUTH_USER_VERIFICATION_URL, '/', verificationToken.token].join('') + const message = (await body({ confirmLink })) const { isOk, code, info, error } = await mailer({ to: name ? { name: name?.toString(), address: verificationToken.email } : verificationToken.email, - subject: 'Complete email verification for A-Naklejka', - html: `

Click here to confirm email

`, + subject: await __ct({ + key: 'mailer.subject.send_verification_email', + params: { site_name: env('SITE_NAME') }, + }), + text: message?.text, + html: message?.html, }) if (isOk) { @@ -22,4 +32,24 @@ const sendVerificationEmail = async (email: string, name?: string | null) => { } } -export { sendVerificationEmail } \ No newline at end of file +const sendPasswordResetEmail = async ( + email: string, + name?: string | null, +) => { + const resetToken = await generatePasswordResetToken(email) + const resetLink: string = [env('SITE_URL'), AUTH_NEW_PASSWORD_URL, '/', resetToken.token].join('') + + const { isOk, code, info, error } = await mailer({ + to: name ? { name: name?.toString(), address: resetToken.email } : resetToken.email, + subject: 'Reset your password at A-Naklejka', + html: `

Click here to reset password

`, + }) + + if (isOk) { + return { success: code === 250 ? 'auth.email.success.reset_email_sent' : info?.response } + } else { + return { error: env('DEBUG') === 'true' ? error?.response : 'auth.email.error.reset_password_sending_error' } + } +} + +export { sendVerificationEmail, sendPasswordResetEmail } \ No newline at end of file diff --git a/actions/user-verification.ts b/actions/user-verification.ts index afced33..7f047f1 100644 --- a/actions/user-verification.ts +++ b/actions/user-verification.ts @@ -25,7 +25,7 @@ export const userVerification = async (token: string) => { }) } catch (e) { console.error(e) - return { error: 'Could not update user data! Please, try again by reloading page!' } + return { error: 'db.error.update.user_data' } } try { diff --git a/app/[locale]/(root)/(routes)/about/page.tsx b/app/[locale]/(root)/(routes)/about/page.tsx index b269573..be7d52c 100644 --- a/app/[locale]/(root)/(routes)/about/page.tsx +++ b/app/[locale]/(root)/(routes)/about/page.tsx @@ -6,9 +6,7 @@ export const metadata: Metadata = { } const AboutPage = () => { - return <>About -
- + return <>ABOUT } export default AboutPage diff --git a/app/[locale]/(root)/page.tsx b/app/[locale]/(root)/page.tsx index 47fe4bd..1099127 100644 --- a/app/[locale]/(root)/page.tsx +++ b/app/[locale]/(root)/page.tsx @@ -1,8 +1,11 @@ import { Poppins } from 'next/font/google' import { getScopedI18n } from '@/locales/server' -import { cn, env } from '@/lib/utils' +import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import LoginButton from '@/components/auth/login-button' +import Image from 'next/image' +import wolf from '@/img/Gray wolf portrait.jpg' +import { Grid } from 'react-loader-spinner' const font = Poppins({ subsets: ['latin'], weight: ['600'], @@ -18,10 +21,10 @@ export default async function Home () { 🔐 {t('title')}

{t('subtitle')}

+ Picture of a wolf
-
diff --git a/app/[locale]/auth/new-password/[token]/page.tsx b/app/[locale]/auth/new-password/[token]/page.tsx new file mode 100644 index 0000000..8ea4d6a --- /dev/null +++ b/app/[locale]/auth/new-password/[token]/page.tsx @@ -0,0 +1,5 @@ +import { NewPasswordForm } from '@/components/auth/new-password-form' + +export default function NewPasswordPage ({ params }: { params: { token: string } }) { + return +} \ No newline at end of file diff --git a/app/[locale]/auth/reset/page.tsx b/app/[locale]/auth/reset/page.tsx new file mode 100644 index 0000000..45ff08f --- /dev/null +++ b/app/[locale]/auth/reset/page.tsx @@ -0,0 +1,5 @@ +import { ResetForm } from '@/components/auth/reset-form' + +const ResetPage = () => + +export default ResetPage diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css index 451d5ef..2de449b 100644 --- a/app/[locale]/globals.css +++ b/app/[locale]/globals.css @@ -8,6 +8,20 @@ body, height: 100%; } +input[aria-invalid='true'], input:not(:placeholder-shown):invalid { + + color: hsl(0 84.2% 60.2%); + border: 1px solid rgb(239, 68, 68); + outline-color: hsl(0 84.2% 92.2%) !important; +} + +input[aria-invalid='false']:not(:placeholder-shown) { + + color: hsl(140.8 53.1% 53.1%); + border: 1px solid rgb(72, 199, 116); + outline-color: hsl(140.8 53.1% 92.2%) !important; +} + @layer base { :root { --background: 0 0% 100%; diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index e56506c..68255ed 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -4,6 +4,7 @@ import './globals.css' import { ReactElement } from 'react' import { I18nProviderClient } from '@/locales/client' import { lc } from '@/lib/utils' +import { Loading } from '@/components/loading' const inter = Inter({ subsets: ['cyrillic'] }) @@ -19,11 +20,13 @@ export default function RootLayout ({ params: { locale }, children, }: Readonly) { - return ( + return ( + {/*}>*/} - Loading...

}> + }> {children} + {/*
*/} ) } diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..7c95284 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,25 @@ +// eslint-disable-next-line validate-filename/naming-rules +import type { MetadataRoute } from 'next' +import { env } from '@/lib/utils' + +export default function robots (): MetadataRoute.Robots { + const url = new URL(env('SITE_URL')) + const host = ['80', '443'].includes(url.port) ? url.hostname : url.host + + return { + rules: [ + { + userAgent: ['YandexBot', 'Applebot'], + disallow: ['/'], + }, + { + userAgent: '*', + allow: ['/'], + disallow: ['/auth/', '/api/'], + crawlDelay: 3, + }, + ], + //sitemap: 'https://acme.com/sitemap.xml', + host, + } +} \ No newline at end of file diff --git a/components/auth/card-wrapper.tsx b/components/auth/card-wrapper.tsx index 0111f8b..58d1cfc 100644 --- a/components/auth/card-wrapper.tsx +++ b/components/auth/card-wrapper.tsx @@ -3,6 +3,8 @@ import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card' import { Header } from '@/components/auth/header' import { Social } from '@/components/auth/social' import { BackButton } from '@/components/auth/back-button' +import { Suspense } from 'react' +import { Loading } from '@/components/loading' type Props = { children: React.ReactNode @@ -24,32 +26,31 @@ export const CardWrapper = ({ continueWithLabel, }: Props) => { return ( - - -
- - - {children} - - {showSocial && -
-
- -
-
+ }> + + +
+ + + {children} + + {showSocial && +
+
+ +
+
{continueWithLabel} +
-
- - {/**/} - - } - - - - + + } + + + + + ) } \ No newline at end of file diff --git a/components/auth/error-card.tsx b/components/auth/error-card.tsx index 4cc38c6..3286545 100644 --- a/components/auth/error-card.tsx +++ b/components/auth/error-card.tsx @@ -14,9 +14,9 @@ const ErrorCard = () => { backButtonLabel={t('auth.form.error.back_button_label')} backButtonHref={AUTH_LOGIN_URL} > -
- -

ssss

+
+ +

Hush little baby... this is prohibited zone!

) diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx index 78bdd8e..0857040 100644 --- a/components/auth/login-form.tsx +++ b/components/auth/login-form.tsx @@ -21,7 +21,8 @@ import FormError from '@/components/form-error' import FormSuccess from '@/components/form-success' import { login } from '@/actions/login' import { LoginSchema } from '@/schemas' -import { AUTH_REGISTER_URL } from '@/config/routes' +import { AUTH_REGISTER_URL, AUTH_RESET_PASSWORD_URL } from '@/config/routes' +import Link from 'next/link' export const LoginForm = () => { const t = useI18n() @@ -61,12 +62,12 @@ export const LoginForm = () => { backButtonLabel={t('auth.form.login.back_button_label')} backButtonHref={AUTH_REGISTER_URL} showSocial - continueWithLabel={t('form.label.continue_with')} + continueWithLabel={t('auth.form.label.continue_with')} >
{ disabled={isPending} placeholder={t('form.placeholder.email')} type="email" - autoComplete="username" + autoComplete="email" /> @@ -96,9 +97,14 @@ export const LoginForm = () => { autoComplete="current-password" /> + )}/>
+ + + + ) +} diff --git a/components/auth/register-form.tsx b/components/auth/register-form.tsx index a4b3997..e624b3c 100644 --- a/components/auth/register-form.tsx +++ b/components/auth/register-form.tsx @@ -24,6 +24,7 @@ import { RegisterSchema } from '@/schemas' import { AUTH_LOGIN_URL } from '@/config/routes' export const RegisterForm = () => { + // TODO: create repeat password field // const [currentPassword, setCurrentPassword] = useState('') // const [password, setPassword] = useState('') // const [passwordConfirmation, setPasswordConfirmation] = useState('') @@ -59,7 +60,7 @@ export const RegisterForm = () => { backButtonLabel={t('auth.form.register.back_button_label')} backButtonHref={AUTH_LOGIN_URL} showSocial - continueWithLabel={t('form.label.continue_with')} + continueWithLabel={t('auth.form.label.continue_with')} >
{ disabled={isPending} placeholder={t('form.placeholder.email')} type="email" - autoComplete="username" + autoComplete="email" /> @@ -115,7 +116,7 @@ export const RegisterForm = () => { diff --git a/components/auth/reset-form.tsx b/components/auth/reset-form.tsx new file mode 100644 index 0000000..697e4be --- /dev/null +++ b/components/auth/reset-form.tsx @@ -0,0 +1,88 @@ +'use client' + +import { infer as zInfer } from 'zod' +import { useState, useTransition } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { CardWrapper } from '@/components/auth/card-wrapper' +import { useI18n } from '@/locales/client' +import { Button } from '@/components/ui/button' +import FormError from '@/components/form-error' +import FormSuccess from '@/components/form-success' +import { ResetSchema } from '@/schemas' +import { AUTH_LOGIN_URL } from '@/config/routes' +import { reset } from '@/actions/reset' + +export const ResetForm = () => { + const t = useI18n() + + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [isPending, startTransition] = useTransition() + + const form = useForm>({ + resolver: zodResolver(ResetSchema), defaultValues: { + email: '', + }, + }) + + const onSubmit = (values: zInfer) => { + setError('') + setSuccess('') + + startTransition(() => { + reset(values).then((data) => { + // @ts-ignore + setError(t(data?.error)) + // @ts-ignore + setSuccess(t(data?.success)) + }) + }) + } + + return ( +
+ +
+ ( + {t('form.label.email')} + + + + + )}/> +
+ + + + + + +
) +} diff --git a/components/auth/social.tsx b/components/auth/social.tsx index e1171f1..6dd3ad1 100644 --- a/components/auth/social.tsx +++ b/components/auth/social.tsx @@ -11,12 +11,12 @@ export const Social = () => { return (
- - {/*