diff --git a/src/app/account/actions.ts b/src/app/account/actions.ts new file mode 100644 index 0000000..3b1ddb2 --- /dev/null +++ b/src/app/account/actions.ts @@ -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 { + 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; +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 5a4c86f..2b2d9b4 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -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 (
-

{needsInitialSetup ? 'Finish setting up your account' : 'Your account'}

-

- {needsInitialSetup ? 'Choose a unique username to get started. You can update other details later.' : 'Manage profile details and avatar.'} -

-
+
+
+
+

{needsInitialSetup ? 'Finish setting up your account' : 'Your account'}

+
+

+ {needsInitialSetup ? 'Choose a unique username to get started. You can update other details later.' : 'Manage profile details and avatar.'} +

+
+ {!needsInitialSetup && } +
+ {passwordUpdated && ( +
+ Your password was updated successfully. +
+ )} +
{needsInitialSetup ? ( ) : ( diff --git a/src/app/account/update-password/page.tsx b/src/app/account/update-password/page.tsx new file mode 100644 index 0000000..9654aa5 --- /dev/null +++ b/src/app/account/update-password/page.tsx @@ -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 ( +
+ {from === 'account' && ( +
+ + + +
+ )} +
+

Change your password

+

Enter a new password for your account.

+
+ +
+
+
+ ); +} + + + diff --git a/src/app/auth/confirm/route.ts b/src/app/auth/confirm/route.ts index bb218e4..d609158 100644 --- a/src/app/auth/confirm/route.ts +++ b/src/app/auth/confirm/route.ts @@ -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) } diff --git a/src/app/login/actions.ts b/src/app/login/actions.ts index d18e586..efbb396 100644 --- a/src/app/login/actions.ts +++ b/src/app/login/actions.ts @@ -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 { + 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 +} diff --git a/src/app/login/forgot/page.tsx b/src/app/login/forgot/page.tsx new file mode 100644 index 0000000..1af6e19 --- /dev/null +++ b/src/app/login/forgot/page.tsx @@ -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 ( +
+
+

Forgot password

+

Enter your email and we'll send you a reset link.

+
+ +
+

+ Remember now? + + Back to login + +

+
+
+ ); +} + + diff --git a/src/components/Account/AccountOptionsMenu.tsx b/src/components/Account/AccountOptionsMenu.tsx new file mode 100644 index 0000000..15de1b6 --- /dev/null +++ b/src/components/Account/AccountOptionsMenu.tsx @@ -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 ( + + + + + + + + Update password + + + + ); +} + + diff --git a/src/components/Account/UpdatePasswordForm.tsx b/src/components/Account/UpdatePasswordForm.tsx new file mode 100644 index 0000000..2c88dfe --- /dev/null +++ b/src/components/Account/UpdatePasswordForm.tsx @@ -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(null); + const [state, formAction, isPending] = useActionState(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 ( +
+ {state?.ok === false && state.error && ( +
+ {state.error} +
+ )} +
+ +
+ 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 + /> + +
+ {password && passwordError && ( + {passwordError} + )} +
+ +
+ +
+ 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 + /> + +
+ {confirm && !passwordsMatch && ( + Passwords do not match. + )} +
+ +
+ {!isValid && (password || confirm) ? ( + Passwords must match and be at least 6 characters. + ) : ( +
+ )} + +
+ + ); +} + + diff --git a/src/components/Auth/ForgotPasswordForm.tsx b/src/components/Auth/ForgotPasswordForm.tsx new file mode 100644 index 0000000..f834010 --- /dev/null +++ b/src/components/Auth/ForgotPasswordForm.tsx @@ -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(requestPasswordReset, null); + const emailValid = /.+@.+\..+/.test(email); + + return ( +
+ {state?.ok && !state.error && ( +
+ If that email exists, you'll receive a reset link shortly. +
+ )} + {state?.ok === false && state.error && ( +
+ {state.error} +
+ )} +
+ + 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 + /> +
+ +
+ +
+
+ ); +} + + + diff --git a/src/components/Auth/LoginForm.tsx b/src/components/Auth/LoginForm.tsx index 05b68a1..8d26d58 100644 --- a/src/components/Auth/LoginForm.tsx +++ b/src/components/Auth/LoginForm.tsx @@ -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() {
+
+ + Forgot your password? + +
+
{!isValid && (email || password) ? ( Please enter a valid email and password. diff --git a/supabase/config.toml b/supabase/config.toml index 3eca269..dd12aba 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -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. diff --git a/supabase/templates/recovery.html b/supabase/templates/recovery.html new file mode 100644 index 0000000..5148143 --- /dev/null +++ b/supabase/templates/recovery.html @@ -0,0 +1,79 @@ + + + + + + + + Reset your password + + + +
+ + + + +
+ + + + + + + + + + + +
+
+ +