"use client" import { useState, useEffect } from "react" import { useRouter } from "next/navigation" import { useMutation, useQueryClient } from "@tanstack/react-query" import { createSupabaseBrowserClient } from "@/lib/supabase/client" import { useUserProfile } from "@/lib/queries/use-user-profile" import { queryKeys } from "@/lib/queries/query-keys" import { TIER_LIMITS } from "@asa-news/shared" import { notify } from "@/lib/notify" import type { Factor } from "@supabase/supabase-js" type EnrollmentState = | { step: "idle" } | { step: "enrolling"; factorIdentifier: string; qrCodeSvg: string; otpauthUri: string } export function AccountSettings() { const { data: userProfile, isLoading } = useUserProfile() const [isEditingName, setIsEditingName] = useState(false) const [editedName, setEditedName] = useState("") const [isRequestingData, setIsRequestingData] = useState(false) const [newEmailAddress, setNewEmailAddress] = useState("") const [emailPassword, setEmailPassword] = useState("") const [currentPassword, setCurrentPassword] = useState("") const [newPassword, setNewPassword] = useState("") const [confirmNewPassword, setConfirmNewPassword] = useState("") const [passwordMfaCode, setPasswordMfaCode] = useState("") const [emailMfaCode, setEmailMfaCode] = useState("") const [enrolledFactors, setEnrolledFactors] = useState([]) const [isTotpLoading, setIsTotpLoading] = useState(true) const [enrollmentState, setEnrollmentState] = useState({ step: "idle" }) const [factorName, setFactorName] = useState("") const [verificationCode, setVerificationCode] = useState("") const [isTotpProcessing, setIsTotpProcessing] = useState(false) const [unenrollConfirmIdentifier, setUnenrollConfirmIdentifier] = useState(null) const supabaseClient = createSupabaseBrowserClient() const queryClient = useQueryClient() const router = useRouter() const updateDisplayName = useMutation({ mutationFn: async (displayName: string | null) => { const { error } = await supabaseClient.auth.updateUser({ data: { display_name: displayName }, }) if (error) throw error }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) notify("display name updated") }, onError: (error: Error) => { notify("failed to update display name: " + error.message) }, }) async function elevateToAal2(mfaCode: string) { const { data: factorsData } = await supabaseClient.auth.mfa.listFactors() const verifiedFactors = factorsData?.totp.filter((factor) => factor.status === "verified") ?? [] if (verifiedFactors.length === 0) return if (!mfaCode || mfaCode.length !== 6) { throw new Error("enter your 6-digit authenticator code") } const { data: challengeData, error: challengeError } = await supabaseClient.auth.mfa.challenge({ factorId: verifiedFactors[0].id }) if (challengeError) throw new Error("mfa challenge failed: " + challengeError.message) const { error: verifyError } = await supabaseClient.auth.mfa.verify({ factorId: verifiedFactors[0].id, challengeId: challengeData.id, code: mfaCode, }) if (verifyError) throw new Error("invalid authenticator code") } const updateEmailAddress = useMutation({ mutationFn: async ({ emailAddress, password, mfaCode, }: { emailAddress: string password: string mfaCode: string }) => { const { data: { user }, } = await supabaseClient.auth.getUser() if (!user?.email) throw new Error("not authenticated") const { error: signInError } = await supabaseClient.auth.signInWithPassword({ email: user.email, password, }) if (signInError) throw new Error("incorrect password") await elevateToAal2(mfaCode) const { error } = await supabaseClient.auth.updateUser({ email: emailAddress, }) if (error) throw error }, onSuccess: () => { setNewEmailAddress("") setEmailPassword("") setEmailMfaCode("") notify("confirmation email sent to your new address") }, onError: (error: Error) => { notify("failed to update email: " + error.message) }, }) const updatePassword = useMutation({ mutationFn: async ({ currentPassword: current, newPassword: updated, mfaCode, }: { currentPassword: string newPassword: string mfaCode: string }) => { const { data: { user }, } = await supabaseClient.auth.getUser() if (!user?.email) throw new Error("not authenticated") const { error: signInError } = await supabaseClient.auth.signInWithPassword({ email: user.email, password: current, }) if (signInError) throw new Error("current password is incorrect") await elevateToAal2(mfaCode) const { error } = await supabaseClient.auth.updateUser({ password: updated, }) if (error) throw error }, onSuccess: async () => { setCurrentPassword("") setNewPassword("") setConfirmNewPassword("") setPasswordMfaCode("") notify("password updated — signing out all sessions") await supabaseClient.auth.signOut({ scope: "global" }) router.push("/sign-in") }, onError: (error: Error) => { notify("failed to update password: " + error.message) }, }) async function loadFactors() { const { data, error } = await supabaseClient.auth.mfa.listFactors() if (error) { notify("failed to load mfa factors") setIsTotpLoading(false) return } setEnrolledFactors( data.totp.filter((factor) => factor.status === "verified") ) setIsTotpLoading(false) } useEffect(() => { loadFactors() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) async function handleBeginEnrollment() { setIsTotpProcessing(true) const enrollOptions: { factorType: "totp"; friendlyName?: string } = { factorType: "totp", } if (factorName.trim()) { enrollOptions.friendlyName = factorName.trim() } const { data, error } = await supabaseClient.auth.mfa.enroll(enrollOptions) setIsTotpProcessing(false) if (error) { notify("failed to start mfa enrolment: " + error.message) return } setEnrollmentState({ step: "enrolling", factorIdentifier: data.id, qrCodeSvg: data.totp.qr_code, otpauthUri: data.totp.uri, }) setVerificationCode("") } async function handleVerifyEnrollment() { if (enrollmentState.step !== "enrolling") return if (verificationCode.length !== 6) return setIsTotpProcessing(true) const { data: challengeData, error: challengeError } = await supabaseClient.auth.mfa.challenge({ factorId: enrollmentState.factorIdentifier, }) if (challengeError) { setIsTotpProcessing(false) notify("failed to create mfa challenge: " + challengeError.message) return } const { error: verifyError } = await supabaseClient.auth.mfa.verify({ factorId: enrollmentState.factorIdentifier, challengeId: challengeData.id, code: verificationCode, }) setIsTotpProcessing(false) if (verifyError) { notify("invalid code — please try again") setVerificationCode("") return } notify("two-factor authentication enabled") setEnrollmentState({ step: "idle" }) setVerificationCode("") setFactorName("") await supabaseClient.auth.refreshSession() await loadFactors() } async function handleCancelEnrollment() { if (enrollmentState.step === "enrolling") { await supabaseClient.auth.mfa.unenroll({ factorId: enrollmentState.factorIdentifier, }) } setEnrollmentState({ step: "idle" }) setVerificationCode("") setFactorName("") } async function handleUnenrollFactor(factorIdentifier: string) { setIsTotpProcessing(true) const { error } = await supabaseClient.auth.mfa.unenroll({ factorId: factorIdentifier, }) setIsTotpProcessing(false) if (error) { notify("failed to remove factor: " + error.message) return } notify("two-factor authentication removed") setUnenrollConfirmIdentifier(null) await supabaseClient.auth.refreshSession() await loadFactors() } if (isLoading) { return

loading account ...

} if (!userProfile) { return

failed to load account

} const tier = userProfile.tier const tierLimits = TIER_LIMITS[tier] async function handleRequestData() { setIsRequestingData(true) try { const response = await fetch("/api/account/data") if (!response.ok) throw new Error("export failed") const blob = await response.blob() const url = URL.createObjectURL(blob) const anchor = document.createElement("a") anchor.href = url anchor.download = `asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json` anchor.click() URL.revokeObjectURL(url) notify("data exported") } catch { notify("failed to export data") } finally { setIsRequestingData(false) } } function handleSaveName() { const trimmedName = editedName.trim() updateDisplayName.mutate(trimmedName || null) setIsEditingName(false) } function handleUpdateEmail(event: React.FormEvent) { event.preventDefault() const trimmedEmail = newEmailAddress.trim() if (!trimmedEmail || !emailPassword) return updateEmailAddress.mutate({ emailAddress: trimmedEmail, password: emailPassword, mfaCode: emailMfaCode }) } function handleUpdatePassword(event: React.FormEvent) { event.preventDefault() if (!currentPassword) { notify("current password is required") return } if (!newPassword || newPassword !== confirmNewPassword) { notify("passwords do not match") return } if (newPassword.length < 8) { notify("password must be at least 8 characters") return } updatePassword.mutate({ currentPassword, newPassword, mfaCode: passwordMfaCode }) } return (

display name

{isEditingName ? (
setEditedName(event.target.value)} placeholder="display name" className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" onKeyDown={(event) => { if (event.key === "Enter") handleSaveName() if (event.key === "Escape") setIsEditingName(false) }} autoFocus />
) : (
{userProfile.displayName ?? "not set"}
)}

email address

{userProfile.email ?? "no email on file"}

setNewEmailAddress(event.target.value)} placeholder="new email address" className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> setEmailPassword(event.target.value)} placeholder="current password" className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> {enrolledFactors.length > 0 && ( setEmailMfaCode(event.target.value.replace(/\D/g, ""))} placeholder="authenticator code" className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> )}

change password

setCurrentPassword(event.target.value)} placeholder="current password" className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> setNewPassword(event.target.value)} placeholder="new password" className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> setConfirmNewPassword(event.target.value)} placeholder="confirm new password" className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> {enrolledFactors.length > 0 && ( setPasswordMfaCode(event.target.value.replace(/\D/g, ""))} placeholder="authenticator code" className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" /> )}

two-factor authentication

add an extra layer of security to your account with a time-based one-time password (totp) authenticator app

{isTotpLoading ? (

loading ...

) : ( <> {enrollmentState.step === "idle" && enrolledFactors.length === 0 && (
setFactorName(event.target.value)} placeholder="authenticator name (optional)" className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" />
)} {enrollmentState.step === "enrolling" && (

scan this qr code with your authenticator app, then enter the 6-digit code below

totp qr code
can't scan? copy manual entry key {enrollmentState.otpauthUri}
{ const filtered = event.target.value.replace(/\D/g, "") setVerificationCode(filtered) }} placeholder="000000" className="w-32 border border-border bg-background-primary px-3 py-2 text-center font-mono text-lg tracking-widest text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" autoFocus onKeyDown={(event) => { if (event.key === "Enter") handleVerifyEnrollment() if (event.key === "Escape") handleCancelEnrollment() }} />
)} {enrolledFactors.length > 0 && enrollmentState.step === "idle" && (
{enrolledFactors.map((factor) => (
{factor.friendly_name || "totp authenticator"} added{" "} {new Date(factor.created_at).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric", })}
{unenrollConfirmIdentifier === factor.id ? (
remove?
) : ( )}
))}
)} )}

usage

your data

download all your data (profile, subscriptions, folders, highlights, saved entries)

support

need help or have feedback? reach out at{" "} support@asa.news

) } function UsageRow({ label, current, maximum, }: { label: string current: number maximum: number }) { const isNearLimit = current >= maximum * 0.8 const isAtLimit = current >= maximum return (
{label} {current} / {maximum}
) }