feat: ability to update email (#5370)

* chore: update email

* wip: verify email endpoint

* wip: implement success screen

* wip: social warning

* Update form for email update

* Update email form UI

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/5417/head
Arik Chakma 8 months ago committed by GitHub
parent 4e96a58e54
commit 730af9b973
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      package.json
  2. 8
      pnpm-lock.yaml
  3. 82
      src/components/AuthenticationFlow/TriggerVerifyEmail.tsx
  4. 57
      src/components/ProfileSettings/ProfileSettingsPage.tsx
  5. 245
      src/components/UpdateEmail/UpdateEmailForm.tsx
  6. 84
      src/components/UpdatePassword/UpdatePasswordForm.tsx
  7. 5
      src/components/UpdateProfile/UpdateProfileForm.tsx
  8. 4
      src/pages/account/settings.astro
  9. 10
      src/pages/verify-email.astro

@ -41,7 +41,7 @@
"image-size": "^1.1.1",
"jose": "^5.2.2",
"js-cookie": "^3.0.5",
"lucide-react": "^0.334.0",
"lucide-react": "^0.358.0",
"nanoid": "^5.0.5",
"nanostores": "^0.9.5",
"node-html-parser": "^6.1.12",

@ -60,8 +60,8 @@ dependencies:
specifier: ^3.0.5
version: 3.0.5
lucide-react:
specifier: ^0.334.0
version: 0.334.0(react@18.2.0)
specifier: ^0.358.0
version: 0.358.0(react@18.2.0)
nanoid:
specifier: ^5.0.5
version: 5.0.5
@ -4236,8 +4236,8 @@ packages:
engines: {node: '>=12'}
dev: false
/lucide-react@0.334.0(react@18.2.0):
resolution: {integrity: sha512-y0Rv/Xx6qAq4FutZ3L/efl3O9vl6NC/1p0YOg6mBfRbQ4k1JCE2rz0rnV7WC8Moxq1RY99vLATvjcqUegGJTvA==}
/lucide-react@0.358.0(react@18.2.0):
resolution: {integrity: sha512-rBSptRjZTMBm24zsFhR6pK/NgbT18JegZGKcH4+1H3+UigMSRpeoWLtR/fAwMYwYnlJOZB+y8WpeHne9D6X6Kg==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0
dependencies:

@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { httpPatch } from '../../lib/http';
import { setAuthToken } from '../../lib/jwt';
import { Spinner } from '../ReactIcons/Spinner';
import { ErrorIcon2 } from '../ReactIcons/ErrorIcon2';
import { getUrlParams } from '../../lib/browser';
import { CheckIcon } from '../ReactIcons/CheckIcon';
export function TriggerVerifyEmail() {
const { code } = getUrlParams() as { code: string };
// const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<'loading' | 'error' | 'success'>(
'loading',
);
const [error, setError] = useState('');
const triggerVerify = (code: string) => {
setStatus('loading');
httpPatch<{ token: string }>(
`${import.meta.env.PUBLIC_API_URL}/v1-verify-new-email/${code}`,
{},
)
.then(({ response, error }) => {
if (!response?.token) {
setError(error?.message || 'Something went wrong. Please try again.');
setStatus('error');
return;
}
setAuthToken(response.token);
setStatus('success');
})
.catch((err) => {
setStatus('error');
setError('Something went wrong. Please try again.');
});
};
useEffect(() => {
if (!code) {
setStatus('error');
setError('Something went wrong. Please try again later.');
return;
}
triggerVerify(code);
}, [code]);
const isLoading = status === 'loading';
if (status === 'success') {
return (
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
<CheckIcon additionalClasses={'h-16 w-16 opacity-100'} />
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
Email Update Successful
</h2>
<p className="text-sm sm:text-base">
Your email has been changed successfully. Happy learning!
</p>
</div>
);
}
return (
<div className="mx-auto flex max-w-md flex-col items-center pt-0 sm:pt-12">
<div className="mx-auto max-w-md text-center">
{isLoading && <Spinner className="mx-auto h-16 w-16" />}
{error && <ErrorIcon2 className="mx-auto h-16 w-16" />}
<h2 className="mb-1 mt-4 text-center text-xl font-semibold sm:mb-3 sm:mt-4 sm:text-2xl">
Verifying your new Email
</h2>
<div className="text-sm sm:text-base">
{isLoading && <p>Please wait while we verify your new Email..</p>}
{error && <p className="text-red-700">{error}</p>}
</div>
</div>
</div>
);
}

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { UpdateEmailForm } from '../UpdateEmail/UpdateEmailForm';
import UpdatePasswordForm from '../UpdatePassword/UpdatePasswordForm';
import { pageProgressMessage } from '../../stores/page';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
export function ProfileSettingsPage() {
const toast = useToast();
const [authProvider, setAuthProvider] = useState('');
const [currentEmail, setCurrentEmail] = useState('');
const [newEmail, setNewEmail] = useState('');
const loadProfile = async () => {
const { error, response } = await httpGet(
`${import.meta.env.PUBLIC_API_URL}/v1-me`,
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
const { authProvider, email, newEmail } = response;
setAuthProvider(authProvider);
setCurrentEmail(email);
setNewEmail(newEmail || '');
};
useEffect(() => {
loadProfile().finally(() => {
pageProgressMessage.set('');
});
}, []);
return (
<>
<UpdatePasswordForm authProvider={authProvider} />
<hr className="my-8" />
<UpdateEmailForm
authProvider={authProvider}
currentEmail={currentEmail}
newEmail={newEmail}
key={newEmail}
onSendVerificationCode={(newEmail) => {
setNewEmail(newEmail);
loadProfile().finally(() => {});
}}
onVerificationCancel={() => {
loadProfile().finally(() => {});
}}
/>
</>
);
}

@ -0,0 +1,245 @@
import { type FormEvent, useState } from 'react';
import { httpPatch } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
import { cn } from '../../lib/classname';
import { ArrowUpRight, X } from 'lucide-react';
type UpdateEmailFormProps = {
authProvider: string;
currentEmail: string;
newEmail?: string;
onSendVerificationCode?: (newEmail: string) => void;
onVerificationCancel?: () => void;
};
export function UpdateEmailForm(props: UpdateEmailFormProps) {
const {
authProvider,
currentEmail,
newEmail: defaultNewEmail = '',
onSendVerificationCode,
onVerificationCancel,
} = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(defaultNewEmail !== '');
const [newEmail, setNewEmail] = useState(defaultNewEmail);
const [isResendDone, setIsResendDone] = useState(false);
const handleSentVerificationCode = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!newEmail || !newEmail.includes('@') || isSubmitted) {
return;
}
setIsLoading(true);
pageProgressMessage.set('Sending verification code');
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-update-user-email`,
{ email: newEmail },
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
pageProgressMessage.set('');
return;
}
pageProgressMessage.set('');
setIsLoading(false);
setIsSubmitted(true);
onSendVerificationCode?.(newEmail);
};
const handleResendVerificationCode = async () => {
if (isResendDone) {
toast.error('You have already resent the verification code');
return;
}
setIsLoading(true);
pageProgressMessage.set('Resending verification code');
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-resend-email-verification-code`,
{ email: newEmail },
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
pageProgressMessage.set('');
return;
}
toast.success('Verification code has been resent');
pageProgressMessage.set('');
setIsResendDone(true);
setIsLoading(false);
};
const handleCancelEmailVerification = async () => {
setIsLoading(true);
pageProgressMessage.set('Cancelling email verification');
const { response, error } = await httpPatch(
`${import.meta.env.PUBLIC_API_URL}/v1-cancel-email-verification`,
{},
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
pageProgressMessage.set('');
return;
}
pageProgressMessage.set('');
onVerificationCancel?.();
setIsSubmitted(false);
setNewEmail('');
setIsLoading(false);
};
if (authProvider && authProvider !== 'email') {
return (
<div className="block">
<h2 className="text-xl font-bold sm:text-2xl">Update Email</h2>
<p className="mt-2 text-gray-400">
You have used {authProvider} when signing up. Please set your password
first.
</p>
<div className="mt-4 flex w-full flex-col">
<label
htmlFor="current-email"
className="text-sm leading-none text-slate-500"
>
Current Email
</label>
<input
type="email"
name="current-email"
id="current-email"
autoComplete="current-email"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
disabled
value={currentEmail}
/>
</div>
<p className="mt-3 rounded-lg border border-red-600 px-2 py-1 text-red-600">
Please set your password first to update your email.
</p>
</div>
);
}
return (
<>
<div className="mb-8 block">
<h2 className="text-xl font-bold sm:text-2xl">Update Email</h2>
<p className="mt-2 text-gray-400">
Use the form below to update your email.
</p>
</div>
<form onSubmit={handleSentVerificationCode} className="space-y-4">
<div className="flex w-full flex-col">
<label
htmlFor="current-email"
className="text-sm leading-none text-slate-500"
>
Current Email
</label>
<input
type="email"
name="current-email"
id="current-email"
autoComplete="current-email"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
disabled
value={currentEmail}
/>
</div>
<div
className={cn('flex w-full flex-col', {
'rounded-lg border border-green-500 p-3': isSubmitted,
})}
>
<div className="flex items-center justify-between gap-2">
<label
htmlFor="new-email"
className="text-sm leading-none text-slate-500"
>
New Email
</label>
{isSubmitted && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleResendVerificationCode}
disabled={isLoading || isResendDone}
className="flex items-center gap-1 text-sm font-medium leading-none text-green-600 transition-colors hover:text-green-700"
>
<span className="hidden sm:block">
Resend Verification Link
</span>
<span className="sm:hidden">Resend Code</span>
</button>
</div>
)}
</div>
<input
type="email"
name="new-email"
id="new-email"
autoComplete={'new-email'}
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
placeholder="Enter new email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
disabled={isSubmitted}
/>
{!isSubmitted && (
<button
type="submit"
disabled={
isLoading || !newEmail || !newEmail.includes('@') || isSubmitted
}
className="mt-3 inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Send Verification Link'}
</button>
)}
{isSubmitted && (
<>
<button
type="button"
onClick={handleCancelEmailVerification}
disabled={isLoading}
className="font-regular mt-4 w-full rounded-lg border border-red-600 py-2 text-sm text-red-600 outline-none transition-colors hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-red-500 focus:ring-offset-1"
>
Cancel Update
</button>
<div className="mt-3 flex items-center gap-2 rounded-lg bg-green-100 p-4">
<span className="text-sm text-green-800">
A verification link has been sent to your{' '}
<span>new email address</span>. Please follow the instructions
in email to verify and update your email.
</span>
</div>
</>
)}
</div>
</form>
</>
);
}

@ -1,26 +1,28 @@
import { type FormEvent, useEffect, useState } from 'react';
import { httpGet, httpPost } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import { type FormEvent, useState } from 'react';
import { httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
type UpdatePasswordFormProps = {
authProvider: string;
};
export default function UpdatePasswordForm(props: UpdatePasswordFormProps) {
const { authProvider } = props;
const toast = useToast();
export default function UpdatePasswordForm() {
const [authProvider, setAuthProvider] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newPasswordConfirmation, setNewPasswordConfirmation] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
setSuccess('');
if (newPassword !== newPasswordConfirmation) {
setError('Passwords do not match');
toast.error('Passwords do not match');
setIsLoading(false);
return;
@ -32,50 +34,26 @@ export default function UpdatePasswordForm() {
oldPassword: authProvider === 'email' ? currentPassword : 'social-auth',
password: newPassword,
confirmPassword: newPasswordConfirmation,
}
},
);
if (error) {
setError(error.message || 'Something went wrong');
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
setError('');
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirmation('');
setSuccess('Password updated successfully');
toast.success('Password updated successfully');
setIsLoading(false);
setTimeout(() => {
window.location.reload();
}, 1000);
};
const loadProfile = async () => {
setIsLoading(true);
const { error, response } = await httpGet(
`${import.meta.env.PUBLIC_API_URL}/v1-me`
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
const { authProvider } = response;
setAuthProvider(authProvider);
setIsLoading(false);
};
useEffect(() => {
loadProfile().finally(() => {
pageProgressMessage.set('');
});
}, []);
return (
<form onSubmit={handleSubmit}>
<div className="mb-8 hidden md:block">
@ -98,7 +76,7 @@ export default function UpdatePasswordForm() {
type="password"
name="current-password"
id="current-password"
autoComplete={"current-password"}
autoComplete={'current-password'}
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-100"
required
minLength={6}
@ -122,7 +100,7 @@ export default function UpdatePasswordForm() {
type="password"
name="new-password"
id="new-password"
autoComplete={"new-password"}
autoComplete={'new-password'}
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
required
minLength={6}
@ -145,7 +123,7 @@ export default function UpdatePasswordForm() {
name="new-password-confirmation"
id="new-password-confirmation"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
autoComplete={"new-password"}
autoComplete={'new-password'}
required
minLength={6}
placeholder="Confirm New Password"
@ -156,19 +134,11 @@ export default function UpdatePasswordForm() {
/>
</div>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
{success && (
<p className="mt-2 rounded-lg bg-green-100 p-2 text-green-700">
{success}
</p>
)}
<button
type="submit"
disabled={isLoading}
disabled={
isLoading || !newPassword || newPassword !== newPasswordConfirmation
}
className="inline-flex w-full items-center justify-center rounded-lg bg-black p-2 py-3 text-sm font-medium text-white outline-none focus:ring-2 focus:ring-black focus:ring-offset-1 disabled:bg-gray-400"
>
{isLoading ? 'Please wait...' : 'Update Password'}

@ -30,7 +30,7 @@ export function UpdateProfileForm() {
linkedin: linkedin || undefined,
twitter: twitter || undefined,
website: website || undefined,
}
},
);
if (error || !response) {
@ -45,11 +45,10 @@ export function UpdateProfileForm() {
};
const loadProfile = async () => {
// Set the loading state
setIsLoading(true);
const { error, response } = await httpGet(
`${import.meta.env.PUBLIC_API_URL}/v1-me`
`${import.meta.env.PUBLIC_API_URL}/v1-me`,
);
if (error || !response) {

@ -1,8 +1,8 @@
---
import AccountSidebar from '../../components/AccountSidebar.astro';
import UpdatePasswordForm from '../../components/UpdatePassword/UpdatePasswordForm';
import AccountLayout from '../../layouts/AccountLayout.astro';
import DeleteAccount from '../../components/DeleteAccount/DeleteAccount.astro';
import { ProfileSettingsPage } from '../../components/ProfileSettings/ProfileSettingsPage';
---
<AccountLayout
@ -12,7 +12,7 @@ import DeleteAccount from '../../components/DeleteAccount/DeleteAccount.astro';
initialLoadingMessage={'Loading settings'}
>
<AccountSidebar activePageId='settings' activePageTitle='Settings'>
<UpdatePasswordForm client:only="react" />
<ProfileSettingsPage client:load />
<hr class='my-8' />
<DeleteAccount />
</AccountSidebar>

@ -0,0 +1,10 @@
---
import AccountLayout from '../layouts/AccountLayout.astro';
import { TriggerVerifyEmail } from '../components/AuthenticationFlow/TriggerVerifyEmail';
---
<AccountLayout title='Verify email' noIndex={true}>
<div class='container py-16'>
<TriggerVerifyEmail client:load />
</div>
</AccountLayout>
Loading…
Cancel
Save