mirror of
https://codeberg.org/MarkusThielker/finances.git
synced 2025-07-01 19:19:18 +00:00
Initial commit
This commit is contained in:
commit
52aed24a96
48 changed files with 6980 additions and 0 deletions
54
src/app/account/page.tsx
Normal file
54
src/app/account/page.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import React from 'react';
|
||||
import { getUser } from '@/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
import signOut from '@/lib/actions/signOut';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import SignOutForm from '@/components/form/signOutForm';
|
||||
import { URL_HOME, URL_SIGN_IN } from '@/lib/constants';
|
||||
|
||||
export default async function AccountPage() {
|
||||
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(URL_SIGN_IN);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center relative">
|
||||
<Button variant="ghost" size="icon" className="absolute top-4 left-4" asChild>
|
||||
<Link href={URL_HOME}>
|
||||
<ChevronLeft/>
|
||||
</Link>
|
||||
</Button>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Hey, {user?.username}!</CardTitle>
|
||||
<CardDescription>This is your account overview.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<Label>ID</Label>
|
||||
<Input
|
||||
disabled
|
||||
value={user?.id}/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<Input
|
||||
disabled
|
||||
value={user?.username}/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<SignOutForm onSubmit={signOut}/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
13
src/app/auth/layout.tsx
Normal file
13
src/app/auth/layout.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
25
src/app/auth/signin/page.tsx
Normal file
25
src/app/auth/signin/page.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import SignInForm from '@/components/form/signInForm';
|
||||
import signIn from '@/lib/actions/signIn';
|
||||
import Link from 'next/link';
|
||||
import { URL_SIGN_UP } from '@/lib/constants';
|
||||
|
||||
export default async function SignInPage() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign in</CardTitle>
|
||||
<CardDescription>Sign into your existing account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignInForm onSubmit={signIn}/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Link href={URL_SIGN_UP}>
|
||||
Don't have an account? Sign up
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
25
src/app/auth/signup/page.tsx
Normal file
25
src/app/auth/signup/page.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import signUp from '@/lib/actions/signUp';
|
||||
import SignUpForm from '@/components/form/signUpForm';
|
||||
import Link from 'next/link';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
|
||||
export default async function SignUpPage() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign up</CardTitle>
|
||||
<CardDescription>Create a new account.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignUpForm onSubmit={signUp}/>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Link href={URL_SIGN_IN}>
|
||||
Already have an account? Sign in
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
62
src/app/globals.css
Normal file
62
src/app/globals.css
Normal file
|
@ -0,0 +1,62 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 20 14.3% 4.1%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 20 14.3% 4.1%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 20 14.3% 4.1%;
|
||||
--primary: 24.6 95% 53.1%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 60 4.8% 95.9%;
|
||||
--secondary-foreground: 24 9.8% 10%;
|
||||
--muted: 60 4.8% 95.9%;
|
||||
--muted-foreground: 25 5.3% 44.7%;
|
||||
--accent: 60 4.8% 95.9%;
|
||||
--accent-foreground: 24 9.8% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 20 5.9% 90%;
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 24.6 95% 53.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 20 14.3% 4.1%;
|
||||
--foreground: 60 9.1% 97.8%;
|
||||
--card: 20 14.3% 4.1%;
|
||||
--card-foreground: 60 9.1% 97.8%;
|
||||
--popover: 20 14.3% 4.1%;
|
||||
--popover-foreground: 60 9.1% 97.8%;
|
||||
--primary: 20.5 90.2% 48.2%;
|
||||
--primary-foreground: 60 9.1% 97.8%;
|
||||
--secondary: 12 6.5% 15.1%;
|
||||
--secondary-foreground: 60 9.1% 97.8%;
|
||||
--muted: 12 6.5% 15.1%;
|
||||
--muted-foreground: 24 5.4% 63.9%;
|
||||
--accent: 12 6.5% 15.1%;
|
||||
--accent-foreground: 60 9.1% 97.8%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 60 9.1% 97.8%;
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--ring: 20.5 90.2% 48.2%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
29
src/app/layout.tsx
Normal file
29
src/app/layout.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
|
||||
const inter = Inter({subsets: ['latin']});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={cn('dark', inter.className)}>
|
||||
<main>
|
||||
{children}
|
||||
<Toaster/>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
18
src/app/page.tsx
Normal file
18
src/app/page.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { User } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { URL_ACCOUNT } from '@/lib/constants';
|
||||
|
||||
export default async function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-24 text-3xl space-y-4 relative">
|
||||
<Button variant="ghost" size="icon" className="absolute top-4 right-4" asChild>
|
||||
<Link href={URL_ACCOUNT}>
|
||||
<User/>
|
||||
</Link>
|
||||
</Button>
|
||||
Next Base
|
||||
</main>
|
||||
);
|
||||
}
|
69
src/auth.ts
Normal file
69
src/auth.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { Lucia } from 'lucia';
|
||||
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const prismaClient = new PrismaClient();
|
||||
|
||||
const adapter = new PrismaAdapter(prismaClient.session, prismaClient.user);
|
||||
|
||||
export const lucia = new Lucia(adapter, {
|
||||
sessionCookie: {
|
||||
expires: false,
|
||||
attributes: {
|
||||
sameSite: 'strict',
|
||||
domain: process.env.NODE_ENV === 'production' ? process.env.COOKIE_DOMAIN : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
},
|
||||
getUserAttributes: (attributes) => {
|
||||
return {
|
||||
username: attributes.username,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare module 'lucia' {
|
||||
interface Register {
|
||||
Lucia: typeof lucia;
|
||||
DatabaseUserAttributes: DatabaseUserAttributes;
|
||||
}
|
||||
}
|
||||
|
||||
interface DatabaseUserAttributes {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export function getSessionId() {
|
||||
return cookies().get(lucia.sessionCookieName)?.value ?? null;
|
||||
}
|
||||
|
||||
export async function getSession() {
|
||||
const sessionId = getSessionId();
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
const {session} = await lucia.validateSession(sessionId);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function getUser() {
|
||||
const sessionId = getSessionId();
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
const {user, session} = await lucia.validateSession(sessionId);
|
||||
try {
|
||||
if (session && session.fresh) {
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
}
|
||||
if (!session) {
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
}
|
||||
} catch {
|
||||
// Next.js throws error when attempting to set cookies when rendering page
|
||||
}
|
||||
return user;
|
||||
}
|
71
src/components/form/signInForm.tsx
Normal file
71
src/components/form/signInForm.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { signInFormSchema } from '@/lib/form-schemas/signInFormSchema';
|
||||
import { ActionResponse } from '@/lib/actions/types/ActionResponse';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { sonnerContent } from '@/components/ui/sonner';
|
||||
|
||||
export default function SignInForm({onSubmit}: {
|
||||
onSubmit: (data: z.infer<typeof signInFormSchema>) => Promise<ActionResponse>
|
||||
}) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<z.infer<typeof signInFormSchema>>({
|
||||
resolver: zodResolver(signInFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: z.infer<typeof signInFormSchema>) => {
|
||||
const response = await onSubmit(data);
|
||||
toast(sonnerContent(response));
|
||||
if (response.redirect) {
|
||||
router.push(response.redirect);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="••••••••" type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full">Sign in</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
25
src/components/form/signOutForm.tsx
Normal file
25
src/components/form/signOutForm.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
|
||||
import { ActionResponse } from '@/lib/actions/types/ActionResponse';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { sonnerContent } from '@/components/ui/sonner';
|
||||
|
||||
export default function SignOutForm({onSubmit}: { onSubmit: () => Promise<ActionResponse> }) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
const response = await onSubmit();
|
||||
toast(sonnerContent(response));
|
||||
if (response.redirect) {
|
||||
router.push(response.redirect);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button className="w-full" onClick={handleSignOut}>Sign out</Button>
|
||||
);
|
||||
}
|
84
src/components/form/signUpForm.tsx
Normal file
84
src/components/form/signUpForm.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { signUpFormSchema } from '@/lib/form-schemas/signUpFormSchema';
|
||||
import { ActionResponse } from '@/lib/actions/types/ActionResponse';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { sonnerContent } from '@/components/ui/sonner';
|
||||
|
||||
export default function SignUpForm({onSubmit}: {
|
||||
onSubmit: (data: z.infer<typeof signUpFormSchema>) => Promise<ActionResponse>
|
||||
}) {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<z.infer<typeof signUpFormSchema>>({
|
||||
resolver: zodResolver(signUpFormSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (data: z.infer<typeof signUpFormSchema>) => {
|
||||
const response = await onSubmit(data);
|
||||
toast(sonnerContent(response));
|
||||
if (response.redirect) {
|
||||
router.push(response.redirect);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="••••••••" type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirm"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="••••••••" type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full">Create Account</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({className, variant, size, asChild = false, ...props}, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({variant, size, className}))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({className, ...props}, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({className, ...props}, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({className, ...props}, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({className, ...props}, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({className, ...props}, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({className, ...props}, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
169
src/components/ui/form.tsx
Normal file
169
src/components/ui/form.tsx
Normal file
|
@ -0,0 +1,169 @@
|
|||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{name: props.name}}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const {getFieldState, formState} = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error('useFormField should be used within <FormField>');
|
||||
}
|
||||
|
||||
const {id} = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({className, ...props}, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{id}}>
|
||||
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
});
|
||||
FormItem.displayName = 'FormItem';
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({className, ...props}, ref) => {
|
||||
const {error, formItemId} = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && 'text-destructive', className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormLabel.displayName = 'FormLabel';
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({...props}, ref) => {
|
||||
const {error, formItemId, formDescriptionId, formMessageId} = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = 'FormControl';
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({className, ...props}, ref) => {
|
||||
const {formDescriptionId} = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormDescription.displayName = 'FormDescription';
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({className, children, ...props}, ref) => {
|
||||
const {error, formMessageId} = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn('text-sm font-medium text-destructive', className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
});
|
||||
FormMessage.displayName = 'FormMessage';
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
26
src/components/ui/input.tsx
Normal file
26
src/components/ui/input.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({className, type, ...props}, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
26
src/components/ui/label.tsx
Normal file
26
src/components/ui/label.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({className, ...props}, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
60
src/components/ui/sonner.tsx
Normal file
60
src/components/ui/sonner.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
'use client';
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
import { AlertCircle, CheckCircle, HelpCircle, XCircle } from 'lucide-react';
|
||||
import React, { JSX } from 'react';
|
||||
import { ActionResponse } from '@/lib/actions/types/ActionResponse';
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({...props}: ToasterProps) => {
|
||||
const {theme = 'system'} = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function sonnerContent(value: ActionResponse) {
|
||||
|
||||
let icon: JSX.Element | undefined = undefined;
|
||||
switch (value.type) {
|
||||
case 'success':
|
||||
icon = <CheckCircle className="w-5 h-5 text-green-600"/>;
|
||||
break;
|
||||
case 'error':
|
||||
icon = <XCircle className="w-5 h-5 text-red-600"/>;
|
||||
break;
|
||||
case 'warning':
|
||||
icon = <AlertCircle className="w-5 h-5 text-yellow-600"/>;
|
||||
break;
|
||||
case 'info':
|
||||
icon = <HelpCircle className="w-5 h-5 text-blue-600"/>;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center space-x-2 text-md">
|
||||
{icon}
|
||||
<span>{value.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toaster, sonnerContent };
|
40
src/lib/actions/signIn.ts
Normal file
40
src/lib/actions/signIn.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { z } from 'zod';
|
||||
import { Argon2id } from 'oslo/password';
|
||||
import { lucia, prismaClient } from '@/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { signInFormSchema } from '@/lib/form-schemas/signInFormSchema';
|
||||
import { ActionResponse } from '@/lib/actions/types/ActionResponse';
|
||||
import { URL_HOME } from '@/lib/constants';
|
||||
|
||||
export default async function signIn({username, password}: z.infer<typeof signInFormSchema>): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
const existingUser = await prismaClient.user.findFirst({
|
||||
where: {
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
});
|
||||
if (!existingUser) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Incorrect username or password',
|
||||
};
|
||||
}
|
||||
|
||||
const validPassword = await new Argon2id().verify(existingUser.password, password);
|
||||
if (!validPassword) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Incorrect username or password',
|
||||
};
|
||||
}
|
||||
|
||||
const session = await lucia.createSession(existingUser.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return {
|
||||
type: 'success',
|
||||
message: 'Signed in successfully',
|
||||
redirect: URL_HOME,
|
||||
};
|
||||
}
|
26
src/lib/actions/signOut.ts
Normal file
26
src/lib/actions/signOut.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { getSession, lucia } from '@/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { ActionResponse } from '@/lib/actions/types/ActionResponse';
|
||||
import { URL_SIGN_IN } from '@/lib/constants';
|
||||
|
||||
export default async function signOut(): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'You aren\'t signed in',
|
||||
};
|
||||
}
|
||||
|
||||
await lucia.invalidateSession(session.id);
|
||||
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return {
|
||||
type: 'success',
|
||||
message: 'Signed out successfully',
|
||||
redirect: URL_SIGN_IN,
|
||||
};
|
||||
}
|
45
src/lib/actions/signUp.ts
Normal file
45
src/lib/actions/signUp.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { z } from 'zod';
|
||||
import { Argon2id } from 'oslo/password';
|
||||
import { generateId } from 'lucia';
|
||||
import { lucia, prismaClient } from '@/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { signUpFormSchema } from '@/lib/form-schemas/signUpFormSchema';
|
||||
import { ActionResponse } from '@/lib/actions/types/ActionResponse';
|
||||
import { URL_HOME } from '@/lib/constants';
|
||||
|
||||
export default async function signUp({username, password}: z.infer<typeof signUpFormSchema>): Promise<ActionResponse> {
|
||||
'use server';
|
||||
|
||||
const hashedPassword = await new Argon2id().hash(password);
|
||||
const userId = generateId(15);
|
||||
|
||||
const existingUser = await prismaClient.user.findFirst({
|
||||
where: {
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return {
|
||||
type: 'error',
|
||||
message: 'Username already exists',
|
||||
};
|
||||
}
|
||||
|
||||
await prismaClient.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
username: username,
|
||||
password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await lucia.createSession(userId, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return {
|
||||
type: 'success',
|
||||
message: 'Signed up successfully',
|
||||
redirect: URL_HOME,
|
||||
};
|
||||
}
|
5
src/lib/actions/types/ActionResponse.ts
Normal file
5
src/lib/actions/types/ActionResponse.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface ActionResponse {
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
redirect?: string;
|
||||
}
|
8
src/lib/constants.ts
Normal file
8
src/lib/constants.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// auth urls
|
||||
export const URL_AUTH = '/auth';
|
||||
export const URL_SIGN_IN = `${URL_AUTH}/signin`;
|
||||
export const URL_SIGN_UP = `${URL_AUTH}/signup`;
|
||||
|
||||
// main urls
|
||||
export const URL_HOME = '/';
|
||||
export const URL_ACCOUNT = '/account';
|
6
src/lib/form-schemas/signInFormSchema.ts
Normal file
6
src/lib/form-schemas/signInFormSchema.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const signInFormSchema = z.object({
|
||||
username: z.string().min(3).max(16),
|
||||
password: z.string().min(8).max(255),
|
||||
});
|
10
src/lib/form-schemas/signUpFormSchema.ts
Normal file
10
src/lib/form-schemas/signUpFormSchema.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const signUpFormSchema = z.object({
|
||||
username: z.string().min(3).max(16),
|
||||
password: z.string().min(8).max(255),
|
||||
confirm: z.string().min(8).max(255),
|
||||
}).refine(data => data.password === data.confirm, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirm'],
|
||||
});
|
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
27
src/middleware.ts
Normal file
27
src/middleware.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { URL_AUTH, URL_HOME, URL_SIGN_IN } from './lib/constants';
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
|
||||
// get session id from cookies
|
||||
const sessionId = request.cookies.get('auth_session')?.value ?? null;
|
||||
|
||||
// redirect to home if user is already authenticated
|
||||
if (request.nextUrl.pathname.startsWith(URL_AUTH) && sessionId) {
|
||||
return NextResponse.redirect(new URL(URL_HOME, request.url));
|
||||
}
|
||||
|
||||
// redirect to sign in if user is not authenticated
|
||||
if (!request.nextUrl.pathname.startsWith(URL_AUTH) && !sessionId) {
|
||||
return NextResponse.redirect(new URL(URL_SIGN_IN, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue