PurpleLogin.tsx (9885B)
1 import { CheckCircle, Frown, Mail, Sparkles } from "lucide-react"; 2 import { Button } from "../ui/Button"; 3 import { FormattedMessage, useIntl } from "react-intl"; 4 import Link from "next/link"; 5 import Image from "next/image"; 6 import { useEffect, useRef, useState } from "react"; 7 import { NostrEvent, Relay, nip19 } from "nostr-tools" 8 import { Info } from "lucide-react"; 9 import { Input } from "@/components/ui/Input" 10 import { Label } from "@/components/ui/Label" 11 import { InputOTP6Digits } from "@/components/ui/InputOTP"; 12 import { AccountInfo, Profile, getProfile, getPurpleAccountInfo } from "@/utils/PurpleUtils"; 13 import { useLocalStorage } from "usehooks-ts"; 14 import { ErrorDialog } from "../ErrorDialog"; 15 import { PurpleLayout } from "../PurpleLayout"; 16 import { NostrUserInput } from "../NostrUserInput"; 17 import { OTPAuth } from "../OTPAuth"; 18 19 20 // TODO: Double-check this regex and make it more accurate 21 const NPUB_REGEX = /^npub[0-9A-Za-z]+$/ 22 23 export function PurpleLogin() { 24 const intl = useIntl() 25 const [sessionToken, setSessionToken] = useLocalStorage('session_token', null) 26 const [pubkey, setPubkey] = useState<string | null>(null) // The pubkey of the user, if verified 27 const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay 28 const [error, setError] = useState<string | null>(null) // An error message to display to the user 29 const [npubValidationError, setNpubValidationError] = useState<string | null>(null) 30 const [npub, setNpub] = useState<string>("") 31 const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server 32 const [otpSent, setOTPSent] = useState<boolean>(false) 33 const [otpVerified, setOTPVerified] = useState<boolean>(false) 34 const [otpInvalid, setOTPInvalid] = useState<boolean>(false) 35 const loginSuccessful = sessionToken !== null && otpVerified === true; 36 37 // MARK: - Functions 38 39 const fetchProfile = async () => { 40 if (!pubkey) { 41 return 42 } 43 try { 44 const profile = await getProfile(pubkey) 45 setProfile(profile) 46 } 47 catch (e) { 48 console.error(e) 49 setError("Failed to get profile info from the relay. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") 50 } 51 } 52 53 const fetchAccountInfo = async () => { 54 if (!pubkey) { 55 setExistingAccountInfo(undefined) 56 return 57 } 58 try { 59 const accountInfo = await getPurpleAccountInfo(pubkey) 60 setExistingAccountInfo(accountInfo) 61 } 62 catch (e) { 63 console.error(e) 64 setError("Failed to get account info from our servers. Please wait a few minutes and refresh the page. If the problem persists, please contact support.") 65 } 66 } 67 68 const beginLogin = async () => { 69 if (!pubkey || !existingAccountInfo) { 70 return 71 } 72 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/accounts/" + pubkey + "/request-otp", { 73 method: 'POST', 74 headers: { 75 'Content-Type': 'application/json' 76 }, 77 }) 78 if (!response.ok) { 79 setError("Failed to send OTP. Please try again later.") 80 return 81 } 82 setOTPSent(true) 83 setOTPInvalid(false) 84 } 85 86 const completeOTP = async (otp: string) => { 87 if (!pubkey || !existingAccountInfo || !otp) { 88 return 89 } 90 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/accounts/" + pubkey + "/verify-otp", { 91 method: 'POST', 92 headers: { 93 'Content-Type': 'application/json' 94 }, 95 body: JSON.stringify({ otp_code: otp }) 96 }) 97 if (response.status != 200 && response.status != 401) { 98 setError("Failed to verify OTP. Please try again later.") 99 return 100 } 101 const json = await response.json() 102 if (json.valid) { 103 setSessionToken(json.session_token) 104 setOTPVerified(true) 105 } 106 else { 107 setOTPInvalid(true) 108 } 109 } 110 111 const getRedirectURL = () => { 112 const url = new URL(window.location.href) 113 // Get the redirect URL from the query parameters, or fallback to the account page as the default redirect URL 114 const redirect = url.searchParams.get("redirect") || "/purple/account" 115 // Make sure the redirect URL is within the same domain for security reasons 116 if (redirect && (redirect.startsWith(window.location.origin) || redirect.startsWith("/"))) { 117 return redirect 118 } 119 return null 120 } 121 122 // MARK: - Effects and hooks 123 124 // Load the profile when the pubkey changes 125 useEffect(() => { 126 if (pubkey) { 127 fetchProfile() 128 fetchAccountInfo() 129 } 130 }, [pubkey]) 131 132 useEffect(() => { 133 if (sessionToken) { 134 const redirectUrl = getRedirectURL() 135 if (redirectUrl) { 136 window.location.href = redirectUrl 137 } 138 } 139 }, [sessionToken]) 140 141 useEffect(() => { 142 setOTPSent(false) 143 setOTPVerified(false) 144 if (npub.length > 0 && !NPUB_REGEX.test(npub)) { 145 setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" })) 146 setProfile(undefined) 147 } 148 else { 149 setNpubValidationError(null) 150 if (npub.length > 0) { 151 try { 152 const decoded = nip19.decode(npub) 153 setPubkey(decoded.data as string) 154 } 155 catch (e) { 156 setPubkey(null) 157 setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" })) 158 } 159 } 160 else { 161 setProfile(undefined) 162 } 163 } 164 }, [npub]) 165 166 // MARK: - Render 167 168 return (<> 169 <ErrorDialog error={error} setError={setError} /> 170 <PurpleLayout> 171 <h2 className="text-2xl text-left text-purple-200 font-semibold break-keep mb-2"> 172 {intl.formatMessage({ id: "purple.login.title", defaultMessage: "Access your account" })} 173 </h2> 174 <div className="flex flex-col text-center md:text-left mb-8"> 175 <span className="text-xs text-purple-200/50"> 176 {intl.formatMessage({ id: "purple.login.description", defaultMessage: "Use this page to access your Purple account details" })} 177 </span> 178 </div> 179 <NostrUserInput 180 pubkey={pubkey} 181 setPubkey={setPubkey} 182 onProfileChange={setProfile} 183 disabled={loginSuccessful} 184 profileHeader={<> 185 {existingAccountInfo !== null && existingAccountInfo !== undefined && otpSent !== true && ( 186 <div className="text-purple-200/50 font-normal flex items-center gap-2 rounded-full px-6 py-2 justify-center mt-2 mb-2"> 187 <Sparkles className="w-4 h-4 shrink-0 text-purple-50" /> 188 <div className="flex flex-col"> 189 <div className="text-purple-200/90 font-semibold text-md"> 190 {intl.formatMessage({ id: "purple.login.this-account-exists", defaultMessage: "Yay! We found your account" })} 191 </div> 192 </div> 193 </div> 194 )} 195 <div className="text-purple-200/50 font-normal text-sm"> 196 {otpSent ? intl.formatMessage({ id: "purple.login.logging-into", defaultMessage: "Logging into:" }) 197 : intl.formatMessage({ id: "purple.login.is-this-you", defaultMessage: "Is this you?" })} 198 </div> 199 </>} 200 profileFooter={<> 201 {existingAccountInfo === null && ( 202 <div className="text-purple-200/50 font-normal flex items-center gap-2 bg-purple-300/10 rounded-full px-8 py-2 justify-center mt-2 mb-2 w-fit mx-auto"> 203 <Frown className="w-6 h-6 shrink-0 text-purple-50" /> 204 <div className="flex flex-col"> 205 <div className="text-purple-200/90 font-semibold text-md"> 206 {intl.formatMessage({ id: "purple.login.this-account-does-not-exist", defaultMessage: "This account does not exist" })} 207 </div> 208 <Link className="text-purple-200/90 font-normal text-sm underline" href="/purple/checkout" target="_blank"> 209 {intl.formatMessage({ id: "purple.login.create-account", defaultMessage: "Join Purple today" })} 210 </Link> 211 </div> 212 </div> 213 )} 214 </>} 215 /> 216 {((profile || profile === null) && pubkey) && (<> 217 <div className="mt-2 mb-4 flex flex-col items-center"> 218 {existingAccountInfo !== null && !otpSent && ( 219 <Button variant="default" className="w-full" onClick={() => beginLogin()}>Continue</Button> 220 )} 221 {otpSent && (<> 222 <OTPAuth 223 pubkey={pubkey} 224 verifyOTP={completeOTP} 225 sendOTP={beginLogin} 226 otpVerified={otpVerified} 227 setOTPVerified={setOTPVerified} 228 otpInvalid={otpInvalid} 229 setOTPInvalid={setOTPInvalid} 230 setError={setError} 231 disabled={loginSuccessful} 232 /> 233 {loginSuccessful && (<> 234 <div className="flex flex-col justify-center items-center gap-2 mt-8"> 235 <CheckCircle className="w-12 h-12 shrink-0 text-green-500" /> 236 <div className="text-white text-sm text-center text-purple-200/80 mb-4"> 237 {intl.formatMessage({ id: "purple.login.login-successful", defaultMessage: "Login successful. You should be automatically redirected. If not, please click the button below." })} 238 </div> 239 </div> 240 {/* Continue link */} 241 <Link href={getRedirectURL() || "/purple/account"}> 242 <Button variant="default" className="w-full"> 243 {intl.formatMessage({ id: "purple.login.continue", defaultMessage: "Continue" })} 244 </Button> 245 </Link> 246 </>)} 247 </>)} 248 </div> 249 </>)} 250 </PurpleLayout> 251 </>) 252 }