diff --git a/package-lock.json b/package-lock.json index e84e3c6..8411e4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,15 +16,18 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "iron-session": "^8.0.4", "lucide-react": "^0.542.0", "luxon": "^3.7.2", - "next": "15.5.2", + "next": "^15.5.2", + "next-safe-action": "^8.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-icons": "^5.5.0", "tailwind-merge": "^3.3.1", "viem": "^2.37.5", - "wagmi": "^2.16.9" + "wagmi": "^2.16.9", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -2733,6 +2736,15 @@ "zod": "3.22.4" } }, + "node_modules/@reown/appkit-wallet/node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@reown/appkit/node_modules/@noble/ciphers": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.2.1.tgz", @@ -5106,6 +5118,15 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-es": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.2.tgz", @@ -7034,6 +7055,21 @@ "node": ">= 0.4" } }, + "node_modules/iron-session": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/iron-session/-/iron-session-8.0.4.tgz", + "integrity": "sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==", + "funding": [ + "https://github.com/sponsors/vvo", + "https://github.com/sponsors/brc-dd" + ], + "license": "MIT", + "dependencies": { + "cookie": "^0.7.2", + "iron-webcrypto": "^1.2.1", + "uncrypto": "^0.1.3" + } + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -7991,6 +8027,7 @@ "version": "15.5.2", "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "license": "MIT", "dependencies": { "@next/env": "15.5.2", "@swc/helpers": "0.5.15", @@ -8038,6 +8075,30 @@ } } }, + "node_modules/next-safe-action": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/next-safe-action/-/next-safe-action-8.0.11.tgz", + "integrity": "sha512-gqJLmnQLAoFCq1kRBopN46New+vx1n9J9Y/qDQLXpv/VqU40AWxDakvshwwnWAt8R0kLvlakNYNLX5PqlXWSMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/TheEdoRan" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=ES9JRPSC66XKW" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.17" + }, + "peerDependencies": { + "next": ">= 14.0.0", + "react": ">= 18.2.0", + "react-dom": ">= 18.2.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -10815,9 +10876,9 @@ } }, "node_modules/zod": { - "version": "3.22.4", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", - "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 0629f0d..b78e3e5 100644 --- a/package.json +++ b/package.json @@ -22,15 +22,18 @@ "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "iron-session": "^8.0.4", "lucide-react": "^0.542.0", "luxon": "^3.7.2", - "next": "15.5.2", + "next": "^15.5.2", + "next-safe-action": "^8.0.11", "react": "19.1.0", "react-dom": "19.1.0", "react-icons": "^5.5.0", "tailwind-merge": "^3.3.1", "viem": "^2.37.5", - "wagmi": "^2.16.9" + "wagmi": "^2.16.9", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/actions/auth/signin/action.ts b/src/actions/auth/signin/action.ts new file mode 100644 index 0000000..896ae1c --- /dev/null +++ b/src/actions/auth/signin/action.ts @@ -0,0 +1,32 @@ +'use server'; + +import { actionClient } from '@/lib/action'; +import { signin} from './logic'; +import { signinSchema} from './schema'; + +export const signinAction = actionClient + .inputSchema(signinSchema) + .metadata({ actionName: 'signin' }) + .action(async ({ parsedInput }) => { + const { email } = parsedInput; + + try { + const result = await signin(parsedInput); + + if (result.success) { + return result.data; + } + + throw new Error(result.error, { cause: { internal: true } }); + } catch (err) { + const error = err as Error; + const cause = error.cause as { internal: boolean } | undefined; + + if (cause?.internal) { + throw new Error(error.message, { cause: error }); + } + + console.error('Sign in error:', error); + throw new Error('Something went wrong'); + } + }); diff --git a/src/actions/auth/signin/logic.ts b/src/actions/auth/signin/logic.ts new file mode 100644 index 0000000..7f89554 --- /dev/null +++ b/src/actions/auth/signin/logic.ts @@ -0,0 +1,44 @@ +import 'server-only'; + +import { prisma } from "@/lib/prisma"; +import { signinInput } from "./schema"; +import bcrypt from "bcryptjs"; +import { error, Result, success } from "@/lib/result"; +import { getSession } from "@/lib/session"; +import type { User } from "@prisma/client"; + +export async function signin(input: signinInput): Promise>> { + const { email, password } = input; + + const normalisedEmail = email.toLowerCase().trim(); + + //Find user by email + const user = await prisma.user.findUnique({ + where: { + email: normalisedEmail + } + }) + if (!user) { + console.error('signin error: User not found'); + return error('Invalid credentials'); + } + + //Verify Password + const isValidPassword = await bcrypt.compare(password, user.password!); + + if (!isValidPassword) { + console.error('signin error: Invalid Password'); + return error('Invalid credentials'); + } + + // Omit password from returned user object + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password: _password, ...userWithoutPassword } = user; + + //Session + const session = await getSession(); + session.user = { id: userWithoutPassword.id }; + await session.save(); + + return success(userWithoutPassword); +} \ No newline at end of file diff --git a/src/actions/auth/signin/schema.ts b/src/actions/auth/signin/schema.ts new file mode 100644 index 0000000..995d4fa --- /dev/null +++ b/src/actions/auth/signin/schema.ts @@ -0,0 +1,8 @@ +import {z} from 'zod'; + +export const signinSchema = z.object({ + email: z.string().email("Invalid Email Format"), + password: z.string().min(1,'Password is required') +}); + +export type signinInput = z.infer \ No newline at end of file diff --git a/src/actions/auth/signout/action.ts b/src/actions/auth/signout/action.ts new file mode 100644 index 0000000..737d704 --- /dev/null +++ b/src/actions/auth/signout/action.ts @@ -0,0 +1,28 @@ +'use server'; + +import { authActionClient } from '@/lib/action'; +import { signout} from './logic'; + +export const signoutAction = authActionClient.metadata({ actionName: 'signout' }).action(async ({ ctx }) => { + const userId = ctx.session.user.id; + + try { + const result = await signout(); + + if (result.success) { + return result.data; + } + + throw new Error(result.error, { cause: { internal: true } }); + } catch (err) { + const error = err as Error; + const cause = error.cause as { internal: boolean } | undefined; + + if (cause?.internal) { + throw new Error(error.message, { cause: error }); + } + + console.error('Sign out error:', error, { userId }); + throw new Error('Something went wrong'); + } +}); diff --git a/src/actions/auth/signout/logic.ts b/src/actions/auth/signout/logic.ts new file mode 100644 index 0000000..98b6aae --- /dev/null +++ b/src/actions/auth/signout/logic.ts @@ -0,0 +1,10 @@ +import 'server-only'; + +import { Result, success } from '@/lib/result'; +import { getSession } from '@/lib/session'; + +export async function signout(): Promise> { + const session = await getSession(); + await session.destroy(); + return success(undefined); +} diff --git a/src/actions/auth/signup/action.ts b/src/actions/auth/signup/action.ts new file mode 100644 index 0000000..9edaffc --- /dev/null +++ b/src/actions/auth/signup/action.ts @@ -0,0 +1,31 @@ +'use server'; + +import { actionClient } from '@/lib/action'; +import { Signup} from './logic'; +import { SignupSchema} from './schema'; + +export const signupAction = actionClient + .inputSchema(SignupSchema) + .metadata({ actionName: 'signup' }) + .action(async ({ parsedInput }) => { + try { + const result = await Signup(parsedInput); + + if (result.success) { + return result.data; + } + + // Set session + throw new Error(result.error, { cause: { internal: true } }); + } catch (err) { + const error = err as Error; + const cause = error.cause as { internal: boolean } | undefined; + + if (cause?.internal) { + throw new Error(error.message, { cause: error }); + } + + console.error('Sign up error:', error); + throw new Error('Something went wrong'); + } + }); diff --git a/src/actions/auth/signup/logic.ts b/src/actions/auth/signup/logic.ts new file mode 100644 index 0000000..45a4396 --- /dev/null +++ b/src/actions/auth/signup/logic.ts @@ -0,0 +1,38 @@ +import 'server-only'; + +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; +import { signupInput } from "./schema"; +import { error, Result, success } from "@/lib/result"; +import { getSession } from "@/lib/session"; +import type { User } from "@prisma/client"; + +export async function Signup(input: signupInput): Promise> { + const { email, name, password } = input; + + const normalisedEmail = email.toLowerCase().trim(); + + const exisitingUser = await prisma.user.findUnique({ + where: { + email: normalisedEmail + } + }); + if (exisitingUser) { + console.error('Singup error: User with this email already exists') + return error('Something went wrong') + }; + + const hashedPassword = await bcrypt.hash(password, 12); + const user = await prisma.user.create({ + data: { + name, + email: normalisedEmail, + password: hashedPassword + } + }); + const session = await getSession(); + session.user = { id: user.id }; + await session.save(); + + return success(user); +} \ No newline at end of file diff --git a/src/actions/auth/signup/schema.ts b/src/actions/auth/signup/schema.ts new file mode 100644 index 0000000..208e79b --- /dev/null +++ b/src/actions/auth/signup/schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const signupSchema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email format'), + password: z.string() + .min(8, 'Password must be at least 8 characters long') + .regex(/(?=.*[a-z])/, 'Password must contain at least one lowercase letter') + .regex(/(?=.*[A-Z])/, 'Password must contain at least one uppercase letter') + .regex(/(?=.*\d)/, 'Password must contain at least one number') + +}) + +export type signupInput = z.infer \ No newline at end of file diff --git a/src/lib/action.ts b/src/lib/action.ts index 6266a12..f2c153c 100644 --- a/src/lib/action.ts +++ b/src/lib/action.ts @@ -1,66 +1,62 @@ -"use server"; -import { prisma } from "./prisma"; +import { getSession } from './session'; +import { prisma } from './prisma'; +import { createSafeActionClient } from 'next-safe-action'; +import { headers } from 'next/headers'; +import * as zod from 'zod'; +export function defineMetadataSchema() { + return zod.object({ + actionName: zod.string() + }); +} -export async function addUser(address: string, email?: string) { +export const actionClient = createSafeActionClient({ + defineMetadataSchema, + handleServerError: (err: Error) => err.message, + defaultValidationErrorsShape: 'flattened' +}) + /** + * Middleware used for auth purposes. + * Returns the context with the session object. + */ + .use(async ({ next }) => { + const session = await getSession(); + const headerList = headers(); - const normalizedAddress = address ? address.trim().toLowerCase() : undefined; - if (!normalizedAddress && !email) { - throw new Error("Must provide wallet address or email"); + return next({ + ctx: { session, headers: headerList } + }); + }); + +export const authActionClient = actionClient.use(async ({ next, ctx }) => { + const userId = ctx.session.user?.id; + + if (!userId) { + console.error('Malformed cookie', new Error('Invalid user, not allowed')); + throw new Error('Not Authorised'); + } + + const user = await prisma.user.findFirst({ + where: { + id: userId } + }); - const selectSafe = { - id: true, - email: true, - walletAddress: true, - role: true, - name: true, - image: true, - subscriptionType: true, - createdAt: true, - updatedAt: true, - lastLoginAt: true, - isActive: true, - } as const; + if (!user) { + console.error('Not Authorised', new Error('Invalid user, not allowed')); + throw new Error('Not Authorised'); + } - // Prefer wallet path when available (idempotent via upsert) - if (normalizedAddress) { - try { - const user = await prisma.user.upsert({ - where: { walletAddress: normalizedAddress }, - update: { lastLoginAt: new Date() }, - create: { - walletAddress: normalizedAddress, - email: email ?? null, - lastLoginAt: new Date(), - }, - select: selectSafe, - }); - // Optionally link email if provided and not set yet - if (email && !user.email) { - return await prisma.user.update({ - where: { id: user.id }, - data: { email }, - select: selectSafe, - }); - } - return user; - } catch (e) { - // Handle race: if unique constraint hit, re-fetch - const existing = await prisma.user.findUnique({ - where: { walletAddress: normalizedAddress }, - select: selectSafe, - }); - if (existing) return existing; - throw e; + return next({ + ctx: { + ...ctx, + session: { + destroy: ctx.session.destroy, + save: ctx.session.save, + user: { + id: userId } + } } - - // Email-only path (idempotent) - return prisma.user.upsert({ - where: { email: email as string }, - update: { lastLoginAt: new Date() }, - create: { email: email as string }, - select: selectSafe, - }); -} \ No newline at end of file + }); +}); diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..70289ea --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,4 @@ +import { APP_ENV } from "./env"; + +export const cookieName = "authormaton-session"; +export const IS_PRODUCTION = APP_ENV === "production"; diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..e588fe9 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,12 @@ +import 'server-only'; + +const rawEnv = process.env.APP_ENV ?? process.env.NODE_ENV ?? 'development'; +const APP_ENV: 'development' | 'production' = rawEnv === 'production' ? 'production' : 'development'; + +const AUTH_SECRET = process.env.AUTH_SECRET as string; + +if (!AUTH_SECRET || AUTH_SECRET.length < 32) { + throw new Error('AUTH_SECRET must be set and at least 32 characters long'); +} + +export { APP_ENV, AUTH_SECRET }; diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 0000000..76a7252 --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,25 @@ +/** + * Universal return type for server actions + * Provides a consistent structure for success and error responses + */ +export type Result = { success: true; data: T } | { success: false; error: string }; + +/** + * Creates a success result with data + */ +export function success(data: T): Result { + return { + data, + success: true + }; +} + +/** + * Creates an error result with error message + */ +export function error(message: string): Result { + return { + success: false, + error: message + }; +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..28877dd --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,29 @@ + +import { getIronSession } from 'iron-session'; +import { cookies } from 'next/headers'; +import { cookieName, IS_PRODUCTION } from './constants'; +import { AUTH_SECRET } from './env'; + +export type SessionData = { + user?: { + id: string; + }; +}; + +export async function getSession() { + if (!AUTH_SECRET || AUTH_SECRET.length < 32) { + // Do not leak exact reason to clients; log server-side only. + console.error('Invalid AUTH_SECRET: must be at least 32 characters.'); + throw new Error('Server misconfiguration'); + } + const session = await getIronSession(await cookies(), { + password: AUTH_SECRET, + cookieName: cookieName, + cookieOptions: { + secure: IS_PRODUCTION, + httpOnly: true + } + }); + + return session; +}