refactor: auth flow, middleware and login page #55

Merged
dominik merged 5 commits from refactor/54-auth_flow_middleware_and_login_pages into main 2025-05-13 18:34:58 +00:00
11 changed files with 135 additions and 58 deletions

View file

@ -7,5 +7,3 @@ AUTH_AUTHENTIK_SECRET=
AUTH_AUTHENTIK_ISSUER= AUTH_AUTHENTIK_ISSUER=
NEXT_PUBLIC_APP_URL= NEXT_PUBLIC_APP_URL=
MEETUP_SKIP_LOGIN=

View file

@ -94,10 +94,6 @@ This project is built with a modern tech stack:
# Base URL of your application # Base URL of your application
NEXT_PUBLIC_APP_URL="http://localhost:3000" NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Development: Skip login flow (set to "true" to bypass authentication)
# Ensure this is NOT set to "true" in production.
MEETUP_SKIP_LOGIN="false"
``` ```
4. **Apply database migrations (Prisma):** 4. **Apply database migrations (Prisma):**
@ -111,11 +107,20 @@ This project is built with a modern tech stack:
- (Optional: If you need to generate Prisma Client without running migrations, use `npx prisma generate`) - (Optional: If you need to generate Prisma Client without running migrations, use `npx prisma generate`)
5. **Run the development server:** 5. **Run the development server:**
```bash ```bash
yarn dev yarn dev
``` ```
Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application.
The test user for the application is:
```bash
email: test@example.com
password: password
```
**Self-Hosting with Docker (Planned):** **Self-Hosting with Docker (Planned):**
- A Docker image and `docker-compose.yml` file will be provided in the future to allow for easy self-hosting of the MeetUP application. This setup will also include database services. Instructions will be updated here once available. - A Docker image and `docker-compose.yml` file will be provided in the future to allow for easy self-hosting of the MeetUP application. This setup will also include database services. Instructions will be updated here once available.

View file

@ -1,4 +1,3 @@
import { Logout } from '@/components/user/sso-logout-button';
import { RedirectButton } from '@/components/user/redirect-button'; import { RedirectButton } from '@/components/user/redirect-button';
import { ThemePicker } from '@/components/user/theme-picker'; import { ThemePicker } from '@/components/user/theme-picker';
@ -8,7 +7,7 @@ export default function Home() {
<div className='absolute top-4 right-4'>{<ThemePicker />}</div> <div className='absolute top-4 right-4'>{<ThemePicker />}</div>
<div> <div>
<h1>Home</h1> <h1>Home</h1>
<Logout /> <RedirectButton redirectUrl='/logout' buttonText='Logout' />
<RedirectButton redirectUrl='/settings' buttonText='Settings' /> <RedirectButton redirectUrl='/settings' buttonText='Settings' />
</div> </div>
</div> </div>

View file

@ -1,4 +1,4 @@
import { auth } from '@/auth'; import { auth, providerMap } from '@/auth';
import SSOLogin from '@/components/user/sso-login-button'; import SSOLogin from '@/components/user/sso-login-button';
import LoginForm from '@/components/user/login-form'; import LoginForm from '@/components/user/login-form';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
@ -35,11 +35,15 @@ export default async function LoginPage() {
<CardContent className='gap-6 flex flex-col'> <CardContent className='gap-6 flex flex-col'>
<LoginForm /> <LoginForm />
<hr /> {providerMap.length > 0 && <hr />}
{process.env.AUTH_AUTHENTIK_ISSUER && ( {providerMap.map((provider) => (
<SSOLogin provider='authentik' providerDisplayName='SSO' /> <SSOLogin
)} key={provider.id}
provider={provider.id}
providerDisplayName={provider.name}
/>
))}
dominik marked this conversation as resolved

Ich finde mehrere Provider gleichzeitig, etwas fragwürdig aber ich halte dich mal nicht auf.

Ich finde mehrere Provider gleichzeitig, etwas fragwürdig aber ich halte dich mal nicht auf.
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

40
src/app/logout/page.tsx Normal file
View file

@ -0,0 +1,40 @@
import { signOut } from '@/auth';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function SignOutPage() {
return (
<div className='flex flex-col items-center justify-center h-screen'>
<form
action={async () => {
'use server';
await signOut({ redirectTo: '/login' });
}}
>
<Card className='w-[350px] max-w-screen'>
<CardHeader>
<CardTitle className='text-lg text-center'>Logout</CardTitle>
<CardDescription className='text-center'>
Are you sure you want to log out?
</CardDescription>
</CardHeader>
<CardContent className='gap-6 flex flex-col'>
<Button
className='hover:bg-blue-600 hover:text-white'
type='submit'
variant='secondary'
>
Logout
</Button>
</CardContent>
</Card>
</form>
</div>
);
}

View file

@ -1,3 +1,9 @@
export default function Home() { import { auth } from '@/auth';
return <div></div>; import { redirect } from 'next/navigation';
export default async function Home() {
const session = await auth();
if (!session?.user) redirect('/login');
else redirect('/home');
} }

View file

@ -1,13 +1,49 @@
import NextAuth from 'next-auth'; import NextAuth from 'next-auth';
import type { Provider } from 'next-auth/providers';
import Credentials from 'next-auth/providers/credentials';
import Authentik from 'next-auth/providers/authentik'; import Authentik from 'next-auth/providers/authentik';
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',
};
},
}),
process.env.AUTH_AUTHENTIK_ID && Authentik,
].filter(Boolean) as Provider[];
export const providerMap = providers
.map((provider) => {
if (typeof provider === 'function') {
const providerData = provider();
return { id: providerData.id, name: providerData.name };
} else {
return { id: provider.id, name: provider.name };
}
})
.filter((provider) => provider.id !== 'credentials');
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [process.env.AUTH_AUTHENTIK_ISSUER ? Authentik : null].filter( providers,
(x) => x !== null, session: {
), strategy: 'jwt',
},
pages: {
signIn: '/login',
signOut: '/logout',
},
callbacks: { callbacks: {
authorized: async ({ auth }) => { authorized({ auth }) {
return !!auth; return !!auth?.user;
}, },
}, },
}); });

View file

@ -6,23 +6,24 @@ export default function LabeledInput({
label, label,
placeholder, placeholder,
value, value,
name,
}: { }: {
type: 'text' | 'email' | 'password'; type: 'text' | 'email' | 'password';
label: string; label: string;
placeholder?: string; placeholder?: string;
value?: string; value?: string;
name?: string;
}) { }) {
const elementId = Math.random().toString(36).substring(2, 15);
return ( return (
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
<Label htmlFor={elementId}>{label}</Label> <Label htmlFor={name}>{label}</Label>
<Input <Input
type={type} type={type}
placeholder={placeholder} placeholder={placeholder}
defaultValue={value} defaultValue={value}
id={elementId} id={name}
name={name}
/> />
</div> </div>
); );

View file

@ -1,18 +1,38 @@
import { signIn } from '@/auth';
import LabeledInput from '@/components/labeled-input'; import LabeledInput from '@/components/labeled-input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';
const SIGNIN_ERROR_URL = '/error';
export default function LoginForm() { export default function LoginForm() {
return ( return (
<form className='flex flex-col gap-5 w-full'> <form
className='flex flex-col gap-5 w-full'
action={async (formData) => {
'use server';
try {
await signIn('credentials', formData);
} catch (error) {
if (error instanceof AuthError) {
return redirect(`${SIGNIN_ERROR_URL}?error=${error.type}`);
}
throw error;
}
}}
>
<LabeledInput <LabeledInput
type='email' type='email'
label='E-Mail' label='E-Mail'
placeholder='Enter your E-Mail' placeholder='Enter your E-Mail'
name='email'
/> />
<LabeledInput <LabeledInput
type='password' type='password'
label='Password' label='Password'
placeholder='Enter your Password' placeholder='Enter your Password'
name='password'
/> />
<Button <Button
className='hover:bg-blue-600 hover:text-white' className='hover:bg-blue-600 hover:text-white'

View file

@ -1,18 +0,0 @@
import { signOut } from '@/auth';
import { IconButton } from '@/components/icon-button';
import { faDoorOpen } from '@fortawesome/free-solid-svg-icons';
export function Logout() {
return (
<form
action={async () => {
'use server';
await signOut({ redirectTo: '/login' });
}}
>
<IconButton type='submit' variant='destructive' icon={faDoorOpen}>
Sign Out
</IconButton>
</form>
);
}

View file

@ -1,18 +1,4 @@
import { auth } from '@/auth'; export { auth as middleware } from '@/auth';
export default auth((req) => {
if (
!req.auth &&
req.nextUrl.pathname !== '/login' &&
process.env.MEETUP_SKIP_LOGIN !== 'true'
) {
const newUrl = new URL('/login', req.nextUrl.origin);
return Response.redirect(newUrl);
} else if (req.auth != null && req.nextUrl.pathname === '/') {
const newUrl = new URL('/home', req.nextUrl.origin);
return Response.redirect(newUrl);
}
});
export const config = { export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],