optimize auth
This commit is contained in:
parent
65131f9653
commit
c6034de16a
118
package.json
118
package.json
|
|
@ -1,60 +1,62 @@
|
||||||
{
|
{
|
||||||
"name": "dashboard-template",
|
"name": "dashboard-template",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
},
|
"key:generate": "bun run src/core/utils/generateJwtSecret.ts"
|
||||||
"prisma": {
|
},
|
||||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
"prisma": {
|
||||||
},
|
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
"dependencies": {
|
},
|
||||||
"@auth/prisma-adapter": "^1.1.0",
|
"dependencies": {
|
||||||
"@mantine/core": "^7.5.0",
|
"@auth/prisma-adapter": "^1.1.0",
|
||||||
"@mantine/form": "^7.5.0",
|
"@mantine/core": "^7.5.2",
|
||||||
"@mantine/hooks": "^7.5.0",
|
"@mantine/form": "^7.5.2",
|
||||||
"@mantine/notifications": "^7.5.0",
|
"@mantine/hooks": "^7.5.2",
|
||||||
"@prisma/client": "5.8.1",
|
"@mantine/notifications": "^7.5.2",
|
||||||
"@tanstack/react-query": "^4.36.1",
|
"@prisma/client": "5.8.1",
|
||||||
"@tanstack/react-query-devtools": "^4.36.1",
|
"@tanstack/react-query": "^4.36.1",
|
||||||
"@tanstack/react-table": "^8.11.7",
|
"@tanstack/react-query-devtools": "^4.36.1",
|
||||||
"@trpc/client": "^10.45.0",
|
"@tanstack/react-table": "^8.11.7",
|
||||||
"@trpc/next": "^10.45.0",
|
"@trpc/client": "^10.45.0",
|
||||||
"@trpc/react-query": "^10.45.0",
|
"@trpc/next": "^10.45.0",
|
||||||
"@trpc/server": "^10.45.0",
|
"@trpc/react-query": "^10.45.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@trpc/server": "^10.45.0",
|
||||||
"@types/jsonwebtoken": "^9.0.5",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
"bcrypt": "^5.1.1",
|
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||||
"client-only": "^0.0.1",
|
"bcrypt": "^5.1.1",
|
||||||
"clsx": "^2.1.0",
|
"client-only": "^0.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"clsx": "^2.1.0",
|
||||||
"mantine-form-zod-resolver": "^1.1.0",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"next": "14.1.0",
|
"mantine-form-zod-resolver": "^1.1.0",
|
||||||
"react": "^18.2.0",
|
"next": "14.1.0",
|
||||||
"react-dom": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-icons": "^5.0.1",
|
"react-dom": "^18.2.0",
|
||||||
"sass": "^1.70.0",
|
"react-icons": "^5.0.1",
|
||||||
"server-only": "^0.0.1",
|
"sass": "^1.70.0",
|
||||||
"superjson": "^2.2.1",
|
"server-only": "^0.0.1",
|
||||||
"ts-node": "^10.9.2",
|
"superjson": "^2.2.1",
|
||||||
"zod": "^3.22.4"
|
"ts-node": "^10.9.2",
|
||||||
},
|
"zod": "^3.22.4"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/node": "^20.11.7",
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.48",
|
"@types/bun": "^1.0.5",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/node": "^20.11.7",
|
||||||
"autoprefixer": "^10.4.17",
|
"@types/react": "^18.2.48",
|
||||||
"eslint": "^8.56.0",
|
"@types/react-dom": "^18.2.18",
|
||||||
"eslint-config-next": "14.0.4",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.4.33",
|
"eslint": "^8.56.0",
|
||||||
"postcss-preset-mantine": "^1.12.3",
|
"eslint-config-next": "14.0.4",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss": "^8.4.33",
|
||||||
"prisma": "^5.8.1",
|
"postcss-preset-mantine": "^1.12.3",
|
||||||
"tailwindcss": "^3.4.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"typescript": "^5.3.3"
|
"prisma": "^5.8.1",
|
||||||
}
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
@ -11,39 +8,39 @@ datasource db {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
email String? @unique
|
email String? @unique
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
photoProfile UserPhotoProfiles?
|
photoProfile UserPhotoProfiles?
|
||||||
roles Role[]
|
directPermissions Permission[] @relation("PermissionToUser")
|
||||||
directPermissions Permission[]
|
roles Role[] @relation("RoleToUser")
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserPhotoProfiles {
|
model UserPhotoProfiles {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String @unique
|
userId String @unique
|
||||||
path String
|
path String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
model Role {
|
model Role {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
isActive Boolean @default(false)
|
isActive Boolean @default(false)
|
||||||
users User[]
|
permissions Permission[] @relation("PermissionToRole")
|
||||||
permissions Permission[]
|
users User[] @relation("RoleToUser")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Permission {
|
model Permission {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
code String @unique
|
code String @unique
|
||||||
name String
|
name String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
isActive Boolean @default(false)
|
isActive Boolean @default(false)
|
||||||
roles Role[]
|
roles Role[] @relation("PermissionToRole")
|
||||||
directUsers User[]
|
directUsers User[] @relation("PermissionToUser")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import hashPassword from "../../src/features/auth/tools/hashPassword";
|
import hashPassword from "../../src/modules/auth/utils/hashPassword";
|
||||||
import { User, PrismaClient, Prisma } from "@prisma/client";
|
import { User, PrismaClient, Prisma } from "@prisma/client";
|
||||||
import { DefaultArgs } from "@prisma/client/runtime/library";
|
import { DefaultArgs } from "@prisma/client/runtime/library";
|
||||||
import { log } from "console";
|
import { log } from "console";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
import guestOnly from "@/modules/auth/actions/guestOnly";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import guestOnly from "@/features/auth/actions/guestOnly";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -11,4 +10,4 @@ export default async function LoginLayout({ children }: Props) {
|
||||||
await guestOnly()
|
await guestOnly()
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
import signIn from "@/modules/auth/actions/signIn";
|
||||||
import getUser from "@/features/auth/actions/getUser";
|
|
||||||
import signIn from "@/features/auth/actions/signIn";
|
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
|
|
@ -40,7 +38,7 @@ export default function LoginPage() {
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="pink"
|
color="pink"
|
||||||
title=""
|
title=""
|
||||||
// icon={icon}
|
// icon={icon}
|
||||||
>
|
>
|
||||||
{state.errors.message}
|
{state.errors.message}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
"use client"
|
"use client";
|
||||||
import getUser from "@//features/auth/actions/getUser";
|
|
||||||
import logout from "@/features/auth/actions/logout";
|
import logout from "@/modules/auth/actions/logout";
|
||||||
import { redirect } from "next/navigation";
|
import { useEffect } from "react";
|
||||||
import React, { useEffect } from "react";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LogoutPage component handles the logout process.
|
* LogoutPage component handles the logout process.
|
||||||
* It checks if a user is logged in, logs them out, and redirects to the login page.
|
* It checks if a user is logged in, logs them out, and redirects to the login page.
|
||||||
*/
|
*/
|
||||||
export default function LogoutPage() {
|
export default function LogoutPage() {
|
||||||
|
useEffect(() => {
|
||||||
|
const logoutAction = async () => await logout();
|
||||||
|
|
||||||
useEffect(() => {
|
logoutAction();
|
||||||
|
}, []);
|
||||||
const logoutAction = async () => await logout()
|
|
||||||
|
|
||||||
logoutAction()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return <div></div>;
|
return <div></div>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
|
import guestOnly from "@/modules/auth/actions/guestOnly";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import guestOnly from "@/features/auth/actions/guestOnly";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RegisterLayout({ children }: Props) {
|
export default async function RegisterLayout({ children }: Props) {
|
||||||
|
await guestOnly();
|
||||||
await guestOnly()
|
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import createUser from "@/features/auth/actions/createUser";
|
import createUser from "@/modules/auth/actions/createUser";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
PasswordInput,
|
PasswordInput,
|
||||||
|
|
@ -9,63 +9,69 @@ import {
|
||||||
TextInput,
|
TextInput,
|
||||||
Group,
|
Group,
|
||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Button,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export interface RegisterFormSchema {
|
export interface RegisterFormSchema {
|
||||||
email: string,
|
email: string;
|
||||||
password: string,
|
password: string;
|
||||||
passwordConfirmation: string,
|
passwordConfirmation: string;
|
||||||
name: string,
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState("")
|
|
||||||
|
|
||||||
const form = useForm<RegisterFormSchema>({
|
const form = useForm<RegisterFormSchema>({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
passwordConfirmation: "",
|
passwordConfirmation: "",
|
||||||
name: ""
|
name: "",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
email: (value: string) =>
|
||||||
|
/^\S+@\S+$/.test(value) ? null : "Invalid email",
|
||||||
|
password: (value: string) =>
|
||||||
|
value.length >= 6
|
||||||
|
? null
|
||||||
|
: "Password should be at least 6 characters",
|
||||||
|
passwordConfirmation: (value: string, values: RegisterFormSchema) =>
|
||||||
|
value === values.password ? null : "Passwords should match",
|
||||||
|
name: (value: string) =>
|
||||||
|
value.length > 0 ? null : "Name is required",
|
||||||
},
|
},
|
||||||
validate: {
|
|
||||||
email: (value: string) => (/^\S+@\S+$/.test(value) ? null : 'Invalid email'),
|
|
||||||
password: (value: string) => (value.length >= 6 ? null : 'Password should be at least 6 characters'),
|
|
||||||
passwordConfirmation: (value: string, values: RegisterFormSchema) => value === values.password ? null : 'Passwords should match',
|
|
||||||
name: (value: string) => (value.length > 0 ? null : 'Name is required'),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async (values: RegisterFormSchema) => {
|
const handleSubmit = async (values: RegisterFormSchema) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
Object.entries(values)
|
Object.entries(values).forEach(([key, value]) => {
|
||||||
.forEach(([key, value]) => {
|
formData.append(key, value);
|
||||||
formData.append(key, value)
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const response = await createUser(formData);
|
const response = await createUser(formData);
|
||||||
|
|
||||||
if (!response.success){
|
if (!response.success) {
|
||||||
setErrorMessage(response.error.message);
|
setErrorMessage(response.error.message);
|
||||||
|
|
||||||
if (response.error.errors){
|
if (response.error.errors) {
|
||||||
const errors = Object.entries(response.error.errors)
|
const errors = Object.entries(response.error.errors).reduce(
|
||||||
.reduce((prev, [k,v]) => {
|
(prev, [k, v]) => {
|
||||||
prev[k] = v[0]
|
prev[k] = v[0];
|
||||||
return prev;
|
return prev;
|
||||||
}, {} as {[k: string]: string})
|
},
|
||||||
|
{} as { [k: string]: string }
|
||||||
|
);
|
||||||
|
|
||||||
form.setErrors(errors)
|
form.setErrors(errors);
|
||||||
console.log(form.errors)
|
console.log(form.errors);
|
||||||
} else {
|
} else {
|
||||||
form.clearErrors()
|
form.clearErrors();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen flex items-center justify-center">
|
<div className="w-screen h-screen flex items-center justify-center">
|
||||||
|
|
@ -73,9 +79,11 @@ export default function RegisterPage() {
|
||||||
<Text size="lg" fw={500} mb={30}>
|
<Text size="lg" fw={500} mb={30}>
|
||||||
Register
|
Register
|
||||||
</Text>
|
</Text>
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
<form
|
||||||
|
onSubmit={form.onSubmit((values) => handleSubmit(values))}
|
||||||
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Name"
|
label="Name"
|
||||||
placeholder="Enter your name"
|
placeholder="Enter your name"
|
||||||
name="name"
|
name="name"
|
||||||
|
|
@ -96,7 +104,7 @@ export default function RegisterPage() {
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
{...form.getInputProps("password")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label="Repeat Password"
|
label="Repeat Password"
|
||||||
placeholder="Repeat yout password"
|
placeholder="Repeat yout password"
|
||||||
name="passwordConfirmation"
|
name="passwordConfirmation"
|
||||||
|
|
@ -112,14 +120,14 @@ export default function RegisterPage() {
|
||||||
c="dimmed"
|
c="dimmed"
|
||||||
// onClick={() => toggle()}
|
// onClick={() => toggle()}
|
||||||
size="xs"
|
size="xs"
|
||||||
href="/login"
|
href="/login"
|
||||||
>
|
>
|
||||||
Already have an account? Login
|
Already have an account? Login
|
||||||
</Anchor>
|
</Anchor>
|
||||||
|
|
||||||
<Button type="submit" radius="xl">
|
<Button type="submit" radius="xl">
|
||||||
Register
|
Register
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
@tailwind base;
|
@layer tailwind{
|
||||||
|
@tailwind base;
|
||||||
|
}
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
15
src/core/db/index.ts
Normal file
15
src/core/db/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const prismaClientSingleton = () => {
|
||||||
|
return new PrismaClient();
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = globalThis.prisma ?? prismaClientSingleton();
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalThis.prisma = prisma;
|
||||||
31
src/core/error/BaseError.ts
Normal file
31
src/core/error/BaseError.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
export const BaseErrorCodes = ["UNKOWN_ERROR"] as const;
|
||||||
|
|
||||||
|
interface ErrorOptions {
|
||||||
|
message?: string;
|
||||||
|
errorCode: (typeof BaseErrorCodes)[number] | (string & {});
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseError extends Error {
|
||||||
|
public readonly errorCode: (typeof BaseErrorCodes)[number] | (string & {});
|
||||||
|
|
||||||
|
constructor(options: ErrorOptions) {
|
||||||
|
super(options.message ?? "Undetermined Error");
|
||||||
|
this.errorCode = options.errorCode ?? "UNKOWN_ERROR";
|
||||||
|
|
||||||
|
Object.setPrototypeOf(this, new.target.prototype);
|
||||||
|
|
||||||
|
console.error("error:", options)
|
||||||
|
}
|
||||||
|
|
||||||
|
getActionResponseObject() {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: this.message,
|
||||||
|
errorCode: this.errorCode,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseError;
|
||||||
105
src/modules/auth/actions/createUser.ts
Normal file
105
src/modules/auth/actions/createUser.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
"use server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import prisma from "@/core/db";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { hashPassword } from "../utils/hashPassword";
|
||||||
|
import { createJwtToken } from "../utils/createJwtToken";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for the schema of a new user.
|
||||||
|
*/
|
||||||
|
interface CreateUserSchema {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation schema for creating a user.
|
||||||
|
*/
|
||||||
|
const createUserSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
passwordConfirmation: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.passwordConfirmation, {
|
||||||
|
message: "Password confirmation must match the password",
|
||||||
|
path: ["passwordConfirmation"],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user in the system.
|
||||||
|
*
|
||||||
|
* @param formData - The form data containing user details.
|
||||||
|
* @returns An object indicating the result of the operation.
|
||||||
|
*/
|
||||||
|
export default async function createUser(formData: FormData) {
|
||||||
|
//TODO: Add Throttling
|
||||||
|
//TODO: Add validation check if the user is already logged in
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedData = {
|
||||||
|
email: formData.get("email")?.toString() ?? "",
|
||||||
|
name: formData.get("name")?.toString() ?? "",
|
||||||
|
password: formData.get("password")?.toString() ?? "",
|
||||||
|
passwordConfirmation: formData
|
||||||
|
.get("passwordConfirmation")
|
||||||
|
?.toString(),
|
||||||
|
};
|
||||||
|
const validatedFields = createUserSchema.safeParse(parsedData);
|
||||||
|
|
||||||
|
if (!validatedFields.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: "",
|
||||||
|
errors: validatedFields.error.flatten().fieldErrors,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: validatedFields.data.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: "",
|
||||||
|
errors: {
|
||||||
|
email: ["Email already exists"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: validatedFields.data.name,
|
||||||
|
email: validatedFields.data.email,
|
||||||
|
passwordHash: await hashPassword(validatedFields.data.password),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = createJwtToken({ id: user.id });
|
||||||
|
cookies().set("token", token);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Handle unexpected errors
|
||||||
|
console.error(e);
|
||||||
|
//@ts-ignore
|
||||||
|
console.log(e.message);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
"An unexpected error occurred on the server. Please try again or contact the administrator.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
39
src/modules/auth/actions/getUser.ts
Normal file
39
src/modules/auth/actions/getUser.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import "server-only";
|
||||||
|
import getUserFromToken from "../utils/getUserFromToken";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the user details based on the JWT token from cookies.
|
||||||
|
* This function is designed to be used in a server-side context within a Next.js application.
|
||||||
|
* It attempts to parse the user's token, fetch the user's details, and format the response.
|
||||||
|
* If the token is invalid or the user cannot be found, it gracefully handles these cases.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves to the user's details object or null if the user cannot be authenticated or an error occurs.
|
||||||
|
* @throws an error if an unexpected error occurs during execution.
|
||||||
|
*/
|
||||||
|
export default async function getUser() {
|
||||||
|
try {
|
||||||
|
const token = cookies().get("token");
|
||||||
|
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const user = await getUserFromToken(token.value);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
photoUrl: user.photoProfile?.path ?? null,
|
||||||
|
};
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Handle specific authentication errors gracefully
|
||||||
|
if (e instanceof AuthError && e.errorCode === "INVALID_JWT_TOKEN") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/modules/auth/actions/guestOnly.ts
Normal file
12
src/modules/auth/actions/guestOnly.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import getUser from "./getUser";
|
||||||
|
|
||||||
|
export default async function guestOnly() {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
redirect("dashboard");
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/modules/auth/actions/logout.ts
Normal file
16
src/modules/auth/actions/logout.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles user logout by deleting the authentication token and redirecting to the login page.
|
||||||
|
* This function is intended to be used on the server side.
|
||||||
|
*
|
||||||
|
* @returns A promise that resolves when the logout process is complete.
|
||||||
|
*/
|
||||||
|
export default async function logout() {
|
||||||
|
cookies().delete("token");
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
99
src/modules/auth/actions/signIn.ts
Normal file
99
src/modules/auth/actions/signIn.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"use server";
|
||||||
|
import prisma from "@/core/db";
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
import comparePassword from "../utils/comparePassword";
|
||||||
|
import { createJwtToken } from "../utils/createJwtToken";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the sign-in process for a user.
|
||||||
|
*
|
||||||
|
* This function validates a user's credentials (email and password), checks against the database,
|
||||||
|
* and on successful validation, redirects the user to the dashboard and sets a cookie with a JWT token.
|
||||||
|
* If the validation fails at any stage, it throws a custom AuthError.
|
||||||
|
*
|
||||||
|
* @param prevState - The previous state of the application, not currently used.
|
||||||
|
* @param rawFormData - The raw form data containing the user's email and password.
|
||||||
|
* @returns A promise that resolves to a redirect to the dashboard on successful authentication,
|
||||||
|
* or an object containing error details on failure.
|
||||||
|
* @throws Specific authentication error based on the failure stage.
|
||||||
|
*/
|
||||||
|
export default async function signIn(prevState: any, rawFormData: FormData) {
|
||||||
|
//TODO: Add Throttling
|
||||||
|
//TODO: Add validation check if the user is already logged in
|
||||||
|
try {
|
||||||
|
const formData = {
|
||||||
|
email: rawFormData.get("email") as string,
|
||||||
|
password: rawFormData.get("password") as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retrieve user from the database by email
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: formData.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Throw if user not found
|
||||||
|
if (!user)
|
||||||
|
throw new AuthError({
|
||||||
|
errorCode: "EMAIL_NOT_FOUND",
|
||||||
|
message: "Email or Password does not match",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Throw if user has no password hash
|
||||||
|
// TODO: Add check if the user uses another provider
|
||||||
|
if (!user.passwordHash)
|
||||||
|
throw new AuthError({ errorCode: "EMPTY_USER_HASH" });
|
||||||
|
|
||||||
|
// Compare the provided password with the user's stored password hash
|
||||||
|
const isMatch = await comparePassword(
|
||||||
|
formData.password,
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
if (!isMatch)
|
||||||
|
throw new AuthError({
|
||||||
|
errorCode: "INVALID_CREDENTIALS",
|
||||||
|
message: "Email or Password does not match",
|
||||||
|
});
|
||||||
|
|
||||||
|
//Set cookie
|
||||||
|
//TODO: Auth: Add expiry
|
||||||
|
const token = createJwtToken({ id: user.id });
|
||||||
|
|
||||||
|
cookies().set("token", token);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// Custom error handling for authentication errors
|
||||||
|
if (e instanceof AuthError) {
|
||||||
|
// Specific error handling for known authentication errors
|
||||||
|
switch (e.errorCode) {
|
||||||
|
case "EMAIL_NOT_FOUND":
|
||||||
|
case "INVALID_CREDENTIALS":
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
message:
|
||||||
|
"Email/Password combination is incorrect. Please try again.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
// Handle other types of authentication errors
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
message: e.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error handling for unexpected server errors
|
||||||
|
return {
|
||||||
|
errors: {
|
||||||
|
message:
|
||||||
|
"An unexpected error occurred on the server. Please try again or contact the administrator.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/dashboard");
|
||||||
|
}
|
||||||
5
src/modules/auth/authConfig.ts
Normal file
5
src/modules/auth/authConfig.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
const authConfig = {
|
||||||
|
saltRounds: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authConfig;
|
||||||
28
src/modules/auth/error/AuthError.ts
Normal file
28
src/modules/auth/error/AuthError.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import BaseError from "@/core/error/BaseError";
|
||||||
|
|
||||||
|
export const AuthErrorCodes = [
|
||||||
|
"EMAIL_NOT_FOUND",
|
||||||
|
"EMPTY_USER_HASH",
|
||||||
|
"INVALID_CREDENTIALS",
|
||||||
|
"INVALID_JWT_TOKEN",
|
||||||
|
"JWT_SECRET_EMPTY",
|
||||||
|
"USER_ALREADY_EXISTS",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface AuthErrorOptions {
|
||||||
|
message?: string;
|
||||||
|
errorCode: (typeof AuthErrorCodes)[number] | (string & {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AuthError extends BaseError {
|
||||||
|
errorCode: (typeof AuthErrorCodes)[number] | (string & {});
|
||||||
|
|
||||||
|
constructor(options: AuthErrorOptions) {
|
||||||
|
super({
|
||||||
|
errorCode: options.errorCode,
|
||||||
|
message: options.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.errorCode = options.errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/modules/auth/types/UserClaims.d.ts
vendored
Normal file
7
src/modules/auth/types/UserClaims.d.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { User } from "@prisma/client";
|
||||||
|
|
||||||
|
type UserClaims = {
|
||||||
|
id: User["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserClaims;
|
||||||
17
src/modules/auth/utils/comparePassword.ts
Normal file
17
src/modules/auth/utils/comparePassword.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares a plain text password with a hashed password.
|
||||||
|
*
|
||||||
|
* @param password - The plain text password to compare.
|
||||||
|
* @param hash - The hashed password to compare against.
|
||||||
|
* @returns True if the passwords match, false otherwise.
|
||||||
|
*/
|
||||||
|
async function comparePassword(
|
||||||
|
password: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default comparePassword;
|
||||||
20
src/modules/auth/utils/createJwtToken.ts
Normal file
20
src/modules/auth/utils/createJwtToken.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { SignOptions } from "jsonwebtoken";
|
||||||
|
import UserClaims from "../types/UserClaims";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a JWT token based on user claims.
|
||||||
|
*
|
||||||
|
* @param userClaims - The user claims to encode in the JWT.
|
||||||
|
* @param options - Optional signing options.
|
||||||
|
* @returns The generated JWT token.
|
||||||
|
*/
|
||||||
|
export function createJwtToken(
|
||||||
|
userClaims: UserClaims,
|
||||||
|
options?: SignOptions
|
||||||
|
): string {
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) throw new AuthError({ errorCode: "JWT_SECRET_EMPTY" });
|
||||||
|
return jwt.sign(userClaims, secret, options);
|
||||||
|
}
|
||||||
21
src/modules/auth/utils/decodeJwtToken.ts
Normal file
21
src/modules/auth/utils/decodeJwtToken.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||||
|
import AuthError from "../error/AuthError";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a JWT token and retrieves the payload.
|
||||||
|
*
|
||||||
|
* @param token - The JWT token to decode.
|
||||||
|
* @returns The decoded payload.
|
||||||
|
*/
|
||||||
|
function decodeJwtToken(token: string): JwtPayload | string {
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) throw new AuthError({ errorCode: "JWT_SECRET_NOT_EMPTY" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, secret) as JwtPayload;
|
||||||
|
} catch (error) {
|
||||||
|
throw new AuthError({ errorCode: "INVALID_JWT_TOKEN" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default decodeJwtToken;
|
||||||
34
src/modules/auth/utils/getUserFromToken.ts
Normal file
34
src/modules/auth/utils/getUserFromToken.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { cache } from "react";
|
||||||
|
import decodeJwtToken from "./decodeJwtToken";
|
||||||
|
import prisma from "@/core/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves user data from the database based on the provided JWT token.
|
||||||
|
*
|
||||||
|
* This function decodes the JWT token to extract the user ID, and then queries the database using Prisma
|
||||||
|
* to fetch the user's details, including the profile photo, roles, and direct permissions.
|
||||||
|
*
|
||||||
|
* @param token - The JWT token containing the user's ID.
|
||||||
|
* @returns The user's data if the user exists, or null if no user is found.
|
||||||
|
* Throws an error if the token is invalid or the database query fails.
|
||||||
|
*/
|
||||||
|
const getUserFromToken = cache(async (token: string) => {
|
||||||
|
// Decode the JWT token to extract the user ID
|
||||||
|
const decodedToken = decodeJwtToken(token) as { id: string; iat: number };
|
||||||
|
|
||||||
|
// Fetch the user from the database
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
include: {
|
||||||
|
photoProfile: true,
|
||||||
|
roles: true,
|
||||||
|
directPermissions: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: decodedToken.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default getUserFromToken;
|
||||||
14
src/modules/auth/utils/hashPassword.ts
Normal file
14
src/modules/auth/utils/hashPassword.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import authConfig from "../authConfig";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a plain text password using bcrypt.
|
||||||
|
*
|
||||||
|
* @param password - The plain text password to hash.
|
||||||
|
* @returns The hashed password.
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, authConfig.saltRounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default hashPassword;
|
||||||
Loading…
Reference in New Issue
Block a user