Compare commits
3 Commits
53cadc289a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b259d71c | |||
| db66161d81 | |||
| f17a002ac6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -115,3 +115,5 @@ fabric.properties
|
||||
.idea/caches/build_file_checksums.ser
|
||||
/prisma/_____migrations___/
|
||||
/resources/images/
|
||||
/crib.md
|
||||
/**/**/*.log
|
||||
|
||||
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 { AuthError } from 'next-auth'
|
||||
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>) => {
|
||||
const validatedFields = LoginSchema.safeParse(values)
|
||||
@@ -15,7 +23,7 @@ export const login = async (values: zInfer<typeof LoginSchema>) => {
|
||||
return { error: 'auth.form.error.invalid_fields' }
|
||||
}
|
||||
|
||||
const { email, password } = validatedFields.data
|
||||
const { email, password, code } = validatedFields.data
|
||||
|
||||
const existingUser = await getUserByEmail(email)
|
||||
|
||||
@@ -27,6 +35,40 @@ export const login = async (values: zInfer<typeof LoginSchema>) => {
|
||||
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 {
|
||||
await signIn('credentials', {
|
||||
email, password, 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()
|
||||
}
|
||||
@@ -2,11 +2,32 @@
|
||||
|
||||
import mailer from '@/lib/mailer'
|
||||
import { AUTH_NEW_PASSWORD_URL, AUTH_USER_VERIFICATION_URL } from '@/config/routes'
|
||||
import { generatePasswordResetToken, generateVerificationToken } from '@/lib/tokens'
|
||||
import { generatePasswordResetToken, generateTwoFactorToken, generateVerificationToken } from '@/lib/tokens'
|
||||
import { env } from '@/lib/utils'
|
||||
import { __ct } from '@/lib/translate'
|
||||
import { body } from '@/templates/email/send-verification-email'
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use server'
|
||||
|
||||
import db from '@/lib/db'
|
||||
import { getVerificationTokenByToken } from '@/data/verification-token'
|
||||
import { getUserByEmail } from '@/data/user'
|
||||
import { deleteVerificationToken, getVerificationTokenByToken } from '@/data/verification-token'
|
||||
import { getUserByEmail, updateUserEmailVerified } from '@/data/user'
|
||||
|
||||
export const userVerification = async (token: string) => {
|
||||
const existingToken = await getVerificationTokenByToken(token)
|
||||
@@ -17,25 +16,9 @@ export const userVerification = async (token: string) => {
|
||||
|
||||
if (!existingUser) return { error: 'Email associated with token not found!' }
|
||||
|
||||
try {
|
||||
await db.user.update({
|
||||
where: { id: existingUser.id }, data: {
|
||||
email: existingToken.email, emailVerified: new Date(),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return { error: 'db.error.update.user_data' }
|
||||
}
|
||||
await updateUserEmailVerified(existingUser.id, existingToken.email)
|
||||
|
||||
try {
|
||||
await db.verificationToken.delete({
|
||||
where: { id: existingToken.id },
|
||||
})
|
||||
} catch (e) {
|
||||
// TODO: log error on disc or db
|
||||
console.error(e)
|
||||
}
|
||||
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 (
|
||||
<div>
|
||||
{JSON.stringify(session)}
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
await signOut()
|
||||
}}>
|
||||
<button type="submit">SignOut {session?.user.role}</button>
|
||||
</form>
|
||||
<div className="bg-neutral-100 p-10">
|
||||
<button onClick={btnOnClick} type="submit">SignOut</button>
|
||||
</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,10 +1,3 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'key',
|
||||
description: '...',
|
||||
}
|
||||
|
||||
const AboutPage = () => {
|
||||
return <>ABOUT</>
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Button } from '@/components/ui/button'
|
||||
import LoginButton from '@/components/auth/login-button'
|
||||
import Image from 'next/image'
|
||||
import wolf from '@/img/Gray wolf portrait.jpg'
|
||||
import { Grid } from 'react-loader-spinner'
|
||||
|
||||
const font = Poppins({
|
||||
subsets: ['latin'], weight: ['600'],
|
||||
|
||||
@@ -10,7 +10,6 @@ type Props = {
|
||||
const AuthLayout = ({ children }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<Navbar/>
|
||||
<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">
|
||||
{children}
|
||||
|
||||
@@ -30,7 +30,7 @@ input[aria-invalid='false']:not(:placeholder-shown) {
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary: 200 98% 39%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { ReactElement } from 'react'
|
||||
import { I18nProviderClient } from '@/locales/client'
|
||||
import { lc } from '@/lib/utils'
|
||||
import { Loading } from '@/components/loading'
|
||||
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'] })
|
||||
|
||||
@@ -12,21 +15,22 @@ export const metadata: Metadata = {
|
||||
title: 'Create Next App', description: 'Generated by create next app',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type RootLayoutProps = {
|
||||
params: { locale: string }; children: ReactElement;
|
||||
}
|
||||
|
||||
export default function RootLayout ({
|
||||
params: { locale }, children,
|
||||
}: Readonly<Props>) {
|
||||
export default async function RootLayout ({ params: { locale }, children }: Readonly<RootLayoutProps>) {
|
||||
const session = await auth()
|
||||
|
||||
return (<html lang={lc(locale)?.java}>
|
||||
{/*<Suspense fallback={<Loading/>}>*/}
|
||||
return (<SessionProvider session={session}>
|
||||
<html lang={lc(locale)?.java}>
|
||||
<body className={inter.className}>
|
||||
<I18nProviderClient locale={locale} fallback={<Loading/>}>
|
||||
<I18nProviderClient locale={locale} fallback="Loading...">
|
||||
<Navbar/>
|
||||
<Toaster/>
|
||||
{children}
|
||||
</I18nProviderClient>
|
||||
</body>
|
||||
{/*</Suspense>*/}
|
||||
</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 })
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export default function robots (): MetadataRoute.Robots {
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: ['/'],
|
||||
disallow: ['/auth/', '/api/'],
|
||||
disallow: ['/auth/', '/api/', '/en/auth/', '/en/api/'],
|
||||
crawlDelay: 3,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'
|
||||
import { Header } from '@/components/auth/header'
|
||||
import { Social } from '@/components/auth/social'
|
||||
import { BackButton } from '@/components/auth/back-button'
|
||||
import { Suspense } from 'react'
|
||||
import { Loading } from '@/components/loading'
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
@@ -26,9 +25,8 @@ export const CardWrapper = ({
|
||||
continueWithLabel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Suspense fallback={<Loading/>}>
|
||||
<Card
|
||||
className="border-8 border-muted shadow-2xl max-w-[430px] w-full sm:min-w-[430px]">
|
||||
className={`shadow-2xl max-w-[430px] w-full sm:min-w-[430px]`}>
|
||||
<CardHeader>
|
||||
<Header label={headerLabel} title={headerTitle}/>
|
||||
</CardHeader>
|
||||
@@ -36,7 +34,7 @@ export const CardWrapper = ({
|
||||
{children}
|
||||
</CardContent>
|
||||
{showSocial && <CardFooter className="flex-wrap">
|
||||
<div className="relative flex-none w-[100%] mb-4" style={{ background: 'block' }}>
|
||||
<div className="relative flex-none w-[100%] mb-4">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t"></span>
|
||||
</div>
|
||||
@@ -51,6 +49,5 @@ export const CardWrapper = ({
|
||||
<BackButton label={backButtonLabel} href={backButtonHref}/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,7 @@ 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 { 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'
|
||||
@@ -28,10 +21,9 @@ export const LoginForm = () => {
|
||||
const t = useI18n()
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const urlError = searchParams.get('error') === 'OAuthAccountNotLinked'
|
||||
? t('auth.form.error.email_in_use')
|
||||
: ''
|
||||
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()
|
||||
@@ -48,10 +40,26 @@ export const LoginForm = () => {
|
||||
|
||||
startTransition(() => {
|
||||
login(values).then((data) => {
|
||||
// @ts-ignore
|
||||
//@ts-ignore
|
||||
if (data?.error) {
|
||||
form.reset() //@ts-ignore
|
||||
setError(t(data?.error))
|
||||
// @ts-ignore
|
||||
}
|
||||
|
||||
//@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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -67,9 +75,24 @@ export const LoginForm = () => {
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-0"
|
||||
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>
|
||||
@@ -84,7 +107,6 @@ export const LoginForm = () => {
|
||||
</FormControl>
|
||||
<FormMessage className="text-xs"/>
|
||||
</FormItem>)}/>
|
||||
{/*Password*/}
|
||||
<FormField control={form.control} name="password"
|
||||
render={({ field }) => (<FormItem>
|
||||
<FormLabel>{t('form.label.password')}</FormLabel>
|
||||
@@ -103,12 +125,12 @@ export const LoginForm = () => {
|
||||
</Button>
|
||||
<FormMessage className="text-xs"/>
|
||||
</FormItem>)}/>
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
<FormSuccess message={success}/>
|
||||
<FormError message={error || urlError}/>
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{t('form.label.login')}
|
||||
{showTwoFactor ? t('form.button.two_factor') : t('form.button.login')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export const NewPasswordForm = ({ token }: { token: string }) => {
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField control={form.control} name="password"
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ResetForm = () => {
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField control={form.control} name="email"
|
||||
|
||||
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}</>
|
||||
}
|
||||
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
|
||||
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'
|
||||
|
||||
type Props = {
|
||||
type FormErrorProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const FormError = ({ message }: Props) => {
|
||||
const FormError = ({ message }: FormErrorProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
|
||||
<div className="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
|
||||
<TriangleAlert className="w-4 h-4"/>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { CircleCheck } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
type FormSuccessProps = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const FormSuccess = ({ message }: Props) => {
|
||||
const FormSuccess = ({ message }: FormSuccessProps) => {
|
||||
if (!message) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-lime-500/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-lime-800">
|
||||
<div 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"/>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Grid } from 'react-loader-spinner'
|
||||
|
||||
export function Loading () {
|
||||
return (
|
||||
<h1>
|
||||
<Grid
|
||||
visible={true}
|
||||
height="666"
|
||||
width="666"
|
||||
color="#4fa94d"
|
||||
ariaLabel="grid-loading"
|
||||
radius="12.5"
|
||||
wrapperStyle={{}}
|
||||
wrapperClass="grid-wrapper"
|
||||
/>
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useChangeLocale, useCurrentLocale } from '@/locales/client'
|
||||
import { LC, type loc } from '@/config/locales'
|
||||
import { ChangeEvent } from 'react'
|
||||
import styles from '@/styles/locale-switcher.module.scss'
|
||||
|
||||
export default function LocaleSwitcher () {
|
||||
const changeLocale = useChangeLocale()
|
||||
const locale = useCurrentLocale()
|
||||
const selectHandler = (e: ChangeEvent<HTMLSelectElement>) => changeLocale(
|
||||
e.target.value as loc)
|
||||
const selectHandler = (e: ChangeEvent<HTMLSelectElement>) =>
|
||||
changeLocale(e.target.value as loc)
|
||||
|
||||
return (
|
||||
//@ts-ignore
|
||||
<select onChange={selectHandler} defaultValue={locale}
|
||||
className={styles['yo-locale-switcher']} aria-label="Switch language">
|
||||
{LC.map(item => (
|
||||
<option key={item.iso} value={item.code}>
|
||||
// {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>
|
||||
)
|
||||
</option>))}
|
||||
</select></form>)
|
||||
}
|
||||
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,
|
||||
}
|
||||
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,4 +1,4 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import NextAuth, { type DefaultSession } from 'next-auth'
|
||||
import { UserRole } from '@prisma/client'
|
||||
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||
import db from '@/lib/db'
|
||||
@@ -7,10 +7,18 @@ import { getUserById } from '@/data/user'
|
||||
import { AUTH_ERROR_URL, AUTH_LOGIN_URL } from '@/config/routes'
|
||||
import { getCurrentLocale } from '@/locales/server'
|
||||
import { type loc } from '@/config/locales'
|
||||
import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation'
|
||||
|
||||
export type ExtendedUser = DefaultSession['user'] & {
|
||||
role: UserRole,
|
||||
locale: loc,
|
||||
isTwoFactorEnabled?: boolean,
|
||||
image?: string
|
||||
}
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
user: { role: UserRole, locale: loc }
|
||||
user: ExtendedUser
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +52,15 @@ export const {
|
||||
// Prevent sign in without email verification
|
||||
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
|
||||
},
|
||||
@@ -58,6 +74,10 @@ export const {
|
||||
session.user.role = token.role as UserRole
|
||||
}
|
||||
|
||||
if (session.user) {
|
||||
session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean
|
||||
}
|
||||
|
||||
session.user.locale = getCurrentLocale()
|
||||
|
||||
return session
|
||||
@@ -70,6 +90,7 @@ export const {
|
||||
if (!existingUser) return token
|
||||
|
||||
token.role = existingUser.role
|
||||
token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled
|
||||
|
||||
return token
|
||||
},
|
||||
|
||||
@@ -37,4 +37,6 @@ const LC: Locale[] = [
|
||||
|
||||
const locales: loc[] = LC.map((locale: Locale) => locale.code)
|
||||
|
||||
export { locales, defaultLocale, fallbackLocale, LC, importLocales, type loc }
|
||||
const SKIP_I18N_URLS = '/api/'
|
||||
|
||||
export { locales, defaultLocale, fallbackLocale, LC, importLocales, type loc, SKIP_I18N_URLS }
|
||||
@@ -1,6 +1,9 @@
|
||||
import { UUID_V4_REGEX } from '@/config/validation'
|
||||
|
||||
export const USER_PROFILE_URL: string = '/cabinet'
|
||||
export const USER_SERVER_URL: string = `${USER_PROFILE_URL}/server`
|
||||
export const USER_CLIENT_URL: string = `${USER_PROFILE_URL}/client`
|
||||
export const USER_ADMIN_URL: string = `${USER_PROFILE_URL}/admin`
|
||||
export const AUTH_URL: string = '/auth/'
|
||||
export const AUTH_LOGIN_URL: string = `${AUTH_URL}login`
|
||||
export const AUTH_REGISTER_URL: string = `${AUTH_URL}register`
|
||||
@@ -9,6 +12,8 @@ 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.
|
||||
* These routes do not requite authentication.
|
||||
@@ -31,10 +36,10 @@ export const authRoutesRegEx = [
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
export const apiAuthPrefix: string = '/api/auth'
|
||||
export const apiAuthPrefixRegEx: string = '/api/(auth|admin)'
|
||||
|
||||
/**
|
||||
* The default redirect path after logging in.
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
'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 {
|
||||
} catch (err) {
|
||||
journal.error({ getPasswordResetTokenByToken: err, token })
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -11,7 +15,8 @@ export const getPasswordResetTokenByToken = async (token: string) => {
|
||||
export const getPasswordResetTokenByEmail = async (email: string) => {
|
||||
try {
|
||||
return await db.passwordResetToken.findFirst({ where: { email } })
|
||||
} catch {
|
||||
} 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
|
||||
}
|
||||
}
|
||||
23
data/user.ts
23
data/user.ts
@@ -1,10 +1,14 @@
|
||||
'use server'
|
||||
|
||||
import { User } from '@prisma/client'
|
||||
import db from '@/lib/db'
|
||||
import journal from '@/actions/logger'
|
||||
|
||||
export const getUserByEmail = async (email: string): Promise<User | null> => {
|
||||
try {
|
||||
return await db.user.findUnique({ where: { email } })
|
||||
} catch {
|
||||
} catch (err) {
|
||||
journal.error({ getUserByEmail: err, email })
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -12,7 +16,22 @@ export const getUserByEmail = async (email: string): Promise<User | null> => {
|
||||
export const getUserById = async (id: string): Promise<User | null> => {
|
||||
try {
|
||||
return await db.user.findUnique({ where: { id } })
|
||||
} catch {
|
||||
} catch (err) {
|
||||
journal.error({ getUserById: err, id })
|
||||
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 @@
|
||||
'use server'
|
||||
|
||||
import db from '@/lib/db'
|
||||
import journal from '@/actions/logger'
|
||||
|
||||
export const getVerificationTokenByToken = async (token: string) => {
|
||||
try {
|
||||
return await db.verificationToken.findUnique({ where: { token } })
|
||||
} catch {
|
||||
} catch (err) {
|
||||
journal.error({ getVerificationTokenByToken: err, token })
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -11,7 +15,18 @@ export const getVerificationTokenByToken = async (token: string) => {
|
||||
export const getVerificationTokenByEmail = async (email: string) => {
|
||||
try {
|
||||
return await db.verificationToken.findFirst({ where: { email } })
|
||||
} catch {
|
||||
} catch (err) {
|
||||
journal.error({ getVerificationTokenByEmail: err, email })
|
||||
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
|
||||
}
|
||||
@@ -3,5 +3,7 @@
|
||||
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)
|
||||
return (await readdir(source, { withFileTypes: true }))
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import crypto from 'crypto'
|
||||
import { v4 as uuidV4 } from 'uuid'
|
||||
import {
|
||||
VERIFICATION_TOKEN_EXPIRATION_DURATION,
|
||||
} from '@/config/auth'
|
||||
import { VERIFICATION_TOKEN_EXPIRATION_DURATION } from '@/config/auth'
|
||||
import db from '@/lib/db'
|
||||
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()
|
||||
@@ -21,9 +39,7 @@ export const generatePasswordResetToken = async (email: string) => {
|
||||
|
||||
const passwordResetToken = await db.passwordResetToken.create({
|
||||
data: {
|
||||
email,
|
||||
token,
|
||||
expires,
|
||||
email, token, expires,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -46,9 +62,7 @@ export const generateVerificationToken = async (email: string) => {
|
||||
|
||||
const verificationToken = await db.verificationToken.create({
|
||||
data: {
|
||||
email,
|
||||
token,
|
||||
expires,
|
||||
email, token, expires,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
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: {
|
||||
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
confirmed_email: 'для підтвердження електронної пошти',
|
||||
subject: {
|
||||
send_verification_email: 'Завершіть верифікацію Вашої електронної пошти для сайту {site_name}',
|
||||
send_2FA_code: 'Ваш код двофакторної аутентифікації із сайту {site_name}',
|
||||
},
|
||||
body: {
|
||||
send_verification_email: {
|
||||
|
||||
@@ -45,17 +45,20 @@ export default {
|
||||
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
|
||||
@@ -5,6 +5,11 @@ export default {
|
||||
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',
|
||||
|
||||
@@ -15,4 +15,7 @@ export default {
|
||||
name: {
|
||||
required: `Name is required`,
|
||||
},
|
||||
two_factor: {
|
||||
length: 'Code must contain exactly {length} digits',
|
||||
},
|
||||
} as const
|
||||
@@ -45,15 +45,19 @@ export default {
|
||||
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
|
||||
@@ -5,6 +5,11 @@ export default {
|
||||
confirm_password: 'Підтвердьте пароль',
|
||||
login: 'Лоґін',
|
||||
name: 'Ім\'я та прізвище',
|
||||
two_factor: 'Код двофакторної перевірки',
|
||||
},
|
||||
button: {
|
||||
two_factor: 'Підтвердити',
|
||||
login: 'Лоґін',
|
||||
},
|
||||
placeholder: {
|
||||
email: 'dead.end@acme.com',
|
||||
|
||||
@@ -15,4 +15,7 @@ export default {
|
||||
name: {
|
||||
required: `Необхідно вказати ім'я`,
|
||||
},
|
||||
two_factor: {
|
||||
length: 'Код має містити рівно {length} цифр',
|
||||
},
|
||||
} as const
|
||||
@@ -3,64 +3,56 @@ import NextAuth from 'next-auth'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { defaultLocale, locales } from '@/config/locales'
|
||||
import authConfig from '@/auth.config'
|
||||
import { apiAuthPrefix, AUTH_LOGIN_URL, authRoutesRegEx, DEFAULT_LOGIN_REDIRECT, publicRoutes } from '@/config/routes'
|
||||
import { apiAuthPrefixRegEx, AUTH_LOGIN_URL, authRoutesRegEx, DEFAULT_LOGIN_REDIRECT, publicRoutes } from '@/config/routes'
|
||||
import { testPathnameRegex } from '@/lib/utils'
|
||||
import { createI18nMiddleware } from 'next-international/middleware'
|
||||
import { CSP } from '@/lib/CSP'
|
||||
|
||||
interface AppRouteHandlerFnContext {
|
||||
params?: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
const I18nMiddleware = createI18nMiddleware({
|
||||
locales, defaultLocale, urlMappingStrategy: 'rewriteDefault',
|
||||
})
|
||||
|
||||
const { auth } = NextAuth(authConfig)
|
||||
|
||||
export const middleware = (request: NextRequest, event: AppRouteHandlerFnContext): NextResponse | null => {
|
||||
return NextAuth(authConfig).auth((request): any => {
|
||||
|
||||
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
|
||||
const isLoggedIn: boolean = !!request.auth
|
||||
const isApiAuthRoute: boolean = nextUrl.pathname.startsWith(apiAuthPrefix)
|
||||
const isPublicRoute: boolean = testPathnameRegex(publicRoutes, nextUrl.pathname)
|
||||
const isAuthRoute: boolean = testPathnameRegex(authRoutesRegEx, nextUrl.pathname)
|
||||
|
||||
if (isApiAuthRoute) {
|
||||
return null
|
||||
if (nextUrl.pathname.match(apiAuthPrefixRegEx)) {
|
||||
return csp.next()
|
||||
}
|
||||
|
||||
const I18nMiddleware = createI18nMiddleware({
|
||||
locales, defaultLocale, urlMappingStrategy: 'rewriteDefault',
|
||||
})
|
||||
const isLoggedIn: boolean = !!request.auth
|
||||
const isPublicRoute: boolean = testPathnameRegex(publicRoutes, nextUrl.pathname)
|
||||
const isAuthRoute: boolean = testPathnameRegex(authRoutesRegEx, nextUrl.pathname)
|
||||
|
||||
if (isAuthRoute) {
|
||||
if (isLoggedIn) {
|
||||
return NextResponse.redirect(new URL(DEFAULT_LOGIN_REDIRECT, nextUrl))
|
||||
}
|
||||
return I18nMiddleware(request)
|
||||
return csp.next(I18nMiddleware)
|
||||
}
|
||||
|
||||
if (!isLoggedIn && !isPublicRoute) {
|
||||
return NextResponse.redirect(new URL(AUTH_LOGIN_URL, nextUrl))
|
||||
}
|
||||
|
||||
return I18nMiddleware(request)
|
||||
return csp.next(I18nMiddleware)
|
||||
|
||||
})(request, event) as NextResponse
|
||||
}
|
||||
|
||||
// export const config = {
|
||||
// matcher: [
|
||||
// /*
|
||||
// * Match all request paths except for the ones starting with:
|
||||
// * - api (API routes)
|
||||
// * - _next/static (static files)
|
||||
// * - _next/image (image optimization files)
|
||||
// * - favicon.ico (favicon file)
|
||||
// */
|
||||
// {
|
||||
// source: '/((?!.+\\.[\\w]+$|api|_next/image|favicon.ico|robots.txt|trpc).*)', missing: [
|
||||
// { type: 'header', key: 'next-router-prefetch' }, { type: 'header', key: 'purpose', value: 'prefetch' }],
|
||||
// }],
|
||||
// }
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!.+\\.[\\w]+$|_next|_next/image|_next/static).*)', '/(api|trpc)(.*)',
|
||||
'/((?!.+\\.[\\w]+$|_next|_next/image|_next/static|favicon.ico|robots.txt).*)',
|
||||
'/',
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import path from 'node:path'
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ['pino'],
|
||||
},
|
||||
sassOptions: {
|
||||
includePaths: [path.join(path.resolve('.'), 'styles')],
|
||||
},
|
||||
|
||||
923
package-lock.json
generated
923
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "a-naklejka",
|
||||
"version": "0.1.0",
|
||||
"name": "yo-next-space",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "chcp 65001 && next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "chcp 65001 && next start",
|
||||
"lint": "next lint",
|
||||
"browserslist:update": "npx update-browserslist-db@latest",
|
||||
"browserslist": "npx browserslist"
|
||||
@@ -19,6 +19,8 @@
|
||||
"@auth/prisma-adapter": "^1.5.2",
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@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-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
@@ -30,7 +32,10 @@
|
||||
"next": "14.1.4",
|
||||
"next-auth": "^5.0.0-beta.16",
|
||||
"next-international": "^1.2.4",
|
||||
"next-themes": "^0.3.0",
|
||||
"nodemailer": "^6.9.13",
|
||||
"pino": "^9.0.0",
|
||||
"pino-http": "^9.0.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.51.2",
|
||||
@@ -38,6 +43,7 @@
|
||||
"react-loader-spinner": "^6.1.6",
|
||||
"sharp": "^0.33.3",
|
||||
"shart": "^0.0.4",
|
||||
"sonner": "^1.4.41",
|
||||
"tailwind-merge": "^2.2.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -54,6 +60,8 @@
|
||||
"eslint": "^8",
|
||||
"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",
|
||||
"prisma": "^5.12.1",
|
||||
"sass": "^1.74.1",
|
||||
|
||||
@@ -35,6 +35,8 @@ model User {
|
||||
role UserRole @default(CUSTOMER)
|
||||
extendedData Json?
|
||||
accounts Account[]
|
||||
isTwoFactorEnabled Boolean @default(false)
|
||||
twoFactorComfirmation TwoFactorComfirmation?
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -73,3 +75,18 @@ model PasswordResetToken {
|
||||
|
||||
@@unique([email, token])
|
||||
}
|
||||
|
||||
model TwoFactorToken {
|
||||
id String @id @default(cuid())
|
||||
email String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([email, token])
|
||||
}
|
||||
|
||||
model TwoFactorComfirmation {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { MAX_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, PASSWORD_STRENGTH_ACME } from '@/config/validation'
|
||||
import { object, string } from 'zod'
|
||||
import { object, optional, string } from 'zod'
|
||||
|
||||
const _2FA_CODE_LENGTH = 6
|
||||
const _2FA_CODE_REGEX = /^\d{6}$/
|
||||
|
||||
// all translations is implemented in '@/components/ui/form' via TranslateClientFragment
|
||||
|
||||
@@ -7,6 +10,7 @@ const minPasswordMessage = JSON.stringify(['schema.password.length.min', { min:
|
||||
const maxPasswordMessage = JSON.stringify(['schema.password.length.max', { max: MAX_PASSWORD_LENGTH }])
|
||||
const maxPasswordStrength = JSON.stringify(
|
||||
['schema.password.strength.acme', { min: MIN_PASSWORD_LENGTH, max: MAX_PASSWORD_LENGTH }])
|
||||
const regexCodeMessage = JSON.stringify(['schema.two_factor.length', { length: _2FA_CODE_LENGTH }])
|
||||
|
||||
const email = string().trim().toLowerCase().email({ message: 'schema.email.required' })
|
||||
const password = string().trim().regex(new RegExp(PASSWORD_STRENGTH_ACME, 'mg'),
|
||||
@@ -14,7 +18,13 @@ const password = string().trim().regex(new RegExp(PASSWORD_STRENGTH_ACME, 'mg'),
|
||||
max(MAX_PASSWORD_LENGTH, { message: maxPasswordMessage })
|
||||
|
||||
export const LoginSchema = object({
|
||||
email, password: string().trim().min(1, { message: 'schema.password.required' }),
|
||||
email,
|
||||
password: string().trim().min(1, { message: 'schema.password.required' }),
|
||||
code: string({ message: regexCodeMessage }).
|
||||
trim().
|
||||
length(_2FA_CODE_LENGTH, { message: regexCodeMessage }).
|
||||
regex(_2FA_CODE_REGEX, { message: regexCodeMessage }).
|
||||
optional(),
|
||||
})
|
||||
|
||||
export const RegisterSchema = object({
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
.yo-locale-switcher {
|
||||
appearance: none;
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
color: darkblue;
|
||||
//width: 2.5rem;
|
||||
//height: 2.5rem;
|
||||
border-radius: 2px;
|
||||
margin: .5rem 1rem;
|
||||
font-size: .75rem;
|
||||
}
|
||||
@@ -29,6 +29,9 @@
|
||||
"@/config/*": [
|
||||
"./config/*"
|
||||
],
|
||||
"@/hooks/*": [
|
||||
"./hooks/*"
|
||||
],
|
||||
"@/data/*": [
|
||||
"./data/*"
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user