User Access
Deploy / deploy (push) Successful in 2m3s

This commit is contained in:
twotalesanimation
2026-08-18 15:23:22 +02:00
parent ecf47e56cc
commit 9dcf310806
14 changed files with 334 additions and 9 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "xbid-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+1 -1
View File
@@ -30,7 +30,7 @@ services:
environment: environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/xbid DATABASE_URL: postgresql://postgres:postgres@postgres:5432/xbid
NEXTAUTH_SECRET: change-me-in-production NEXTAUTH_SECRET: hZTKvtZtchjbJulsVE8Kf5YPLibR619JoK/z7vO2i/s=
NEXTAUTH_URL: https://xbid.twotalesvfx.com NEXTAUTH_URL: https://xbid.twotalesvfx.com
depends_on: depends_on:
+1
View File
@@ -10,6 +10,7 @@
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:seed": "ts-node -P tsconfig.seed.json prisma/seed.ts", "db:seed": "ts-node -P tsconfig.seed.json prisma/seed.ts",
"db:create-user": "ts-node -P tsconfig.seed.json scripts/create-user.ts",
"db:studio": "prisma studio" "db:studio": "prisma studio"
}, },
"prisma": { "prisma": {
+50
View File
@@ -0,0 +1,50 @@
import { config } from "dotenv";
config();
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
import bcrypt from "bcryptjs";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
const prisma = new PrismaClient({ adapter } as any);
async function main() {
const [, , email, password, name] = process.argv;
if (!email || !password) {
console.error("Usage: npm run db:create-user -- <email> <password> [name]");
process.exit(1);
}
if (password.length < 8) {
console.error("Password must be at least 8 characters long.");
process.exit(1);
}
const hashedPassword = await bcrypt.hash(password, 12);
const normalizedEmail = email.toLowerCase().trim();
const user = await prisma.user.upsert({
where: { email: normalizedEmail },
create: {
email: normalizedEmail,
password: hashedPassword,
name: name ?? null,
role: "ADMIN",
},
update: {
password: hashedPassword,
...(name ? { name } : {}),
},
});
console.log(`✅ User ready: ${user.email} (role: ${user.role})`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
+2 -7
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { Sidebar } from "@/components/layout/sidebar"; import { AppShell } from "@/components/layout/app-shell";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@@ -15,12 +15,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return ( return (
<html lang="en" className="dark h-full"> <html lang="en" className="dark h-full">
<body className={`${inter.className} bg-background text-foreground antialiased h-full`}> <body className={`${inter.className} bg-background text-foreground antialiased h-full`}>
<div className="flex h-full overflow-hidden"> <AppShell>{children}</AppShell>
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
<Toaster richColors position="top-right" /> <Toaster richColors position="top-right" />
</body> </body>
</html> </html>
+38
View File
@@ -0,0 +1,38 @@
import { Suspense } from "react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/lib/auth";
import { LoginForm } from "@/components/auth/login-form";
import { Film } from "lucide-react";
export const metadata = {
title: "Sign in — xBid",
};
export default async function LoginPage() {
const session = await getServerSession(authOptions);
if (session) {
redirect("/");
}
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-500">
<Film className="h-5 w-5 text-black" />
</div>
<div>
<h1 className="text-lg font-semibold tracking-tight">
x<span className="text-amber-500">Bid</span>
</h1>
<p className="text-sm text-muted-foreground">Sign in to continue</p>
</div>
</div>
<Suspense fallback={null}>
<LoginForm />
</Suspense>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
setPending(false);
if (result?.error) {
setError("Invalid email or password.");
return;
}
router.push(callbackUrl);
router.refresh();
}
return (
<Card>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={pending}>
{pending ? "Signing in…" : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
import { Sidebar } from "@/components/layout/sidebar";
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
if (pathname === "/login") {
return <main className="h-full overflow-y-auto">{children}</main>;
}
return (
<div className="flex h-full overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
);
}
+9
View File
@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { signOut } from "next-auth/react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
LayoutDashboard, LayoutDashboard,
@@ -13,6 +14,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Settings, Settings,
LogOut,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -89,6 +91,13 @@ export function Sidebar() {
</Link> </Link>
); );
})()} })()}
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
>
<LogOut className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Sign out</span>}
</button>
</div> </div>
<button <button
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
+63
View File
@@ -0,0 +1,63 @@
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
pages: {
signIn: "/login",
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email.toLowerCase().trim() },
});
if (!user?.password) {
return null;
}
const isValid = await bcrypt.compare(credentials.password, user.password);
if (!isValid) {
return null;
}
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = (user as { role?: string }).role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
};
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
const PUBLIC_PATHS = ["/login"];
export default async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublic =
PUBLIC_PATHS.includes(pathname) || pathname.startsWith("/api/auth");
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET,
});
if (!token && !isPublic) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
if (token && pathname === "/login") {
return NextResponse.redirect(new URL("/", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};
+21
View File
@@ -0,0 +1,21 @@
import type { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
} & DefaultSession["user"];
}
interface User {
role?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
role?: string;
}
}
+1 -1
View File
@@ -5,5 +5,5 @@
"moduleResolution": "node", "moduleResolution": "node",
"noEmit": false "noEmit": false
}, },
"include": ["prisma/seed.ts"] "include": ["prisma/seed.ts", "scripts/create-user.ts"]
} }