mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-03-21 17:54:09 -05:00
Add password recovery functionality
This commit is contained in:
parent
ec860cebc1
commit
c22f9a53dc
25
src/app/account/actions.ts
Normal file
25
src/app/account/actions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
45
src/app/account/update-password/page.tsx
Normal file
45
src/app/account/update-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
33
src/app/login/forgot/page.tsx
Normal file
33
src/app/login/forgot/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
34
src/components/Account/AccountOptionsMenu.tsx
Normal file
34
src/components/Account/AccountOptionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
123
src/components/Account/UpdatePasswordForm.tsx
Normal file
123
src/components/Account/UpdatePasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
56
src/components/Auth/ForgotPasswordForm.tsx
Normal file
56
src/components/Auth/ForgotPasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
79
supabase/templates/recovery.html
Normal file
79
supabase/templates/recovery.html
Normal 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 }}&type=recovery&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>
|
||||
Loading…
Reference in New Issue
Block a user