diff --git a/apps/dispatch/app/(auth)/layout.tsx b/apps/dispatch/app/(auth)/layout.tsx new file mode 100644 index 00000000..a377d687 --- /dev/null +++ b/apps/dispatch/app/(auth)/layout.tsx @@ -0,0 +1,27 @@ +import { NextPage } from 'next'; +import { ReactNode } from 'react'; + +const AuthLayout: NextPage< + Readonly<{ + children: React.ReactNode; + }> +> = ({ children }) => ( +
+
+
+
+
+ {children} +
+
+
+
+); + +export default AuthLayout; diff --git a/apps/dispatch/app/(auth)/login/_components/Login.tsx b/apps/dispatch/app/(auth)/login/_components/Login.tsx new file mode 100644 index 00000000..76e5c388 --- /dev/null +++ b/apps/dispatch/app/(auth)/login/_components/Login.tsx @@ -0,0 +1,60 @@ +'use client'; +import { signIn } from 'next-auth/react'; +import { useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { Toaster } from 'react-hot-toast'; + +export const Login = () => { + const [isLoading, setIsLoading] = useState(false); + const searchParams = useSearchParams(); + + useEffect(() => { + const signInWithCode = async () => { + const code = searchParams.get('code'); + if (code) { + setIsLoading(true); + await signIn('credentials', { + code: code, + callbackUrl: '/', + }); + setIsLoading(false); + } + }; + signInWithCode(); + }, [searchParams]); + + return ( +
+
+ +
+

Login

+ + Noch keinen Account? Zur{' '} + + Registrierung + + + +
+ + + +
+
+ ); +}; diff --git a/apps/dispatch/app/(auth)/login/page.tsx b/apps/dispatch/app/(auth)/login/page.tsx new file mode 100644 index 00000000..2e247e5c --- /dev/null +++ b/apps/dispatch/app/(auth)/login/page.tsx @@ -0,0 +1,9 @@ +import { Login } from './_components/Login'; + +export default async () => { + return ( + <> + + + ); +}; diff --git a/apps/dispatch/app/(auth)/logout/page.tsx b/apps/dispatch/app/(auth)/logout/page.tsx new file mode 100644 index 00000000..8214e355 --- /dev/null +++ b/apps/dispatch/app/(auth)/logout/page.tsx @@ -0,0 +1,16 @@ +'use client'; +import { signOut } from 'next-auth/react'; +import { useEffect } from 'react'; + +export default () => { + useEffect(() => { + signOut({ + callbackUrl: '/login', + }); + }, []); + return ( +
+

logging out...

+
+ ); +}; diff --git a/apps/dispatch/app/(auth)/oauth/_components/Authorize.tsx b/apps/dispatch/app/(auth)/oauth/_components/Authorize.tsx new file mode 100644 index 00000000..22b5085b --- /dev/null +++ b/apps/dispatch/app/(auth)/oauth/_components/Authorize.tsx @@ -0,0 +1,43 @@ +'use client'; +import { useSearchParams } from 'next/navigation'; +import { Service } from '../page'; +import { generateToken } from './action'; + +export const Authorize = ({ service }: { service: Service }) => { + const searchParams = useSearchParams(); + const legitimeUrl = service.approvedUrls.some((url) => + searchParams.get('redirect_uri')?.startsWith(url) + ); + if (!legitimeUrl) + return ( +
+

Unerlaubter Zugriff

+

Du greifst von einem ncith genehmigtem Server auf diese URL zu

+
+ ); + + return ( +
e.preventDefault()}> +

Zugriff zulassen

+

+ Die Anwendung {service.name} möchte auf deine Daten + zugreifen. +

+
+ + +
+
+ ); +}; diff --git a/apps/dispatch/app/(auth)/oauth/_components/action.ts b/apps/dispatch/app/(auth)/oauth/_components/action.ts new file mode 100644 index 00000000..52204e89 --- /dev/null +++ b/apps/dispatch/app/(auth)/oauth/_components/action.ts @@ -0,0 +1,24 @@ +'use server'; +import { getServerSession } from '../../../api/auth/[...nextauth]/auth'; +import { Service } from '../page'; +import { PrismaClient } from '@repo/db'; + +const prisma = new PrismaClient(); + +export const generateToken = async (service: Service) => { + const session = await getServerSession(); + if (!session) return null; + + const accessToken = Array.from({ length: 10 }, () => + Math.floor(Math.random() * 10) + ).join(''); + + const code = await prisma.oAuthToken.create({ + data: { + clientId: service.id, + userId: session.user.id, + accessToken: accessToken, + }, + }); + return code; +}; diff --git a/apps/dispatch/app/(auth)/oauth/page.tsx b/apps/dispatch/app/(auth)/oauth/page.tsx new file mode 100644 index 00000000..f14951cb --- /dev/null +++ b/apps/dispatch/app/(auth)/oauth/page.tsx @@ -0,0 +1,26 @@ +import { Authorize } from './_components/Authorize'; + +export const services = [ + { + id: '123456', + service: 'dispatch', + name: 'Leitstellendisposition', + approvedUrls: ['http://localhost:3001'], + }, +]; +export type Service = (typeof services)[number]; + +export default async ({ + searchParams, +}: { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}) => { + const { service: serviceId } = await searchParams; + const service = services.find((service) => service.id === serviceId); + + if (!service) { + return
Service not found
; + } + + return ; +}; diff --git a/apps/dispatch/app/(dispatch)/_components/Navbar.tsx b/apps/dispatch/app/(dispatch)/_components/Navbar.tsx index 563c1279..dd122015 100644 --- a/apps/dispatch/app/(dispatch)/_components/Navbar.tsx +++ b/apps/dispatch/app/(dispatch)/_components/Navbar.tsx @@ -2,7 +2,7 @@ import { ToggleTalkButton } from '../_components/ToggleTalkButton'; import { ChangeRufgruppe } from '../_components/ChangeRufgruppe'; import { Notifications } from '../_components/Notifications'; -import { MoonIcon, SunIcon } from '@radix-ui/react-icons'; +import Link from 'next/link'; export default function Navbar() { return ( @@ -61,7 +61,9 @@ export default function Navbar() { Einstellungen
  • - Logout + +

    Logout

    +
  • diff --git a/apps/dispatch/app/(dispatch)/layout.tsx b/apps/dispatch/app/(dispatch)/layout.tsx index b5aee3a6..21940d5f 100644 --- a/apps/dispatch/app/(dispatch)/layout.tsx +++ b/apps/dispatch/app/(dispatch)/layout.tsx @@ -1,16 +1,23 @@ import type { Metadata } from 'next'; import Navbar from './_components/Navbar'; +import { useSession } from 'next-auth/react'; +import { redirect } from 'next/navigation'; +import { getServerSession } from '../api/auth/[...nextauth]/auth'; export const metadata: Metadata = { title: 'VAR Leitstelle v2', description: 'Die neue VAR Leitstelle.', }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await getServerSession(); + if (!session) { + redirect('/login'); + } return ( <> diff --git a/apps/dispatch/app/_components/AuthSessionProvider.tsx b/apps/dispatch/app/_components/AuthSessionProvider.tsx new file mode 100644 index 00000000..6c1234e6 --- /dev/null +++ b/apps/dispatch/app/_components/AuthSessionProvider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; +import { Session } from 'next-auth'; + +export const NextAuthSessionProvider = ({ + children, + session, +}: { + children: React.ReactNode; + session: Session | null; +}) => {children}; diff --git a/apps/dispatch/app/api/auth/[...nextauth]/auth.ts b/apps/dispatch/app/api/auth/[...nextauth]/auth.ts new file mode 100644 index 00000000..9b9d5db2 --- /dev/null +++ b/apps/dispatch/app/api/auth/[...nextauth]/auth.ts @@ -0,0 +1,71 @@ +import { + AuthOptions, + getServerSession as getNextAuthServerSession, +} from 'next-auth'; +import { PrismaAdapter } from '@next-auth/prisma-adapter'; +import Credentials from 'next-auth/providers/credentials'; +import { PrismaClient } from '@repo/db'; +const prisma = new PrismaClient(); + +export const options: AuthOptions = { + providers: [ + Credentials({ + credentials: { + code: { label: 'code', type: 'code' }, + }, + async authorize(credentials, req) { + try { + if (!credentials) throw new Error('No credentials provided'); + const code = await prisma.oAuthToken.findFirstOrThrow({ + where: { + accessToken: credentials.code, + }, + }); + const user = await prisma.user.findFirstOrThrow({ + where: { + id: code.userId, + }, + }); + console.log(code, user); + + if (!user) return null; + + return user; + } catch (error) { + return null; + } + }, + }), + ], + secret: process.env.NEXTAUTH_SECRET, + session: { + strategy: 'jwt', + maxAge: 30 * 24 * 60 * 60, + }, + + adapter: PrismaAdapter(prisma), + callbacks: { + jwt: async ({ token, user }) => { + if (user && 'firstname' in user) { + return { + ...token, + ...user, + }; + } + return token; + }, + session: async ({ session, user, token }) => { + return { + ...session, + user: token, + }; + }, + }, + pages: { + signIn: '/login', + signOut: '/logout', + error: '/authError', + }, +} satisfies AuthOptions; + +export const getServerSession = async () => getNextAuthServerSession(options); diff --git a/apps/dispatch/app/api/auth/[...nextauth]/route.ts b/apps/dispatch/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 00000000..26aa23e4 --- /dev/null +++ b/apps/dispatch/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from 'next-auth'; +import { options } from './auth'; + +const handler = NextAuth(options); + +export { handler as GET, handler as POST }; diff --git a/apps/dispatch/app/layout.tsx b/apps/dispatch/app/layout.tsx index 24d7a906..be30267e 100644 --- a/apps/dispatch/app/layout.tsx +++ b/apps/dispatch/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from 'next'; import localFont from 'next/font/local'; import './globals.css'; +import { NextAuthSessionProvider } from './_components/AuthSessionProvider'; +import { getServerSession } from './api/auth/[...nextauth]/auth'; const geistSans = localFont({ src: './fonts/GeistVF.woff', @@ -16,17 +18,20 @@ export const metadata: Metadata = { description: 'Die neue VAR Leitstelle.', }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await getServerSession(); return ( - + - {children} + + {children} + ); diff --git a/apps/dispatch/app/login/page.tsx b/apps/dispatch/app/login/page.tsx deleted file mode 100644 index 0cc4a61a..00000000 --- a/apps/dispatch/app/login/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function Login() { - redirect("/"); -} \ No newline at end of file diff --git a/apps/dispatch/package.json b/apps/dispatch/package.json index 5239bfbf..71a44541 100644 --- a/apps/dispatch/package.json +++ b/apps/dispatch/package.json @@ -16,6 +16,7 @@ "@tailwindcss/postcss": "^4.0.2", "leaflet": "^1.9.4", "next": "^15.1.0", + "next-auth": "^4.24.11", "postcss": "^8.5.1", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/hub/app/(auth)/login/_components/Login.tsx b/apps/hub/app/(auth)/login/_components/Login.tsx index b96b6da7..20b8660a 100644 --- a/apps/hub/app/(auth)/login/_components/Login.tsx +++ b/apps/hub/app/(auth)/login/_components/Login.tsx @@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { signIn } from 'next-auth/react'; import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { Toaster, toast } from 'react-hot-toast'; @@ -9,7 +10,7 @@ import { z } from 'zod'; export const Login = () => { const [isLoading, setIsLoading] = useState(false); - + const searchParams = useSearchParams(); const schema = z.object({ email: z.string().email(), password: z.string(), @@ -20,14 +21,14 @@ export const Login = () => { const form = useForm({ resolver: zodResolver(schema), }); - console.log(form.formState.errors); + return (
    { setIsLoading(true); const data = await signIn('credentials', { - callbackUrl: '/', + callbackUrl: searchParams.get('redirect') || '/', email: form.getValues('email'), password: form.getValues('password'), }); diff --git a/apps/hub/app/(auth)/oauth/_components/Authorize.tsx b/apps/hub/app/(auth)/oauth/_components/Authorize.tsx index 22b5085b..239fab5d 100644 --- a/apps/hub/app/(auth)/oauth/_components/Authorize.tsx +++ b/apps/hub/app/(auth)/oauth/_components/Authorize.tsx @@ -1,13 +1,18 @@ 'use client'; -import { useSearchParams } from 'next/navigation'; +import { redirect, useSearchParams } from 'next/navigation'; import { Service } from '../page'; import { generateToken } from './action'; +import { useSession } from 'next-auth/react'; export const Authorize = ({ service }: { service: Service }) => { const searchParams = useSearchParams(); const legitimeUrl = service.approvedUrls.some((url) => searchParams.get('redirect_uri')?.startsWith(url) ); + const { data: session } = useSession(); + console.log(session); + if (!session) + redirect('/login?redirect=' + encodeURIComponent(window.location.href)); if (!legitimeUrl) return (
    @@ -32,7 +37,7 @@ export const Authorize = ({ service }: { service: Service }) => { className="btn btn-primary" onClick={async () => { const code = await generateToken(service); - window.location.href = `${searchParams.get('redirect_uri')}?code=${code}`; + window.location.href = `${searchParams.get('redirect_uri')}?code=${code?.accessToken}`; }} > Zulassen diff --git a/package-lock.json b/package-lock.json index 69fb1480..e6dfeaad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@tailwindcss/postcss": "^4.0.2", "leaflet": "^1.9.4", "next": "^15.1.0", + "next-auth": "^4.24.11", "postcss": "^8.5.1", "react": "^19.0.0", "react-dom": "^19.0.0",