mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Further improve auth experience
This commit is contained in:
@@ -4,6 +4,7 @@ import "./globals.css";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import { BaseRomProvider } from "@/contexts/BaseRomContext";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -30,14 +31,16 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
|
||||
>
|
||||
<BaseRomProvider>
|
||||
<div className="fixed inset-0 -z-10">
|
||||
<div className="aurora" />
|
||||
</div>
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
<Footer />
|
||||
</BaseRomProvider>
|
||||
<AuthProvider>
|
||||
<BaseRomProvider>
|
||||
<div className="fixed inset-0 -z-10">
|
||||
<div className="aurora" />
|
||||
</div>
|
||||
<Header />
|
||||
<main className="flex-1 flex flex-col">{children}</main>
|
||||
<Footer />
|
||||
</BaseRomProvider>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { AuthError } from '@supabase/supabase-js'
|
||||
import { AuthError, User } from '@supabase/supabase-js'
|
||||
|
||||
import { createClient } from '@/utils/supabase/server'
|
||||
|
||||
@@ -21,7 +21,10 @@ function getErrorMessage(error: AuthError): string {
|
||||
return error.message || 'Unable to log in. Please try again later.';
|
||||
}
|
||||
|
||||
export type AuthActionState = { error?: string | null }
|
||||
export type AuthActionState = |
|
||||
{ error: string, user: null, redirectTo: null } |
|
||||
{ error: null, user: User | null, redirectTo: string } |
|
||||
null
|
||||
|
||||
export async function login(state: AuthActionState, payload: FormData) {
|
||||
const supabase = await createClient()
|
||||
@@ -31,12 +34,20 @@ export async function login(state: AuthActionState, payload: FormData) {
|
||||
password: payload.get('password') as string,
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword(data)
|
||||
const { data: authData, error } = await supabase.auth.signInWithPassword(data)
|
||||
|
||||
if (error) {
|
||||
return { error: getErrorMessage(error) }
|
||||
return { error: getErrorMessage(error), user: null, redirectTo: null }
|
||||
}
|
||||
|
||||
revalidatePath('/', 'layout')
|
||||
redirect('/account')
|
||||
const redirectTo = (payload.get('redirectTo') as string | null)
|
||||
const isValidInternalPath = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')
|
||||
|
||||
return {
|
||||
error: null,
|
||||
user: authData.user,
|
||||
redirectTo: isValidInternalPath ? redirectTo : '/account'
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import LoginForm from "@/components/Auth/LoginForm";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function LoginPage({ searchParams }: { searchParams?: { redirectTo?: string } }) {
|
||||
const redirectTo = searchParams?.redirectTo;
|
||||
return (
|
||||
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
|
||||
<div className="card p-6">
|
||||
@@ -10,7 +12,8 @@ export default function LoginPage() {
|
||||
<LoginForm />
|
||||
</div>
|
||||
<p className="mt-6 text-sm text-foreground/70">
|
||||
New here? <a className="text-[var(--accent)] hover:underline" href="/signup">Create an account</a>
|
||||
New here?
|
||||
<Link className="ml-1 text-[var(--accent)] hover:underline" href={redirectTo ? `/signup?redirectTo=${encodeURIComponent(redirectTo)}` : "/signup"}>Create an account</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,12 @@ export default function Home() {
|
||||
>
|
||||
Submit a patch
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex h-12 items-center justify-center rounded-md px-5 text-base font-medium text-foreground/90 hover:underline"
|
||||
>
|
||||
Already a creator? Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,5 +55,7 @@ export async function signup(state: AuthActionState, payload: FormData) {
|
||||
}
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
redirect('/account');
|
||||
const redirectTo = (payload.get('redirectTo') as string | null) || null
|
||||
const isValidInternalPath = redirectTo && redirectTo.startsWith('/') && !redirectTo.startsWith('//')
|
||||
redirect(isValidInternalPath ? redirectTo! : '/account');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import SignupForm from "@/components/Auth/SignupForm";
|
||||
|
||||
export default function SignupPage() {
|
||||
export default function SignupPage({ searchParams }: { searchParams?: { redirectTo?: string } }) {
|
||||
const redirectTo = searchParams?.redirectTo;
|
||||
return (
|
||||
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
|
||||
<div className="card p-6">
|
||||
@@ -10,7 +12,8 @@ export default function SignupPage() {
|
||||
<SignupForm />
|
||||
</div>
|
||||
<p className="mt-6 text-sm text-foreground/70">
|
||||
Already have an account? <a className="text-[var(--accent)] hover:underline" href="/login">Log in</a>
|
||||
Already have an account?
|
||||
<Link className="ml-1 text-[var(--accent)] hover:underline" href="/login">Log in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import SubmitForm from "@/components/Submit/SubmitForm";
|
||||
import { createClient } from "@/utils/supabase/server";
|
||||
import SubmitAuthOverlay from "@/components/Submit/SubmitAuthOverlay";
|
||||
|
||||
export default async function SubmitPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
export default function SubmitPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-lg px-6 py-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Submit your ROM hack</h1>
|
||||
@@ -8,6 +13,7 @@ export default function SubmitPage() {
|
||||
<div className="mt-8">
|
||||
<SubmitForm />
|
||||
</div>
|
||||
{!user && <SubmitAuthOverlay />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import React, { useActionState} from "react";
|
||||
import React, { useActionState, useEffect} from "react";
|
||||
import { FiEye, FiEyeOff } from "react-icons/fi";
|
||||
import { AuthActionState, login } from "@/app/login/actions";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { redirect, useSearchParams } from "next/navigation";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
|
||||
export default function LoginForm() {
|
||||
const { setUser } = useAuthContext();
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const urlError = searchParams.get("error");
|
||||
|
||||
const [state, formAction] = useActionState<AuthActionState, FormData>(login, { error: null });
|
||||
const [state, formAction] = useActionState<AuthActionState, FormData>(login, null);
|
||||
const errorMessage = urlError === "EMAIL_CONFIRMATION_ERROR" ?
|
||||
"Email verification failed. Try again or request a new link." :
|
||||
state?.error || null;
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const emailValid = /.+@.+\..+/.test(email);
|
||||
const passwordValid = password.length > 1;
|
||||
const isValid = emailValid && passwordValid;
|
||||
|
||||
useEffect(() => {
|
||||
if (state && state.error === null) {
|
||||
setUser(state.user);
|
||||
if (state.redirectTo) {
|
||||
redirect(state.redirectTo);
|
||||
}
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<form className="grid gap-5 group">
|
||||
{redirectTo && (
|
||||
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||
)}
|
||||
{(errorMessage) && (
|
||||
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
|
||||
{errorMessage}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useActionState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { FiEye, FiEyeOff } from "react-icons/fi";
|
||||
import { AuthActionState, signup } from "@/app/signup/actions";
|
||||
import { validateEmail, validatePassword } from "@/utils/auth";
|
||||
|
||||
export default function SignupForm() {
|
||||
const searchParams = useSearchParams();
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirm, setConfirm] = React.useState("");
|
||||
@@ -27,8 +29,13 @@ export default function SignupForm() {
|
||||
setPasswordError(error);
|
||||
}, [password]);
|
||||
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
return (
|
||||
<form className="grid gap-5 group">
|
||||
{redirectTo && (
|
||||
<input type="hidden" name="redirectTo" value={redirectTo} />
|
||||
)}
|
||||
{(state?.error) && (
|
||||
<div className="rounded-md bg-red-500/10 ring-1 ring-red-600/40 px-3 py-2 text-sm text-red-300">
|
||||
{state?.error}
|
||||
|
||||
@@ -12,6 +12,9 @@ export default function Footer() {
|
||||
<Link href="/submit" className="hover:underline">
|
||||
Submit
|
||||
</Link>
|
||||
<Link href="/login" className="hover:underline font-medium text-foreground">
|
||||
Already a creator? Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -4,6 +4,9 @@ import Link from "next/link";
|
||||
import React from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useBaseRoms } from "@/contexts/BaseRomContext";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import Avatar from "@/components/Account/Avatar";
|
||||
|
||||
function NavLink({ href, label, className = "" }: { href: string; label: React.ReactNode; className?: string }) {
|
||||
const pathname = usePathname();
|
||||
@@ -23,7 +26,38 @@ function NavLink({ href, label, className = "" }: { href: string; label: React.R
|
||||
|
||||
export default function Header() {
|
||||
const { countReady } = useBaseRoms();
|
||||
const { user } = useAuthContext();
|
||||
const pathname = usePathname();
|
||||
const supabase = createClient();
|
||||
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(false);
|
||||
const [userId, setUserId] = React.useState<string | null>(null);
|
||||
const [avatarUrl, setAvatarUrl] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!isMounted) return;
|
||||
const authed = Boolean(data.user);
|
||||
setIsAuthenticated(authed);
|
||||
setUserId(data.user?.id ?? null);
|
||||
// Best-effort fetch profile avatar_url for header avatar
|
||||
if (authed && data.user?.id) {
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('avatar_url')
|
||||
.eq('id', data.user.id)
|
||||
.single();
|
||||
setAvatarUrl(profile?.avatar_url ?? null);
|
||||
} else {
|
||||
setAvatarUrl(null);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [supabase, user]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-[var(--border)] backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex h-16 max-w-screen-2xl items-center justify-between px-6">
|
||||
@@ -52,6 +86,16 @@ export default function Header() {
|
||||
>
|
||||
Submit
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link
|
||||
href="/account"
|
||||
className="ml-1 inline-flex items-center justify-center rounded-full ring-1 ring-[var(--border)] p-[2px] hover:bg-[var(--surface-2)]"
|
||||
aria-label="Open account"
|
||||
title="Account"
|
||||
>
|
||||
<Avatar uid={userId} url={avatarUrl} size={28} />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
64
src/components/Submit/SubmitAuthOverlay.tsx
Normal file
64
src/components/Submit/SubmitAuthOverlay.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const SubmitAuthOverlay: React.FC = () => {
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const previousHtmlOverflow = html.style.overflow;
|
||||
const previousBodyOverflow = body.style.overflow;
|
||||
const previousBodyPaddingRight = body.style.paddingRight;
|
||||
const scrollBarWidth = window.innerWidth - html.clientWidth;
|
||||
|
||||
html.style.overflow = 'hidden';
|
||||
body.style.overflow = 'hidden';
|
||||
if (scrollBarWidth > 0) {
|
||||
body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
html.style.overflow = previousHtmlOverflow;
|
||||
body.style.overflow = previousBodyOverflow;
|
||||
body.style.paddingRight = previousBodyPaddingRight;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-16 bottom-0 z-[100] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Creators only"
|
||||
className="relative z-[101] mb-16 card p-6 max-w-md w-full rounded-lg"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-lg font-semibold">Creators only</div>
|
||||
<p className="mt-1 text-sm text-foreground/80">
|
||||
You need an account to submit new romhacks for others to play. It only takes a minute.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center items-center gap-3">
|
||||
<Link
|
||||
href="/signup"
|
||||
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold rounded-md text-[var(--accent-foreground)]"
|
||||
>
|
||||
<span>Create account</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/login?redirectTo=%2Fsubmit"
|
||||
className="inline-flex h-11 items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubmitAuthOverlay;
|
||||
45
src/contexts/AuthContext.tsx
Normal file
45
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { User } from "@supabase/supabase-js";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
setUser: (user: User | null) => void;
|
||||
}
|
||||
|
||||
export const AuthContext = React.createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = React.useState<User | null>(null);
|
||||
const supabase = createClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchUser = async () => {
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (!isMounted) return;
|
||||
setUser(data.user ?? null);
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [supabase]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, setUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthContext() {
|
||||
const context = React.useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuthContext must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user