auth impl
All checks were successful
Deploy / deploy (ubuntu-latest, 2.44.0) (push) Successful in 1m30s

This commit is contained in:
Braydon 2024-09-17 01:45:08 -04:00
parent 243882d489
commit 5c305d9629
22 changed files with 550 additions and 12 deletions

1
.env.example Normal file

@ -0,0 +1 @@
NEXT_PUBLIC_API_ENDPOINT=https://api.pulseapp.cc/v1

BIN
bun.lockb

Binary file not shown.

@ -14,18 +14,28 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^11.5.4",
"lucide-react": "^0.441.0", "lucide-react": "^0.441.0",
"next": "14.2.8", "next": "14.2.8",
"next-client-cookies": "^1.1.1",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.53.0",
"react-social-icons": "^6.18.0",
"react-turnstile": "^1.1.3",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"tailwind-merge": "^2.5.2", "tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7" "tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8",
"zustand": "^5.0.0-rc.2"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "typescript": "^5",

@ -0,0 +1,41 @@
"use client";
import { ReactElement } from "react";
import Branding from "@/components/branding";
import OAuthProvider from "@/components/auth/oauth-provider";
import { Separator } from "@/components/ui/separator";
import AuthForm from "@/components/auth/auth-form";
import { motion } from "framer-motion";
const AuthPage = (): ReactElement => (
<main className="min-h-screen flex justify-center items-center">
<motion.div
className="flex flex-col gap-3"
initial={{ x: -50, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.4 }}
>
<h1 className="text-3xl font-bold select-none pointer-events-none">
Good Evening,
</h1>
<OAuthProviders />
<Separator className="-mb-1" />
<AuthForm />
</motion.div>
<Branding className="absolute left-5 bottom-5 opacity-60" size="sm" />
</main>
);
const OAuthProviders = (): ReactElement => (
<div className="flex flex-col gap-2">
<p className="opacity-50 select-none pointer-events-none">
Continue with a third party...
</p>
<div className="flex gap-2.5">
<OAuthProvider name="GitHub" icon="github" link="#" />
<OAuthProvider name="Google" icon="google" link="#" />
</div>
</div>
);
export default AuthPage;

@ -0,0 +1,6 @@
import { ReactElement } from "react";
const DashboardPage = (): ReactElement => (
<main className="min-h-screen">PulseApp Dashboard</main>
);
export default DashboardPage;

@ -5,6 +5,7 @@ import { ReactElement, ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NextFont } from "next/dist/compiled/@next/font"; import { NextFont } from "next/dist/compiled/@next/font";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { CookiesProvider } from "next-client-cookies/server";
const inter: NextFont = Inter({ subsets: ["latin"] }); const inter: NextFont = Inter({ subsets: ["latin"] });
@ -46,10 +47,10 @@ const RootLayout = ({
<div <div
style={{ style={{
background: background:
"linear-gradient(to top, hsla(240, 6%, 10%, 0.5), hsl(var(--background)))", "linear-gradient(to top, hsla(240, 6%, 10%, 0.7), hsl(var(--background)))",
}} }}
> >
{children} <CookiesProvider>{children}</CookiesProvider>
</div> </div>
</ThemeProvider> </ThemeProvider>
</body> </body>

Binary file not shown.

Binary file not shown.

@ -0,0 +1,11 @@
import { create } from "zustand";
import { User } from "@/app/types/user";
export type UserStore = {
user: User | undefined;
};
const useUserStore = create<UserStore>((set) => ({
user: undefined,
}));
export default useUserStore;

@ -0,0 +1,24 @@
/**
* An error from the API.
*/
export type ApiError = {
/**
* The status of this error.
*/
status: string;
/**
* The HTTP code of this error.
*/
code: number;
/**
* The message for this error.
*/
message: string;
/**
* The timestamp of this error.
*/
timestamp: Date;
};

19
src/app/types/session.ts Normal file

@ -0,0 +1,19 @@
/**
* A session of a {@link User}.
*/
export type Session = {
/**
* The access token for this session.
*/
accessToken: string;
/**
* The refresh token for this session.
*/
refreshToken: string;
/**
* The unix time this session expires.
*/
expires: number;
};

36
src/app/types/user.ts Normal file

@ -0,0 +1,36 @@
export type User = {
/**
* The snowflake id of this user.
*/
snowflake: `${bigint}`;
/**
* This user's email.
*/
email: string;
/**
* This user's username.
*/
username: string;
/**
* The tier of this user.
*/
tier: "FREE";
/**
* The flags for this user.
*/
flags: number;
/**
* The date this user last logged in.
*/
lastLogin: Date;
/**
* The date this user was created.
*/
created: Date;
};

@ -0,0 +1,23 @@
import { ReactElement } from "react";
const AnimatedRightChevron = (): ReactElement => (
<svg
className="stroke-white stroke-2 top-[1] relative fill-transparent"
aria-hidden="true"
width="12"
height="12"
viewBox="0 0 10 10"
>
<g fillRule="evenodd">
<path
className="transition-opacity opacity-0 group-hover:opacity-100"
d="M0 5h7"
/>
<path
className="group-hover:translate-x-[3px] transition-transform"
d="M1 1l4 4-4 4"
/>
</g>
</svg>
);
export default AnimatedRightChevron;

@ -0,0 +1,188 @@
"use client";
import { z } from "zod";
import { ReactElement, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import AnimatedRightChevron from "@/components/animated-right-chevron";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { apiRequest } from "@/lib/api";
import { Session } from "@/app/types/session";
import { Cookies, useCookies } from "next-client-cookies";
import { useRouter } from "next/navigation";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { motion } from "framer-motion";
/**
* Define the form schemas for the various stages.
*/
const EmailSchema = z.object({
email: z.string().email("Must be a valid email address"),
});
const RegisterSchema = z.object({
email: z.string().email("Must be a valid email address"),
username: z.string(),
password: z.string(),
passwordConfirmation: z.string(),
captchaResponse: z.string(),
});
const LoginSchema = z.object({
email: z.union([
z.string().email("Must be a valid email address"),
z.string({ message: "Must be a valid username" }),
]),
password: z.string(),
// captchaResponse: z.string(),
});
const inputVariants = {
hidden: { x: -50, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { duration: 0.15 } },
};
const AuthForm = (): ReactElement => {
const [stage, setStage] = useState<"email" | "register" | "login">("email");
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | undefined>(undefined);
const cookies: Cookies = useCookies();
const router: AppRouterInstance = useRouter();
// Build the form
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(
stage === "email"
? EmailSchema
: stage === "register"
? RegisterSchema
: LoginSchema
),
});
/**
* Handle submitting the form.
*/
const onSubmit = async ({
email,
username,
password,
passwordConfirmation,
captchaResponse,
}: any) => {
setLoading(true);
if (stage === "email") {
const { data, error } = await apiRequest<{ exists: boolean }>(
`/user/exists?email=${email}`
);
setStage(data?.exists ? "login" : "register");
} else if (stage === "login") {
const { data, error } = await apiRequest<Session>(
`/auth/login`,
"POST",
{
email,
password,
captchaResponse,
}
);
if (error) {
setError(error.message);
} else {
cookies.set("session", JSON.stringify(data), {
expires:
((data?.expires as number) - Date.now()) / 86_400_000,
secure: true,
sameSite: "lax",
});
router.push("/dashboard");
return;
}
}
setLoading(false);
};
// Render the form
return (
<form className="flex flex-col gap-2" onSubmit={handleSubmit(onSubmit)}>
<p className="opacity-50 select-none pointer-events-none">
Or use your email address...
</p>
{/* Email Address */}
<Input
className="rounded-lg"
type="email"
placeholder="bob@example.com"
{...register("email")}
/>
{/* Username */}
{stage === "register" && (
<Input
className="rounded-lg"
placeholder="@username"
{...register("username")}
/>
)}
{/* Password */}
{stage !== "email" && (
<motion.div
key="password"
initial="hidden"
animate="visible"
exit="exit"
variants={inputVariants}
>
<Input
className="rounded-lg"
type="password"
placeholder="Password"
{...register("password")}
/>
</motion.div>
)}
{/* Password Confirmation */}
{stage === "register" && (
<Input
className="rounded-lg"
type="password"
placeholder="Confirm Password"
{...register("passwordConfirmation")}
/>
)}
{/*{stage !== "email" && (*/}
{/* <Turnstile*/}
{/* responseFieldName="captchaResponse"*/}
{/* sitekey="0x4AAAAAAAj8DEQFLe1isCek"*/}
{/* {...register("captchaResponse")}*/}
{/* />*/}
{/*)}*/}
{/* Submit Form */}
<Button
className="h-11 mt-2 flex gap-2 items-center bg-zinc-800/75 text-white hover:bg-zinc-800/75 hover:opacity-75 transition-all transform-gpu group"
type="submit"
disabled={loading}
>
{loading && <ArrowPathIcon className="w-4 h-4 animate-spin" />}
{stage === "email"
? "Continue"
: stage === "register"
? "Register"
: "Login"}
{!loading && <AnimatedRightChevron />}
</Button>
</form>
);
};
export default AuthForm;

@ -0,0 +1,46 @@
import { ReactElement } from "react";
import { Button } from "@/components/ui/button";
import { SocialIcon } from "react-social-icons";
import Link from "next/link";
/**
* The props for this oauth provider.
*/
type OAuthProviderProps = {
/**
* The name of this provider.
*/
name: string;
/**
* The icon of this provider.
*/
icon: string;
/**
* The link to login with this provider.
*/
link: string;
};
const OAuthProvider = ({
name,
icon,
link,
}: OAuthProviderProps): ReactElement => (
<Link href={link}>
<Button
className="h-12 bg-zinc-800/85 text-white border border-zinc-700/35 hover:bg-muted hover:opacity-75 transition-all transform-gpu"
disabled
>
<SocialIcon
className="w-10 h-10"
as="div"
bgColor="transparent"
network={icon}
/>
<span>{name}</span>
</Button>
</Link>
);
export default OAuthProvider;

@ -0,0 +1,4 @@
import { ReactElement } from "react";
const RegisterView = (): ReactElement => <div />;
export default RegisterView;

@ -0,0 +1,42 @@
import Link from "next/link";
import Image from "next/image";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const brandingVariants = cva(
"relative hover:opacity-75 transition-all transform-gpu",
{
variants: {
size: {
sm: "w-16 h-16",
default: "w-24 h-24",
lg: "w-32 h-32",
},
},
defaultVariants: {
size: "default",
},
}
);
/**
* The props for this component.
*/
type BrandingProps = {
/**
* The size of the branding.
*/
size?: "sm" | "default" | "lg";
/**
* The optional class name to apply to the branding.
*/
className?: string;
};
const Branding = ({ size, className }: BrandingProps) => (
<Link className={cn(brandingVariants({ size, className }))} href="/">
<Image src="/media/logo.png" alt="PulseApp Logo" fill />
</Link>
);
export default Branding;

@ -2,18 +2,13 @@ import { Button } from "@/components/ui/button";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ReactElement } from "react"; import { ReactElement } from "react";
import Branding from "@/components/branding";
const Greeting = (): ReactElement => ( const Greeting = (): ReactElement => (
<div className="h-screen flex flex-col gap-4 justify-center text-center items-center"> <div className="h-screen flex flex-col gap-4 justify-center text-center items-center">
<div className="flex flex-col gap-2 items-center select-none pointer-events-none"> <div className="flex flex-col gap-2 items-center select-none pointer-events-none">
{/* Logo */} {/* Logo */}
<Image <Branding className="animate-pulse" size="lg" />
className="animate-pulse"
src="/media/logo.png"
alt="PulseApp Logo"
width={128}
height={128}
/>
{/* Greeting */} {/* Greeting */}
<h1 className="text-3xl text-red-500 font-bold">Pulse App</h1> <h1 className="text-3xl text-red-500 font-bold">Pulse App</h1>

@ -0,0 +1,25 @@
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-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

@ -0,0 +1,33 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal"
? "h-[1px] w-full"
: "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

33
src/lib/api.ts Normal file

@ -0,0 +1,33 @@
/**
* Send a request to the API.
*
* @param endpoint the endpoint to request
* @param body the optional request body to send
* @param method the request method to use
*/
export const apiRequest = async <T>(
endpoint: string,
method?: string | undefined,
body?: any | undefined
): Promise<{ data: T | undefined; error: ApiError | undefined }> => {
const response: Response = await fetch(
`${process.env.NEXT_PUBLIC_API_ENDPOINT}${endpoint}`,
{
method: method ?? "GET",
body:
method === "POST" && body
? new URLSearchParams(body)
: undefined,
headers: {
"Content-Type": `application/${method === "POST" ? "x-www-form-urlencoded" : "json"}`,
},
}
);
const data: T = await response.json();
if (response.status !== 200) {
return { data: undefined, error: data as ApiError };
}
return { data: data as T, error: undefined };
};
import { ApiError } from "@/app/types/api-error";

@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx"; import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} };