Compare commits
2 Commits
master
...
53cadc289a
| Author | SHA1 | Date | |
|---|---|---|---|
| 53cadc289a | |||
| b1ad7b5c3e |
@@ -1,3 +1,37 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
"extends": [
|
||||
"next/core-web-vitals"
|
||||
],
|
||||
"plugins": [
|
||||
"validate-filename"
|
||||
],
|
||||
"rules": {
|
||||
"validate-filename/naming-rules": [
|
||||
"error",
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"case": "kebab",
|
||||
"target": "**/components/**",
|
||||
"patterns": "^[a-z0-9-]+.tsx$"
|
||||
},
|
||||
{
|
||||
"case": "kebab",
|
||||
"target": "**/app/**",
|
||||
"patterns": "^(default|page|layout|loading|error|not-found|route|template).(tsx|ts)$"
|
||||
},
|
||||
{
|
||||
"case": "camel",
|
||||
"target": "**/hooks/**",
|
||||
"patterns": "^use"
|
||||
},
|
||||
{
|
||||
"case": "camel",
|
||||
"target": "**/providers/**",
|
||||
"patterns": "^[a-zA-Z]*Provider"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -112,4 +112,6 @@ fabric.properties
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
.idea/caches/build_file_checksums.ser
|
||||
/prisma/_____migrations___/
|
||||
/resources/images/
|
||||
|
||||
6
.idea/tailwindcss.xml
generated
Normal file
6
.idea/tailwindcss.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="TailwindSettings">
|
||||
<option name="lspConfiguration" value="{ "includeLanguages": { "ftl": "html", "jinja": "html", "jinja2": "html", "smarty": "html", "tmpl": "gohtml", "cshtml": "html", "vbhtml": "html", "razor": "html" }, "files": { "exclude": [ "**/.git/**", "**/node_modules/**", "**/.hg/**", "**/.svn/**" ] }, "emmetCompletions": true, "classAttributes": ["class", "className", "ngClass"], "colorDecorators": true, "showPixelEquivalents": true, "rootFontSize": 16, "hovers": true, "suggestions": true, "codeActions": true, "validate": true, "lint": { "invalidScreen": "error", "invalidVariant": "error", "invalidTailwindDirective": "error", "invalidApply": "error", "invalidConfigPath": "error", "cssConflict": "warning", "recommendedVariantOrder": "warning" }, "experimental": { "configFile": null, "classRegex": [] } }" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -43,12 +43,12 @@ export const login = async (values: zInfer<typeof LoginSchema>) => {
|
||||
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,
|
||||
})
|
||||
|
||||
66
actions/new-password.ts
Normal file
66
actions/new-password.ts
Normal file
@@ -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<typeof NewPasswordSchema>, 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' }
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
'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 db from '@/lib/db'
|
||||
import { getUserByEmail } from '@/data/user'
|
||||
import { sendVerificationEmail } from '@/actions/send-verification-email'
|
||||
|
||||
|
||||
24
actions/reset.ts
Normal file
24
actions/reset.ts
Normal file
@@ -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<typeof ResetSchema>) => {
|
||||
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)
|
||||
}
|
||||
@@ -1,27 +1,55 @@
|
||||
'use server'
|
||||
|
||||
import mailer from '@/lib/mailer'
|
||||
import { env } from 'process'
|
||||
import { AUTH_EMAIL_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_EMAIL_VERIFICATION_URL, '?token=', verificationToken].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 },
|
||||
`test-xyhy2bvhj@srv1.mail-tester.com`] : verificationToken.email,
|
||||
subject: 'Complete email verification for A-Naklejka',
|
||||
html: `<p>Click <a href="${confirmLink}">here</a> to confirm email</p>`,
|
||||
to: name ? { name: name?.toString(), address: verificationToken.email } : verificationToken.email,
|
||||
subject: await __ct({
|
||||
key: 'mailer.subject.send_verification_email',
|
||||
params: { site_name: env('SITE_NAME') },
|
||||
}),
|
||||
text: message?.text,
|
||||
html: message?.html,
|
||||
})
|
||||
|
||||
if (isOk) {
|
||||
return { success: code === 250 ? 'auth.email.success.confirmation_email_sent' : info?.response }
|
||||
} else {
|
||||
return { error: env.DEBUG === 'true' ? error?.response : 'auth.email.error.verification_email_sending_error' }
|
||||
return { error: env('DEBUG') === 'true' ? error?.response : 'auth.email.error.verification_email_sending_error' }
|
||||
}
|
||||
}
|
||||
|
||||
export { sendVerificationEmail }
|
||||
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: `<p>Click <a href="${resetLink}">here</a> to reset password</p>`,
|
||||
})
|
||||
|
||||
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 }
|
||||
41
actions/user-verification.ts
Normal file
41
actions/user-verification.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
'use server'
|
||||
|
||||
import db from '@/lib/db'
|
||||
import { getVerificationTokenByToken } from '@/data/verification-token'
|
||||
import { getUserByEmail } from '@/data/user'
|
||||
|
||||
export const userVerification = async (token: string) => {
|
||||
const existingToken = await getVerificationTokenByToken(token)
|
||||
|
||||
if (!existingToken) return { error: 'No verification token found!' }
|
||||
|
||||
const tokenHasExpired: boolean = new Date(existingToken.expires) < new Date()
|
||||
|
||||
if (tokenHasExpired) return { error: 'Unfortunately your token has expired!' }
|
||||
|
||||
const existingUser = await getUserByEmail(existingToken.email)
|
||||
|
||||
if (!existingUser) return { error: 'Email associated with token not found!' }
|
||||
|
||||
try {
|
||||
await db.user.update({
|
||||
where: { id: existingUser.id }, data: {
|
||||
email: existingToken.email, emailVerified: new Date(),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return { error: 'db.error.update.user_data' }
|
||||
}
|
||||
|
||||
try {
|
||||
await db.verificationToken.delete({
|
||||
where: { id: existingToken.id },
|
||||
})
|
||||
} catch (e) {
|
||||
// TODO: log error on disc or db
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
return { success: 'User verified!' }
|
||||
}
|
||||
@@ -1,23 +1,12 @@
|
||||
'use client'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import mailer from '@/lib/mailer'
|
||||
export const metadata: Metadata = {
|
||||
title: 'key',
|
||||
description: '...',
|
||||
}
|
||||
|
||||
export default function AboutPage () {
|
||||
const onClick = () => {
|
||||
mailer({
|
||||
to: [
|
||||
{ name: 'Yevhen', address: 'it@amok.space' },
|
||||
{ name: 'Євген', address: 'yevhen.odynets@gmail.com' },
|
||||
],
|
||||
subject: 'ПОСИЛЕННЯ МОБІЛІЗАЦІЇ В УКРАЇНІ',
|
||||
html: `<div>Коли Рада <strong>розгляне</strong> законопроєкт про мобілізацію у <del>другому</del> читанні</div>`,
|
||||
}).catch(console.error)
|
||||
}
|
||||
const AboutPage = () => {
|
||||
return <>ABOUT</>
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent">
|
||||
sendmail
|
||||
</button>
|
||||
)
|
||||
}
|
||||
export default AboutPage
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* @type {JSX.Element}
|
||||
*/
|
||||
export default function AboutUsPage () {
|
||||
return (<div>AboutUsPage</div>)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { Poppins } from 'next/font/google'
|
||||
import { getScopedI18n } from '@/locales/server'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import LoginButton from '@/components/auth/LoginButton'
|
||||
import { bg as bgg } from '@/config/layout'
|
||||
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'],
|
||||
@@ -19,6 +21,7 @@ export default async function Home () {
|
||||
🔐 {t('title')}
|
||||
</h1>
|
||||
<p className="text-lg text-white">{t('subtitle')}</p>
|
||||
<Image src={wolf} alt="Picture of a wolf" width={430} placeholder="blur"/>
|
||||
<div>
|
||||
<LoginButton>
|
||||
<Button variant="secondary" size="lg">{t('sign_in')}</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ErrorCard from '@/components/auth/ErrorCard'
|
||||
import ErrorCard from '@/components/auth/error-card'
|
||||
|
||||
const AuthErrorPage = () => {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ReactElement } from 'react'
|
||||
import Navbar from '@/components/auth/Navbar'
|
||||
import Navbar from '@/components/auth/navbar'
|
||||
|
||||
type Props = {
|
||||
//params: { locale: string };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LoginForm } from '@/components/auth/LoginForm'
|
||||
import { LoginForm } from '@/components/auth/login-form'
|
||||
|
||||
const LoginPage = () => {
|
||||
return (
|
||||
|
||||
5
app/[locale]/auth/new-password/[token]/page.tsx
Normal file
5
app/[locale]/auth/new-password/[token]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NewPasswordForm } from '@/components/auth/new-password-form'
|
||||
|
||||
export default function NewPasswordPage ({ params }: { params: { token: string } }) {
|
||||
return <NewPasswordForm token={params.token}/>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RegisterForm } from '@/components/auth/RegisterForm'
|
||||
import { RegisterForm } from '@/components/auth/register-form'
|
||||
|
||||
const RegisterPage = () => {
|
||||
return (
|
||||
|
||||
5
app/[locale]/auth/reset/page.tsx
Normal file
5
app/[locale]/auth/reset/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ResetForm } from '@/components/auth/reset-form'
|
||||
|
||||
const ResetPage = () => <ResetForm/>
|
||||
|
||||
export default ResetPage
|
||||
5
app/[locale]/auth/user-verification/[token]/page.tsx
Normal file
5
app/[locale]/auth/user-verification/[token]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import UserVerificationForm from '@/components/auth/user-verification-form'
|
||||
|
||||
export default function TokenVerificationPage ({ params }: { params: { token: string } }) {
|
||||
return <UserVerificationForm token={params.token}/>
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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<Props>) {
|
||||
|
||||
return (<html lang={lc(locale).java}>
|
||||
return (<html lang={lc(locale)?.java}>
|
||||
{/*<Suspense fallback={<Loading/>}>*/}
|
||||
<body className={inter.className}>
|
||||
<I18nProviderClient locale={locale} fallback={<p>Loading...</p>}>
|
||||
<I18nProviderClient locale={locale} fallback={<Loading/>}>
|
||||
{children}
|
||||
</I18nProviderClient>
|
||||
</body>
|
||||
{/*</Suspense>*/}
|
||||
</html>)
|
||||
}
|
||||
|
||||
25
app/robots.ts
Normal file
25
app/robots.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,21 @@ import type { NextAuthConfig } from 'next-auth'
|
||||
import Credentials from 'next-auth/providers/credentials'
|
||||
import Google from 'next-auth/providers/google'
|
||||
import Github from 'next-auth/providers/github'
|
||||
//import Facebook from 'next-auth/providers/facebook'
|
||||
//import Twitter from 'next-auth/providers/twitter'
|
||||
import { LoginSchema } from '@/schemas'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { getUserByEmail } from '@/data/user'
|
||||
import { env } from 'process'
|
||||
import { env } from '@/lib/utils'
|
||||
|
||||
export default {
|
||||
secret: env.AUTH_SECRET,
|
||||
secret: env('AUTH_SECRET'),
|
||||
providers: [
|
||||
Google({
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
clientId: env('GOOGLE_CLIENT_ID'),
|
||||
clientSecret: env('GOOGLE_CLIENT_SECRET'),
|
||||
}),
|
||||
Github({
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
||||
clientId: env('GITHUB_CLIENT_ID'),
|
||||
clientSecret: env('GITHUB_CLIENT_SECRET'),
|
||||
}),
|
||||
//Twitter({}),
|
||||
/*Facebook({
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useI18n } from '@/locales/client'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
}
|
||||
|
||||
const _ = (message: string): string => {
|
||||
const t = useI18n()
|
||||
if (message.startsWith('["')) {
|
||||
const data = JSON.parse(message)
|
||||
if (data.length > 1) {
|
||||
message = data.shift()
|
||||
// @ts-ignore
|
||||
return t(message, ...data)
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return t(message)
|
||||
}
|
||||
|
||||
const TranslateClientFragment = ({ message }: Props) => {
|
||||
return <>{_(message)}</>
|
||||
}
|
||||
|
||||
export default TranslateClientFragment
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client'
|
||||
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/BackButton'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
headerLabel: string
|
||||
headerTitle: string
|
||||
backButtonLabel: string
|
||||
backButtonHref: string
|
||||
showSocial?: boolean
|
||||
continueWithLabel?: string
|
||||
}
|
||||
|
||||
export const CardWrapper = ({
|
||||
children,
|
||||
headerLabel,
|
||||
headerTitle,
|
||||
backButtonLabel,
|
||||
backButtonHref,
|
||||
showSocial,
|
||||
continueWithLabel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Card
|
||||
className="max-w-[414px] w-[100%] shadow-md md:min-w-[414px] sm:w-full">
|
||||
<CardHeader>
|
||||
<Header label={headerLabel} title={headerTitle}/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{children}
|
||||
</CardContent>
|
||||
{showSocial && <CardFooter className="flex-wrap">
|
||||
<div className="relative flex-none w-[100%] mb-4"
|
||||
style={{ display: 'block' }}>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t"></span>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span
|
||||
className="bg-background px-2 text-muted-foreground">{continueWithLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*<Separator className="my-4"/>*/}
|
||||
<Social/>
|
||||
</CardFooter>}
|
||||
<CardFooter>
|
||||
<BackButton label={backButtonLabel} href={backButtonHref}/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
56
components/auth/card-wrapper.tsx
Normal file
56
components/auth/card-wrapper.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
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
|
||||
headerLabel: string
|
||||
headerTitle: string
|
||||
backButtonLabel: string
|
||||
backButtonHref: string
|
||||
showSocial?: boolean
|
||||
continueWithLabel?: string
|
||||
}
|
||||
|
||||
export const CardWrapper = ({
|
||||
children,
|
||||
headerLabel,
|
||||
headerTitle,
|
||||
backButtonLabel,
|
||||
backButtonHref,
|
||||
showSocial,
|
||||
continueWithLabel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Suspense fallback={<Loading/>}>
|
||||
<Card
|
||||
className="border-8 border-muted shadow-2xl max-w-[430px] w-full sm:min-w-[430px]">
|
||||
<CardHeader>
|
||||
<Header label={headerLabel} title={headerTitle}/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{children}
|
||||
</CardContent>
|
||||
{showSocial && <CardFooter className="flex-wrap">
|
||||
<div className="relative flex-none w-[100%] mb-4" style={{ background: 'block' }}>
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t"></span>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span
|
||||
className="bg-background px-2 text-muted-foreground">{continueWithLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Social/>
|
||||
</CardFooter>}
|
||||
<CardFooter>
|
||||
<BackButton label={backButtonLabel} href={backButtonHref}/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { CardWrapper } from '@/components/auth/CardWrapper'
|
||||
import { CardWrapper } from '@/components/auth/card-wrapper'
|
||||
import { AUTH_LOGIN_URL } from '@/config/routes'
|
||||
import { useI18n } from '@/locales/client'
|
||||
import { TriangleAlert } from 'lucide-react'
|
||||
@@ -14,9 +14,9 @@ const ErrorCard = () => {
|
||||
backButtonLabel={t('auth.form.error.back_button_label')}
|
||||
backButtonHref={AUTH_LOGIN_URL}
|
||||
>
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<TriangleAlert className="w-4 h-4 text-destructive"/>
|
||||
<p>ssss</p>
|
||||
<div className="w-full flex items-center justify-center text-destructive">
|
||||
<TriangleAlert className="w-4 h-4 mr-1.5"/>
|
||||
<p>Hush little baby... this is prohibited zone!</p>
|
||||
</div>
|
||||
</CardWrapper>
|
||||
)
|
||||
@@ -14,14 +14,15 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { CardWrapper } from '@/components/auth/CardWrapper'
|
||||
import { CardWrapper } from '@/components/auth/card-wrapper'
|
||||
import { useI18n } from '@/locales/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import FormError from '@/components/FormError'
|
||||
import FormSuccess from '@/components/FormSuccess'
|
||||
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')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
className="space-y-0"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField control={form.control} name="email"
|
||||
@@ -78,7 +79,7 @@ export const LoginForm = () => {
|
||||
disabled={isPending}
|
||||
placeholder={t('form.placeholder.email')}
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs"/>
|
||||
@@ -96,9 +97,14 @@ export const LoginForm = () => {
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<Button variant="link" size="sm" asChild
|
||||
className="mt-0 p-0 items-start font-light text-sky-900">
|
||||
<Link href={AUTH_RESET_PASSWORD_URL}>{t('auth.form.login.reset_password_link_text')}</Link>
|
||||
</Button>
|
||||
<FormMessage className="text-xs"/>
|
||||
</FormItem>)}/>
|
||||
</div>
|
||||
|
||||
<FormSuccess message={success}/>
|
||||
<FormError message={error || urlError}/>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
@@ -107,6 +113,4 @@ export const LoginForm = () => {
|
||||
</form>
|
||||
</Form>
|
||||
</CardWrapper>)
|
||||
}
|
||||
|
||||
//1:30:00
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client'
|
||||
//import { useScopedI18n } from '@/locales/client'
|
||||
import LocaleSwitcher from '@/components/LocaleSwitcher'
|
||||
import LocaleSwitcher from '@/components/locale-switcher'
|
||||
|
||||
export default function Navbar () {
|
||||
//const t = useScopedI18n('navbar')
|
||||
88
components/auth/new-password-form.tsx
Normal file
88
components/auth/new-password-form.tsx
Normal file
@@ -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 { NewPasswordSchema } from '@/schemas'
|
||||
import { AUTH_LOGIN_URL } from '@/config/routes'
|
||||
import { newPassword } from '@/actions/new-password'
|
||||
|
||||
export const NewPasswordForm = ({ token }: { token: string }) => {
|
||||
const t = useI18n()
|
||||
|
||||
const [error, setError] = useState<string | undefined>('')
|
||||
const [success, setSuccess] = useState<string | undefined>('')
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const form = useForm<zInfer<typeof NewPasswordSchema>>({
|
||||
resolver: zodResolver(NewPasswordSchema), defaultValues: {
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (values: zInfer<typeof NewPasswordSchema>) => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
startTransition(() => {
|
||||
newPassword(values, token).then((data) => {
|
||||
// @ts-ignore
|
||||
setError(t(data?.error))
|
||||
// @ts-ignore
|
||||
setSuccess(t(data?.success))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (<CardWrapper
|
||||
headerTitle={t('auth.title')}
|
||||
headerLabel={t('auth.form.new_password.header_label')}
|
||||
backButtonLabel={t('auth.form.new_password.back_button_label')}
|
||||
backButtonHref={AUTH_LOGIN_URL}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField control={form.control} name="password"
|
||||
render={({ field }) => (<FormItem>
|
||||
<FormLabel>{t('form.label.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isPending}
|
||||
type="password"
|
||||
placeholder="******"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs"/>
|
||||
</FormItem>)}/>
|
||||
</div>
|
||||
|
||||
<FormSuccess message={success}/>
|
||||
<FormError message={error}/>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{t('auth.form.new_password.button')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardWrapper>)
|
||||
}
|
||||
@@ -13,17 +13,18 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { CardWrapper } from '@/components/auth/CardWrapper'
|
||||
import { CardWrapper } from '@/components/auth/card-wrapper'
|
||||
import { useI18n } from '@/locales/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import FormError from '@/components/FormError'
|
||||
import FormSuccess from '@/components/FormSuccess'
|
||||
import FormError from '@/components/form-error'
|
||||
import FormSuccess from '@/components/form-success'
|
||||
|
||||
import { register } from '@/actions/register'
|
||||
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')}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -91,7 +92,7 @@ export const RegisterForm = () => {
|
||||
disabled={isPending}
|
||||
placeholder={t('form.placeholder.email')}
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs"/>
|
||||
@@ -115,7 +116,7 @@ export const RegisterForm = () => {
|
||||
<FormSuccess message={success}/>
|
||||
<FormError message={error}/>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{t('form.label.register')}
|
||||
{t('auth.form.register.button')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
88
components/auth/reset-form.tsx
Normal file
88
components/auth/reset-form.tsx
Normal file
@@ -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<string | undefined>('')
|
||||
const [success, setSuccess] = useState<string | undefined>('')
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const form = useForm<zInfer<typeof ResetSchema>>({
|
||||
resolver: zodResolver(ResetSchema), defaultValues: {
|
||||
email: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (values: zInfer<typeof ResetSchema>) => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
startTransition(() => {
|
||||
reset(values).then((data) => {
|
||||
// @ts-ignore
|
||||
setError(t(data?.error))
|
||||
// @ts-ignore
|
||||
setSuccess(t(data?.success))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (<CardWrapper
|
||||
headerTitle={t('auth.title')}
|
||||
headerLabel={t('auth.form.reset.header_label')}
|
||||
backButtonLabel={t('auth.form.reset.back_button_label')}
|
||||
backButtonHref={AUTH_LOGIN_URL}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField control={form.control} name="email"
|
||||
render={({ field }) => (<FormItem>
|
||||
<FormLabel>{t('form.label.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={isPending}
|
||||
placeholder={t('form.placeholder.email')}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs"/>
|
||||
</FormItem>)}/>
|
||||
</div>
|
||||
|
||||
<FormSuccess message={success}/>
|
||||
<FormError message={error}/>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{t('auth.form.reset.button')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardWrapper>)
|
||||
}
|
||||
@@ -11,12 +11,12 @@ export const Social = () => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full gap-x-2">
|
||||
<Button size="lg" className="w-full" variant="outline"
|
||||
onClick={() => SignInProvider('google')}>
|
||||
<Button size="lg" className="w-full" variant="outline" role="button"
|
||||
onClick={() => SignInProvider('google')} aria-label="Sign in with Google">
|
||||
<FcGoogle className="w-5 h-5"/>
|
||||
</Button>
|
||||
<Button size="lg" className="w-full" variant="outline"
|
||||
onClick={() => SignInProvider('github')}>
|
||||
<Button size="lg" className="w-full" variant="outline" role="button"
|
||||
onClick={() => SignInProvider('github')} aria-label="Sign in with Github">
|
||||
<FaGithub className="w-5 h-5"/>
|
||||
</Button>
|
||||
{/*<Button size="lg" className="w-full" variant="outline" onClick={() => {}}>
|
||||
43
components/auth/user-verification-form.tsx
Normal file
43
components/auth/user-verification-form.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import { CardWrapper } from '@/components/auth/card-wrapper'
|
||||
import { AUTH_LOGIN_URL } from '@/config/routes'
|
||||
import { useI18n } from '@/locales/client'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { userVerification } from '@/actions/user-verification'
|
||||
import FormSuccess from '@/components/form-success'
|
||||
import FormError from '@/components/form-error'
|
||||
import { Bars } from 'react-loader-spinner'
|
||||
|
||||
const UserVerificationForm = ({ token }: { token: string }) => {
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [success, setSuccess] = useState<string | undefined>(undefined)
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
|
||||
userVerification(token).then(data => {
|
||||
setSuccess(data?.success)
|
||||
setError(data?.error)
|
||||
}).catch(() => {
|
||||
setError('something went wrong')
|
||||
})
|
||||
}, [token])
|
||||
|
||||
useEffect(() => onSubmit(), [onSubmit])
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
return (<CardWrapper
|
||||
headerTitle={t('auth.title')}
|
||||
headerLabel={t('auth.form.verification.header_label')}
|
||||
backButtonLabel={t('auth.form.verification.back_button_label')}
|
||||
backButtonHref={AUTH_LOGIN_URL}
|
||||
>
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<Bars visible={!success && !error} color="hsl(var(--primary))" ariaLabel="loading" wrapperClass="opacity-50"/>
|
||||
<FormSuccess message={success}/>
|
||||
<FormError message={error}/>
|
||||
</div>
|
||||
</CardWrapper>)
|
||||
}
|
||||
|
||||
export default UserVerificationForm
|
||||
20
components/loading.tsx
Normal file
20
components/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Grid } from 'react-loader-spinner'
|
||||
|
||||
export function Loading () {
|
||||
return (
|
||||
<h1>
|
||||
<Grid
|
||||
visible={true}
|
||||
height="666"
|
||||
width="666"
|
||||
color="#4fa94d"
|
||||
ariaLabel="grid-loading"
|
||||
radius="12.5"
|
||||
wrapperStyle={{}}
|
||||
wrapperClass="grid-wrapper"
|
||||
/>
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useChangeLocale, useCurrentLocale } from '@/locales/client'
|
||||
import { LC, type loc } from '@/config/locales'
|
||||
import { ChangeEvent } from 'react'
|
||||
import styles from '@/styles/LocaleSwitcher.module.scss'
|
||||
import styles from '@/styles/locale-switcher.module.scss'
|
||||
|
||||
export default function LocaleSwitcher () {
|
||||
const changeLocale = useChangeLocale()
|
||||
@@ -13,7 +13,7 @@ export default function LocaleSwitcher () {
|
||||
return (
|
||||
//@ts-ignore
|
||||
<select onChange={selectHandler} defaultValue={locale}
|
||||
className={styles['yo-locale-switcher']}>
|
||||
className={styles['yo-locale-switcher']} aria-label="Switch language">
|
||||
{LC.map(item => (
|
||||
<option key={item.iso} value={item.code}>
|
||||
{item.iso.toUpperCase()}
|
||||
24
components/translate-client-fragment.tsx
Normal file
24
components/translate-client-fragment.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
import { useI18n } from '@/locales/client'
|
||||
|
||||
export const __ = (key: any, params?: any): React.ReactNode => {
|
||||
const t = useI18n()
|
||||
|
||||
if (key.startsWith('["')) {
|
||||
const data = JSON.parse(key)
|
||||
|
||||
if (data.length > 1) {
|
||||
key = data.shift()
|
||||
// @ts-ignore
|
||||
return t(key, ...data)
|
||||
}
|
||||
}
|
||||
|
||||
return t(key, params)
|
||||
}
|
||||
|
||||
const TranslateClientFragment = ({ message, args }: { message: any, args?: any }) => {
|
||||
return <>{__(message, args)}</>
|
||||
}
|
||||
|
||||
export default TranslateClientFragment
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
useFormContext,
|
||||
} from 'react-hook-form'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { cn, env } from '@/lib/utils'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import TranslateClientFragment from '@/components/TranslateClientFragment'
|
||||
import TranslateClientFragment from '@/components/translate-client-fragment'
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
@@ -132,8 +132,7 @@ const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{!process.env.IS_SERVER_FLAG && typeof body === 'string' &&
|
||||
body.includes('schema.message')
|
||||
{!env('IS_SERVER_FLAG') && typeof body === 'string' && body.match(/^(|\[")schema\./)
|
||||
? <TranslateClientFragment message={body}/>
|
||||
: body}
|
||||
</p>)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import { UserRole } from '@prisma/client'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import { db } from '@/lib/db'
|
||||
import db from '@/lib/db'
|
||||
import authConfig from '@/auth.config'
|
||||
import { getUserById } from '@/data/user'
|
||||
import { AUTH_ERROR_URL, AUTH_LOGIN_URL } from '@/config/routes'
|
||||
import { getCurrentLocale } from '@/locales/server'
|
||||
import { type loc } from '@/config/locales'
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: { role: UserRole }
|
||||
user: { role: UserRole, locale: loc }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +49,7 @@ export const {
|
||||
return true
|
||||
},
|
||||
async session ({ token, session }) {
|
||||
|
||||
if (token.sub && session.user) {
|
||||
session.user.id = token.sub
|
||||
}
|
||||
@@ -55,13 +58,15 @@ export const {
|
||||
session.user.role = token.role as UserRole
|
||||
}
|
||||
|
||||
session.user.locale = getCurrentLocale()
|
||||
|
||||
return session
|
||||
},
|
||||
async jwt ({ token }) {
|
||||
if (!token.sub) return token
|
||||
|
||||
const existingUser = await getUserById(token.sub)
|
||||
|
||||
|
||||
if (!existingUser) return token
|
||||
|
||||
token.role = existingUser.role
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const bg: string = 'bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-400 to-blue-800'
|
||||
@@ -1,13 +1,23 @@
|
||||
// @https://www.localeplanet.com/icu/index.html
|
||||
type loc = ('uk' | 'en')
|
||||
|
||||
const defaultLocale = 'uk'
|
||||
type Locale = {
|
||||
id: string,
|
||||
java: string,
|
||||
iso: string,
|
||||
code: loc,
|
||||
name: string,
|
||||
originalName: string,
|
||||
}
|
||||
|
||||
export type loc = ('uk' | 'en')
|
||||
const defaultLocale: loc = 'uk'
|
||||
const fallbackLocale: loc = 'en'
|
||||
|
||||
const importLocales = {
|
||||
uk: () => import('@/locales/uk'), en: () => import('@/locales/en'),
|
||||
}
|
||||
const LC = [
|
||||
} as const
|
||||
|
||||
const LC: Locale[] = [
|
||||
{
|
||||
id: 'uk_UA',
|
||||
java: 'uk-UA',
|
||||
@@ -23,8 +33,8 @@ const LC = [
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
originalName: 'English',
|
||||
}]
|
||||
}] as const
|
||||
|
||||
const locales = LC.map(locale => locale.code)
|
||||
const locales: loc[] = LC.map((locale: Locale) => locale.code)
|
||||
|
||||
export { locales, defaultLocale, LC, importLocales }
|
||||
export { locales, defaultLocale, fallbackLocale, LC, importLocales, type loc }
|
||||
@@ -1,16 +1,16 @@
|
||||
import { env } from 'process'
|
||||
import SMTPTransport from 'nodemailer/lib/smtp-transport'
|
||||
import { env } from '@/lib/utils'
|
||||
|
||||
export const from: string = `"${env.MAIL_SERVER_SENDER_NAME}" <${env.MAIL_SERVER_USERNAME}>`
|
||||
export const from: string = `"${env('MAIL_SERVER_SENDER_NAME')}" <${env('MAIL_SERVER_USERNAME')}>`
|
||||
|
||||
export const transportOptions: SMTPTransport | SMTPTransport.Options | string = {
|
||||
host: env.MAIL_SERVER_HOST,
|
||||
debug: env.MAIL_SERVER_DEBUG === 'true',
|
||||
logger: env.MAIL_SERVER_LOG === 'true',
|
||||
port: parseInt(env.MAIL_SERVER_PORT as string),
|
||||
secure: env.MAIL_SERVER_PORT === '465', // Use `true` for port 465, `false` for all other ports
|
||||
host: env('MAIL_SERVER_HOST'),
|
||||
debug: env('MAIL_SERVER_DEBUG') === 'true' && env('NODE_ENV') !== 'production',
|
||||
logger: env('MAIL_SERVER_LOG') === 'true' && env('NODE_ENV') !== 'production',
|
||||
port: parseInt(env('MAIL_SERVER_PORT')),
|
||||
secure: env('MAIL_SERVER_PORT') === '465', // Use `true` for port 465, `false` for all other ports
|
||||
auth: {
|
||||
user: env.MAIL_SERVER_USERNAME, pass: env.MAIL_SERVER_PASSWORD,
|
||||
user: env('MAIL_SERVER_USERNAME'), pass: env('MAIL_SERVER_PASSWORD'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { locales } from '@/config/locales'
|
||||
import { UUID_V4_REGEX } from '@/config/validation'
|
||||
|
||||
export const USER_PROFILE_URL: string = '/cabinet'
|
||||
export const AUTH_LOGIN_URL: string = '/auth/login'
|
||||
export const AUTH_REGISTER_URL: string = '/auth/register'
|
||||
export const AUTH_ERROR_URL: string = '/auth/error'
|
||||
export const AUTH_EMAIL_VERIFICATION_URL: string = '/auth/email-verification'
|
||||
export const AUTH_URL: string = '/auth/'
|
||||
export const AUTH_LOGIN_URL: string = `${AUTH_URL}login`
|
||||
export const AUTH_REGISTER_URL: string = `${AUTH_URL}register`
|
||||
export const AUTH_RESET_PASSWORD_URL: string = `${AUTH_URL}reset`
|
||||
export const AUTH_ERROR_URL: string = `${AUTH_URL}error`
|
||||
export const AUTH_USER_VERIFICATION_URL: string = `${AUTH_URL}user-verification`
|
||||
export const AUTH_NEW_PASSWORD_URL: string = `${AUTH_URL}new-password`
|
||||
|
||||
/**
|
||||
* An array of routes that accessible to the public.
|
||||
@@ -12,7 +15,7 @@ export const AUTH_EMAIL_VERIFICATION_URL: string = '/auth/email-verification'
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const publicRoutes: string[] = [
|
||||
'/', '/about']
|
||||
'/', '/((about)(|/.*))', `(${AUTH_USER_VERIFICATION_URL}|${AUTH_NEW_PASSWORD_URL})/${UUID_V4_REGEX}`]
|
||||
|
||||
/**
|
||||
* An array of routes that are used for authentication.
|
||||
@@ -20,7 +23,11 @@ export const publicRoutes: string[] = [
|
||||
* @type {string[]}
|
||||
*/
|
||||
export const authRoutes: string[] = [
|
||||
AUTH_LOGIN_URL, AUTH_REGISTER_URL, AUTH_ERROR_URL, AUTH_EMAIL_VERIFICATION_URL]
|
||||
AUTH_LOGIN_URL, AUTH_REGISTER_URL, AUTH_ERROR_URL, AUTH_RESET_PASSWORD_URL]
|
||||
|
||||
export const authRoutesRegEx = [
|
||||
AUTH_URL + '(' +
|
||||
authRoutes.map((uri: string) => uri.replace(AUTH_URL, '')).join('|') + ')']
|
||||
|
||||
/**
|
||||
* The prefix for API authentication routes.
|
||||
@@ -35,10 +42,3 @@ export const apiAuthPrefix: string = '/api/auth'
|
||||
*/
|
||||
export const DEFAULT_LOGIN_REDIRECT: string = USER_PROFILE_URL
|
||||
|
||||
export const testPathnameRegex = (
|
||||
pages: string[], pathName: string): boolean => {
|
||||
const pattern: string = `^(/(${locales.join('|')}))?(${pages.flatMap(
|
||||
(p) => (p === '/' ? ['', '/'] : p)).join('|')})/?$`
|
||||
|
||||
return RegExp(pattern, 'is').test(pathName)
|
||||
}
|
||||
@@ -1,2 +1,8 @@
|
||||
export const MIN_PASSWORD_LENGTH: number = 6
|
||||
export const PASSWORD_SALT_LENGTH: number = 10
|
||||
export const MAX_PASSWORD_LENGTH: number = 15
|
||||
export const PASSWORD_SALT_LENGTH: number = 10
|
||||
export const UUID_V4_REGEX: string = '[\x30-\x39\x61-\x66]{8}-[\x30-\x39\x61-\x66]{4}-4[\x30-\x39\x61-\x66]{3}-[\x30-\x39\x61-\x66]{4}-[\x30-\x39\x61-\x66]{12}'
|
||||
|
||||
export const PASSWORD_STRENGTH_ACME: string = `(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E])` //.{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_LENGTH}
|
||||
export const PASSWORD_STRENGTH_STRONG: string = `^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=|.*?[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E])$`
|
||||
|
||||
|
||||
17
data/password-reset-token.ts
Normal file
17
data/password-reset-token.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import db from '@/lib/db'
|
||||
|
||||
export const getPasswordResetTokenByToken = async (token: string) => {
|
||||
try {
|
||||
return await db.passwordResetToken.findUnique({ where: { token } })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getPasswordResetTokenByEmail = async (email: string) => {
|
||||
try {
|
||||
return await db.passwordResetToken.findFirst({ where: { email } })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { User } from '@prisma/client'
|
||||
import { db } from '@/lib/db'
|
||||
import db from '@/lib/db'
|
||||
|
||||
export const getUserByEmail = async (email: string): Promise<User | null> => {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db } from '@/lib/db'
|
||||
import db from '@/lib/db'
|
||||
|
||||
export const getVerificationTokenByToken = async (token: string) => {
|
||||
try {
|
||||
|
||||
17
lib/db.ts
17
lib/db.ts
@@ -1,10 +1,17 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import * as process from 'process'
|
||||
import { env } from '@/lib/utils'
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined
|
||||
const prismaClientSingleton = () => {
|
||||
return new PrismaClient()
|
||||
}
|
||||
|
||||
export const db = globalThis.prisma || new PrismaClient()
|
||||
declare global {
|
||||
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>
|
||||
}
|
||||
|
||||
const db = globalThis.prismaGlobal ?? prismaClientSingleton()
|
||||
|
||||
export default db
|
||||
|
||||
if (env('NODE_ENV') !== 'production') globalThis.prismaGlobal = db
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalThis.prisma = db
|
||||
7
lib/server.ts
Normal file
7
lib/server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import { readdir } from 'fs/promises'
|
||||
|
||||
export const getDirectories = async (source: string) => {
|
||||
return (await readdir(source, { withFileTypes: true })).filter(dirent => dirent.isDirectory()).map(dirent => dirent.name)
|
||||
}
|
||||
@@ -1,14 +1,38 @@
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { v4 as uuidV4 } from 'uuid'
|
||||
import {
|
||||
VERIFICATION_TOKEN_EXPIRATION_DURATION,
|
||||
} from '@/config/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import db from '@/lib/db'
|
||||
import { getVerificationTokenByEmail } from '@/data/verification-token'
|
||||
import { getPasswordResetTokenByEmail } from '@/data/password-reset-token'
|
||||
|
||||
export const generatePasswordResetToken = async (email: string) => {
|
||||
const token = uuidV4()
|
||||
const expires = new Date(new Date().getTime() + VERIFICATION_TOKEN_EXPIRATION_DURATION)
|
||||
const existingToken = await getPasswordResetTokenByEmail(email)
|
||||
|
||||
if (existingToken) {
|
||||
await db.passwordResetToken.delete({
|
||||
where: {
|
||||
id: existingToken.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const passwordResetToken = await db.passwordResetToken.create({
|
||||
data: {
|
||||
email,
|
||||
token,
|
||||
expires,
|
||||
},
|
||||
})
|
||||
|
||||
return passwordResetToken
|
||||
}
|
||||
|
||||
export const generateVerificationToken = async (email: string) => {
|
||||
const token = uuid()
|
||||
const expires = new Date(
|
||||
new Date().getTime() + VERIFICATION_TOKEN_EXPIRATION_DURATION)
|
||||
const token = uuidV4()
|
||||
const expires = new Date(new Date().getTime() + VERIFICATION_TOKEN_EXPIRATION_DURATION)
|
||||
|
||||
const existingToken = await getVerificationTokenByEmail(email)
|
||||
|
||||
|
||||
75
lib/translate.ts
Normal file
75
lib/translate.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
'use server'
|
||||
|
||||
import { fallbackLocale, type loc, locales } from '@/config/locales'
|
||||
import { getCurrentLocale } from '@/locales/server'
|
||||
import { getDirectories } from '@/lib/server'
|
||||
|
||||
type Params = { [index: string]: number | string }
|
||||
|
||||
interface DoParamsProps {
|
||||
key: string;
|
||||
params?: Params | null | undefined;
|
||||
}
|
||||
|
||||
const doParams = async ({ key, params }: DoParamsProps): Promise<string> => {
|
||||
if (key.trim().length === 0 || Object?.keys({ params }).length === 0) return key
|
||||
|
||||
for (let val in params) {key = key.replace(`{${val}}`, params[val] as string)}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
export const __ct = async ({ key, params }: { key: string | null | undefined, params?: {} }, locale?: loc) => {
|
||||
key = (key ?? '').trim()
|
||||
if (key.length === 0) return key
|
||||
|
||||
locale ??= getCurrentLocale()
|
||||
|
||||
if (!locales.includes(locale)) {
|
||||
locale = fallbackLocale
|
||||
}
|
||||
|
||||
const keys = key.split('.')
|
||||
const scopes = await getDirectories(`${process.cwd()}/locales/custom`)
|
||||
|
||||
if (keys.length < 2 && !scopes.includes(keys[0])) return key
|
||||
const scope = keys.shift()
|
||||
|
||||
let data: any = await import(`@/locales/custom/${scope}/${locale}`).then(({ default: data }) => data).catch(() => false)
|
||||
if (data === false) return key
|
||||
|
||||
let c: number = keys.length
|
||||
|
||||
if (c === 1) {
|
||||
const _ = data.hasOwnProperty(keys[0]) && typeof data[keys[0]] === 'string' ? data[keys[0]] : key
|
||||
return await doParams({ key: _, params })
|
||||
}
|
||||
|
||||
for (let i in keys) {
|
||||
if (data.hasOwnProperty(keys[i])) {
|
||||
data = data[keys[i]]
|
||||
c--
|
||||
}
|
||||
}
|
||||
|
||||
return await doParams({ key: c === 0 ? data : key, params })
|
||||
}
|
||||
|
||||
export const _ctBatch = async (keys: { [index: string]: string | [string, Params] }, scope?: string | null) => {
|
||||
|
||||
for (const k in keys) {
|
||||
let key: string = scope ? scope + '.' : ''
|
||||
let params: Params | undefined = undefined
|
||||
|
||||
if (Array.isArray(keys[k])) {
|
||||
key += keys[k][0]
|
||||
params = keys[k][1] as Params
|
||||
} else {
|
||||
key += keys[k]
|
||||
}
|
||||
|
||||
keys[k] = await __ct({ key, params })
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
21
lib/utils.ts
21
lib/utils.ts
@@ -1,7 +1,8 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { LC } from '@/config/locales'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { LC, locales } from '@/config/locales'
|
||||
|
||||
import { env as dotEnv } from 'process'
|
||||
|
||||
export function cn (...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -10,3 +11,19 @@ export function cn (...inputs: ClassValue[]) {
|
||||
export function lc (locale: string) {
|
||||
return LC.filter(lc => locale === lc.code)[0]
|
||||
}
|
||||
|
||||
export function env (variable: string, defaultValue?: string | ''): string {
|
||||
return (dotEnv[variable] ?? defaultValue ?? '')
|
||||
}
|
||||
|
||||
export const testPathnameRegex = (
|
||||
pages: string[], pathName: string): boolean => {
|
||||
const pattern: string = `^(/(${locales.join('|')}))?(${pages.flatMap(
|
||||
(p) => (p === '/' ? ['', '/'] : p)).join('|')})/?$`
|
||||
|
||||
//console.log(pattern)
|
||||
|
||||
return RegExp(pattern, 'is').test(pathName)
|
||||
}
|
||||
|
||||
|
||||
|
||||
15
locales/custom/mailer/en.ts
Normal file
15
locales/custom/mailer/en.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
follow: 'Follow the link',
|
||||
click: 'Click',
|
||||
here: 'here',
|
||||
confirmed_email: 'to confirm email',
|
||||
subject: {
|
||||
send_verification_email: 'Complete email verification for site {site_name}',
|
||||
},
|
||||
body: {
|
||||
send_verification_email: {
|
||||
p1: 'You just signed up for {site_name}',
|
||||
p2: 'If you have not registered on this site, simply ignore this message.',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
15
locales/custom/mailer/uk.ts
Normal file
15
locales/custom/mailer/uk.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export default {
|
||||
follow: 'Перейдіть за посиланням',
|
||||
click: 'Клацніть',
|
||||
here: 'тут',
|
||||
confirmed_email: 'для підтвердження електронної пошти',
|
||||
subject: {
|
||||
send_verification_email: 'Завершіть верифікацію Вашої електронної пошти для сайту {site_name}',
|
||||
},
|
||||
body: {
|
||||
send_verification_email: {
|
||||
p1: 'Ви щойно зареструвалися на сайті {site_name}',
|
||||
p2: 'Якщо Ви не реєструвалися на цьому сайті, просто проігноруйте дане повідомлення.',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
@@ -1,60 +1,13 @@
|
||||
import pages from '@/locales/en/pages'
|
||||
import auth from '@/locales/en/auth'
|
||||
import form from '@/locales/en/form'
|
||||
import schema from '@/locales/en/schema'
|
||||
import db from '@/locales/en/db'
|
||||
|
||||
export default {
|
||||
auth: {
|
||||
title: 'Auth',
|
||||
subtitle: 'Simple authentication service',
|
||||
sign_in: 'Sign In',
|
||||
common: {
|
||||
something_went_wrong: 'Something went wrong!',
|
||||
},
|
||||
form: {
|
||||
login: {
|
||||
header_label: 'Welcome back',
|
||||
back_button_label: 'Don\'t have an account?',
|
||||
},
|
||||
register: {
|
||||
header_label: 'Create an account',
|
||||
back_button_label: 'Already have an account?',
|
||||
},
|
||||
error: {
|
||||
email_in_use: 'Email already in use with different provider!',
|
||||
header_label: 'Oops! Something went wrong!',
|
||||
back_button_label: 'Back to login',
|
||||
email_taken: 'Can\'t create an user! Wait for verification by provided email.',
|
||||
invalid_fields: 'Invalid fields!',
|
||||
invalid_credentials: 'Invalid Credentials!',
|
||||
access_denied: 'Access denied!',
|
||||
},
|
||||
},
|
||||
email: {
|
||||
success: {
|
||||
confirmation_email_sent: 'Confirmation email sent!',
|
||||
},
|
||||
error: {
|
||||
verification_email_sending_error: 'Could not send verification email!',
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
message: {
|
||||
email_required: 'Invalid email address',
|
||||
password_required: `Password is required`,
|
||||
name_required: `Name is required`,
|
||||
password_min: `Password must be at least {min} characters`,
|
||||
},
|
||||
},
|
||||
form: {
|
||||
label: {
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirm_password: 'Confirm password',
|
||||
login: 'Login',
|
||||
name: 'Name',
|
||||
register: 'Register',
|
||||
continue_with: 'Or continue with',
|
||||
},
|
||||
placeholder: {
|
||||
email: 'john.doe@example.com',
|
||||
name: 'John Doe',
|
||||
},
|
||||
},
|
||||
pages,
|
||||
auth,
|
||||
form,
|
||||
schema,
|
||||
db,
|
||||
} as const
|
||||
61
locales/en/auth.ts
Normal file
61
locales/en/auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export default {
|
||||
title: 'Auth',
|
||||
subtitle: 'Simple authentication service',
|
||||
sign_in: 'Sign In',
|
||||
common: {
|
||||
something_went_wrong: 'Something went wrong!',
|
||||
},
|
||||
form: {
|
||||
label: {
|
||||
continue_with: 'Or continue with',
|
||||
},
|
||||
login: {
|
||||
header_label: 'Welcome back',
|
||||
back_button_label: 'Don\'t have an account?',
|
||||
reset_password_link_text: 'Forgot password?',
|
||||
},
|
||||
register: {
|
||||
button: 'Register',
|
||||
header_label: 'Create an account',
|
||||
back_button_label: 'Already have an account?',
|
||||
},
|
||||
verification: {
|
||||
header_label: 'Confirming your account',
|
||||
back_button_label: 'Back to login',
|
||||
},
|
||||
reset: {
|
||||
button: 'Send reset email',
|
||||
header_label: 'Forgot your password?',
|
||||
back_button_label: 'Back to login',
|
||||
},
|
||||
new_password: {
|
||||
button: 'Reset password',
|
||||
header_label: 'Enter a new password',
|
||||
back_button_label: 'Back to login',
|
||||
},
|
||||
error: {
|
||||
email_in_use: 'Email already in use with different provider!',
|
||||
header_label: 'Oops! Something went wrong!',
|
||||
back_button_label: 'Back to login',
|
||||
email_taken: 'Can\'t create an user! Wait for verification by provided email.',
|
||||
invalid_fields: 'Invalid fields!',
|
||||
invalid_credentials: 'Invalid Credentials!',
|
||||
invalid_email: 'Email does not exist!',
|
||||
access_denied: 'Access denied!',
|
||||
missing_token: 'Missing token!',
|
||||
invalid_token: 'Invalid token!',
|
||||
expired_token: 'Token has expired!',
|
||||
},
|
||||
},
|
||||
email: {
|
||||
success: {
|
||||
confirmation_email_sent: 'Confirmation email sent!',
|
||||
reset_email_sent: 'A password reset letter has been sent to the specified email address!',
|
||||
},
|
||||
error: {
|
||||
verification_email_sending_error: 'Could not send verification email!',
|
||||
reset_password_sending_error: 'Could not send reset password email!',
|
||||
|
||||
},
|
||||
},
|
||||
} as const
|
||||
17
locales/en/db.ts
Normal file
17
locales/en/db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default {
|
||||
error: {
|
||||
update: {
|
||||
user_data: 'Could not update user data! Please, try again by reloading the page!',
|
||||
user_password: 'Could not update user password! Please, try again by reloading the page!',
|
||||
},
|
||||
common: {
|
||||
something_wrong: 'Oops! Something went wrong. Please, try again.',
|
||||
},
|
||||
},
|
||||
success: {
|
||||
update: {
|
||||
password_updated: 'Password updated successfully!',
|
||||
},
|
||||
},
|
||||
|
||||
} as const
|
||||
13
locales/en/form.ts
Normal file
13
locales/en/form.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
label: {
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirm_password: 'Confirm password',
|
||||
login: 'Login',
|
||||
name: 'Name',
|
||||
},
|
||||
placeholder: {
|
||||
email: 'dead.end@acme.com',
|
||||
name: 'John Doe',
|
||||
},
|
||||
} as const
|
||||
6
locales/en/pages.ts
Normal file
6
locales/en/pages.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
404: {
|
||||
status: '404 Not Found',
|
||||
title: 'Page Not Found',
|
||||
},
|
||||
} as const
|
||||
18
locales/en/schema.ts
Normal file
18
locales/en/schema.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
password: {
|
||||
required: 'Password is required',
|
||||
strength: {
|
||||
acme: 'Password must contain at least a single lowercase, uppercase, digit and special character. The length must be between {min} and {max} characters.',
|
||||
},
|
||||
length: {
|
||||
min: 'Password must be at least {min} characters',
|
||||
max: 'Password must be maximally {max} characters',
|
||||
},
|
||||
},
|
||||
email: {
|
||||
required: 'Email address is required or invalid format',
|
||||
},
|
||||
name: {
|
||||
required: `Name is required`,
|
||||
},
|
||||
} as const
|
||||
@@ -1,60 +1,13 @@
|
||||
import pages from '@/locales/uk/pages'
|
||||
import auth from '@/locales/uk/auth'
|
||||
import form from '@/locales/uk/form'
|
||||
import schema from '@/locales/uk/schema'
|
||||
import db from '@/locales/uk/db'
|
||||
|
||||
export default {
|
||||
auth: {
|
||||
title: 'Auth',
|
||||
subtitle: 'Простий сервіс аутентифікації',
|
||||
sign_in: 'Увійти',
|
||||
common: {
|
||||
something_went_wrong: 'Щось пішло не так!',
|
||||
},
|
||||
form: {
|
||||
login: {
|
||||
header_label: 'Вхід до облікового запису',
|
||||
back_button_label: 'Не маєте облікового запису?',
|
||||
},
|
||||
register: {
|
||||
header_label: 'Реєстрація облікового запису',
|
||||
back_button_label: 'Вже маєте обліковий запис?',
|
||||
},
|
||||
error: {
|
||||
email_in_use: 'Електронна пошта вже використовується з іншим логін-провайдером!',
|
||||
header_label: 'Отакої! Щось пішло не так!',
|
||||
back_button_label: 'Назад до форми входу до облікового запису',
|
||||
email_taken: 'Не можу створити користувача! Не пройдена верифікація за допомогою вказаної електронної пошти.',
|
||||
invalid_fields: 'Недійсні поля!',
|
||||
invalid_credentials: 'Недійсні облікові дані!',
|
||||
access_denied: 'У доступі відмовлено!',
|
||||
},
|
||||
},
|
||||
email: {
|
||||
success: {
|
||||
confirmation_email_sent: 'Лист із підтвердженням надіслано!',
|
||||
},
|
||||
error: {
|
||||
verification_email_sending_error: 'Не вдалося надіслати електронний лист для підтвердження!',
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
message: {
|
||||
email_required: 'Невірна адреса електронної пошти',
|
||||
password_required: `Необхідно ввести пароль`,
|
||||
name_required: `Необхідно вказати ім'я`,
|
||||
password_min: `Пароль має містити принаймні {min} символів`,
|
||||
},
|
||||
},
|
||||
form: {
|
||||
label: {
|
||||
email: 'Електронна пошта',
|
||||
password: 'Пароль',
|
||||
confirm_password: 'Підтвердьте пароль',
|
||||
login: 'Лоґін',
|
||||
name: 'Ім\'я та прізвище',
|
||||
register: 'Створити обліковий запис',
|
||||
continue_with: 'Або продовжити за допомогою',
|
||||
},
|
||||
placeholder: {
|
||||
email: 'polina.melnyk@mocking.net',
|
||||
name: 'Поліна Мельник',
|
||||
},
|
||||
},
|
||||
pages,
|
||||
auth,
|
||||
form,
|
||||
schema,
|
||||
db,
|
||||
} as const
|
||||
59
locales/uk/auth.ts
Normal file
59
locales/uk/auth.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export default {
|
||||
title: 'Auth',
|
||||
subtitle: 'Простий сервіс аутентифікації',
|
||||
sign_in: 'Увійти',
|
||||
common: {
|
||||
something_went_wrong: 'Щось пішло не так!',
|
||||
},
|
||||
form: {
|
||||
label: {
|
||||
continue_with: 'Або продовжити за допомогою',
|
||||
},
|
||||
login: {
|
||||
header_label: 'Вхід до облікового запису',
|
||||
back_button_label: 'Не маєте облікового запису?',
|
||||
reset_password_link_text: 'Забули пароль?',
|
||||
},
|
||||
register: {
|
||||
button: 'Створити обліковий запис',
|
||||
header_label: 'Реєстрація облікового запису',
|
||||
back_button_label: 'Вже маєте обліковий запис?',
|
||||
},
|
||||
verification: {
|
||||
header_label: 'Підтвердження вашого облікового запису',
|
||||
back_button_label: 'Назад до входу',
|
||||
},
|
||||
reset: {
|
||||
button: 'Скинути пароль',
|
||||
header_label: 'Забули ваш пароль?',
|
||||
back_button_label: 'Назад до входу',
|
||||
},
|
||||
new_password: {
|
||||
button: 'Підтвердити новий пароль',
|
||||
header_label: 'Введіть новий пароль',
|
||||
back_button_label: 'Назад до входу',
|
||||
},
|
||||
error: {
|
||||
email_in_use: 'Електронна пошта вже використовується з іншим логін-провайдером!',
|
||||
header_label: 'Отакої! Щось пішло не так!',
|
||||
back_button_label: 'Назад до форми входу до облікового запису',
|
||||
email_taken: 'Не можу створити користувача! Не пройдена верифікація за допомогою вказаної електронної пошти.',
|
||||
invalid_fields: 'Недійсні поля!',
|
||||
invalid_credentials: 'Недійсні облікові дані!',
|
||||
invalid_email: 'Електронну пошту не знайдено!',
|
||||
access_denied: 'У доступі відмовлено!',
|
||||
missing_token: 'Відсутній токен!',
|
||||
invalid_token: 'Недійсний токен!',
|
||||
expired_token: 'Сплив термін дії токена!',
|
||||
},
|
||||
},
|
||||
email: {
|
||||
success: {
|
||||
confirmation_email_sent: 'Лист із підтвердженням надіслано!',
|
||||
reset_email_sent: 'Лист для скидання паролю надіслано на вказану електронну адресу',
|
||||
},
|
||||
error: {
|
||||
verification_email_sending_error: 'Не вдалося надіслати електронний лист для підтвердження!',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
16
locales/uk/db.ts
Normal file
16
locales/uk/db.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
error: {
|
||||
update: {
|
||||
user_data: 'Не вдалося оновити дані користувача! Будь ласка, спробуйте ще раз, оновивши сторінку!',
|
||||
user_password: 'Не вдалося оновити пароль користувача! Будь ласка, спробуйте ще раз, оновивши сторінку!',
|
||||
},
|
||||
common: {
|
||||
something_wrong: 'Отакої! Щось пішло не так. Будь ласка, спробуйте ще раз.',
|
||||
},
|
||||
},
|
||||
success: {
|
||||
update: {
|
||||
password_updated: 'Пароль успішно оновлено!',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
13
locales/uk/form.ts
Normal file
13
locales/uk/form.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default {
|
||||
label: {
|
||||
email: 'Електронна пошта',
|
||||
password: 'Пароль',
|
||||
confirm_password: 'Підтвердьте пароль',
|
||||
login: 'Лоґін',
|
||||
name: 'Ім\'я та прізвище',
|
||||
},
|
||||
placeholder: {
|
||||
email: 'dead.end@acme.com',
|
||||
name: 'Джон Доу',
|
||||
},
|
||||
} as const
|
||||
6
locales/uk/pages.ts
Normal file
6
locales/uk/pages.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
404: {
|
||||
status: '404 Не знайдено',
|
||||
title: 'Сторінку не знайдено',
|
||||
},
|
||||
} as const
|
||||
18
locales/uk/schema.ts
Normal file
18
locales/uk/schema.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default {
|
||||
password: {
|
||||
required: 'Необхідно ввести пароль',
|
||||
length: {
|
||||
min: 'Пароль має містити принаймні {min} символів',
|
||||
max: 'Максимальна кількість символів у паролі: {max}',
|
||||
},
|
||||
strength: {
|
||||
acme: 'Пароль повинен містити принаймні один малий, приписний, цифровий та спеціальний символ. Довжина паролю має бути від {min} до {max} символів.',
|
||||
},
|
||||
},
|
||||
email: {
|
||||
required: 'Адреса електронної пошти обов’язкова або не дійсна',
|
||||
},
|
||||
name: {
|
||||
required: `Необхідно вказати ім'я`,
|
||||
},
|
||||
} as const
|
||||
@@ -1,32 +1,24 @@
|
||||
import { NextURL } from 'next/dist/server/web/next-url'
|
||||
import NextAuth from 'next-auth'
|
||||
import { createI18nMiddleware } from 'next-international/middleware'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { defaultLocale, locales } from '@/config/locales'
|
||||
import authConfig from '@/auth.config'
|
||||
import {
|
||||
apiAuthPrefix,
|
||||
AUTH_LOGIN_URL,
|
||||
authRoutes,
|
||||
DEFAULT_LOGIN_REDIRECT,
|
||||
publicRoutes,
|
||||
testPathnameRegex,
|
||||
} from '@/config/routes'
|
||||
import { NextURL } from 'next/dist/server/web/next-url'
|
||||
import { apiAuthPrefix, AUTH_LOGIN_URL, authRoutesRegEx, DEFAULT_LOGIN_REDIRECT, publicRoutes } from '@/config/routes'
|
||||
import { testPathnameRegex } from '@/lib/utils'
|
||||
import { createI18nMiddleware } from 'next-international/middleware'
|
||||
|
||||
interface AppRouteHandlerFnContext {
|
||||
params?: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
export const middleware = (
|
||||
request: NextRequest,
|
||||
event: AppRouteHandlerFnContext): NextResponse | null => {
|
||||
export const middleware = (request: NextRequest, event: AppRouteHandlerFnContext): NextResponse | null => {
|
||||
return NextAuth(authConfig).auth((request): any => {
|
||||
|
||||
const { nextUrl }: { nextUrl: NextURL } = request
|
||||
const isLoggedIn: boolean = !!request.auth
|
||||
const isApiAuthRoute: boolean = nextUrl.pathname.startsWith(apiAuthPrefix)
|
||||
const isPublicRoute: boolean = testPathnameRegex(publicRoutes,
|
||||
nextUrl.pathname)
|
||||
const isAuthRoute: boolean = testPathnameRegex(authRoutes, nextUrl.pathname)
|
||||
const isPublicRoute: boolean = testPathnameRegex(publicRoutes, nextUrl.pathname)
|
||||
const isAuthRoute: boolean = testPathnameRegex(authRoutesRegEx, nextUrl.pathname)
|
||||
|
||||
if (isApiAuthRoute) {
|
||||
return null
|
||||
@@ -52,9 +44,23 @@ export const middleware = (
|
||||
})(request, event) as NextResponse
|
||||
}
|
||||
|
||||
// export const config = {
|
||||
// matcher: [
|
||||
// /*
|
||||
// * Match all request paths except for the ones starting with:
|
||||
// * - api (API routes)
|
||||
// * - _next/static (static files)
|
||||
// * - _next/image (image optimization files)
|
||||
// * - favicon.ico (favicon file)
|
||||
// */
|
||||
// {
|
||||
// source: '/((?!.+\\.[\\w]+$|api|_next/image|favicon.ico|robots.txt|trpc).*)', missing: [
|
||||
// { type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' }],
|
||||
// }],
|
||||
// }
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!.+\\.[\\w]+$|_next).*)',
|
||||
'/(api|static|trpc)(.*)'],
|
||||
}
|
||||
|
||||
'/((?!.+\\.[\\w]+$|_next|_next/image|_next/static).*)', '/(api|trpc)(.*)',
|
||||
],
|
||||
}
|
||||
859
package-lock.json
generated
859
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -6,8 +6,15 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"browserslist:update": "npx update-browserslist-db@latest",
|
||||
"browserslist": "npx browserslist"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.25%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^1.5.2",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
@@ -28,6 +35,9 @@
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.51.2",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-loader-spinner": "^6.1.6",
|
||||
"sharp": "^0.33.3",
|
||||
"shart": "^0.0.4",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -43,10 +53,14 @@
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.1.4",
|
||||
"eslint-plugin-validate-filename": "^0.0.4",
|
||||
"postcss": "^8",
|
||||
"prisma": "^5.12.1",
|
||||
"sass": "^1.74.1",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRole" AS ENUM ('SUPERVISOR', 'ADMIN', 'EDITOR', 'SUPPLIER', 'CUSTOMER', 'USER', 'OBSERVER', 'SYSTEM', 'CRON');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT,
|
||||
"emailVerified" TIMESTAMP(3),
|
||||
"image" TEXT,
|
||||
"password" TEXT,
|
||||
"role" "UserRole" NOT NULL DEFAULT 'CUSTOMER',
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
|
||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_email_token_key" ON "VerificationToken"("email", "token");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "extendedData" JSONB;
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -64,3 +64,12 @@ model VerificationToken {
|
||||
|
||||
@@unique([email, token])
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([email, token])
|
||||
}
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { MIN_PASSWORD_LENGTH } from '@/config/validation'
|
||||
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, PASSWORD_STRENGTH_ACME } from '@/config/validation'
|
||||
import { object, string } from 'zod'
|
||||
|
||||
const passwordMessage = JSON.stringify(
|
||||
['schema.message.password_min', { min: MIN_PASSWORD_LENGTH }])
|
||||
// all translations is implemented in '@/components/ui/form' via TranslateClientFragment
|
||||
|
||||
const minPasswordMessage = JSON.stringify(['schema.password.length.min', { min: MIN_PASSWORD_LENGTH }])
|
||||
const maxPasswordMessage = JSON.stringify(['schema.password.length.max', { max: MAX_PASSWORD_LENGTH }])
|
||||
const maxPasswordStrength = JSON.stringify(
|
||||
['schema.password.strength.acme', { min: MIN_PASSWORD_LENGTH, max: MAX_PASSWORD_LENGTH }])
|
||||
|
||||
const email = string().trim().toLowerCase().email({ message: 'schema.email.required' })
|
||||
const password = string().trim().regex(new RegExp(PASSWORD_STRENGTH_ACME, 'mg'),
|
||||
{ message: maxPasswordStrength }).min(MIN_PASSWORD_LENGTH, { message: minPasswordMessage }).
|
||||
max(MAX_PASSWORD_LENGTH, { message: maxPasswordMessage })
|
||||
|
||||
export const LoginSchema = object({
|
||||
email: string().
|
||||
trim().
|
||||
email({ message: 'schema.message.email_required' }).
|
||||
toLowerCase(),
|
||||
password: string().
|
||||
trim().
|
||||
min(1, { message: 'schema.message.password_required' }),
|
||||
email, password: string().trim().min(1, { message: 'schema.password.required' }),
|
||||
})
|
||||
|
||||
export const RegisterSchema = object({
|
||||
email: string().
|
||||
email, password, name: string().trim().min(1, { message: 'schema.name.required' }),
|
||||
})
|
||||
|
||||
email({ message: 'schema.message.email_required' }).
|
||||
toLowerCase(),
|
||||
password: string().
|
||||
trim().
|
||||
min(MIN_PASSWORD_LENGTH, { message: passwordMessage }),
|
||||
name: string().trim().min(1, { message: 'schema.message.name_required' }),
|
||||
})
|
||||
export const ResetSchema = object({
|
||||
email,
|
||||
})
|
||||
|
||||
export const NewPasswordSchema = object({
|
||||
password,
|
||||
})
|
||||
|
||||
28
templates/email/send-verification-email.ts
Normal file
28
templates/email/send-verification-email.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { _ctBatch } from '@/lib/translate'
|
||||
import { env } from '@/lib/utils'
|
||||
|
||||
type Props = {
|
||||
confirmLink: string
|
||||
}
|
||||
|
||||
const keys: any = {
|
||||
follow: 'follow',
|
||||
click: 'click',
|
||||
here: 'here',
|
||||
confirm: 'confirmed_email',
|
||||
p1: ['body.send_verification_email.p1', { site_name: env('SITE_NAME') }],
|
||||
p2: 'body.send_verification_email.p2',
|
||||
} as const
|
||||
|
||||
export const body = async ({ confirmLink }: Props) => {
|
||||
const t: any = await _ctBatch(keys, 'mailer')
|
||||
|
||||
return {
|
||||
text: `${t?.p1}\n
|
||||
${t?.follow}: ${confirmLink} ${t?.confirm}
|
||||
${t?.p2}` as const,
|
||||
html: `<p>${t?.p1}</p>
|
||||
<p>${t?.click} <a href="${confirmLink}">${t?.here}</a> ${t?.confirm}</p>
|
||||
<p>${t?.p2}</p>` as const,
|
||||
} as const
|
||||
}
|
||||
@@ -38,6 +38,9 @@
|
||||
"@/locales/*": [
|
||||
"./locales/*"
|
||||
],
|
||||
"@/img/*": [
|
||||
"./resources/images/*"
|
||||
],
|
||||
"@/components/*": [
|
||||
"./components/*"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user