Add password recovery functionality

This commit is contained in:
Jared Schoeny 2025-10-29 19:48:20 -10:00
parent ec860cebc1
commit c22f9a53dc
12 changed files with 481 additions and 14 deletions

View File

@ -0,0 +1,25 @@
"use server";
import { createClient } from "@/utils/supabase/server";
export type UpdateState =
| { ok: true; error: null }
| { ok: false; error: string }
| null;
export async function updatePassword(state: UpdateState, payload: FormData): Promise<UpdateState> {
const password = (payload.get("newPassword") as string | null) || "";
const supabase = await createClient();
if (password.length < 6) {
return { ok: false, error: "Password must be at least 6 characters." } as const;
}
const { error } = await supabase.auth.updateUser({ password });
if (error) {
return { ok: false, error: error.message || "Unable to update password." } as const;
}
return { ok: true, error: null } as const;
}

View File

@ -1,11 +1,17 @@
import AccountForm from "@/components/Account/AccountForm";
import AccountOptionsMenu from "@/components/Account/AccountOptionsMenu";
import AccountSetupForm from "../../components/Account/AccountSetupForm";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
export default async function AccountPage() {
interface AccountPageProps {
searchParams: Promise<{ passwordUpdated?: string }>;
}
export default async function AccountPage({ searchParams }: AccountPageProps) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
const { passwordUpdated } = await searchParams;
if (!user) {
redirect('/login')
@ -22,11 +28,23 @@ export default async function AccountPage() {
return (
<div className="mx-auto my-auto max-w-screen-lg px-6 py-10">
<h1 className="text-3xl font-bold tracking-tight">{needsInitialSetup ? 'Finish setting up your account' : 'Your account'}</h1>
<p className="mt-2 text-[15px] text-foreground/80">
{needsInitialSetup ? 'Choose a unique username to get started. You can update other details later.' : 'Manage profile details and avatar.'}
</p>
<div className="mt-8 card p-6 max-w-md">
<div className="flex flex-row justify-between items-end">
<div className="flex flex-col">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">{needsInitialSetup ? 'Finish setting up your account' : 'Your account'}</h1>
</div>
<p className="mt-2 text-[15px] text-foreground/80 max-w-md">
{needsInitialSetup ? 'Choose a unique username to get started. You can update other details later.' : 'Manage profile details and avatar.'}
</p>
</div>
{!needsInitialSetup && <AccountOptionsMenu />}
</div>
{passwordUpdated && (
<div className="mt-8 rounded-md bg-green-500/10 ring-1 ring-green-600/40 px-3 py-2 text-sm text-green-300">
Your password was updated successfully.
</div>
)}
<div className={`${passwordUpdated ? 'mt-4' : 'mt-8'} card p-6 max-w-md`}>
{needsInitialSetup ? (
<AccountSetupForm user={user} />
) : (

View File

@ -0,0 +1,45 @@
import UpdatePasswordForm from "@/components/Account/UpdatePasswordForm";
import Button from "@/components/Button";
import Link from "next/link";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import { FaChevronLeft } from "react-icons/fa";
interface UpdatePasswordPageProps {
searchParams: Promise<{ from?: string }>;
}
export default async function UpdatePasswordPage({ searchParams }: UpdatePasswordPageProps) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
const { from } = await searchParams;
if (!user) {
redirect('/login?redirectTo=%2Faccount%2Fupdate-password');
}
return (
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
{from === 'account' && (
<div className="mb-4">
<Link href="/account">
<Button variant="secondary" size="sm">
<FaChevronLeft size={12} className="inline-block -ml-1 mr-1" />
Back to account
</Button>
</Link>
</div>
)}
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Change your password</h1>
<p className="mt-1 text-sm text-foreground/70">Enter a new password for your account.</p>
<div className="mt-6">
<UpdatePasswordForm />
</div>
</div>
</div>
);
}

View File

@ -7,13 +7,15 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const next = '/account'
const rawNext = searchParams.get('next') || '/account'
const next = rawNext.startsWith('/') ? rawNext : `/${rawNext}`
// Create redirect link without the secret token
const redirectTo = request.nextUrl.clone()
redirectTo.pathname = next
redirectTo.searchParams.delete('token_hash')
redirectTo.searchParams.delete('type')
redirectTo.searchParams.delete('next')
if (token_hash && type) {
const supabase = await createClient()
@ -22,14 +24,20 @@ export async function GET(request: NextRequest) {
type,
token_hash,
})
if (!error) {
redirectTo.searchParams.delete('next')
if (error) {
redirectTo.pathname = '/login'
redirectTo.searchParams.set('error', error.code || 'UNKNOWN_ERROR')
return NextResponse.redirect(redirectTo)
} else {
return NextResponse.redirect(redirectTo)
}
}
// return the user to login with an error message
redirectTo.pathname = '/login'
redirectTo.searchParams.set('error', 'EMAIL_CONFIRMATION_ERROR')
// Clear all search params
for (const key of searchParams.keys()) {
redirectTo.searchParams.delete(key)
}
return NextResponse.redirect(redirectTo)
}

View File

@ -51,3 +51,39 @@ export async function login(state: AuthActionState, payload: FormData) {
}
}
export type ResetActionState =
| { ok: true; error: null }
| { ok: false; error: string }
| null
export async function requestPasswordReset(
state: ResetActionState,
payload: FormData
): Promise<ResetActionState> {
const supabase = await createClient()
const email = (payload.get('resetEmail') as string | null)?.trim() || ''
if (!/.+@.+\..+/.test(email)) {
return { ok: false, error: 'Please enter a valid email address.' } as const
}
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
// Fallback to localhost if env is not set in dev
const baseUrl = siteUrl && /^https?:\/\//.test(siteUrl)
? siteUrl.replace(/\/$/, '')
: 'http://localhost:3000'
const redirectTo = `${baseUrl}/auth/confirm?next=${encodeURIComponent('/account/update-password')}`
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo,
})
if (error) {
// Do not reveal whether the email exists return a generic error
return { ok: false, error: 'Unable to send reset email. Please try again later.' } as const
}
return { ok: true, error: null } as const
}

View File

@ -0,0 +1,33 @@
import ForgotPasswordForm from "@/components/Auth/ForgotPasswordForm";
import Link from "next/link";
interface ForgotPasswordPageProps {
searchParams: Promise<{ redirectTo?: string }>;
}
export default async function ForgotPasswordPage({ searchParams }: ForgotPasswordPageProps) {
const { redirectTo } = await searchParams;
return (
<div className="mx-auto my-auto max-w-md w-full px-6 py-10">
<div className="card p-6">
<h1 className="text-2xl font-semibold tracking-tight">Forgot password</h1>
<p className="mt-1 text-sm text-foreground/70">Enter your email and we'll send you a reset link.</p>
<div className="mt-6">
<ForgotPasswordForm />
</div>
<p className="mt-6 text-sm text-foreground/70">
Remember now?
<Link
className="ml-1 text-[var(--accent)] hover:underline"
href={redirectTo ? `/login?redirectTo=${encodeURIComponent(redirectTo)}` : "/login"}
>
Back to login
</Link>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
"use client";
import React from "react";
import { FiMoreVertical } from "react-icons/fi";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
export default function AccountOptionsMenu() {
return (
<Menu as="div" className="relative">
<MenuButton
aria-label="More options"
title="Options"
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
>
<FiMoreVertical size={18} />
</MenuButton>
<MenuItems
transition
className="absolute right-0 z-10 mt-2 w-56 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
>
<MenuItem
as="a"
href="/account/update-password?from=account"
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10"
>
Update password
</MenuItem>
</MenuItems>
</Menu>
);
}

View File

@ -0,0 +1,123 @@
"use client";
import React, { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { validatePassword } from "@/utils/auth";
import { updatePassword, UpdateState } from "@/app/account/actions";
export default function UpdatePasswordForm() {
const router = useRouter();
const [password, setPassword] = React.useState("");
const [confirm, setConfirm] = React.useState("");
const [showPassword, setShowPassword] = React.useState(false);
const [showConfirmPassword, setShowConfirmPassword] = React.useState(false);
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const [state, formAction, isPending] = useActionState<UpdateState, FormData>(updatePassword, null);
const passwordsMatch = password === confirm;
const isValid = !passwordError && passwordsMatch && confirm.length > 0;
useEffect(() => {
const { error } = validatePassword(password);
setPasswordError(error);
}, [password]);
useEffect(() => {
if (state?.ok) {
// After password is updated, redirect to account
router.replace("/account?passwordUpdated=1");
}
}, [state, router]);
return (
<form className="grid gap-5 group">
{state?.ok === false && 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}
</div>
)}
<div className="grid gap-2">
<label htmlFor="newPassword" className="text-sm text-foreground/80">New password</label>
<div className="relative">
<input
id="newPassword"
name="newPassword"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 characters"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
password && passwordError ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
aria-label={showPassword ? "Hide password" : "Show password"}
title={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <FiEyeOff className="h-4 w-4" /> : <FiEye className="h-4 w-4" />}
</button>
</div>
{password && passwordError && (
<span className="text-xs text-red-500/70">{passwordError}</span>
)}
</div>
<div className="grid gap-2">
<label htmlFor="confirmPassword" className="text-sm text-foreground/80">Confirm new password</label>
<div className="relative">
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Re-enter your new password"
className={`h-11 w-full rounded-md px-3 pr-10 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
confirm && !passwordsMatch ?
"ring-red-600/40 bg-red-500/10 dark:ring-red-400/40 dark:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
<button
type="button"
onClick={() => setShowConfirmPassword((v) => !v)}
className="absolute inset-y-0 right-2 my-auto inline-flex h-8 w-8 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/10"
aria-label={showConfirmPassword ? "Hide password" : "Show password"}
title={showConfirmPassword ? "Hide password" : "Show password"}
>
{showConfirmPassword ? <FiEyeOff className="h-4 w-4" /> : <FiEye className="h-4 w-4" />}
</button>
</div>
{confirm && !passwordsMatch && (
<span className="text-xs text-red-500/70">Passwords do not match.</span>
)}
</div>
<div className="flex flex-col items-center gap-3">
{!isValid && (password || confirm) ? (
<span className="text-xs text-red-500/70 italic h-3 group-has-focus:invisible">Passwords must match and be at least 6 characters.</span>
) : (
<div className="h-3" />
)}
<button
type="submit"
formAction={formAction}
disabled={!isValid || isPending}
className="shine-wrap btn-premium h-11 min-w-[9rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Update password</span>
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,56 @@
"use client";
import React, { useActionState } from "react";
import { ResetActionState, requestPasswordReset } from "@/app/login/actions";
export default function ForgotPasswordForm() {
const [email, setEmail] = React.useState("");
const [state, formAction, isPending] = useActionState<ResetActionState, FormData>(requestPasswordReset, null);
const emailValid = /.+@.+\..+/.test(email);
return (
<form className="grid gap-5 group">
{state?.ok && !state.error && (
<div className="rounded-md bg-green-500/10 ring-1 ring-green-600/40 px-3 py-2 text-sm text-green-300">
If that email exists, you'll receive a reset link shortly.
</div>
)}
{state?.ok === false && 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}
</div>
)}
<div className="grid gap-2">
<label htmlFor="email" className="text-sm text-foreground/80">Email</label>
<input
id="email"
name="resetEmail"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={`h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] ${
email && !emailValid ?
"not-focus:ring-red-600/40 not-focus:bg-red-500/10 dark:not-focus:ring-red-400/40 dark:not-focus:bg-red-950/20" :
"bg-[var(--surface-2)] ring-[var(--border)]"
}`}
required
/>
</div>
<div className="flex flex-col items-center gap-3">
<button
type="submit"
formAction={formAction}
disabled={!emailValid || isPending}
className="shine-wrap btn-premium h-11 min-w-[7.5rem] text-sm font-semibold dark:disabled:opacity-70 disabled:cursor-not-allowed disabled:[box-shadow:0_0_0_1px_var(--border)]"
>
<span>Send reset link</span>
</button>
</div>
</form>
);
}

View File

@ -1,6 +1,7 @@
"use client";
import React, { useActionState, useEffect} from "react";
import Link from "next/link";
import { FiEye, FiEyeOff } from "react-icons/fi";
import { AuthActionState, login } from "@/app/login/actions";
import { useRouter, useSearchParams } from "next/navigation";
@ -101,6 +102,15 @@ export default function LoginForm() {
</div>
</div>
<div className="flex justify-end">
<Link
href={redirectTo ? `/login/forgot?redirectTo=${encodeURIComponent(redirectTo)}` : "/login/forgot"}
className="text-xs text-foreground/70 hover:underline"
>
Forgot your password?
</Link>
</div>
<div className="flex flex-col items-center gap-3">
{!isValid && (email || password) ? (
<span className="text-xs text-red-500/70 italic h-3 group-has-focus:invisible">Please enter a valid email and password.</span>

View File

@ -193,10 +193,10 @@ otp_expiry = 3600
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Customize email templates
[auth.email.template.recovery]
subject = "Password Recovery"
content_path = "./supabase/templates/recovery.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.

View File

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reset your password</title>
<style>
/* Basic email reset */
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
img { -ms-interpolation-mode: bicubic; border: 0; outline: none; text-decoration: none; }
table { border-collapse: collapse; }
body { margin: 0; padding: 0; width: 100%; height: 100%; }
/* Layout */
.wrapper { width: 100%; padding: 24px 0; }
.container { width: 100%; max-width: 640px; margin: 0 auto; background: #ffffff; border: 1px solid #e5e5e5; }
.header { padding: 24px; background: #fafafa; border-bottom: 1px solid #e5e5e5; }
.brand { font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; font-weight: 800; letter-spacing: -0.02em; font-size: 20px; margin: 0; color: #171717; }
.content { padding: 24px; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color: #171717; }
.heading { margin: 0 0 8px 0; font-size: 20px; line-height: 1.3; font-weight: 700; }
.muted { color: #4b5563; font-size: 14px; line-height: 1.6; }
.card { background: #ffffff; border: 1px solid #e5e5e5; border-radius: 10px; padding: 16px; }
.spacer { height: 12px; }
/* Button */
.btn { display: inline-block; padding: 12px 18px; border-radius: 999px; text-decoration: none; color: #ffffff; font-weight: 600; font-size: 14px; background: #f43f5e; }
.code { font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; background: #fafafa; border: 1px solid #ededed; border-radius: 8px; padding: 10px 12px; display: block; color: #0a0a0a; }
/* Footer */
.footer { padding: 16px 24px 24px 24px; text-align: center; color: #6b7280; font-size: 12px; font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }
</style>
</head>
<body>
<div class="wrapper">
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="#0a0a0a">
<tr>
<td align="center" style="padding: 0 16px;">
<table role="presentation" class="container" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="#ffffff">
<tr>
<td class="header">
<h1 class="brand">Hackdex</h1>
</td>
</tr>
<tr>
<td class="content">
<h2 class="heading">Reset your password</h2>
<p class="muted">We received a request to reset the password for your account. Click the button below to choose a new password.</p>
<div class="spacer"></div>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
<a class="btn" href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=account/update-password" target="_blank" rel="noopener">Set new password</a>
</td>
</tr>
</table>
<div class="spacer"></div>
<div class="card">
<p class="muted" style="margin:0 0 8px 0;">If the button doesn't work, copy and paste this link into your browser:</p>
<span class="code">{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;type=recovery&amp;next=account/update-password</span>
</div>
<div class="spacer"></div>
<p class="muted" style="margin:0;">If you didn't request a password reset, you can safely ignore this email.</p>
</td>
</tr>
<tr>
<td class="footer">
© 2025 Hackdex. All rights reserved. Please do not reply to this message.
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
</body>
</html>