mirror of
https://github.com/xCyanGrizzly/DragonsStash.git
synced 2026-07-24 07:42:47 +00:00
Init
This commit is contained in:
7
src/app/(auth)/layout.tsx
Normal file
7
src/app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-md space-y-6 px-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/app/(auth)/login/actions.ts
Normal file
32
src/app/(auth)/login/actions.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import { signIn } from "@/lib/auth";
|
||||
import { loginSchema } from "@/schemas/auth.schema";
|
||||
import { AuthError } from "next-auth";
|
||||
|
||||
export async function loginAction(values: { email: string; password: string }) {
|
||||
const parsed = loginSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
return { error: "Invalid email or password" };
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn("credentials", {
|
||||
email: parsed.data.email,
|
||||
password: parsed.data.password,
|
||||
redirect: false,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
switch (error.type) {
|
||||
case "CredentialsSignin":
|
||||
return { error: "Invalid email or password" };
|
||||
default:
|
||||
return { error: "Something went wrong" };
|
||||
}
|
||||
}
|
||||
// This is a redirect error thrown by next-auth on success - rethrow it
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
133
src/app/(auth)/login/page.tsx
Normal file
133
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Flame } from "lucide-react";
|
||||
import { loginSchema, type LoginInput } from "@/schemas/auth.schema";
|
||||
import { loginAction } from "./actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") || "/dashboard";
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema) as any,
|
||||
defaultValues: { email: "", password: "" },
|
||||
});
|
||||
|
||||
function onSubmit(values: LoginInput) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await loginAction(values);
|
||||
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
} catch {
|
||||
// Redirect from server action — Next.js handles this automatically
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Flame className="h-10 w-10 text-primary" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">{APP_NAME}</h1>
|
||||
<p className="text-sm text-muted-foreground">Sign in to manage your inventory</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardDescription>Enter your credentials to continue</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="admin@dragonsstash.local"
|
||||
autoComplete="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/register" className="text-primary underline-offset-4 hover:underline">
|
||||
Register
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
src/app/(auth)/register/actions.ts
Normal file
42
src/app/(auth)/register/actions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { registerSchema } from "@/schemas/auth.schema";
|
||||
import type { ActionResult } from "@/types/api.types";
|
||||
|
||||
export async function registerUser(input: unknown): Promise<ActionResult<{ id: string }>> {
|
||||
const parsed = registerSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: "Validation failed" };
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { email: parsed.data.email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: "An account with this email already exists" };
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(parsed.data.password, 10);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: parsed.data.name,
|
||||
email: parsed.data.email,
|
||||
hashedPassword,
|
||||
role: "USER",
|
||||
settings: {
|
||||
create: {
|
||||
lowStockThreshold: 10,
|
||||
currency: "USD",
|
||||
theme: "dark",
|
||||
units: "metric",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: { id: user.id } };
|
||||
}
|
||||
176
src/app/(auth)/register/page.tsx
Normal file
176
src/app/(auth)/register/page.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Flame } from "lucide-react";
|
||||
import { registerSchema, type RegisterInput } from "@/schemas/auth.schema";
|
||||
import { registerUser } from "./actions";
|
||||
import { loginAction } from "../login/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { APP_NAME } from "@/lib/constants";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const form = useForm<RegisterInput>({
|
||||
resolver: zodResolver(registerSchema) as any,
|
||||
defaultValues: { name: "", email: "", password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
function onSubmit(values: RegisterInput) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await registerUser(values);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-login after registration using server action
|
||||
try {
|
||||
const loginResult = await loginAction({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (loginResult?.error) {
|
||||
setError("Account created but sign in failed. Please try logging in.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} catch {
|
||||
// Redirect from server action
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Flame className="h-10 w-10 text-primary" />
|
||||
<h1 className="text-2xl font-bold tracking-tight">{APP_NAME}</h1>
|
||||
<p className="text-sm text-muted-foreground">Create an account to get started</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Account</CardTitle>
|
||||
<CardDescription>Fill in your details below</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Your name" autoComplete="name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="At least 6 characters"
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Repeat your password"
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isPending}>
|
||||
{isPending ? "Creating account..." : "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user