feat: implement credentials login #90

Merged
dominik merged 1 commit from feat/5-credentials_login_functionality into main 2025-06-14 09:57:29 +00:00
10 changed files with 522 additions and 115 deletions

View file

@ -22,6 +22,7 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@hookform/resolvers": "^5.0.1",
"@prisma/client": "^6.9.0",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
@ -32,6 +33,7 @@
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
@ -40,7 +42,9 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.2.0"
"react-hook-form": "^7.56.4",
"tailwind-merge": "^3.2.0",
"zod": "^3.25.60"
},
"devDependencies": {
"@eslint/eslintrc": "3.3.1",

View file

@ -6,8 +6,6 @@ import { Button } from '@/components/custom-ui/button';
import Image from 'next/image';
import { Separator } from '@/components/custom-ui/separator';
import Logo from '@/components/logo';
import '@/app/globals.css';
import {
Card,
CardContent,
@ -28,12 +26,12 @@ export default async function LoginPage() {
}
return (
<div className='flex flex-col items-center justify-center h-screen'>
<div className='flex flex-col items-center justify-center h-screen'>
<div className='absolute top-4 right-4'>
<div className='flex flex-col items-center min-h-screen'>
<div className='flex flex-col items-center min-h-screen'>
<div className='fixed top-4 right-4'>
<ThemePicker />
</div>
<div>
<div className='mt-auto mb-auto'>
<Card className='w-[350px] max-w-screen;'>
<CardHeader className='grid place-items-center'>
<Logo colorType='colored' logoType='secondary'></Logo>
@ -43,8 +41,6 @@ export default async function LoginPage() {
<Separator className='h-[1px] rounded-sm w-[60%] bg-border' />
{providerMap.length > 0}
{providerMap.map((provider) => (
<SSOLogin
key={provider.id}

View file

@ -1,24 +1,88 @@
import NextAuth from 'next-auth';
import NextAuth, { CredentialsSignin } from 'next-auth';
import { Prisma } from '@/generated/prisma';
import type { Provider } from 'next-auth/providers';
import Credentials from 'next-auth/providers/credentials';
import Authentik from 'next-auth/providers/authentik';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { prisma } from '@/prisma';
import { loginSchema } from './lib/validation/user';
import { ZodError } from 'zod';
class InvalidLoginError extends CredentialsSignin {
constructor(code: string) {
super();
this.code = code;
this.message = code;
}
}
const providers: Provider[] = [
!process.env.DISABLE_PASSWORD_LOGIN &&
Credentials({
credentials: { password: { label: 'Password', type: 'password' } },
authorize(c) {
if (c.password !== 'password') return null;
return {
id: 'test',
name: 'Test User',
email: 'test@example.com',
};
async authorize(c) {
if (process.env.NODE_ENV === 'development' && c.password === 'password')
return {
id: 'test',
name: 'Test User',
email: 'test@example.com',
};
if (process.env.DISABLE_PASSWORD_LOGIN) return null;
try {
const { email, password } = await loginSchema.parseAsync(c);
const user = await prisma.user.findFirst({
where: { email },
include: { accounts: true },
});
if (!user)
throw new InvalidLoginError(
'username/email or password is not correct',
);
if (user.accounts[0].provider !== 'credentials') {
throw new InvalidLoginError(
'username/email or password is not correct',
dominik marked this conversation as resolved Outdated

Sollten wir da wirklich sagen dass der User woanders die eingegebenen credantials hat?

Sollten wir da wirklich sagen dass der User woanders die eingegebenen credantials hat?
);
}
const passwordsMatch = await (
await import('bcryptjs')
).compare(password, user.password_hash!);
if (!passwordsMatch) {
throw new InvalidLoginError(
'username/email or password is not correct',
);
}
if (!user.emailVerified) {
throw new InvalidLoginError(
'Email not verified. Please check your inbox.',
);
}
return user;
} catch (error) {
if (
error instanceof Prisma?.PrismaClientInitializationError ||
error instanceof Prisma?.PrismaClientKnownRequestError
) {
throw new InvalidLoginError('System error. Please contact support');
}
if (error instanceof ZodError) {
throw new InvalidLoginError(error.issues[0].message);
}
throw error;
}
},
}),
process.env.AUTH_AUTHENTIK_ID && Authentik,
@ -50,4 +114,5 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return !!auth?.user;
},
},
debug: process.env.NODE_ENV === 'development',
});

View file

@ -8,6 +8,8 @@ export default function LabeledInput({
value,
name,
autocomplete,
error,
...rest
}: {
type: 'text' | 'email' | 'password';
label: string;
@ -15,7 +17,8 @@ export default function LabeledInput({
value?: string;
name?: string;
autocomplete?: string;
}) {
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>) {
return (
<div className='grid grid-cols-1 gap-1'>
<Label htmlFor={name}>{label}</Label>
@ -27,7 +30,9 @@ export default function LabeledInput({
id={name}
name={name}
autoComplete={autocomplete}
{...rest}
/>
{error && <p className='text-red-500 text-sm mt-1'>{error}</p>}
</div>
);
}

View file

@ -1,107 +1,214 @@
'use client';
import { signIn } from '@/auth';
import React, { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import LabeledInput from '@/components/labeled-input';
import { Button } from '@/components/custom-ui/button';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';
import { useRef, useState } from 'react';
import useZodForm from '@/lib/hooks/useZodForm';
import { loginSchema, registerSchema } from '@/lib/validation/user';
import { loginAction } from '@/lib/auth/login';
import { registerAction } from '@/lib/auth/register';
const SIGNIN_ERROR_URL = '/error';
function LoginFormElement({
setIsSignUp,
formRef,
}: {
setIsSignUp: (value: boolean | ((prev: boolean) => boolean)) => void;
formRef?: React.RefObject<HTMLFormElement | null>;
}) {
const { handleSubmit, formState, register, setError } =
useZodForm(loginSchema);
const router = useRouter();
const onSubmit = handleSubmit(async (data) => {
try {
const { error } = await loginAction(data);
if (error) {
setError('root', {
message: error,
});
return;
} else {
router.push('/home');
router.refresh();
return;
}
} catch (error: unknown) {
if (error instanceof Error)
setError('root', {
message: error?.message,
});
else
setError('root', {
message: 'An unknown error occurred.',
});
}
});
return (
<form className='flex flex-col gap-5 w-full' onSubmit={onSubmit}>
<LabeledInput
type='text'
label='E-Mail or Username'
placeholder='What you are known as'
error={formState.errors.email?.message}
{...register('email')}
/>
<LabeledInput
type='password'
label='Password'
placeholder="Let's hope you remember it"
error={formState.errors.password?.message}
{...register('password')}
/>
<div className='grid grid-rows-2 gap-2'>
<Button type='submit' variant='primary'>
Login
</Button>
<Button
type='button'
variant='outline_primary'
onClick={() => {
formRef?.current?.reset();
setIsSignUp((v) => !v);
}}
>
Sign Up
</Button>
</div>
<div>
{formState.errors.root?.message && (
<p className='text-red-500'>{formState.errors.root?.message}</p>
)}
</div>
</form>
);
}
function RegisterFormElement({
setIsSignUp,
formRef,
}: {
setIsSignUp: (value: boolean | ((prev: boolean) => boolean)) => void;
formRef?: React.RefObject<HTMLFormElement | null>;
}) {
const { handleSubmit, formState, register, setError } =
useZodForm(registerSchema);
const onSubmit = handleSubmit(async (data) => {
try {
const { error } = await registerAction(data);
if (error) {
setError('root', {
message: error,
});
return;
} else {
formRef?.current?.reset();
setIsSignUp(false);
// TODO: Show registration success message (reminder to verify email)
return;
}
} catch (error: unknown) {
if (error instanceof Error)
setError('root', {
message: error?.message,
});
else
setError('root', {
message: 'An unknown error occurred.',
});
}
});
return (
<form
ref={formRef}
className='flex flex-col gap-5 w-full'
onSubmit={onSubmit}
>
<LabeledInput
type='text'
label='First Name'
placeholder='Your first name'
autocomplete='given-name'
error={formState.errors.firstName?.message}
{...register('firstName')}
/>
<LabeledInput
type='text'
label='Last Name'
placeholder='Your last name'
autocomplete='family-name'
error={formState.errors.lastName?.message}
{...register('lastName')}
/>
<LabeledInput
type='email'
label='E-Mail'
placeholder='Your email address'
autocomplete='email'
error={formState.errors.email?.message}
{...register('email')}
/>
<LabeledInput
type='text'
label='Username'
placeholder='Your username'
autocomplete='username'
error={formState.errors.username?.message}
{...register('username')}
/>
<LabeledInput
type='password'
label='Password'
placeholder='Create a password'
autocomplete='new-password'
error={formState.errors.password?.message}
{...register('password')}
/>
<LabeledInput
type='password'
label='Confirm Password'
placeholder='Repeat your password'
autocomplete='new-password'
error={formState.errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<div className='grid grid-rows-2 gap-2'>
<Button type='submit' variant='primary'>
Sign Up
</Button>
<Button
type='button'
variant='outline_primary'
onClick={() => {
formRef?.current?.reset();
setIsSignUp((v) => !v);
}}
>
Back to Login
</Button>
</div>
<div>
{formState.errors.root?.message && (
<p className='text-red-500'>{formState.errors.root?.message}</p>
)}
</div>
</form>
);
}
export default function LoginForm() {
const [isSignUp, setIsSignUp] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
return (
<form
ref={formRef}
className='flex flex-col gap-5 w-full'
action={async (formData) => {
'use client';
try {
if (isSignUp) {
// handle sign up logic here
} else {
await signIn('credentials', formData);
}
} catch (error) {
if (error instanceof AuthError) {
return redirect(`${SIGNIN_ERROR_URL}?error=${error.type}`);
}
throw error;
}
}}
>
{isSignUp ? (
<>
<LabeledInput
type='text'
label='First Name'
placeholder='Your first name'
name='firstName'
autocomplete='given-name'
/>
<LabeledInput
type='text'
label='Last Name'
placeholder='Your last name'
name='lastName'
autocomplete='family-name'
/>
<LabeledInput
type='email'
label='E-Mail'
placeholder='Your email address'
name='email'
autocomplete='email'
/>
<LabeledInput
type='password'
label='Password'
placeholder='Create a password'
name='password'
autocomplete='new-password'
/>
<LabeledInput
type='password'
label='Confirm Password'
placeholder='Repeat your password'
name='confirmPassword'
autocomplete='new-password'
/>
</>
) : (
<>
<LabeledInput
type='email'
label='E-Mail or Username'
placeholder='What you are known as'
name='email'
/>
<LabeledInput
type='password'
label='Password'
placeholder="Let's hope you remember it"
name='password'
/>
</>
)}
<div className='grid grid-rows-2 gap-2'>
<Button type='submit' variant='primary'>
{isSignUp ? 'Sign Up' : 'Login'}
</Button>
<Button
type='button'
variant='outline_primary'
onClick={() => {
formRef.current?.reset();
setIsSignUp((v) => !v);
}}
>
{isSignUp ? 'Back to Login' : 'Sign Up'}
</Button>
</div>
</form>
);
if (isSignUp) {
return <RegisterFormElement setIsSignUp={setIsSignUp} formRef={formRef} />;
}
return <LoginFormElement setIsSignUp={setIsSignUp} formRef={formRef} />;
}

27
src/lib/auth/login.ts Normal file
View file

@ -0,0 +1,27 @@
'use server';
import { z } from 'zod';
import { loginSchema } from '@/lib/validation/user';
import { signIn } from '@/auth';
export async function loginAction(data: z.infer<typeof loginSchema>) {
try {
await signIn('credentials', {
...data,
redirect: false,
});
return {
error: undefined,
};
} catch (error: unknown) {
if (error instanceof Error) {
return {
error: error.message.toString(),
};
}
return {
error: 'An unknown error occurred.',
};
}
}

75
src/lib/auth/register.ts Normal file
View file

@ -0,0 +1,75 @@
'use server';
import type { z } from 'zod';
import bcrypt from 'bcryptjs';
import { registerSchema } from '@/lib/validation/user';
import { prisma } from '@/prisma';
export async function registerAction(data: z.infer<typeof registerSchema>) {
try {
const result = await registerSchema.safeParseAsync(data);
if (!result.success) {
return {
error: result.error.errors[0].message,
};
}
const { email, password, firstName, lastName, username } = result.data;
const user = await prisma.user.findUnique({
where: {
email,
},
});
if (user) {
return {
error: 'User already exist with this email',
};
}
const existingUsername = await prisma.user.findUnique({
where: {
name: username,
},
});
if (existingUsername) {
return {
error: 'Username already exists',
};
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.$transaction(async (tx) => {
const { id } = await tx.user.create({
data: {
email,
name: username,
password_hash: passwordHash,
first_name: firstName,
last_name: lastName,
emailVerified: new Date(), // TODO: handle email verification
},
});
await tx.account.create({
data: {
userId: id,
type: 'credentials',
provider: 'credentials',
providerAccountId: id,
},
});
});
return {};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_error) {
return {
error: 'System error. Please contact support',
};
}
}

View file

@ -0,0 +1,14 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export default function useZodForm<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Schema extends z.ZodType<any, any, any>,
Values extends z.infer<Schema>,
>(schema: Schema, defaultValues?: Values) {
return useForm<Values>({
resolver: zodResolver(schema),
defaultValues,
});
}

View file

@ -0,0 +1,67 @@
import zod from 'zod';
export const loginSchema = zod.object({
email: zod
.string()
.email('Invalid email address')
.min(3, 'Email is required')
.or(
zod
.string()
.min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores',
),
),
password: zod.string().min(1, 'Password is required'),
});
export const registerSchema = zod
.object({
firstName: zod
.string()
.min(1, 'First name is required')
.max(32, 'First name must be at most 32 characters long'),
lastName: zod
.string()
.min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long'),
email: zod
.string()
.email('Invalid email address')
.min(3, 'Email is required'),
password: zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long'),
confirmPassword: zod
.string()
.min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long'),
username: zod
.string()
.min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long')
.regex(
/^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores',
),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
.refine(
(data) =>
!data.password.includes(data.firstName) &&
!data.password.includes(data.lastName) &&
!data.password.includes(data.email) &&
!data.password.includes(data.username),
{
message:
'Password cannot contain your first name, last name, email, or username',
path: ['password'],
},
);

View file

@ -264,6 +264,17 @@ __metadata:
languageName: node
linkType: hard
"@hookform/resolvers@npm:^5.0.1":
version: 5.1.0
resolution: "@hookform/resolvers@npm:5.1.0"
dependencies:
"@standard-schema/utils": "npm:^0.3.0"
peerDependencies:
react-hook-form: ^7.55.0
checksum: 10c0/5bd28ef58a182102f40b7fa2bc73a5e423c8bcf9cb25fee91cb8933026e7efba6f9adfda1ee637294de59c5467c938f4fb99c77a73bbb8c6180482c69d31cbbd
languageName: node
linkType: hard
"@humanfs/core@npm:^0.19.1":
version: 0.19.1
resolution: "@humanfs/core@npm:0.19.1"
@ -1429,6 +1440,13 @@ __metadata:
languageName: node
linkType: hard
"@standard-schema/utils@npm:^0.3.0":
version: 0.3.0
resolution: "@standard-schema/utils@npm:0.3.0"
checksum: 10c0/6eb74cd13e52d5fc74054df51e37d947ef53f3ab9e02c085665dcca3c38c60ece8d735cebbdf18fbb13c775fbcb9becb3f53109b0e092a63f0f7389ce0993fd0
languageName: node
linkType: hard
"@swc/counter@npm:0.1.3":
version: 0.1.3
resolution: "@swc/counter@npm:0.1.3"
@ -2138,6 +2156,15 @@ __metadata:
languageName: node
linkType: hard
"bcryptjs@npm:^3.0.2":
version: 3.0.2
resolution: "bcryptjs@npm:3.0.2"
bin:
bcrypt: bin/bcrypt
checksum: 10c0/a0923cac99f83e913f8f4e4f42df6a27c6593b24d509900331d1280c4050b1544e602a0ac67b43f7bb5c969991c3ed77fd72f19b7dc873be8ee794da3d925c7e
languageName: node
linkType: hard
"brace-expansion@npm:^1.1.7":
version: 1.1.11
resolution: "brace-expansion@npm:1.1.11"
@ -3851,6 +3878,7 @@ __metadata:
"@fortawesome/free-regular-svg-icons": "npm:^6.7.2"
"@fortawesome/free-solid-svg-icons": "npm:^6.7.2"
"@fortawesome/react-fontawesome": "npm:^0.2.2"
"@hookform/resolvers": "npm:^5.0.1"
"@prisma/client": "npm:^6.9.0"
"@radix-ui/react-dropdown-menu": "npm:^2.1.14"
"@radix-ui/react-hover-card": "npm:^1.1.13"
@ -3865,6 +3893,7 @@ __metadata:
"@types/node": "npm:22.15.31"
"@types/react": "npm:19.1.7"
"@types/react-dom": "npm:19.1.6"
bcryptjs: "npm:^3.0.2"
class-variance-authority: "npm:^0.7.1"
clsx: "npm:^2.1.1"
dotenv-cli: "npm:8.0.0"
@ -3880,10 +3909,12 @@ __metadata:
prisma: "npm:6.9.0"
react: "npm:^19.0.0"
react-dom: "npm:^19.0.0"
react-hook-form: "npm:^7.56.4"
tailwind-merge: "npm:^3.2.0"
tailwindcss: "npm:4.1.8"
tw-animate-css: "npm:1.3.4"
typescript: "npm:5.8.3"
zod: "npm:^3.25.60"
languageName: unknown
linkType: soft
@ -4376,6 +4407,15 @@ __metadata:
languageName: node
linkType: hard
"react-hook-form@npm:^7.56.4":
version: 7.57.0
resolution: "react-hook-form@npm:7.57.0"
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
checksum: 10c0/6db0b44b2e88d4db541514e96557723e39381ce9f71b3787bf041635f829143dbd0ae46a1f6c16dee23afe3413fd25539484ba02bf2a35d90aaa1b7483193ea9
languageName: node
linkType: hard
"react-is@npm:^16.13.1":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
@ -5337,3 +5377,10 @@ __metadata:
checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f
languageName: node
linkType: hard
"zod@npm:^3.25.60":
version: 3.25.60
resolution: "zod@npm:3.25.60"
checksum: 10c0/4b5c0fc9fc9020a1b41c9e4093e722181f9535dc4e917cd5a63ce764c405f49ff49d7f5ef4d8c92665d250d92c2216c4c0b0145175c755209762be4ec305c5d2
languageName: node
linkType: hard