Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions src/actions/auth/signin/action.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
44 changes: 44 additions & 0 deletions src/actions/auth/signin/logic.ts
Original file line number Diff line number Diff line change
@@ -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<Result<Omit<User, 'password'>>> {
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);
}
8 changes: 8 additions & 0 deletions src/actions/auth/signin/schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof signinSchema>
28 changes: 28 additions & 0 deletions src/actions/auth/signout/action.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
10 changes: 10 additions & 0 deletions src/actions/auth/signout/logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import 'server-only';

import { Result, success } from '@/lib/result';
import { getSession } from '@/lib/session';

export async function signout(): Promise<Result<undefined>> {
const session = await getSession();
await session.destroy();
return success(undefined);
}
31 changes: 31 additions & 0 deletions src/actions/auth/signup/action.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
38 changes: 38 additions & 0 deletions src/actions/auth/signup/logic.ts
Original file line number Diff line number Diff line change
@@ -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<Result<User>> {
const { email, name, password } = input;

const normalisedEmail = email.toLowerCase().trim();

Comment on lines +13 to +14
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

🧩 Analysis chain

Email normalization is good. Ensure a case-insensitive unique index on email.

Run to verify Prisma schema:


🏁 Script executed:

#!/bin/bash
# Expect: `@unique` (or a unique index) on User.email and a lowercase normalization in app code.
fd -HI --max-depth 3 'schema.prisma' \
  | xargs -I{} rg -nPA3 'model\s+User\b|@unique|@@unique|email' {}

Length of output: 401


Enforce DB-level case-insensitive uniqueness for User.email

  • Signup code already normalizes email: src/actions/auth/signup/logic.ts (lines 23–24).
  • schema.prisma shows model User with email String? @unique (schema.prisma). @unique exists but the column is nullable and uniqueness behavior depends on DB collation — this does not guarantee case-insensitive uniqueness.
  • Action required: make email non-nullable if uniqueness must apply to all users, and enforce case-insensitive uniqueness at the DB level (e.g., PostgreSQL CITEXT column or a unique index on lower(email) applied via migration/raw SQL).

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);
}
14 changes: 14 additions & 0 deletions src/actions/auth/signup/schema.ts
Original file line number Diff line number Diff line change
@@ -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')
Comment on lines +6 to +10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add a maximum password length to mitigate hashing DoS

Very long passwords can amplify Argon2/bcrypt cost. Cap to a sane upper bound (e.g., 128).

     password: z.string()
-        .min(8, 'Password must be at least 8 characters long')
+        .min(8, 'Password must be at least 8 characters long')
+        .max(128, 'Password must be at most 128 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')

Optional: consider allowing long passphrases (e.g., ≥16 chars) as an alternative to composition rules.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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')
password: z.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 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')
🤖 Prompt for AI Agents
In src/actions/auth/signup/schema.ts lines 6 to 10, the password Zod schema
lacks an upper length bound which can enable hashing DoS; add a maximum length
check (e.g., .max(128, 'Password must be at most 128 characters long')) to cap
input size before hashing. Update the schema to enforce min and max together,
and if you want to permit long passphrases, implement a separate acceptance path
or rule (for example allow >=16 chars without strict composition) rather than
removing the max limit.


})

export type signupInput = z.infer<typeof signupSchema>
Loading