Skip to content

Commit 63c0b61

Browse files
committed
implement email verification via settings
1 parent 1066a03 commit 63c0b61

6 files changed

Lines changed: 330 additions & 18 deletions

File tree

src/api/ApiCache.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export default class ApiCache {
149149
return this.clientUser?.id
150150
}
151151

152-
updateClientUser(user: User) {
152+
updateClientUser(user: Partial<ClientUser>) {
153153
this.clientUserReactor?.[1](prev => ({ ...prev, ...user }))
154154
}
155155

src/api/Bitflags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export type PrivacyConfiguration = _Extract<typeof PrivacyConfiguration>
144144

145145
export const UserFlags = generateBitflags({
146146
BOT: 1 << 0,
147+
VERIFIED: 1 << 1,
147148
})
148149
export type UserFlags = _Extract<typeof UserFlags>
149150

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
}

src/components/ui/Modal.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import EmojiUploadModal from "../guilds/EmojiUploadModal";
3636
import EditNicknameModal from "../guilds/EditNicknameModal";
3737
import EditMemberRolesModal from "../guilds/EditMemberRolesModal";
3838
import NewConversationModal from "../friends/NewConversationModal";
39+
import EmailVerificationModal from "../settings/EmailVerificationModal";
3940

4041
export enum ModalId {
4142
NewGuild,
@@ -57,6 +58,7 @@ export enum ModalId {
5758
EditNickname,
5859
EditMemberRoles,
5960
NewConversation,
61+
EmailVerification,
6062
}
6163

6264
type ModalMapping = {
@@ -83,6 +85,7 @@ type ModalMapping = {
8385
[ModalId.EditNickname]: { guildId: bigint, memberId: bigint, current: string },
8486
[ModalId.EditMemberRoles]: { guildId: bigint, memberId: bigint },
8587
[ModalId.NewConversation]: undefined,
88+
[ModalId.EmailVerification]: undefined,
8689
}
8790

8891
type ModalDataPair = {
@@ -237,6 +240,9 @@ export function ModalProvider(props: ParentProps) {
237240
<Match when={context.id === ModalId.NewConversation}>
238241
<NewConversationModal />
239242
</Match>
243+
<Match when={context.id === ModalId.EmailVerification}>
244+
<EmailVerificationModal />
245+
</Match>
240246
</Switch>
241247
</ModalContainer>
242248
</ModalContext.Provider>

src/pages/settings/Account.tsx

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import tooltip from "../../directives/tooltip";
33
import {noop, snowflakes} from "../../utils";
44
import {Accessor, createEffect, createMemo, createSignal, createUniqueId, on, Setter, Show} from "solid-js";
55
import Icon, {IconElement} from "../../components/icons/Icon";
6+
import {ModalId, useModal} from "../../components/ui/Modal";
67
import PenToSquare from "../../components/icons/svg/PenToSquare";
78
import Check from "../../components/icons/svg/Check";
89
import Xmark from "../../components/icons/svg/Xmark";
@@ -19,6 +20,7 @@ import EyeSlash from "../../components/icons/svg/EyeSlash";
1920
import {defaultAvatar} from "../../api/ApiCache";
2021
import CakeCandles from "../../components/icons/svg/CakeCandles";
2122
import {t} from "../../i18n";
23+
import { UserFlags } from "../../api/Bitflags";
2224
noop(tooltip)
2325

2426
export enum EditingState {
@@ -63,6 +65,9 @@ export default function Account() {
6365

6466
const [usernameValue, setUsernameValue] = createSignal(clientUser().username)
6567
const [showEmail, setShowEmail] = createSignal(false)
68+
const {showModal} = useModal()
69+
70+
const isVerified = () => UserFlags.fromValue(clientUser().flags).has('VERIFIED')
6671

6772
const submitEdit = async (e: Event) => {
6873
e.preventDefault()
@@ -172,23 +177,37 @@ export default function Account() {
172177
<h2 class="pt-6 pb-3 px-2 font-title font-bold text-xl">
173178
{t("settings.user.account.credentials")}
174179
</h2>
175-
<div class="flex items-center">
176-
<div class="p-4 rounded-full bg-bg-0/80">
177-
<Icon icon={Envelope} class="w-6 h-6 fill-fg/80" />
180+
<div class="flex justify-center mobile:flex-col gap-2">
181+
<div class="flex items-center flex-grow">
182+
<div class="p-4 rounded-full bg-bg-0/80">
183+
<Icon icon={Envelope} class="w-6 h-6 fill-fg/80" />
184+
</div>
185+
<div class="flex flex-col">
186+
<h3 class="px-2 font-bold text-sm uppercase">
187+
<span class="text-fg/60">{t("settings.user.account.email")}</span>
188+
&nbsp;
189+
<Show when={!isVerified()}>
190+
<span class="text-danger">{t("settings.user.account.not_verified")}</span>
191+
</Show>
192+
</h3>
193+
<p class="px-2 text-fg/80 flex gap-x-2 items-center">
194+
{showEmail() ? clientUser().email : '********' + clientUser().email?.slice(clientUser().email?.lastIndexOf('@'))}
195+
<Icon
196+
icon={showEmail() ? Eye : EyeSlash}
197+
tooltip={t(showEmail() ? 'settings.user.account.hide_email' : 'settings.user.account.show_email')}
198+
class="w-5 h-5 fill-fg/50 cursor-pointer"
199+
onClick={() => setShowEmail(p => !p)}
200+
/>
201+
</p>
202+
</div>
178203
</div>
179-
<div class="flex flex-col flex-grow">
180-
<h3 class="px-2 font-bold text-sm uppercase text-fg/60">
181-
{t("settings.user.account.email")}
182-
</h3>
183-
<p class="px-2 text-fg/80 flex gap-x-2 items-center">
184-
{showEmail() ? clientUser().email : '********' + clientUser().email?.slice(clientUser().email?.lastIndexOf('@'))}
185-
<Icon
186-
icon={showEmail() ? Eye : EyeSlash}
187-
tooltip={t(showEmail() ? 'settings.user.account.hide_email' : 'settings.user.account.show_email')}
188-
class="w-5 h-5 fill-fg/50 cursor-pointer"
189-
onClick={() => setShowEmail(p => !p)}
190-
/>
191-
</p>
204+
<div class="flex gap-2 items-center">
205+
<button
206+
class="btn"
207+
onClick={() => showModal(ModalId.EmailVerification)}
208+
>
209+
{t(isVerified() ? 'settings.user.account.change_email' : 'settings.user.account.verify_email')}
210+
</button>
192211
</div>
193212
</div>
194213
</div>

0 commit comments

Comments
 (0)