Merge pull request #1 from Since-3/feature/authentication

added authentication with proxy
This commit is contained in:
Rajpreet0 2026-09-27 01:53:18 +02:00 • committed by GitHub
commit 011db68599
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 616 additions and 15 deletions

View file

@ -0,0 +1,13 @@
import { verifySession } from "@/lib/supabase/dal"
import { redirect } from "next/navigation";
const AdminDashboard = async () => {
const { isAdmin } = await verifySession();
if (!isAdmin) redirect("/client/dashboard");
return (
<div>AdminDashboard</div>
)
}
export default AdminDashboard

View file

@ -0,0 +1,8 @@
const ClientDashboard = () => {
return (
<div>page</div>
)
}
export default ClientDashboard

View file

@ -115,7 +115,7 @@
}
:root {
--background: var(--ink-0);
--background: oklch(0.99 0 0);
--foreground: var(--ink-950);
--card: var(--ink-0);
--card-foreground: var(--ink-950);

View file

@ -1,8 +1,5 @@
import LoginPageView from "@/modules/loginPage/ui/views/login-view";
export default function Home() {
return (
<div>
</div>
);
return <LoginPageView/>;
}

238
components/ui/field.tsx Normal file
View file

@ -0,0 +1,238 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50 has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}

19
components/ui/label.tsx Normal file
View file

@ -0,0 +1,19 @@
"use client"
import * as React from "react"
import { cn } from "cn"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,24 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "cn"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

8
lib/supabase/admin.ts Normal file
View file

@ -0,0 +1,8 @@
import { createClient } from "@supabase/supabase-js";
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
)
}

43
lib/supabase/dal.ts Normal file
View file

@ -0,0 +1,43 @@
import "server-only"
import { cache } from "react"
import { redirect } from "next/navigation"
import { createClient } from "@/lib/supabase/server"
export const getSession = cache(async () => {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
return user
})
export const getProfile = cache(async () => {
const user = await getSession()
if (!user) return null
const supabase = await createClient()
const { data } = await supabase
.from("profiles")
.select("role, name, email")
.eq("id", user.id)
.single()
return data
})
export const verifySession = cache(async () => {
const user = await getSession()
if (!user) redirect("/")
const profile = await getProfile();
if(!profile) redirect("/")
return {
userId: user.id,
role: profile.role,
isLoggedIn: true,
isAdmin: profile.role === "admin",
isClient: profile.role === "client",
}
})

View file

@ -0,0 +1,14 @@
create type user_role as enum ('admin', 'client');
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text not null,
name text not null,
role user_role not null default 'client',
created_at timestamptz not null default now()
);
alter table profiles enable row level security;
create policy "Users can read their own profile"
on profiles for select
using (auth.uid() = id);

View file

@ -0,0 +1,17 @@
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.profiles (id, email, name, role)
values (
new.id,
new.email,
coalesce(new.raw_user_meta_data->>'name', ''),
'client'
);
return new;
end;
$$;

37
lib/supabase/proxy.ts Normal file
View file

@ -0,0 +1,37 @@
import { createServerClient } from "@supabase/ssr"
import { NextResponse, type NextRequest } from "next/server"
export function createClient(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
response = NextResponse.next({
request: {
headers: request.headers,
},
})
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
)
},
},
}
)
return { supabase, response }
}

View file

@ -1,8 +1,28 @@
import { createClient } from "@supabase/supabase-js";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!
)
}
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component that can't set cookies.
// Safe to ignore because the proxy refreshes the session.
}
},
},
}
);
}

View file

@ -0,0 +1,59 @@
"use server";
import { redirect } from "next/navigation";
import * as z from "zod";
import { createClient } from "@/lib/supabase/server";
export type AuthState = { error: string } | undefined;
const LoginSchema = z.object({
email: z.email({ error: "Bitte eine gültige Email eingeben." }),
password: z.string().min(1, { error: "Passwort ist erforderlich." }),
});
export async function login(
_prevState: AuthState,
formData: FormData
): Promise<AuthState> {
const validatedFields = LoginSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!validatedFields.success) {
const { fieldErrors } = z.flattenError(validatedFields.error);
const message = fieldErrors.email?.[0] ?? fieldErrors.password?.[0] ?? "Bitte Email und Passwort ausfüllen.";
return { error: message }
}
const { email, password } = validatedFields.data;
const supabase = await createClient();
const { data: signInData, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return { error: "Email oder Passwort ist falsch" }
}
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("role")
.eq("id", signInData.user.id)
.single()
if (profileError || !profile) {
return { error: "Profil konnte nicht geladen werden. Bitte kontaktiere den Support." }
}
redirect(profile.role === "admin" ? "/admin/dashboard" : "/client/dashboard")
}
export async function logout() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/");
}

View file

@ -0,0 +1,76 @@
"use client"
import { useActionState, useState } from "react"
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Eye, EyeOff, Lock, User } from 'lucide-react';
import { Input } from "@/components/ui/input"
import Image from "next/image"
import { Button } from "@/components/ui/button";
import { login } from "../../actions/auth";
const LoginPageView = () => {
const [showPassword, setShowPassword] = useState(false)
const [state, formAction, pending] = useActionState(login, undefined);
return (
<div className="bg-background h-screen p-6">
<Image
src="/pictures/since3-logo.svg" alt=""
width={1617}
height={332}
loading="eager"
className="h-5 w-auto md:h-6"/>
<div className="flex flex-col h-full items-center justify-center">
<h1 className="mb-6 text-3xl font-semibold">Login</h1>
<form action={formAction} className="w-full max-w-sm ">
<FieldGroup>
<Field>
<FieldLabel htmlFor="form-email"><User className="w-4 h-4"/> Email <span className="text-destructive">*</span></FieldLabel>
<Input
id="form-email"
name="email"
type="text"
className="bg-white focus-visible:ring-0"
required
/>
</Field>
<Field>
<FieldLabel htmlFor="form-password"><Lock className="w-4 h-4"/> Password <span className="text-destructive">*</span></FieldLabel>
<div className="relative">
<Input
id="form-password"
name="password"
type={showPassword ? "text" : "password"}
className="bg-white focus-visible:ring-0 pr-10"
required
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex items-center px-3 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? "Passwort verbergen" : "Passwort anzeigen"}
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</Field>
<Field>
{state?.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<Button type="submit" disabled={pending} className="cursor-pointer">
{pending ? "Bitte warten..." : "Login"}
</Button>
</Field>
</FieldGroup>
</form>
</div>
<div className="absolute bottom-2">
<p className="text-accent-400 text-sm">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p>
</div>
</div>
)
}
export default LoginPageView

3
package-lock.json generated
View file

@ -19,7 +19,8 @@
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
"supabase-js": "^0.0.1-security",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View file

@ -20,7 +20,8 @@
"react-dom": "19.2.8",
"shadcn": "^4.21.0",
"supabase-js": "^0.0.1-security",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

26
proxy.ts Normal file
View file

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "./lib/supabase/proxy";
const publicRoutes = ["/"]
export default async function proxy(request: NextRequest) {
const { supabase, response } = createClient(request)
const { data: { user } } = await supabase.auth.getUser()
const isPublicRoute = publicRoutes.includes(request.nextUrl.pathname)
if (!user && !isPublicRoute) {
const redirectResponse = NextResponse.redirect(new URL("/", request.url))
response.cookies.getAll().forEach((cookie) => redirectResponse.cookies.set(cookie))
return redirectResponse
}
return response
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|pictures).*)"],
}