PurpleLogin.tsx (12758B)
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 17 18 // TODO: Double-check this regex and make it more accurate 19 const NPUB_REGEX = /^npub[0-9A-Za-z]+$/ 20 21 export function PurpleLogin() { 22 const intl = useIntl() 23 const [sessionToken, setSessionToken] = useLocalStorage('session_token', null) 24 const [pubkey, setPubkey] = useState<string | null>(null) // The pubkey of the user, if verified 25 const [profile, setProfile] = useState<Profile | undefined | null>(undefined) // The profile info fetched from the Damus relay 26 const [error, setError] = useState<string | null>(null) // An error message to display to the user 27 const [npubValidationError, setNpubValidationError] = useState<string | null>(null) 28 const [npub, setNpub] = useState<string>("") 29 const [existingAccountInfo, setExistingAccountInfo] = useState<AccountInfo | null | undefined>(undefined) // The account info fetched from the server 30 const [otpSent, setOTPSent] = useState<boolean>(false) 31 const [otp, setOTP] = useState<string>("") 32 const [otpVerified, setOTPVerified] = useState<boolean>(false) 33 const [otpInvalid, setOTPInvalid] = useState<boolean>(false) 34 const loginSuccessful = sessionToken !== null && otpVerified === true; 35 36 // MARK: - Functions 37 38 const fetchProfile = async () => { 39 if (!pubkey) { 40 return 41 } 42 try { 43 const profile = await getProfile(pubkey) 44 setProfile(profile) 45 } 46 catch (e) { 47 console.error(e) 48 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.") 49 } 50 } 51 52 const fetchAccountInfo = async () => { 53 if (!pubkey) { 54 setExistingAccountInfo(undefined) 55 return 56 } 57 try { 58 const accountInfo = await getPurpleAccountInfo(pubkey) 59 setExistingAccountInfo(accountInfo) 60 } 61 catch (e) { 62 console.error(e) 63 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.") 64 } 65 } 66 67 const beginLogin = async () => { 68 if (!pubkey || !existingAccountInfo) { 69 return 70 } 71 const response = await fetch(process.env.NEXT_PUBLIC_PURPLE_API_BASE_URL + "/accounts/" + pubkey + "/request-otp", { 72 method: 'POST', 73 headers: { 74 'Content-Type': 'application/json' 75 }, 76 }) 77 if (!response.ok) { 78 setError("Failed to send OTP. Please try again later.") 79 return 80 } 81 setOTPSent(true) 82 setOTPInvalid(false) 83 setOTP("") 84 } 85 86 const completeOTP = async () => { 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 setOTP("") 145 if (npub.length > 0 && !NPUB_REGEX.test(npub)) { 146 setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" })) 147 setProfile(undefined) 148 } 149 else { 150 setNpubValidationError(null) 151 if (npub.length > 0) { 152 try { 153 const decoded = nip19.decode(npub) 154 setPubkey(decoded.data as string) 155 } 156 catch (e) { 157 setPubkey(null) 158 setNpubValidationError(intl.formatMessage({ id: "purple.login.npub-validation-error", defaultMessage: "Please enter a valid npub" })) 159 } 160 } 161 else { 162 setProfile(undefined) 163 } 164 } 165 }, [npub]) 166 167 useEffect(() => { 168 if (otp.length != 6) { 169 setOTPInvalid(false) 170 setOTPVerified(false) 171 } 172 }, [otp]) 173 174 175 // MARK: - Render 176 177 return (<> 178 <ErrorDialog error={error} setError={setError} /> 179 <PurpleLayout> 180 <h2 className="text-2xl text-left text-purple-200 font-semibold break-keep mb-2"> 181 {intl.formatMessage({ id: "purple.login.title", defaultMessage: "Access your account" })} 182 </h2> 183 <div className="flex flex-col text-center md:text-left mb-8"> 184 <span className="text-xs text-purple-200/50"> 185 {intl.formatMessage({ id: "purple.login.description", defaultMessage: "Use this page to access your Purple account details" })} 186 </span> 187 </div> 188 <Label htmlFor="npub" className="text-purple-200/70 font-normal"> 189 {intl.formatMessage({ id: "purple.login.npub-label", defaultMessage: "Please enter your public key (npub) below" })} 190 </Label> 191 <Input id="npub" placeholder={intl.formatMessage({ id: "purple.login.npub-placeholder", defaultMessage: "npub…" })} type="text" className="mt-2" value={npub} onChange={(e) => setNpub(e.target.value)} required disabled={loginSuccessful} /> 192 {npubValidationError && 193 <Label htmlFor="npub" className="text-red-500 font-normal"> 194 {npubValidationError} 195 </Label> 196 } 197 {((profile || profile === null) && pubkey) && (<> 198 <div className="mt-2 mb-4 flex flex-col items-center"> 199 {existingAccountInfo !== null && existingAccountInfo !== undefined && otpSent !== true && ( 200 <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"> 201 <Sparkles className="w-4 h-4 shrink-0 text-purple-50" /> 202 <div className="flex flex-col"> 203 <div className="text-purple-200/90 font-semibold text-md"> 204 {intl.formatMessage({ id: "purple.login.this-account-exists", defaultMessage: "Yay! We found your account" })} 205 </div> 206 </div> 207 </div> 208 )} 209 210 <div className="text-purple-200/50 font-normal text-sm"> 211 {otpSent ? intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "Logging into:" }) 212 : intl.formatMessage({ id: "purple.login.is-this-you", defaultMessage: "Is this you?" })} 213 </div> 214 <div className="mt-4 flex flex-col gap-1 items-center justify-center mb-4"> 215 <Image src={profile?.picture || ("https://robohash.org/" + (profile?.pubkey || pubkey))} width={64} height={64} className="rounded-full" alt={profile?.name || intl.formatMessage({ id: "purple.login.unknown-user", defaultMessage: "Generic user avatar" })} /> 216 <div className="text-purple-100/90 font-semibold text-lg"> 217 {profile?.name || (npub.substring(0, 8) + ":" + npub.substring(npub.length - 8))} 218 </div> 219 </div> 220 {existingAccountInfo === null && ( 221 <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"> 222 <Frown className="w-6 h-6 shrink-0 text-purple-50" /> 223 <div className="flex flex-col"> 224 <div className="text-purple-200/90 font-semibold text-md"> 225 {intl.formatMessage({ id: "purple.login.this-account-does-not-exist", defaultMessage: "This account does not exist" })} 226 </div> 227 <Link className="text-purple-200/90 font-normal text-sm underline" href="/purple/checkout" target="_blank"> 228 {intl.formatMessage({ id: "purple.login.create-account", defaultMessage: "Join Purple today" })} 229 </Link> 230 </div> 231 </div> 232 )} 233 {existingAccountInfo !== null && !otpSent && ( 234 <Button variant="default" className="w-full" onClick={() => beginLogin()}>Continue</Button> 235 )} 236 {otpSent && (<> 237 <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"> 238 <div className="flex flex-col items-center"> 239 <Mail className="w-10 h-10 shrink-0 text-purple-100 mb-3" /> 240 <div className="text-purple-200/90 font-semibold text-md whitespace-pre-line text-center"> 241 {intl.formatMessage({ id: "purple.login.otp-sent", defaultMessage: "We sent you a code via a Nostr DM.\n Please enter it below" })} 242 </div> 243 </div> 244 </div> 245 <div className="mx-auto flex justify-center mb-4"> 246 <InputOTP6Digits value={otp} onChange={setOTP} onComplete={() => completeOTP()} disabled={loginSuccessful} /> 247 </div> 248 {otpInvalid && (<div className="my-4 w-full flex flex-col gap-2"> 249 <div className="text-red-500 font-normal text-sm text-center"> 250 {intl.formatMessage({ id: "purple.login.otp-invalid", defaultMessage: "Invalid or expired OTP. Please try again." })} 251 </div> 252 <Button variant="default" className="w-full" onClick={() => beginLogin()}>Resend OTP</Button> 253 </div>)} 254 <div className="text-purple-200/70 text-normal text-left font-semibold flex flex-col md:flex-row gap-3 rounded-lg bg-purple-200/10 p-3 items-center md:items-start"> 255 <Info className="w-6 h-6 shrink-0 mt-0 md:mt-1" /> 256 <div className="flex flex-col text-center md:text-left"> 257 <span className="text-normal md:text-lg mb-2"> 258 {intl.formatMessage({ id: "purple.login.stay-safe.title", defaultMessage: "Stay safe" })} 259 </span> 260 <span className="text-xs text-purple-200/50 whitespace-pre-line"> 261 {intl.formatMessage({ id: "purple.login.stay-safe.message", defaultMessage: "We will never ask you for your nsec or any other sensitive information via Nostr DMs. Beware of impersonators. Please do not share your OTP code with anyone.\n\n If you don't see the OTP code, please check the DM requests tab in Damus." })} 262 </span> 263 </div> 264 </div> 265 {loginSuccessful && (<> 266 <div className="flex flex-col justify-center items-center gap-2 mt-8"> 267 <CheckCircle className="w-12 h-12 shrink-0 text-green-500" /> 268 <div className="text-white text-sm text-center text-purple-200/80 mb-4"> 269 {intl.formatMessage({ id: "purple.login.login-successful", defaultMessage: "Login successful. You should be automatically redirected. If not, please click the button below." })} 270 </div> 271 </div> 272 {/* Continue link */} 273 <Link href={getRedirectURL() || "/purple/account"}> 274 <Button variant="default" className="w-full"> 275 {intl.formatMessage({ id: "purple.login.continue", defaultMessage: "Continue" })} 276 </Button> 277 </Link> 278 </>)} 279 </>)} 280 </div> 281 </>)} 282 </PurpleLayout> 283 </>) 284 }