Compare commits
5 Commits
Author | SHA1 | Date | |
---|---|---|---|
d6b259d71c | |||
db66161d81 | |||
f17a002ac6 | |||
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
@ -113,3 +113,7 @@ fabric.properties
|
|||||||
|
|
||||||
# Android studio 3.1+ serialized cache file
|
# Android studio 3.1+ serialized cache file
|
||||||
.idea/caches/build_file_checksums.ser
|
.idea/caches/build_file_checksums.ser
|
||||||
|
/prisma/_____migrations___/
|
||||||
|
/resources/images/
|
||||||
|
/crib.md
|
||||||
|
/**/**/*.log
|
||||||
|
6
.idea/tailwindcss.xml
Normal file
6
.idea/tailwindcss.xml
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>
|
14
actions/admin.ts
Normal file
14
actions/admin.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { currentRole } from '@/lib/auth'
|
||||||
|
import { UserRole } from '@prisma/client'
|
||||||
|
|
||||||
|
export const admin = async () => {
|
||||||
|
const role = await currentRole()
|
||||||
|
|
||||||
|
if (role === UserRole.ADMIN) {
|
||||||
|
return { success: `Allowed Server Action for ${UserRole.ADMIN}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: `Forbidden Server Action for ${UserRole.ADMIN}` }
|
||||||
|
}
|
45
actions/logger.ts
Normal file
45
actions/logger.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import pino from 'pino'
|
||||||
|
|
||||||
|
const pinoConfigProd: pino.LoggerOptions = {
|
||||||
|
transport: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
target: 'pino/file', options: {
|
||||||
|
destination: './production.log', mkdir: true, minLength: 4096, sync: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
level: 'error',
|
||||||
|
redact: {
|
||||||
|
paths: ['password', '*.password'], remove: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinoConfigDev: pino.LoggerOptions = {
|
||||||
|
redact: {
|
||||||
|
paths: ['password', '*.password'], remove: false,
|
||||||
|
},
|
||||||
|
// formatters: {
|
||||||
|
// bindings: (bindings) => {
|
||||||
|
// return { pid: bindings.pid, host: bindings.hostname, node_version: process.version }
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
transport: {
|
||||||
|
targets: [
|
||||||
|
{
|
||||||
|
//target: 'pino/file',
|
||||||
|
target: 'pino-pretty', options: { destination: `./pretty.log`, mkdir: true, colorize: false }, level: 'error',
|
||||||
|
}, {
|
||||||
|
target: 'pino-pretty', level: 'trace',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const journal = process.env.NODE_ENV === 'production'
|
||||||
|
? pino(pinoConfigProd)
|
||||||
|
: pino(pinoConfigDev)
|
||||||
|
|
||||||
|
export default journal
|
||||||
|
|
||||||
|
// TODO: wait for newer version of https://betterstack.com/docs/logs/javascript/pino/
|
@ -6,7 +6,15 @@ import { signIn } from '@/config/auth'
|
|||||||
import { DEFAULT_LOGIN_REDIRECT } from '@/config/routes'
|
import { DEFAULT_LOGIN_REDIRECT } from '@/config/routes'
|
||||||
import { AuthError } from 'next-auth'
|
import { AuthError } from 'next-auth'
|
||||||
import { getUserByEmail } from '@/data/user'
|
import { getUserByEmail } from '@/data/user'
|
||||||
import { sendVerificationEmail } from '@/actions/send-verification-email'
|
import { sendTwoFactorTokenEmail, sendVerificationEmail } from '@/actions/send-verification-email'
|
||||||
|
import { generateTwoFactorToken } from '@/lib/tokens'
|
||||||
|
import { deleteTwoFactorToken, getTwoFactorTokenByEmail } from '@/data/two-factor-token'
|
||||||
|
import {
|
||||||
|
createTwoFactoComfirmation,
|
||||||
|
deleteTwoFactoComfirmation,
|
||||||
|
getTwoFactorConfirmationByUserId,
|
||||||
|
} from '@/data/two-factor-confirmation'
|
||||||
|
import journal from '@/actions/logger'
|
||||||
|
|
||||||
export const login = async (values: zInfer<typeof LoginSchema>) => {
|
export const login = async (values: zInfer<typeof LoginSchema>) => {
|
||||||
const validatedFields = LoginSchema.safeParse(values)
|
const validatedFields = LoginSchema.safeParse(values)
|
||||||
@ -15,7 +23,7 @@ export const login = async (values: zInfer<typeof LoginSchema>) => {
|
|||||||
return { error: 'auth.form.error.invalid_fields' }
|
return { error: 'auth.form.error.invalid_fields' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = validatedFields.data
|
const { email, password, code } = validatedFields.data
|
||||||
|
|
||||||
const existingUser = await getUserByEmail(email)
|
const existingUser = await getUserByEmail(email)
|
||||||
|
|
||||||
@ -27,6 +35,40 @@ export const login = async (values: zInfer<typeof LoginSchema>) => {
|
|||||||
return await sendVerificationEmail(existingUser.email, existingUser.name)
|
return await sendVerificationEmail(existingUser.email, existingUser.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingUser.isTwoFactorEnabled && existingUser.email) {
|
||||||
|
if (code) {
|
||||||
|
const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email)
|
||||||
|
if (!twoFactorToken || twoFactorToken.token !== code) {
|
||||||
|
return { error: 'auth.form.error.invalid_code' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasExpired = new Date(twoFactorToken.expires) < new Date()
|
||||||
|
if (hasExpired) {
|
||||||
|
return { error: 'auth.form.error.expired_token' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteTwoFactorToken(twoFactorToken.id)
|
||||||
|
|
||||||
|
const existingConfirmation = await getTwoFactorConfirmationByUserId(existingUser.id)
|
||||||
|
|
||||||
|
if (existingConfirmation) {
|
||||||
|
await deleteTwoFactoComfirmation(existingConfirmation.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
await createTwoFactoComfirmation(existingUser.id)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const twoFactorToken = await generateTwoFactorToken(existingUser.email)
|
||||||
|
|
||||||
|
if (twoFactorToken) {
|
||||||
|
const isOk = await sendTwoFactorTokenEmail(twoFactorToken.email, twoFactorToken.token, existingUser.name)
|
||||||
|
return { twoFactor: isOk }
|
||||||
|
}
|
||||||
|
console.error('ERROR.TYPE: could not send token')
|
||||||
|
return { error: 'common.something_went_wrong' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
email, password, redirectTo: DEFAULT_LOGIN_REDIRECT,
|
email, password, redirectTo: DEFAULT_LOGIN_REDIRECT,
|
||||||
@ -43,12 +85,12 @@ export const login = async (values: zInfer<typeof LoginSchema>) => {
|
|||||||
return { error: 'common.something_went_wrong' }
|
return { error: 'common.something_went_wrong' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// TODO: logging must be implemented
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SignInProvider = async (provider: 'google' | 'github' | 'facebook') => {
|
export const SignInProvider = async (provider: 'google' | 'github') => {
|
||||||
await signIn(provider, {
|
await signIn(provider, {
|
||||||
redirectTo: DEFAULT_LOGIN_REDIRECT,
|
redirectTo: DEFAULT_LOGIN_REDIRECT,
|
||||||
})
|
})
|
||||||
|
8
actions/logout.ts
Normal file
8
actions/logout.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { signOut } from '@/config/auth'
|
||||||
|
|
||||||
|
export const logout = async () => {
|
||||||
|
// do something separately from js client bundle prior logging out
|
||||||
|
await signOut()
|
||||||
|
}
|
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'
|
'use server'
|
||||||
|
|
||||||
|
import { RegisterSchema } from '@/schemas'
|
||||||
import { infer as zInfer } from 'zod'
|
import { infer as zInfer } from 'zod'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
|
||||||
import { RegisterSchema } from '@/schemas'
|
|
||||||
import { PASSWORD_SALT_LENGTH } from '@/config/validation'
|
import { PASSWORD_SALT_LENGTH } from '@/config/validation'
|
||||||
import { db } from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import { getUserByEmail } from '@/data/user'
|
import { getUserByEmail } from '@/data/user'
|
||||||
import { sendVerificationEmail } from '@/actions/send-verification-email'
|
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,76 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import mailer from '@/lib/mailer'
|
import mailer from '@/lib/mailer'
|
||||||
import { env } from 'process'
|
import { AUTH_NEW_PASSWORD_URL, AUTH_USER_VERIFICATION_URL } from '@/config/routes'
|
||||||
import { AUTH_EMAIL_VERIFICATION_URL } from '@/config/routes'
|
import { generatePasswordResetToken, generateTwoFactorToken, generateVerificationToken } from '@/lib/tokens'
|
||||||
import { 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) => {
|
export const sendTwoFactorTokenEmail = async (email: string, token: string, name?: string | null) => {
|
||||||
|
const { isOk, code, info, error } = await mailer({
|
||||||
|
to: name ? { name: name?.toString(), address: email } : email,
|
||||||
|
subject: await __ct({
|
||||||
|
key: 'mailer.subject.send_2FA_code',
|
||||||
|
params: { site_name: env('SITE_NAME') },
|
||||||
|
}),
|
||||||
|
text: `Your 2FA code: ${token}`,
|
||||||
|
html: `<p>Your 2FA code: ${token}</p>`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return isOk
|
||||||
|
// TODO: Log this action
|
||||||
|
// if (isOk && code === 250) {
|
||||||
|
// //return //'auth.email.success._2FA_email_sent'
|
||||||
|
// return { success: code === 250 ? 'auth.email.success._2FA_email_sent' : info?.response }
|
||||||
|
// } else {
|
||||||
|
// return { error: env('DEBUG') === 'true' ? error?.response : 'auth.email.error._2FA_email_sending_error' }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVerificationEmail = async (
|
||||||
|
email: string,
|
||||||
|
name?: string | null,
|
||||||
|
) => {
|
||||||
const verificationToken = await generateVerificationToken(email)
|
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({
|
const { isOk, code, info, error } = await mailer({
|
||||||
to: name ? [
|
to: name ? { name: name?.toString(), address: verificationToken.email } : verificationToken.email,
|
||||||
{ name: name?.toString(), address: verificationToken.email },
|
subject: await __ct({
|
||||||
`test-xyhy2bvhj@srv1.mail-tester.com`] : verificationToken.email,
|
key: 'mailer.subject.send_verification_email',
|
||||||
subject: 'Complete email verification for A-Naklejka',
|
params: { site_name: env('SITE_NAME') },
|
||||||
html: `<p>Click <a href="${confirmLink}">here</a> to confirm email</p>`,
|
}),
|
||||||
|
text: message?.text,
|
||||||
|
html: message?.html,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isOk) {
|
if (isOk) {
|
||||||
return { success: code === 250 ? 'auth.email.success.confirmation_email_sent' : info?.response }
|
return { success: code === 250 ? 'auth.email.success.confirmation_email_sent' : info?.response }
|
||||||
} else {
|
} 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 }
|
24
actions/user-verification.ts
Normal file
24
actions/user-verification.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { deleteVerificationToken, getVerificationTokenByToken } from '@/data/verification-token'
|
||||||
|
import { getUserByEmail, updateUserEmailVerified } 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!' }
|
||||||
|
|
||||||
|
await updateUserEmailVerified(existingUser.id, existingToken.email)
|
||||||
|
|
||||||
|
await deleteVerificationToken(existingToken.id)
|
||||||
|
|
||||||
|
return { success: 'User verified!' }
|
||||||
|
}
|
33
app/[locale]/(protected)/_components/navbar.tsx
Normal file
33
app/[locale]/(protected)/_components/navbar.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// eslint-disable-next-line validate-filename/naming-rules
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { CABINET_ROUTES, USER_PROFILE_URL } from '@/config/routes'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
|
import UserButton from '@/components/auth/user-button'
|
||||||
|
import LocaleSwitcher from '@/components/locale-switcher'
|
||||||
|
|
||||||
|
export const Navbar = () => {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
console.log(USER_PROFILE_URL)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-secondary flex justify-between items-center top-0 absolute px-6 py-4 w-full shadow-sm">
|
||||||
|
<div className="flex gap-x-4">
|
||||||
|
{CABINET_ROUTES.map((route) => (
|
||||||
|
<Button asChild key={route} variant={pathname.endsWith(route) ? 'default' : 'outline'} className="border">
|
||||||
|
<Link href={route}>
|
||||||
|
{route[1]?.toUpperCase() + route.substring(2)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-x-2">
|
||||||
|
<LocaleSwitcher/>
|
||||||
|
<UserButton/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
68
app/[locale]/(protected)/cabinet/admin/page.tsx
Normal file
68
app/[locale]/(protected)/cabinet/admin/page.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
import { RoleGate } from '@/components/auth/role-gate'
|
||||||
|
import FormSuccess from '@/components/form-success'
|
||||||
|
import { UserRole } from '@prisma/client'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { admin } from '@/actions/admin'
|
||||||
|
|
||||||
|
const AdminPage = () => {
|
||||||
|
const onServerActionClick = () => {
|
||||||
|
admin()
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
toast.error(data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
toast.success(data.success)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onApiRouteClick = () => {
|
||||||
|
fetch('/api/admin')
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Allow API Route')
|
||||||
|
} else {
|
||||||
|
toast.error('Forbidden API Route')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-[600px]">
|
||||||
|
<CardHeader>
|
||||||
|
<p className="text-2xl font-semibold text-center">
|
||||||
|
🔑 Admin
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<RoleGate allowedRole={UserRole.ADMIN}>
|
||||||
|
<FormSuccess message="You are allowed to see this content!"/>
|
||||||
|
</RoleGate>
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-lg border p-3 shadow-md">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Admin-only API Route
|
||||||
|
</p>
|
||||||
|
<Button onClick={onApiRouteClick}>
|
||||||
|
Click to test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-lg border p-3 shadow-md">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Admin-only Server Action
|
||||||
|
</p>
|
||||||
|
<Button onClick={onServerActionClick}>
|
||||||
|
Click to test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminPage
|
14
app/[locale]/(protected)/cabinet/client/page.tsx
Normal file
14
app/[locale]/(protected)/cabinet/client/page.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { UserInfo } from '@/components/cabinet/user-info'
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser'
|
||||||
|
|
||||||
|
const ClientPage = ({ params }: any) => {
|
||||||
|
const user = useCurrentUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserInfo user={user} label="💻 Client component"/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClientPage
|
@ -1,16 +1,14 @@
|
|||||||
import { auth, signOut } from '@/config/auth'
|
'use client'
|
||||||
|
|
||||||
|
import { logout } from '@/actions/logout'
|
||||||
|
|
||||||
|
const CabinetPage = ({ params }: any) => {
|
||||||
|
|
||||||
|
const btnOnClick = () => logout()
|
||||||
|
|
||||||
const CabinetPage = async () => {
|
|
||||||
const session = await auth()
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="bg-neutral-100 p-10">
|
||||||
{JSON.stringify(session)}
|
<button onClick={btnOnClick} type="submit">SignOut</button>
|
||||||
<form action={async () => {
|
|
||||||
'use server'
|
|
||||||
await signOut()
|
|
||||||
}}>
|
|
||||||
<button type="submit">SignOut {session?.user.role}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
15
app/[locale]/(protected)/cabinet/server/page.tsx
Normal file
15
app/[locale]/(protected)/cabinet/server/page.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { currentUser } from '@/lib/auth'
|
||||||
|
import { UserInfo } from '@/components/cabinet/user-info'
|
||||||
|
|
||||||
|
const ServerPage = async () => {
|
||||||
|
const user = await currentUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserInfo user={user} label="🗄️ Server component"/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServerPage
|
||||||
|
|
17
app/[locale]/(protected)/layout.tsx
Normal file
17
app/[locale]/(protected)/layout.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Navbar } from '@/app/[locale]/(protected)/_components/navbar'
|
||||||
|
|
||||||
|
interface ProtectedLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedLayout = ({ children }: ProtectedLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="w-full h-full flex flex-col justify-center items-center gap-y-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-400 to-blue-800">
|
||||||
|
<Navbar/>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedLayout
|
@ -1,23 +1,5 @@
|
|||||||
'use client'
|
const AboutPage = () => {
|
||||||
|
return <>ABOUT</>
|
||||||
import mailer from '@/lib/mailer'
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
export default AboutPage
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @type {JSX.Element}
|
||||||
|
*/
|
||||||
export default function AboutUsPage () {
|
export default function AboutUsPage () {
|
||||||
return (<div>AboutUsPage</div>)
|
return (<div>AboutUsPage</div>)
|
||||||
}
|
}
|
@ -2,8 +2,9 @@ import { Poppins } from 'next/font/google'
|
|||||||
import { getScopedI18n } from '@/locales/server'
|
import { getScopedI18n } from '@/locales/server'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import LoginButton from '@/components/auth/LoginButton'
|
import LoginButton from '@/components/auth/login-button'
|
||||||
import { bg as bgg } from '@/config/layout'
|
import Image from 'next/image'
|
||||||
|
import wolf from '@/img/Gray wolf portrait.jpg'
|
||||||
|
|
||||||
const font = Poppins({
|
const font = Poppins({
|
||||||
subsets: ['latin'], weight: ['600'],
|
subsets: ['latin'], weight: ['600'],
|
||||||
@ -19,6 +20,7 @@ export default async function Home () {
|
|||||||
🔐 {t('title')}
|
🔐 {t('title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-white">{t('subtitle')}</p>
|
<p className="text-lg text-white">{t('subtitle')}</p>
|
||||||
|
<Image src={wolf} alt="Picture of a wolf" width={430} placeholder="blur"/>
|
||||||
<div>
|
<div>
|
||||||
<LoginButton>
|
<LoginButton>
|
||||||
<Button variant="secondary" size="lg">{t('sign_in')}</Button>
|
<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 = () => {
|
const AuthErrorPage = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ReactElement } from 'react'
|
import { ReactElement } from 'react'
|
||||||
import Navbar from '@/components/auth/Navbar'
|
import Navbar from '@/components/auth/navbar'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
//params: { locale: string };
|
//params: { locale: string };
|
||||||
@ -10,7 +10,6 @@ type Props = {
|
|||||||
const AuthLayout = ({ children }: Props) => {
|
const AuthLayout = ({ children }: Props) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar/>
|
|
||||||
<div
|
<div
|
||||||
className="h-full flex items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-400 to-blue-800">
|
className="h-full flex items-center justify-center bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-400 to-blue-800">
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { LoginForm } from '@/components/auth/LoginForm'
|
import { LoginForm } from '@/components/auth/login-form'
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
return (
|
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 = () => {
|
const RegisterPage = () => {
|
||||||
return (
|
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%;
|
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 {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
@ -16,7 +30,7 @@ body,
|
|||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 200 98% 39%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } from 'next/font/google'
|
import { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
|
||||||
import { ReactElement } from 'react'
|
import { ReactElement } from 'react'
|
||||||
import { I18nProviderClient } from '@/locales/client'
|
import { I18nProviderClient } from '@/locales/client'
|
||||||
import { lc } from '@/lib/utils'
|
import { lc } from '@/lib/utils'
|
||||||
|
import './globals.css'
|
||||||
|
import { SessionProvider } from 'next-auth/react'
|
||||||
|
import { auth } from '@/config/auth'
|
||||||
|
import Navbar from '@/components/auth/navbar'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['cyrillic'] })
|
const inter = Inter({ subsets: ['cyrillic'] })
|
||||||
|
|
||||||
@ -11,19 +15,22 @@ export const metadata: Metadata = {
|
|||||||
title: 'Create Next App', description: 'Generated by create next app',
|
title: 'Create Next App', description: 'Generated by create next app',
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type RootLayoutProps = {
|
||||||
params: { locale: string }; children: ReactElement;
|
params: { locale: string }; children: ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout ({
|
export default async function RootLayout ({ params: { locale }, children }: Readonly<RootLayoutProps>) {
|
||||||
params: { locale }, children,
|
const session = await auth()
|
||||||
}: Readonly<Props>) {
|
|
||||||
|
|
||||||
return (<html lang={lc(locale).java}>
|
return (<SessionProvider session={session}>
|
||||||
|
<html lang={lc(locale)?.java}>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<I18nProviderClient locale={locale} fallback={<p>Loading...</p>}>
|
<I18nProviderClient locale={locale} fallback="Loading...">
|
||||||
|
<Navbar/>
|
||||||
|
<Toaster/>
|
||||||
{children}
|
{children}
|
||||||
</I18nProviderClient>
|
</I18nProviderClient>
|
||||||
</body>
|
</body>
|
||||||
</html>)
|
</html>
|
||||||
|
</SessionProvider>)
|
||||||
}
|
}
|
||||||
|
10
app/api/admin/route.ts
Normal file
10
app/api/admin/route.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { currentRole } from '@/lib/auth'
|
||||||
|
import { UserRole } from '@prisma/client'
|
||||||
|
|
||||||
|
export async function GET () {
|
||||||
|
const role = await currentRole()
|
||||||
|
const status: number = role === UserRole.ADMIN ? 200 : 403
|
||||||
|
|
||||||
|
return new NextResponse(null, { status })
|
||||||
|
}
|
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/', '/en/auth/', '/en/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 Credentials from 'next-auth/providers/credentials'
|
||||||
import Google from 'next-auth/providers/google'
|
import Google from 'next-auth/providers/google'
|
||||||
import Github from 'next-auth/providers/github'
|
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 { LoginSchema } from '@/schemas'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
import { getUserByEmail } from '@/data/user'
|
import { getUserByEmail } from '@/data/user'
|
||||||
import { env } from 'process'
|
import { env } from '@/lib/utils'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
secret: env.AUTH_SECRET,
|
secret: env('AUTH_SECRET'),
|
||||||
providers: [
|
providers: [
|
||||||
Google({
|
Google({
|
||||||
clientId: env.GOOGLE_CLIENT_ID,
|
clientId: env('GOOGLE_CLIENT_ID'),
|
||||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
clientSecret: env('GOOGLE_CLIENT_SECRET'),
|
||||||
}),
|
}),
|
||||||
Github({
|
Github({
|
||||||
clientId: env.GITHUB_CLIENT_ID,
|
clientId: env('GITHUB_CLIENT_ID'),
|
||||||
clientSecret: env.GITHUB_CLIENT_SECRET,
|
clientSecret: env('GITHUB_CLIENT_SECRET'),
|
||||||
}),
|
}),
|
||||||
//Twitter({}),
|
//Twitter({}),
|
||||||
/*Facebook({
|
/*Facebook({
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import { useChangeLocale, useCurrentLocale } from '@/locales/client'
|
|
||||||
import { LC, type loc } from '@/config/locales'
|
|
||||||
import { ChangeEvent } from 'react'
|
|
||||||
import styles from '@/styles/LocaleSwitcher.module.scss'
|
|
||||||
|
|
||||||
export default function LocaleSwitcher () {
|
|
||||||
const changeLocale = useChangeLocale()
|
|
||||||
const locale = useCurrentLocale()
|
|
||||||
const selectHandler = (e: ChangeEvent<HTMLSelectElement>) => changeLocale(
|
|
||||||
e.target.value as loc)
|
|
||||||
|
|
||||||
return (
|
|
||||||
//@ts-ignore
|
|
||||||
<select onChange={selectHandler} defaultValue={locale}
|
|
||||||
className={styles['yo-locale-switcher']}>
|
|
||||||
{LC.map(item => (
|
|
||||||
<option key={item.iso} value={item.code}>
|
|
||||||
{item.iso.toUpperCase()}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)
|
|
||||||
}
|
|
@ -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,65 +0,0 @@
|
|||||||
|
|
||||||
'use client'
|
|
||||||
//https://gist.github.com/mjbalcueva/b21f39a8787e558d4c536bf68e267398
|
|
||||||
|
|
||||||
import { forwardRef, useState } from 'react'
|
|
||||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input, InputProps } from '@/components/ui/input'
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import { FormControl } from '@/components/ui/form'
|
|
||||||
|
|
||||||
const PasswordInput = forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
|
||||||
const disabled = props.value === '' || props.value === undefined ||
|
|
||||||
props.disabled
|
|
||||||
|
|
||||||
return (<div className="relative">
|
|
||||||
<Input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
className={cn('hide-password-toggle pr-10', className)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{showPassword && !disabled ? (
|
|
||||||
<EyeIcon
|
|
||||||
className="h-4 w-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EyeOffIcon
|
|
||||||
className="h-4 w-4"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="sr-only">
|
|
||||||
{showPassword ? 'Hide password' : 'Show password'}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* hides browsers password toggles */}
|
|
||||||
<style>{`
|
|
||||||
.hide-password-toggle::-ms-reveal,
|
|
||||||
.hide-password-toggle::-ms-clear {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
PasswordInput.displayName = 'PasswordInput'
|
|
||||||
|
|
||||||
export { PasswordInput }
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||||
import { Header } from '@/components/auth/Header'
|
import { Header } from '@/components/auth/header'
|
||||||
import { Social } from '@/components/auth/Social'
|
import { Social } from '@/components/auth/social'
|
||||||
import { BackButton } from '@/components/auth/BackButton'
|
import { BackButton } from '@/components/auth/back-button'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@ -25,7 +26,7 @@ export const CardWrapper = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="max-w-[414px] w-[100%] shadow-md md:min-w-[414px] sm:w-full">
|
className={`shadow-2xl max-w-[430px] w-full sm:min-w-[430px]`}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Header label={headerLabel} title={headerTitle}/>
|
<Header label={headerLabel} title={headerTitle}/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -33,8 +34,7 @@ export const CardWrapper = ({
|
|||||||
{children}
|
{children}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
{showSocial && <CardFooter className="flex-wrap">
|
{showSocial && <CardFooter className="flex-wrap">
|
||||||
<div className="relative flex-none w-[100%] mb-4"
|
<div className="relative flex-none w-[100%] mb-4">
|
||||||
style={{ display: 'block' }}>
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t"></span>
|
<span className="w-full border-t"></span>
|
||||||
</div>
|
</div>
|
||||||
@ -43,8 +43,6 @@ export const CardWrapper = ({
|
|||||||
className="bg-background px-2 text-muted-foreground">{continueWithLabel}</span>
|
className="bg-background px-2 text-muted-foreground">{continueWithLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*<Separator className="my-4"/>*/}
|
|
||||||
<Social/>
|
<Social/>
|
||||||
</CardFooter>}
|
</CardFooter>}
|
||||||
<CardFooter>
|
<CardFooter>
|
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { CardWrapper } from '@/components/auth/CardWrapper'
|
import { CardWrapper } from '@/components/auth/card-wrapper'
|
||||||
import { AUTH_LOGIN_URL } from '@/config/routes'
|
import { AUTH_LOGIN_URL } from '@/config/routes'
|
||||||
import { useI18n } from '@/locales/client'
|
import { useI18n } from '@/locales/client'
|
||||||
import { TriangleAlert } from 'lucide-react'
|
import { TriangleAlert } from 'lucide-react'
|
||||||
@ -14,9 +14,9 @@ const ErrorCard = () => {
|
|||||||
backButtonLabel={t('auth.form.error.back_button_label')}
|
backButtonLabel={t('auth.form.error.back_button_label')}
|
||||||
backButtonHref={AUTH_LOGIN_URL}
|
backButtonHref={AUTH_LOGIN_URL}
|
||||||
>
|
>
|
||||||
<div className="w-full flex items-center justify-center">
|
<div className="w-full flex items-center justify-center text-destructive">
|
||||||
<TriangleAlert className="w-4 h-4 text-destructive"/>
|
<TriangleAlert className="w-4 h-4 mr-1.5"/>
|
||||||
<p>ssss</p>
|
<p>Hush little baby... this is prohibited zone!</p>
|
||||||
</div>
|
</div>
|
||||||
</CardWrapper>
|
</CardWrapper>
|
||||||
)
|
)
|
138
components/auth/login-form.tsx
Normal file
138
components/auth/login-form.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { infer as zInfer } from 'zod'
|
||||||
|
import { useState, useTransition } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
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 { login } from '@/actions/login'
|
||||||
|
import { LoginSchema } from '@/schemas'
|
||||||
|
import { AUTH_REGISTER_URL, AUTH_RESET_PASSWORD_URL } from '@/config/routes'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export const LoginForm = () => {
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const urlError = searchParams.get('error') === 'OAuthAccountNotLinked' ? t('auth.form.error.email_in_use') : ''
|
||||||
|
|
||||||
|
const [showTwoFactor, setShowTwoFactor] = useState<boolean>(false)
|
||||||
|
const [error, setError] = useState<string | undefined>('')
|
||||||
|
const [success, setSuccess] = useState<string | undefined>('')
|
||||||
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
|
const form = useForm<zInfer<typeof LoginSchema>>({
|
||||||
|
resolver: zodResolver(LoginSchema), defaultValues: {
|
||||||
|
email: '', password: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (values: zInfer<typeof LoginSchema>) => {
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
login(values).then((data) => {
|
||||||
|
//@ts-ignore
|
||||||
|
if (data?.error) {
|
||||||
|
form.reset() //@ts-ignore
|
||||||
|
setError(t(data?.error))
|
||||||
|
}
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
if (data?.success) {
|
||||||
|
form.reset() //@ts-ignore
|
||||||
|
setSuccess(t(data?.success))
|
||||||
|
}
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
if (data?.twoFactor) { //@ts-ignore
|
||||||
|
setShowTwoFactor(data?.twoFactor)
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
setError('auth.common.something_went_wrong')
|
||||||
|
//TODO: do logging
|
||||||
|
console.log(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<CardWrapper
|
||||||
|
headerLabel={t('auth.form.login.header_label')}
|
||||||
|
headerTitle={t('auth.title')}
|
||||||
|
backButtonLabel={t('auth.form.login.back_button_label')}
|
||||||
|
backButtonHref={AUTH_REGISTER_URL}
|
||||||
|
showSocial
|
||||||
|
continueWithLabel={t('auth.form.label.continue_with')}
|
||||||
|
>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className={showTwoFactor ? 'space-y-6' : 'space-y-2'}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{showTwoFactor && (
|
||||||
|
<FormField control={form.control} name="code"
|
||||||
|
render={({ field }) => (<FormItem>
|
||||||
|
<FormLabel>{t('form.label.two_factor')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="¹₂³₄⁵₆"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage className="text-xs"/>
|
||||||
|
</FormItem>)}/>
|
||||||
|
)}
|
||||||
|
{!showTwoFactor && (<>
|
||||||
|
<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>)}/>
|
||||||
|
<FormField control={form.control} name="password"
|
||||||
|
render={({ field }) => (<FormItem>
|
||||||
|
<FormLabel>{t('form.label.password')}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={isPending}
|
||||||
|
placeholder="******"
|
||||||
|
type="password"
|
||||||
|
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}>
|
||||||
|
{showTwoFactor ? t('form.button.two_factor') : t('form.button.login')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardWrapper>)
|
||||||
|
}
|
21
components/auth/logout-button.tsx
Normal file
21
components/auth/logout-button.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { logout } from '@/actions/logout'
|
||||||
|
|
||||||
|
interface LogoutButtonProps {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogoutButton = ({ children }: LogoutButtonProps) => {
|
||||||
|
const onClick = () => logout()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogoutButton
|
||||||
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
|||||||
'use client'
|
import LocaleSwitcher from '@/components/locale-switcher'
|
||||||
//import { useScopedI18n } from '@/locales/client'
|
|
||||||
import LocaleSwitcher from '@/components/LocaleSwitcher'
|
|
||||||
|
|
||||||
export default function Navbar () {
|
export default function Navbar () {
|
||||||
//const t = useScopedI18n('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-6"
|
||||||
|
>
|
||||||
|
<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,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { useI18n } from '@/locales/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import FormError from '@/components/FormError'
|
import FormError from '@/components/form-error'
|
||||||
import FormSuccess from '@/components/FormSuccess'
|
import FormSuccess from '@/components/form-success'
|
||||||
|
|
||||||
import { register } from '@/actions/register'
|
import { register } from '@/actions/register'
|
||||||
import { RegisterSchema } from '@/schemas'
|
import { RegisterSchema } from '@/schemas'
|
||||||
import { AUTH_LOGIN_URL } from '@/config/routes'
|
import { AUTH_LOGIN_URL } from '@/config/routes'
|
||||||
|
|
||||||
export const RegisterForm = () => {
|
export const RegisterForm = () => {
|
||||||
|
// TODO: create repeat password field
|
||||||
// const [currentPassword, setCurrentPassword] = useState('')
|
// const [currentPassword, setCurrentPassword] = useState('')
|
||||||
// const [password, setPassword] = useState('')
|
// const [password, setPassword] = useState('')
|
||||||
// const [passwordConfirmation, setPasswordConfirmation] = useState('')
|
// const [passwordConfirmation, setPasswordConfirmation] = useState('')
|
||||||
@ -59,7 +60,7 @@ export const RegisterForm = () => {
|
|||||||
backButtonLabel={t('auth.form.register.back_button_label')}
|
backButtonLabel={t('auth.form.register.back_button_label')}
|
||||||
backButtonHref={AUTH_LOGIN_URL}
|
backButtonHref={AUTH_LOGIN_URL}
|
||||||
showSocial
|
showSocial
|
||||||
continueWithLabel={t('form.label.continue_with')}
|
continueWithLabel={t('auth.form.label.continue_with')}
|
||||||
>
|
>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@ -91,7 +92,7 @@ export const RegisterForm = () => {
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder={t('form.placeholder.email')}
|
placeholder={t('form.placeholder.email')}
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="email"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage className="text-xs"/>
|
<FormMessage className="text-xs"/>
|
||||||
@ -115,7 +116,7 @@ export const RegisterForm = () => {
|
|||||||
<FormSuccess message={success}/>
|
<FormSuccess message={success}/>
|
||||||
<FormError message={error}/>
|
<FormError message={error}/>
|
||||||
<Button type="submit" className="w-full" disabled={isPending}>
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
{t('form.label.register')}
|
{t('auth.form.register.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
@ -3,7 +3,6 @@
|
|||||||
import { infer as zInfer } from 'zod'
|
import { infer as zInfer } from 'zod'
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useSearchParams } from 'next/navigation'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -14,39 +13,34 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { useI18n } from '@/locales/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import FormError from '@/components/FormError'
|
import FormError from '@/components/form-error'
|
||||||
import FormSuccess from '@/components/FormSuccess'
|
import FormSuccess from '@/components/form-success'
|
||||||
import { login } from '@/actions/login'
|
import { ResetSchema } from '@/schemas'
|
||||||
import { LoginSchema } from '@/schemas'
|
import { AUTH_LOGIN_URL } from '@/config/routes'
|
||||||
import { AUTH_REGISTER_URL } from '@/config/routes'
|
import { reset } from '@/actions/reset'
|
||||||
|
|
||||||
export const LoginForm = () => {
|
export const ResetForm = () => {
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const urlError = searchParams.get('error') === 'OAuthAccountNotLinked'
|
|
||||||
? t('auth.form.error.email_in_use')
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const [error, setError] = useState<string | undefined>('')
|
const [error, setError] = useState<string | undefined>('')
|
||||||
const [success, setSuccess] = useState<string | undefined>('')
|
const [success, setSuccess] = useState<string | undefined>('')
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const form = useForm<zInfer<typeof LoginSchema>>({
|
const form = useForm<zInfer<typeof ResetSchema>>({
|
||||||
resolver: zodResolver(LoginSchema), defaultValues: {
|
resolver: zodResolver(ResetSchema), defaultValues: {
|
||||||
email: '', password: '',
|
email: '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = (values: zInfer<typeof LoginSchema>) => {
|
const onSubmit = (values: zInfer<typeof ResetSchema>) => {
|
||||||
setError('')
|
setError('')
|
||||||
setSuccess('')
|
setSuccess('')
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
login(values).then((data) => {
|
reset(values).then((data) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
setError(t(data?.error))
|
setError(t(data?.error))
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -56,12 +50,10 @@ export const LoginForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (<CardWrapper
|
return (<CardWrapper
|
||||||
headerLabel={t('auth.form.login.header_label')}
|
|
||||||
headerTitle={t('auth.title')}
|
headerTitle={t('auth.title')}
|
||||||
backButtonLabel={t('auth.form.login.back_button_label')}
|
headerLabel={t('auth.form.reset.header_label')}
|
||||||
backButtonHref={AUTH_REGISTER_URL}
|
backButtonLabel={t('auth.form.reset.back_button_label')}
|
||||||
showSocial
|
backButtonHref={AUTH_LOGIN_URL}
|
||||||
continueWithLabel={t('form.label.continue_with')}
|
|
||||||
>
|
>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
@ -78,35 +70,19 @@ export const LoginForm = () => {
|
|||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
placeholder={t('form.placeholder.email')}
|
placeholder={t('form.placeholder.email')}
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="email"
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage className="text-xs"/>
|
|
||||||
</FormItem>)}/>
|
|
||||||
{/*Password*/}
|
|
||||||
<FormField control={form.control} name="password"
|
|
||||||
render={({ field }) => (<FormItem>
|
|
||||||
<FormLabel>{t('form.label.password')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
disabled={isPending}
|
|
||||||
placeholder="******"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage className="text-xs"/>
|
<FormMessage className="text-xs"/>
|
||||||
</FormItem>)}/>
|
</FormItem>)}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormSuccess message={success}/>
|
<FormSuccess message={success}/>
|
||||||
<FormError message={error || urlError}/>
|
<FormError message={error}/>
|
||||||
<Button type="submit" className="w-full" disabled={isPending}>
|
<Button type="submit" className="w-full" disabled={isPending}>
|
||||||
{t('form.label.login')}
|
{t('auth.form.reset.button')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardWrapper>)
|
</CardWrapper>)
|
||||||
}
|
}
|
||||||
|
|
||||||
//1:30:00
|
|
23
components/auth/role-gate.tsx
Normal file
23
components/auth/role-gate.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { UserRole } from '@prisma/client'
|
||||||
|
import { useCurrentRole } from '@/hooks/useCurrentRole'
|
||||||
|
import FormError from '@/components/form-error'
|
||||||
|
|
||||||
|
interface RoleGateProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
allowedRole: UserRole
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoleGate = ({
|
||||||
|
children,
|
||||||
|
allowedRole,
|
||||||
|
}: RoleGateProps) => {
|
||||||
|
const role = useCurrentRole()
|
||||||
|
|
||||||
|
if (role !== allowedRole) {
|
||||||
|
return <FormError message="You do not have permission to view this content!"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
@ -11,12 +11,12 @@ export const Social = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center w-full gap-x-2">
|
<div className="flex items-center w-full gap-x-2">
|
||||||
<Button size="lg" className="w-full" variant="outline"
|
<Button size="lg" className="w-full" variant="outline" role="button"
|
||||||
onClick={() => SignInProvider('google')}>
|
onClick={() => SignInProvider('google')} aria-label="Sign in with Google">
|
||||||
<FcGoogle className="w-5 h-5"/>
|
<FcGoogle className="w-5 h-5"/>
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="lg" className="w-full" variant="outline"
|
<Button size="lg" className="w-full" variant="outline" role="button"
|
||||||
onClick={() => SignInProvider('github')}>
|
onClick={() => SignInProvider('github')} aria-label="Sign in with Github">
|
||||||
<FaGithub className="w-5 h-5"/>
|
<FaGithub className="w-5 h-5"/>
|
||||||
</Button>
|
</Button>
|
||||||
{/*<Button size="lg" className="w-full" variant="outline" onClick={() => {}}>
|
{/*<Button size="lg" className="w-full" variant="outline" onClick={() => {}}>
|
38
components/auth/user-button.tsx
Normal file
38
components/auth/user-button.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser'
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { IoExitOutline } from 'react-icons/io5'
|
||||||
|
import LogoutButton from '@/components/auth/logout-button'
|
||||||
|
|
||||||
|
const fallbackInitials = (name?: string | null | undefined): string => {
|
||||||
|
return (name ?? '').split(' ').map((w: string) => w[0]).join('').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserButton = () => {
|
||||||
|
const user = useCurrentUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="outline-0">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={user?.image || ''} alt="User Avatar"/>
|
||||||
|
<AvatarFallback className="bg-sky-600 text-muted text-lg font-light">
|
||||||
|
{fallbackInitials(user?.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-28 p-0" align="end">
|
||||||
|
<LogoutButton>
|
||||||
|
<DropdownMenuItem className="cursor-pointer p-2">
|
||||||
|
<IoExitOutline className="w-4 h-4 mr-2"/>
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</LogoutButton>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserButton
|
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
|
44
components/cabinet/user-info.tsx
Normal file
44
components/cabinet/user-info.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { type ExtendedUser } from '@/config/auth'
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
interface UserInfoProps {
|
||||||
|
user?: ExtendedUser
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserInfo = ({ user, label }: UserInfoProps) => {
|
||||||
|
return (
|
||||||
|
<Card className={`max-w-[430px] w-full sm:min-w-[430px] shadow-md`}>
|
||||||
|
<CardHeader>
|
||||||
|
<p className="text-2xl font-semibold text-center">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-lg border p-3 shadow-sm">
|
||||||
|
<span className="text-sm font-medium">ID</span>
|
||||||
|
<span className="trancate text-xs max-w-[180px] font-mono p-1 bg-slate-100 rounded-md">{user?.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-lg border p-3 shadow-sm">
|
||||||
|
<span className="text-sm font-medium">Name</span>
|
||||||
|
<span className="trancate text-xs max-w-[180px] font-mono p-1 bg-slate-100 rounded-md">{user?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-lg border p-3 shadow-sm">
|
||||||
|
<span className="text-sm font-medium">Email</span>
|
||||||
|
<span className="trancate text-xs max-w-[180px] font-mono p-1 bg-slate-100 rounded-md">{user?.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-lg border p-3 shadow-sm">
|
||||||
|
<span className="text-sm font-medium">Role</span>
|
||||||
|
<span className="trancate text-xs max-w-[180px] font-mono p-1 bg-slate-100 rounded-md">{user?.role}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-between items-center rounded-lg border p-3 shadow-sm">
|
||||||
|
<span className="text-sm font-medium">Two factor authentication</span>
|
||||||
|
<Badge variant={user?.isTwoFactorEnabled ? 'success' : 'destructive'}>
|
||||||
|
{user?.isTwoFactorEnabled ? 'ON' : 'OFF'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
@ -1,15 +1,14 @@
|
|||||||
import { TriangleAlert } from 'lucide-react'
|
import { TriangleAlert } from 'lucide-react'
|
||||||
|
|
||||||
type Props = {
|
type FormErrorProps = {
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormError = ({ message }: Props) => {
|
const FormError = ({ message }: FormErrorProps) => {
|
||||||
if (!message) return null
|
if (!message) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
|
||||||
className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
|
|
||||||
<TriangleAlert className="w-4 h-4"/>
|
<TriangleAlert className="w-4 h-4"/>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
</div>
|
</div>
|
@ -1,15 +1,14 @@
|
|||||||
import { CircleCheck } from 'lucide-react'
|
import { CircleCheck } from 'lucide-react'
|
||||||
|
|
||||||
type Props = {
|
type FormSuccessProps = {
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormSuccess = ({ message }: Props) => {
|
const FormSuccess = ({ message }: FormSuccessProps) => {
|
||||||
if (!message) return null
|
if (!message) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-lime-500/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-lime-800">
|
||||||
className="bg-lime-500/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-lime-800">
|
|
||||||
<CircleCheck className="w-4 h-4"/>
|
<CircleCheck className="w-4 h-4"/>
|
||||||
<p>{message}</p>
|
<p>{message}</p>
|
||||||
</div>
|
</div>
|
24
components/locale-switcher.tsx
Normal file
24
components/locale-switcher.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useChangeLocale, useCurrentLocale } from '@/locales/client'
|
||||||
|
import { LC, type loc } from '@/config/locales'
|
||||||
|
import { ChangeEvent } from 'react'
|
||||||
|
|
||||||
|
export default function LocaleSwitcher () {
|
||||||
|
const changeLocale = useChangeLocale()
|
||||||
|
const locale = useCurrentLocale()
|
||||||
|
const selectHandler = (e: ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
changeLocale(e.target.value as loc)
|
||||||
|
|
||||||
|
// {cn(styles['yo-locale-switcher'], 'pr-4')}
|
||||||
|
return (//@ts-ignore
|
||||||
|
<form><select
|
||||||
|
className="appearance-none bg-transparent block text-center text-xs text-sky-600 py-1 px-2 my-2 mx-0 outline-0"
|
||||||
|
aria-label="Switch locale"
|
||||||
|
defaultValue={locale} onChange={selectHandler}
|
||||||
|
>
|
||||||
|
{LC.map(item => (<option key={item.iso} value={item.code} className="pr-4">
|
||||||
|
{item.iso.toUpperCase()}
|
||||||
|
</option>))}
|
||||||
|
</select></form>)
|
||||||
|
}
|
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
|
50
components/ui/avatar.tsx
Normal file
50
components/ui/avatar.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
38
components/ui/badge.tsx
Normal file
38
components/ui/badge.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
success:
|
||||||
|
'border-transparent bg-emerald-500 text-primary-foreground hover:bg-emerald-500/80',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge ({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
@ -10,9 +10,9 @@ import {
|
|||||||
useFormContext,
|
useFormContext,
|
||||||
} from 'react-hook-form'
|
} from 'react-hook-form'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn, env } from '@/lib/utils'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import TranslateClientFragment from '@/components/TranslateClientFragment'
|
import TranslateClientFragment from '@/components/translate-client-fragment'
|
||||||
|
|
||||||
const Form = FormProvider
|
const Form = FormProvider
|
||||||
|
|
||||||
@ -132,8 +132,7 @@ const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<
|
|||||||
className={cn('text-sm font-medium text-destructive', className)}
|
className={cn('text-sm font-medium text-destructive', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{!process.env.IS_SERVER_FLAG && typeof body === 'string' &&
|
{!env('IS_SERVER_FLAG') && typeof body === 'string' && body.match(/^(|\[")schema\./)
|
||||||
body.includes('schema.message')
|
|
||||||
? <TranslateClientFragment message={body}/>
|
? <TranslateClientFragment message={body}/>
|
||||||
: body}
|
: body}
|
||||||
</p>)
|
</p>)
|
||||||
|
31
components/ui/sonner.tsx
Normal file
31
components/ui/sonner.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
@ -1,14 +1,24 @@
|
|||||||
import NextAuth from 'next-auth'
|
import NextAuth, { type DefaultSession } from 'next-auth'
|
||||||
import { UserRole } from '@prisma/client'
|
import { UserRole } from '@prisma/client'
|
||||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||||
import { db } from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
import authConfig from '@/auth.config'
|
import authConfig from '@/auth.config'
|
||||||
import { getUserById } from '@/data/user'
|
import { getUserById } from '@/data/user'
|
||||||
import { AUTH_ERROR_URL, AUTH_LOGIN_URL } from '@/config/routes'
|
import { AUTH_ERROR_URL, AUTH_LOGIN_URL } from '@/config/routes'
|
||||||
|
import { getCurrentLocale } from '@/locales/server'
|
||||||
|
import { type loc } from '@/config/locales'
|
||||||
|
import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation'
|
||||||
|
|
||||||
|
export type ExtendedUser = DefaultSession['user'] & {
|
||||||
|
role: UserRole,
|
||||||
|
locale: loc,
|
||||||
|
isTwoFactorEnabled?: boolean,
|
||||||
|
image?: string
|
||||||
|
}
|
||||||
|
|
||||||
declare module 'next-auth' {
|
declare module 'next-auth' {
|
||||||
interface Session {
|
interface Session {
|
||||||
user: { role: UserRole }
|
user: ExtendedUser
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,11 +52,20 @@ export const {
|
|||||||
// Prevent sign in without email verification
|
// Prevent sign in without email verification
|
||||||
if (!existingUser?.emailVerified) return false
|
if (!existingUser?.emailVerified) return false
|
||||||
|
|
||||||
// TODO: Add 2FA check
|
if (existingUser.isTwoFactorEnabled) {
|
||||||
|
const twoFactorConfirmation = await getTwoFactorConfirmationByUserId(existingUser.id)
|
||||||
|
if (!twoFactorConfirmation) return false
|
||||||
|
|
||||||
|
// Delete 2FA for the next sign in
|
||||||
|
await db.twoFactorComfirmation.delete({
|
||||||
|
where: { id: twoFactorConfirmation.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
async session ({ token, session }) {
|
async session ({ token, session }) {
|
||||||
|
|
||||||
if (token.sub && session.user) {
|
if (token.sub && session.user) {
|
||||||
session.user.id = token.sub
|
session.user.id = token.sub
|
||||||
}
|
}
|
||||||
@ -55,6 +74,12 @@ export const {
|
|||||||
session.user.role = token.role as UserRole
|
session.user.role = token.role as UserRole
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session.user) {
|
||||||
|
session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
session.user.locale = getCurrentLocale()
|
||||||
|
|
||||||
return session
|
return session
|
||||||
},
|
},
|
||||||
async jwt ({ token }) {
|
async jwt ({ token }) {
|
||||||
@ -65,6 +90,7 @@ export const {
|
|||||||
if (!existingUser) return token
|
if (!existingUser) return token
|
||||||
|
|
||||||
token.role = existingUser.role
|
token.role = existingUser.role
|
||||||
|
token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled
|
||||||
|
|
||||||
return token
|
return token
|
||||||
},
|
},
|
||||||
|
@ -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
|
// @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 = {
|
const importLocales = {
|
||||||
uk: () => import('@/locales/uk'), en: () => import('@/locales/en'),
|
uk: () => import('@/locales/uk'), en: () => import('@/locales/en'),
|
||||||
}
|
} as const
|
||||||
const LC = [
|
|
||||||
|
const LC: Locale[] = [
|
||||||
{
|
{
|
||||||
id: 'uk_UA',
|
id: 'uk_UA',
|
||||||
java: 'uk-UA',
|
java: 'uk-UA',
|
||||||
@ -23,8 +33,10 @@ const LC = [
|
|||||||
code: 'en',
|
code: 'en',
|
||||||
name: 'English',
|
name: 'English',
|
||||||
originalName: '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 }
|
const SKIP_I18N_URLS = '/api/'
|
||||||
|
|
||||||
|
export { locales, defaultLocale, fallbackLocale, LC, importLocales, type loc, SKIP_I18N_URLS }
|
@ -1,16 +1,16 @@
|
|||||||
import { env } from 'process'
|
|
||||||
import SMTPTransport from 'nodemailer/lib/smtp-transport'
|
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 = {
|
export const transportOptions: SMTPTransport | SMTPTransport.Options | string = {
|
||||||
host: env.MAIL_SERVER_HOST,
|
host: env('MAIL_SERVER_HOST'),
|
||||||
debug: env.MAIL_SERVER_DEBUG === 'true',
|
debug: env('MAIL_SERVER_DEBUG') === 'true' && env('NODE_ENV') !== 'production',
|
||||||
logger: env.MAIL_SERVER_LOG === 'true',
|
logger: env('MAIL_SERVER_LOG') === 'true' && env('NODE_ENV') !== 'production',
|
||||||
port: parseInt(env.MAIL_SERVER_PORT as string),
|
port: parseInt(env('MAIL_SERVER_PORT')),
|
||||||
secure: env.MAIL_SERVER_PORT === '465', // Use `true` for port 465, `false` for all other ports
|
secure: env('MAIL_SERVER_PORT') === '465', // Use `true` for port 465, `false` for all other ports
|
||||||
auth: {
|
auth: {
|
||||||
user: env.MAIL_SERVER_USERNAME, pass: env.MAIL_SERVER_PASSWORD,
|
user: env('MAIL_SERVER_USERNAME'), pass: env('MAIL_SERVER_PASSWORD'),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
import { locales } from '@/config/locales'
|
import { UUID_V4_REGEX } from '@/config/validation'
|
||||||
|
|
||||||
export const USER_PROFILE_URL: string = '/cabinet'
|
export const USER_PROFILE_URL: string = '/cabinet'
|
||||||
export const AUTH_LOGIN_URL: string = '/auth/login'
|
export const USER_SERVER_URL: string = `${USER_PROFILE_URL}/server`
|
||||||
export const AUTH_REGISTER_URL: string = '/auth/register'
|
export const USER_CLIENT_URL: string = `${USER_PROFILE_URL}/client`
|
||||||
export const AUTH_ERROR_URL: string = '/auth/error'
|
export const USER_ADMIN_URL: string = `${USER_PROFILE_URL}/admin`
|
||||||
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`
|
||||||
|
|
||||||
|
export const CABINET_ROUTES: string[] = [USER_SERVER_URL, USER_CLIENT_URL, USER_ADMIN_URL, USER_PROFILE_URL] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An array of routes that accessible to the public.
|
* An array of routes that accessible to the public.
|
||||||
@ -12,7 +20,7 @@ export const AUTH_EMAIL_VERIFICATION_URL: string = '/auth/email-verification'
|
|||||||
* @type {string[]}
|
* @type {string[]}
|
||||||
*/
|
*/
|
||||||
export const publicRoutes: 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.
|
* An array of routes that are used for authentication.
|
||||||
@ -20,14 +28,18 @@ export const publicRoutes: string[] = [
|
|||||||
* @type {string[]}
|
* @type {string[]}
|
||||||
*/
|
*/
|
||||||
export const authRoutes: 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.
|
* The prefix for API authentication routes.
|
||||||
* Routes that start with this prefix are used for API authentication purpose.
|
* Routes that start with this prefix are used for API authentication purposes.
|
||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
export const apiAuthPrefix: string = '/api/auth'
|
export const apiAuthPrefixRegEx: string = '/api/(auth|admin)'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default redirect path after logging in.
|
* The default redirect path after logging in.
|
||||||
@ -35,10 +47,3 @@ export const apiAuthPrefix: string = '/api/auth'
|
|||||||
*/
|
*/
|
||||||
export const DEFAULT_LOGIN_REDIRECT: string = USER_PROFILE_URL
|
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 MIN_PASSWORD_LENGTH: number = 6
|
||||||
|
export const MAX_PASSWORD_LENGTH: number = 15
|
||||||
export const PASSWORD_SALT_LENGTH: number = 10
|
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])$`
|
||||||
|
|
||||||
|
22
data/password-reset-token.ts
Normal file
22
data/password-reset-token.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import journal from '@/actions/logger'
|
||||||
|
|
||||||
|
export const getPasswordResetTokenByToken = async (token: string) => {
|
||||||
|
try {
|
||||||
|
return await db.passwordResetToken.findUnique({ where: { token } })
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ getPasswordResetTokenByToken: err, token })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPasswordResetTokenByEmail = async (email: string) => {
|
||||||
|
try {
|
||||||
|
return await db.passwordResetToken.findFirst({ where: { email } })
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ getPasswordResetTokenByEmail: err, email })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
33
data/two-factor-confirmation.ts
Normal file
33
data/two-factor-confirmation.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import journal from '@/actions/logger'
|
||||||
|
|
||||||
|
export const createTwoFactoComfirmation = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
return await db.twoFactorComfirmation.create({ data: { userId } })
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ createTwoFactoComfirmation: err, userId })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTwoFactorConfirmationByUserId = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
return await db.twoFactorComfirmation.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ getTwoFactorConfirmationByUserId: err, userId })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteTwoFactoComfirmation = async (id: string) => {
|
||||||
|
try {
|
||||||
|
return await db.twoFactorComfirmation.delete({ where: { id } })
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ deleteTwoFactoComfirmation: err, id })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
33
data/two-factor-token.ts
Normal file
33
data/two-factor-token.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import journal from '@/actions/logger'
|
||||||
|
|
||||||
|
export const getTwoFactorTokenByToken = async (token: string) => {
|
||||||
|
try {
|
||||||
|
return await db.twoFactorToken.findUnique({
|
||||||
|
where: { token },
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ getTwoFactorTokenByToken: err, token })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTwoFactorTokenByEmail = async (email: string) => {
|
||||||
|
try {
|
||||||
|
return await db.twoFactorToken.findFirst({ where: { email } })
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ getTwoFactorTokenByEmail: err, email })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteTwoFactorToken = async (id: string) => {
|
||||||
|
try {
|
||||||
|
return await db.twoFactorToken.delete({ where: { id } })
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ deleteTwoFactorToken: err, id })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
25
data/user.ts
25
data/user.ts
@ -1,10 +1,14 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
import { User } from '@prisma/client'
|
import { User } from '@prisma/client'
|
||||||
import { db } from '@/lib/db'
|
import db from '@/lib/db'
|
||||||
|
import journal from '@/actions/logger'
|
||||||
|
|
||||||
export const getUserByEmail = async (email: string): Promise<User | null> => {
|
export const getUserByEmail = async (email: string): Promise<User | null> => {
|
||||||
try {
|
try {
|
||||||
return await db.user.findUnique({ where: { email } })
|
return await db.user.findUnique({ where: { email } })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
journal.error({ getUserByEmail: err, email })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -12,7 +16,22 @@ export const getUserByEmail = async (email: string): Promise<User | null> => {
|
|||||||
export const getUserById = async (id: string): Promise<User | null> => {
|
export const getUserById = async (id: string): Promise<User | null> => {
|
||||||
try {
|
try {
|
||||||
return await db.user.findUnique({ where: { id } })
|
return await db.user.findUnique({ where: { id } })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
journal.error({ getUserById: err, id })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const updateUserEmailVerified = async (id: string, email: string) => {
|
||||||
|
try {
|
||||||
|
await db.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
email, emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ updateUserEmailVerified: err, id, email })
|
||||||
|
return { error: 'db.error.update.user_data' }
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,13 @@
|
|||||||
import { db } from '@/lib/db'
|
'use server'
|
||||||
|
|
||||||
|
import db from '@/lib/db'
|
||||||
|
import journal from '@/actions/logger'
|
||||||
|
|
||||||
export const getVerificationTokenByToken = async (token: string) => {
|
export const getVerificationTokenByToken = async (token: string) => {
|
||||||
try {
|
try {
|
||||||
return await db.verificationToken.findUnique({ where: { token } })
|
return await db.verificationToken.findUnique({ where: { token } })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
journal.error({ getVerificationTokenByToken: err, token })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -11,7 +15,18 @@ export const getVerificationTokenByToken = async (token: string) => {
|
|||||||
export const getVerificationTokenByEmail = async (email: string) => {
|
export const getVerificationTokenByEmail = async (email: string) => {
|
||||||
try {
|
try {
|
||||||
return await db.verificationToken.findFirst({ where: { email } })
|
return await db.verificationToken.findFirst({ where: { email } })
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
journal.error({ getVerificationTokenByEmail: err, email })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deleteVerificationToken = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await db.verificationToken.delete({
|
||||||
|
where: { id },
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
journal.error({ deleteVerificationToken: err, id })
|
||||||
|
}
|
||||||
|
}
|
7
hooks/useCurrentRole.ts
Normal file
7
hooks/useCurrentRole.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
|
export const useCurrentRole = () => {
|
||||||
|
const session = useSession()
|
||||||
|
|
||||||
|
return session.data?.user?.role
|
||||||
|
}
|
7
hooks/useCurrentUser.ts
Normal file
7
hooks/useCurrentUser.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useSession } from 'next-auth/react'
|
||||||
|
|
||||||
|
export const useCurrentUser = () => {
|
||||||
|
const session = useSession()
|
||||||
|
|
||||||
|
return session.data?.user
|
||||||
|
}
|
60
lib/CSP.ts
Normal file
60
lib/CSP.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// @Docs https://content-security-policy.com/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export class CSP {
|
||||||
|
private readonly request: any
|
||||||
|
private readonly on: boolean
|
||||||
|
private readonly request_headers?: Headers
|
||||||
|
private readonly csp_with_nonce?: string
|
||||||
|
private next_response?: NextResponse
|
||||||
|
|
||||||
|
constructor (request: any, on?: boolean) {
|
||||||
|
this.request = request
|
||||||
|
this.on = on ?? true
|
||||||
|
|
||||||
|
if (this.on) {
|
||||||
|
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
|
||||||
|
this.csp_with_nonce = CSP.contentSecurityPolicyHeaderValue(nonce)
|
||||||
|
this.request_headers = new Headers(this.request.headers)
|
||||||
|
this.request_headers.set('x-nonce', nonce)
|
||||||
|
this.request_headers.set('x-xxx', '123')
|
||||||
|
this.request_headers.set('Content-Security-Policy', this.csp_with_nonce)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static contentSecurityPolicyHeaderValue (nonce: string): string {
|
||||||
|
//style-src 'self' 'nonce-${nonce}';
|
||||||
|
return `
|
||||||
|
default-src 'self';
|
||||||
|
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
|
||||||
|
style-src 'self' 'unsafe-inline';
|
||||||
|
img-src 'self' blob: data: avatars.githubusercontent.com *.googleusercontent.com;
|
||||||
|
connect-src 'self';
|
||||||
|
font-src 'self';
|
||||||
|
object-src 'none';
|
||||||
|
base-uri 'self';
|
||||||
|
form-action 'self';
|
||||||
|
frame-ancestors 'none';
|
||||||
|
upgrade-insecure-requests`.replace(/\s{2,}/g, ' ').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
next (middleware?: (request: NextRequest) => NextResponse<unknown>): NextResponse<unknown> {
|
||||||
|
if (!this.on) {
|
||||||
|
return middleware ? middleware(this.request) : NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (middleware) {
|
||||||
|
const reqNext = new NextRequest(this.request, { headers: this.request_headers })
|
||||||
|
this.next_response = middleware(reqNext)
|
||||||
|
} else {
|
||||||
|
this.next_response = NextResponse.next({
|
||||||
|
request: { headers: this.request_headers },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.next_response.headers.set('Content-Security-Policy', this.csp_with_nonce as string)
|
||||||
|
|
||||||
|
return this.next_response
|
||||||
|
}
|
||||||
|
}
|
13
lib/auth.ts
Normal file
13
lib/auth.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { auth } from '@/config/auth'
|
||||||
|
|
||||||
|
export const currentUser = async () => {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
return session?.user
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentRole = async () => {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
return session?.user?.role
|
||||||
|
}
|
17
lib/db.ts
17
lib/db.ts
@ -1,10 +1,17 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import * as process from 'process'
|
import { env } from '@/lib/utils'
|
||||||
|
|
||||||
declare global {
|
const prismaClientSingleton = () => {
|
||||||
var prisma: PrismaClient | undefined
|
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
|
|
9
lib/server.ts
Normal file
9
lib/server.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
'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,54 @@
|
|||||||
import { v4 as uuid } from 'uuid'
|
import crypto from 'crypto'
|
||||||
import {
|
import { v4 as uuidV4 } from 'uuid'
|
||||||
VERIFICATION_TOKEN_EXPIRATION_DURATION,
|
import { VERIFICATION_TOKEN_EXPIRATION_DURATION } from '@/config/auth'
|
||||||
} from '@/config/auth'
|
import db from '@/lib/db'
|
||||||
import { db } from '@/lib/db'
|
|
||||||
import { getVerificationTokenByEmail } from '@/data/verification-token'
|
import { getVerificationTokenByEmail } from '@/data/verification-token'
|
||||||
|
import { getPasswordResetTokenByEmail } from '@/data/password-reset-token'
|
||||||
|
import { deleteTwoFactorToken, getTwoFactorTokenByEmail } from '@/data/two-factor-token'
|
||||||
|
|
||||||
|
export const generateTwoFactorToken = async (email: string) => {
|
||||||
|
const token = crypto.randomInt(100_000, 1_000_000).toString()
|
||||||
|
const expires = new Date(new Date().getTime() + VERIFICATION_TOKEN_EXPIRATION_DURATION)
|
||||||
|
|
||||||
|
const existingToken = await getTwoFactorTokenByEmail(email)
|
||||||
|
|
||||||
|
if (existingToken) {
|
||||||
|
await deleteTwoFactorToken(existingToken.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await db.twoFactorToken.create({ data: { email, token, expires } })
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
export const generateVerificationToken = async (email: string) => {
|
||||||
const token = uuid()
|
const token = uuidV4()
|
||||||
const expires = new Date(
|
const expires = new Date(new Date().getTime() + VERIFICATION_TOKEN_EXPIRATION_DURATION)
|
||||||
new Date().getTime() + VERIFICATION_TOKEN_EXPIRATION_DURATION)
|
|
||||||
|
|
||||||
const existingToken = await getVerificationTokenByEmail(email)
|
const existingToken = await getVerificationTokenByEmail(email)
|
||||||
|
|
||||||
@ -22,9 +62,7 @@ export const generateVerificationToken = async (email: string) => {
|
|||||||
|
|
||||||
const verificationToken = await db.verificationToken.create({
|
const verificationToken = await db.verificationToken.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email, token, expires,
|
||||||
token,
|
|
||||||
expires,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 { type ClassValue, clsx } from 'clsx'
|
||||||
import { twMerge } from 'tailwind-merge'
|
import { twMerge } from 'tailwind-merge'
|
||||||
import { LC } from '@/config/locales'
|
import { LC, locales } from '@/config/locales'
|
||||||
import bcrypt from 'bcryptjs'
|
|
||||||
|
import { env as dotEnv } from 'process'
|
||||||
|
|
||||||
export function cn (...inputs: ClassValue[]) {
|
export function cn (...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@ -10,3 +11,19 @@ export function cn (...inputs: ClassValue[]) {
|
|||||||
export function lc (locale: string) {
|
export function lc (locale: string) {
|
||||||
return LC.filter(lc => locale === lc.code)[0]
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
16
locales/custom/mailer/en.ts
Normal file
16
locales/custom/mailer/en.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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}',
|
||||||
|
send_2FA_code: 'Your 2FA code from {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
|
16
locales/custom/mailer/uk.ts
Normal file
16
locales/custom/mailer/uk.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export default {
|
||||||
|
follow: 'Перейдіть за посиланням',
|
||||||
|
click: 'Клацніть',
|
||||||
|
here: 'тут',
|
||||||
|
confirmed_email: 'для підтвердження електронної пошти',
|
||||||
|
subject: {
|
||||||
|
send_verification_email: 'Завершіть верифікацію Вашої електронної пошти для сайту {site_name}',
|
||||||
|
send_2FA_code: 'Ваш код двофакторної аутентифікації із сайту {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 {
|
export default {
|
||||||
auth: {
|
pages,
|
||||||
title: 'Auth',
|
auth,
|
||||||
subtitle: 'Simple authentication service',
|
form,
|
||||||
sign_in: 'Sign In',
|
schema,
|
||||||
common: {
|
db,
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const
|
} as const
|
64
locales/en/auth.ts
Normal file
64
locales/en/auth.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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!',
|
||||||
|
invalid_code: 'Invalid code!',
|
||||||
|
expired_code: 'Code 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!',
|
||||||
|
_2FA_email_sent: '2FA email sent!',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
verification_email_sending_error: 'Could not send verification email!',
|
||||||
|
reset_password_sending_error: 'Could not send reset password email!',
|
||||||
|
_2FA_email_sending_error: 'Could not send 2FA 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
|
18
locales/en/form.ts
Normal file
18
locales/en/form.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export default {
|
||||||
|
label: {
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
confirm_password: 'Confirm password',
|
||||||
|
login: 'Login',
|
||||||
|
name: 'Name',
|
||||||
|
two_factor: 'Two Factor Authentication Code',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
two_factor: 'Confirm',
|
||||||
|
login: 'Login',
|
||||||
|
},
|
||||||
|
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
|
21
locales/en/schema.ts
Normal file
21
locales/en/schema.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
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`,
|
||||||
|
},
|
||||||
|
two_factor: {
|
||||||
|
length: 'Code must contain exactly {length} digits',
|
||||||
|
},
|
||||||
|
} 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 {
|
export default {
|
||||||
auth: {
|
pages,
|
||||||
title: 'Auth',
|
auth,
|
||||||
subtitle: 'Простий сервіс аутентифікації',
|
form,
|
||||||
sign_in: 'Увійти',
|
schema,
|
||||||
common: {
|
db,
|
||||||
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: 'Поліна Мельник',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const
|
} as const
|
63
locales/uk/auth.ts
Normal file
63
locales/uk/auth.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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: 'Сплив термін дії токена!',
|
||||||
|
invalid_code: 'Невірний код!',
|
||||||
|
expired_code: 'Сплив термін дії коду!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
success: {
|
||||||
|
confirmation_email_sent: 'Лист із підтвердженням надіслано!',
|
||||||
|
reset_email_sent: 'Лист для скидання паролю надіслано на вказану електронну адресу',
|
||||||
|
_2FA_email_sent: 'Код 2FA надіслано на вказану електронну адресу',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
verification_email_sending_error: 'Не вдалося надіслати електронний лист для підтвердження!',
|
||||||
|
_2FA_email_sending_error: 'Не вдалося надіслати електронний лист з 2FA кодом!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} 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
|
18
locales/uk/form.ts
Normal file
18
locales/uk/form.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export default {
|
||||||
|
label: {
|
||||||
|
email: 'Електронна пошта',
|
||||||
|
password: 'Пароль',
|
||||||
|
confirm_password: 'Підтвердьте пароль',
|
||||||
|
login: 'Лоґін',
|
||||||
|
name: 'Ім\'я та прізвище',
|
||||||
|
two_factor: 'Код двофакторної перевірки',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
two_factor: 'Підтвердити',
|
||||||
|
login: 'Лоґін',
|
||||||
|
},
|
||||||
|
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
|
21
locales/uk/schema.ts
Normal file
21
locales/uk/schema.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export default {
|
||||||
|
password: {
|
||||||
|
required: 'Необхідно ввести пароль',
|
||||||
|
length: {
|
||||||
|
min: 'Пароль має містити принаймні {min} символів',
|
||||||
|
max: 'Максимальна кількість символів у паролі: {max}',
|
||||||
|
},
|
||||||
|
strength: {
|
||||||
|
acme: 'Пароль повинен містити принаймні один малий, приписний, цифровий та спеціальний символ. Довжина паролю має бути від {min} до {max} символів.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
required: 'Адреса електронної пошти обов’язкова або не дійсна',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
required: `Необхідно вказати ім'я`,
|
||||||
|
},
|
||||||
|
two_factor: {
|
||||||
|
length: 'Код має містити рівно {length} цифр',
|
||||||
|
},
|
||||||
|
} as const
|
@ -1,60 +1,58 @@
|
|||||||
|
import { NextURL } from 'next/dist/server/web/next-url'
|
||||||
import NextAuth from 'next-auth'
|
import NextAuth from 'next-auth'
|
||||||
import { createI18nMiddleware } from 'next-international/middleware'
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { defaultLocale, locales } from '@/config/locales'
|
import { defaultLocale, locales } from '@/config/locales'
|
||||||
import authConfig from '@/auth.config'
|
import authConfig from '@/auth.config'
|
||||||
import {
|
import { apiAuthPrefixRegEx, AUTH_LOGIN_URL, authRoutesRegEx, DEFAULT_LOGIN_REDIRECT, publicRoutes } from '@/config/routes'
|
||||||
apiAuthPrefix,
|
import { testPathnameRegex } from '@/lib/utils'
|
||||||
AUTH_LOGIN_URL,
|
import { createI18nMiddleware } from 'next-international/middleware'
|
||||||
authRoutes,
|
import { CSP } from '@/lib/CSP'
|
||||||
DEFAULT_LOGIN_REDIRECT,
|
|
||||||
publicRoutes,
|
|
||||||
testPathnameRegex,
|
|
||||||
} from '@/config/routes'
|
|
||||||
import { NextURL } from 'next/dist/server/web/next-url'
|
|
||||||
|
|
||||||
interface AppRouteHandlerFnContext {
|
interface AppRouteHandlerFnContext {
|
||||||
params?: Record<string, string | string[]>;
|
params?: Record<string, string | string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
if (isApiAuthRoute) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const I18nMiddleware = createI18nMiddleware({
|
const I18nMiddleware = createI18nMiddleware({
|
||||||
locales, defaultLocale, urlMappingStrategy: 'rewriteDefault',
|
locales, defaultLocale, urlMappingStrategy: 'rewriteDefault',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { auth } = NextAuth(authConfig)
|
||||||
|
|
||||||
|
export const middleware = (request: NextRequest, event: AppRouteHandlerFnContext): NextResponse | null => {
|
||||||
|
|
||||||
|
return auth((request): any => {
|
||||||
|
//const csp = new CSP(request, process.env.NODE_ENV === 'production')
|
||||||
|
const csp = new CSP(request, false)
|
||||||
|
const { nextUrl }: { nextUrl: NextURL } = request
|
||||||
|
|
||||||
|
if (nextUrl.pathname.match(apiAuthPrefixRegEx)) {
|
||||||
|
return csp.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLoggedIn: boolean = !!request.auth
|
||||||
|
const isPublicRoute: boolean = testPathnameRegex(publicRoutes, nextUrl.pathname)
|
||||||
|
const isAuthRoute: boolean = testPathnameRegex(authRoutesRegEx, nextUrl.pathname)
|
||||||
|
|
||||||
if (isAuthRoute) {
|
if (isAuthRoute) {
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
return NextResponse.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl))
|
return NextResponse.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl))
|
||||||
}
|
}
|
||||||
return I18nMiddleware(request)
|
return csp.next(I18nMiddleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoggedIn && !isPublicRoute) {
|
if (!isLoggedIn && !isPublicRoute) {
|
||||||
return NextResponse.redirect(new URL(AUTH_LOGIN_URL, nextUrl))
|
return NextResponse.redirect(new URL(AUTH_LOGIN_URL, nextUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
return I18nMiddleware(request)
|
return csp.next(I18nMiddleware)
|
||||||
|
|
||||||
})(request, event) as NextResponse
|
})(request, event) as NextResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!.+\\.[\\w]+$|_next).*)',
|
'/((?!.+\\.[\\w]+$|_next|_next/image|_next/static|favicon.ico|robots.txt).*)',
|
||||||
'/(api|static|trpc)(.*)'],
|
'/',
|
||||||
|
'/(api|trpc)(.*)',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,9 @@ import path from 'node:path'
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
serverComponentsExternalPackages: ['pino'],
|
||||||
|
},
|
||||||
sassOptions: {
|
sassOptions: {
|
||||||
includePaths: [path.join(path.resolve('.'), 'styles')],
|
includePaths: [path.join(path.resolve('.'), 'styles')],
|
||||||
},
|
},
|
||||||
|
1562
package-lock.json
generated
1562
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@ -1,17 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "a-naklejka",
|
"name": "yo-next-space",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "chcp 65001 && next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "chcp 65001 && 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": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^1.5.2",
|
"@auth/prisma-adapter": "^1.5.2",
|
||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@prisma/client": "^5.12.1",
|
"@prisma/client": "^5.12.1",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-separator": "^1.0.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
@ -23,11 +32,18 @@
|
|||||||
"next": "14.1.4",
|
"next": "14.1.4",
|
||||||
"next-auth": "^5.0.0-beta.16",
|
"next-auth": "^5.0.0-beta.16",
|
||||||
"next-international": "^1.2.4",
|
"next-international": "^1.2.4",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
"nodemailer": "^6.9.13",
|
"nodemailer": "^6.9.13",
|
||||||
|
"pino": "^9.0.0",
|
||||||
|
"pino-http": "^9.0.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.51.2",
|
"react-hook-form": "^7.51.2",
|
||||||
"react-icons": "^5.0.1",
|
"react-icons": "^5.0.1",
|
||||||
|
"react-loader-spinner": "^6.1.6",
|
||||||
|
"sharp": "^0.33.3",
|
||||||
|
"shart": "^0.0.4",
|
||||||
|
"sonner": "^1.4.41",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
@ -43,10 +59,16 @@
|
|||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.1.4",
|
"eslint-config-next": "14.1.4",
|
||||||
|
"eslint-plugin-validate-filename": "^0.0.4",
|
||||||
|
"pino-caller": "^3.4.0",
|
||||||
|
"pino-pretty": "^11.0.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prisma": "^5.12.1",
|
"prisma": "^5.12.1",
|
||||||
"sass": "^1.74.1",
|
"sass": "^1.74.1",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"typescript": "^5"
|
"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;
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user