|
| 1 | +import {ModalTemplate, useModal} from "../ui/Modal"; |
| 2 | +import Icon from "../icons/Icon"; |
| 3 | +import Envelope from "../icons/svg/Envelope"; |
| 4 | +import Check from "../icons/svg/Check"; |
| 5 | +import {createSignal, Match, Show, Switch} from "solid-js"; |
| 6 | +import {getApi} from "../../api/Api"; |
| 7 | +import {UserFlags} from "../../api/Bitflags"; |
| 8 | +import {t, tJsx} from '../../i18n' |
| 9 | + |
| 10 | +const t_ = (path: string) => t('modals.verify_email.' + path) |
| 11 | + |
| 12 | +const enum Step { |
| 13 | + Confirm, |
| 14 | + NewEmail, |
| 15 | + Sending, |
| 16 | + Code, |
| 17 | +} |
| 18 | + |
| 19 | +export default function EmailVerificationModal() { |
| 20 | + const api = getApi()! |
| 21 | + const {hideModal} = useModal() |
| 22 | + const clientUser = () => api.cache!.clientUser! |
| 23 | + const isVerified = () => UserFlags.fromValue(clientUser().flags).has('VERIFIED') |
| 24 | + |
| 25 | + const [step, setStep] = createSignal<Step>(isVerified() ? Step.NewEmail : Step.Confirm) |
| 26 | + const [error, setError] = createSignal('') |
| 27 | + const [submitting, setSubmitting] = createSignal(false) |
| 28 | + |
| 29 | + const [newEmail, setNewEmail] = createSignal('') |
| 30 | + const [password, setPassword] = createSignal('') |
| 31 | + const [code, setCode] = createSignal('') |
| 32 | + // the email we actually sent the code to |
| 33 | + const [sentTo, setSentTo] = createSignal<string | null>(null) |
| 34 | + |
| 35 | + const clearError = () => setError('') |
| 36 | + |
| 37 | + // setSentTo((clientUser().email) ?? null) |
| 38 | + // setStep(Step.Code) |
| 39 | + |
| 40 | + const requestVerification = async (overrideEmail?: string) => { |
| 41 | + setSubmitting(true) |
| 42 | + clearError() |
| 43 | + |
| 44 | + const json: Record<string, string> = {} |
| 45 | + const target = overrideEmail ?? newEmail() |
| 46 | + if (target) { |
| 47 | + json.new_email = target |
| 48 | + if (password()) json.password = password() |
| 49 | + } |
| 50 | + |
| 51 | + const response = await api.request('POST', '/auth/verify', { |
| 52 | + json: Object.keys(json).length ? json : undefined as any, |
| 53 | + }) |
| 54 | + setSubmitting(false) |
| 55 | + |
| 56 | + if (!response.ok) { |
| 57 | + setError(response.errorJsonOrThrow().message) |
| 58 | + return false |
| 59 | + } |
| 60 | + |
| 61 | + setSentTo((target || clientUser().email) ?? null) |
| 62 | + setStep(Step.Code) |
| 63 | + return true |
| 64 | + } |
| 65 | + |
| 66 | + const handleEmailCorrect = () => requestVerification() |
| 67 | + |
| 68 | + const handleNewEmailSubmit = async (e: SubmitEvent) => { |
| 69 | + e.preventDefault() |
| 70 | + await requestVerification(newEmail()) |
| 71 | + } |
| 72 | + |
| 73 | + const handleCodeSubmit = async (e: SubmitEvent) => { |
| 74 | + e.preventDefault() |
| 75 | + setSubmitting(true) |
| 76 | + clearError() |
| 77 | + |
| 78 | + const response = await api.request('POST', '/auth/verify/followup', { |
| 79 | + json: { code: code() }, |
| 80 | + }) |
| 81 | + setSubmitting(false) |
| 82 | + |
| 83 | + if (!response.ok) { |
| 84 | + setError(response.errorJsonOrThrow().message) |
| 85 | + return |
| 86 | + } |
| 87 | + |
| 88 | + api.cache?.updateClientUser({ |
| 89 | + email: sentTo() ?? undefined, |
| 90 | + flags: Number(UserFlags.fromValue(clientUser().flags).add('VERIFIED').value), |
| 91 | + }) |
| 92 | + hideModal() |
| 93 | + } |
| 94 | + |
| 95 | + return ( |
| 96 | + <ModalTemplate title={t_(isVerified() ? 'title.change' : 'title.verify')}> |
| 97 | + <div class="min-w-[320px]"> |
| 98 | + <Switch> |
| 99 | + <Match when={step() === Step.Confirm}> |
| 100 | + <p class="text-sm text-center text-fg/60 mt-2 mb-2"> |
| 101 | + {t_('confirm.description')} |
| 102 | + </p> |
| 103 | + <div class="bg-bg-0/60 rounded-lg p-4 font-mono text-center font-medium mb-2"> |
| 104 | + {clientUser().email} |
| 105 | + </div> |
| 106 | + <Show when={error()}> |
| 107 | + <p class="text-sm text-danger mb-3">{error()}</p> |
| 108 | + </Show> |
| 109 | + <div class="flex gap-x-3"> |
| 110 | + <button |
| 111 | + class="btn btn-neutral flex-1" |
| 112 | + onClick={() => { clearError(); setStep(Step.NewEmail) }} |
| 113 | + > |
| 114 | + {t_("confirm.deny")} |
| 115 | + </button> |
| 116 | + <button |
| 117 | + class="btn btn-primary flex-1" |
| 118 | + disabled={submitting()} |
| 119 | + onClick={handleEmailCorrect} |
| 120 | + > |
| 121 | + <Icon icon={Envelope} class="w-4 h-4 fill-fg mr-2" /> |
| 122 | + <Show when={submitting()} fallback={t_("confirm.accept")}> |
| 123 | + {t_("confirm.submitting")} |
| 124 | + </Show> |
| 125 | + </button> |
| 126 | + </div> |
| 127 | + </Match> |
| 128 | + |
| 129 | + <Match when={step() === Step.NewEmail}> |
| 130 | + <p class="text-sm text-center text-fg/60 mt-2 mb-5"> |
| 131 | + {t_(isVerified() ? 'new_email.description_change' : 'new_email.description_verify')} |
| 132 | + </p> |
| 133 | + <form onSubmit={handleNewEmailSubmit} class="flex flex-col gap-y-3"> |
| 134 | + <div> |
| 135 | + <label class="text-sm font-bold uppercase text-fg/50 mx-1 mb-1 block"> |
| 136 | + {t_("new_email.label")} |
| 137 | + </label> |
| 138 | + <input |
| 139 | + type="email" |
| 140 | + class="w-full bg-0 rounded-lg text-sm font-medium p-3 outline-none focus:ring-2 ring-accent" |
| 141 | + placeholder="example@adapt.chat" |
| 142 | + required |
| 143 | + value={newEmail()} |
| 144 | + onInput={e => { clearError(); setNewEmail(e.currentTarget.value) }} |
| 145 | + /> |
| 146 | + </div> |
| 147 | + <Show when={isVerified()}> |
| 148 | + <div> |
| 149 | + <label class="text-sm font-bold uppercase text-fg/50 mx-1 mb-1 block"> |
| 150 | + {t_("new_email.current_password")} |
| 151 | + </label> |
| 152 | + <input |
| 153 | + type="password" |
| 154 | + class="w-full bg-0 rounded-lg text-sm font-medium p-3 outline-none focus:ring-2 ring-accent" |
| 155 | + autocomplete="current-password" |
| 156 | + required |
| 157 | + value={password()} |
| 158 | + onInput={e => { clearError(); setPassword(e.currentTarget.value) }} |
| 159 | + /> |
| 160 | + </div> |
| 161 | + </Show> |
| 162 | + <Show when={error()}> |
| 163 | + <p class="text-sm text-danger">{error()}</p> |
| 164 | + </Show> |
| 165 | + <div class="flex gap-x-3"> |
| 166 | + <Show when={!isVerified()}> |
| 167 | + <button |
| 168 | + type="button" |
| 169 | + class="btn btn-neutral flex-1" |
| 170 | + onClick={() => { clearError(); setStep(Step.Confirm) }} |
| 171 | + > |
| 172 | + {t('generic.back')} |
| 173 | + </button> |
| 174 | + </Show> |
| 175 | + <button |
| 176 | + type="submit" |
| 177 | + class="btn btn-primary flex-1" |
| 178 | + disabled={submitting()} |
| 179 | + > |
| 180 | + <Icon icon={Envelope} class="w-4 h-4 fill-fg mr-2" /> |
| 181 | + <Show when={submitting()} fallback={t_("new_email.submit")}> |
| 182 | + {t_("confirm.submitting")} |
| 183 | + </Show> |
| 184 | + </button> |
| 185 | + </div> |
| 186 | + </form> |
| 187 | + </Match> |
| 188 | + |
| 189 | + <Match when={step() === Step.Code}> |
| 190 | + <p class="text-sm text-center text-fg/60 mt-2 mb-2"> |
| 191 | + {tJsx('modals.verify_email.code.description', { |
| 192 | + recipient: <span class="text-sm text-center font-medium mb-5">{sentTo()}</span>, |
| 193 | + })} |
| 194 | + </p> |
| 195 | + <form onSubmit={handleCodeSubmit} class="flex flex-col gap-y-3"> |
| 196 | + <input |
| 197 | + type="text" |
| 198 | + inputMode="numeric" |
| 199 | + pattern="[0-9]{6}" |
| 200 | + class="w-full bg-0 rounded-lg text-2xl font-mono font-bold tracking-[0.5em] text-center p-3 outline-none focus:ring-2 ring-accent" |
| 201 | + placeholder="000000" |
| 202 | + maxLength={6} |
| 203 | + required |
| 204 | + value={code()} |
| 205 | + onInput={e => { |
| 206 | + const digits = e.currentTarget.value.replace(/\D/g, '') |
| 207 | + e.currentTarget.value = digits |
| 208 | + clearError() |
| 209 | + setCode(digits) |
| 210 | + }} |
| 211 | + /> |
| 212 | + <Show when={error()}> |
| 213 | + <p class="text-sm text-danger">{error()}</p> |
| 214 | + </Show> |
| 215 | + <div class="flex gap-x-3"> |
| 216 | + <button |
| 217 | + type="button" |
| 218 | + class="btn btn-ghost" |
| 219 | + onClick={() => { |
| 220 | + clearError() |
| 221 | + setCode('') |
| 222 | + setStep(isVerified() ? Step.NewEmail : Step.Confirm) |
| 223 | + }} |
| 224 | + > |
| 225 | + {t('generic.back')} |
| 226 | + </button> |
| 227 | + <button |
| 228 | + type="submit" |
| 229 | + class="btn btn-primary flex-1" |
| 230 | + disabled={submitting() || code().length !== 6} |
| 231 | + > |
| 232 | + <Icon icon={Check} class="w-4 h-4 fill-fg mr-2" /> |
| 233 | + <Show when={submitting()} fallback={t_('code.submit')}> |
| 234 | + {t_('code.submitting')} |
| 235 | + </Show> |
| 236 | + </button> |
| 237 | + </div> |
| 238 | + </form> |
| 239 | + {/* |
| 240 | + Leaving this out for now because of the 60s ratelimit |
| 241 | +
|
| 242 | + <button |
| 243 | + class="text-xs text-fg/40 hover:text-fg/70 transition mt-3 w-full text-center" |
| 244 | + onClick={() => { |
| 245 | + clearError() |
| 246 | + // re-send to same address |
| 247 | + const target = sentTo() !== clientUser().email ? sentTo()! : undefined |
| 248 | + requestVerification(target ?? undefined) |
| 249 | + }} |
| 250 | + > |
| 251 | + Didn't receive a code? Resend |
| 252 | + </button> |
| 253 | + */} |
| 254 | + </Match> |
| 255 | + </Switch> |
| 256 | + </div> |
| 257 | + </ModalTemplate> |
| 258 | + ) |
| 259 | +} |
0 commit comments