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 }) => (
+
+);
+
+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 (
+
+ );
+};
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 (
+
+ );
+};
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 (