revamp login and signup screens
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import LogoSVG from '@/assets/logo.svg'
|
||||
const Logo = () => {
|
||||
const Logo = ({ size = '128px' }) => {
|
||||
return (
|
||||
<div className='logo'>
|
||||
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
|
||||
<img src={LogoSVG} alt='logo' width={size} height={size} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
160
src/views/Authorization/AuthFields.jsx
Normal file
160
src/views/Authorization/AuthFields.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import VisibilityOffOutlined from '@mui/icons-material/VisibilityOffOutlined'
|
||||
import VisibilityOutlined from '@mui/icons-material/VisibilityOutlined'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
Link,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { authButtonSx, authInputSx } from './authStyles'
|
||||
|
||||
const labelSx = { fontSize: '0.875rem', fontWeight: 600, mb: 0.75 }
|
||||
|
||||
export const AuthField = ({ label, error, helper, children, ...formProps }) => (
|
||||
<FormControl error={Boolean(error)} {...formProps}>
|
||||
<FormLabel sx={labelSx}>{label}</FormLabel>
|
||||
{children}
|
||||
{(error || helper) && (
|
||||
<FormHelperText
|
||||
sx={{
|
||||
fontSize: '0.8125rem',
|
||||
color: error ? 'danger.plainColor' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{error || helper}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
|
||||
export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<Input size='lg' sx={{ ...authInputSx, ...sx }} {...inputProps} />
|
||||
</AuthField>
|
||||
)
|
||||
|
||||
export const AuthPasswordField = ({
|
||||
label = 'Password',
|
||||
error,
|
||||
helper,
|
||||
sx,
|
||||
...inputProps
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthField label={label} error={error} helper={helper}>
|
||||
<Input
|
||||
size='lg'
|
||||
type={visible ? 'text' : 'password'}
|
||||
sx={{ ...authInputSx, ...sx }}
|
||||
endDecorator={
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
tabIndex={-1}
|
||||
aria-label={visible ? 'Hide password' : 'Show password'}
|
||||
onClick={() => setVisible(v => !v)}
|
||||
sx={{ borderRadius: '8px' }}
|
||||
>
|
||||
{visible ? (
|
||||
<VisibilityOffOutlined fontSize='small' />
|
||||
) : (
|
||||
<VisibilityOutlined fontSize='small' />
|
||||
)}
|
||||
</IconButton>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
</AuthField>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuthSubmitButton = ({ children, sx, ...props }) => (
|
||||
<Button
|
||||
type='submit'
|
||||
size='lg'
|
||||
variant='solid'
|
||||
fullWidth
|
||||
sx={{ ...authButtonSx, ...sx }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const SocialButton = ({ icon, children, sx, ...props }) => (
|
||||
<Button
|
||||
type='button'
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
startDecorator={icon}
|
||||
sx={{
|
||||
...authButtonSx,
|
||||
fontWeight: 500,
|
||||
justifyContent: 'center',
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
export const AuthDivider = ({ children = 'or' }) => (
|
||||
<Box
|
||||
role='separator'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
my: 2.5,
|
||||
'&::before, &::after': {
|
||||
content: '""',
|
||||
flex: 1,
|
||||
height: '1px',
|
||||
bgcolor: 'divider',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
|
||||
export const LegalLinks = () => (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
<Link
|
||||
href='https://donetick.com/privacy'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
{' · '}
|
||||
<Link
|
||||
href='https://donetick.com/terms'
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
>
|
||||
Terms of Use
|
||||
</Link>
|
||||
</Typography>
|
||||
)
|
||||
120
src/views/Authorization/AuthShell.jsx
Normal file
120
src/views/Authorization/AuthShell.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Box, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
const Wordmark = () => (
|
||||
<Typography
|
||||
level='h3'
|
||||
sx={{ fontWeight: 700, letterSpacing: '-0.02em', lineHeight: 1 }}
|
||||
>
|
||||
Done
|
||||
<Box component='span' sx={{ color: 'primary.500' }}>
|
||||
tick
|
||||
</Box>
|
||||
</Typography>
|
||||
)
|
||||
|
||||
/**
|
||||
* Full-height auth layout: edge-to-edge on phones, a centered surface card from
|
||||
* the `sm` breakpoint up. The route renders without a navbar, so the shell owns
|
||||
* its own safe-area padding (the top inset is already reserved by NavBar).
|
||||
*/
|
||||
const AuthShell = ({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
footer,
|
||||
logoSize = 64,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
px: 2,
|
||||
pt: { xs: 3, sm: 5 },
|
||||
pb: 'calc(var(--safe-area-inset-bottom, 0px) + 24px)',
|
||||
bgcolor: 'background.body',
|
||||
}}
|
||||
>
|
||||
{/* my:auto centers the column without the top-clipping that
|
||||
justify-content:center causes once the form outgrows the viewport. */}
|
||||
<Box sx={{ width: '100%', maxWidth: 420, my: 'auto' }}>
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: { xs: 0, sm: '20px' },
|
||||
bgcolor: { xs: 'transparent', sm: 'background.surface' },
|
||||
border: { xs: 'none', sm: '1px solid' },
|
||||
borderColor: { sm: 'divider' },
|
||||
boxShadow: { xs: 'none', sm: 'sm' },
|
||||
p: { xs: 0, sm: 3.5 },
|
||||
animation: 'authPanelIn 240ms cubic-bezier(0.22, 1, 0.36, 1) both',
|
||||
'@keyframes authPanelIn': {
|
||||
from: { opacity: 0, transform: 'translateY(8px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{action && (
|
||||
<Box sx={{ position: 'absolute', top: 0, right: 0 }}>{action}</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Logo size={`${logoSize}px`} />
|
||||
<Wordmark />
|
||||
</Box>
|
||||
|
||||
{title && (
|
||||
<Typography
|
||||
level='h2'
|
||||
sx={{
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
textAlign: 'center',
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{subtitle && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
mt: 0.75,
|
||||
textAlign: 'center',
|
||||
color: 'text.secondary',
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: title || subtitle ? 3 : 0 }}>{children}</Box>
|
||||
</Sheet>
|
||||
|
||||
{footer && <Box sx={{ mt: 2.5 }}>{footer}</Box>}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthShell
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
|
||||
import { Box, Button, LinearProgress } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import Cookies from 'js-cookie'
|
||||
@@ -11,14 +10,16 @@ import { apiClient } from '../../utils/ApiClient'
|
||||
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
|
||||
import { GetUserProfile } from '../../utils/Fetcher'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const AuthenticationLoading = () => {
|
||||
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
|
||||
const { refetch: refetchUserProfile } = useUserProfile()
|
||||
const Navigate = useNavigate()
|
||||
const hasCalledHandleOAuth2 = useRef(false)
|
||||
const [message, setMessage] = useState('Authenticating')
|
||||
const [subMessage, setSubMessage] = useState('Please wait')
|
||||
const [message, setMessage] = useState('Signing you in')
|
||||
const [subMessage, setSubMessage] = useState('This will only take a moment.')
|
||||
const [status, setStatus] = useState('pending')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
@@ -30,14 +31,15 @@ const AuthenticationLoading = () => {
|
||||
// suppress a genuine session expiry later on.
|
||||
handleOAuth2().finally(endOAuthExchange)
|
||||
} else if (provider !== 'oauth2') {
|
||||
setMessage('Unknown Authentication Provider')
|
||||
setSubMessage('Please contact support')
|
||||
setMessage('Unknown sign-in provider')
|
||||
setSubMessage('Please contact support.')
|
||||
setStatus('error')
|
||||
}
|
||||
return endOAuthExchange
|
||||
}, [provider])
|
||||
const getUserProfileAndNavigateToHome = () => {
|
||||
GetUserProfile().then(data => {
|
||||
data.json().then(data => {
|
||||
GetUserProfile().then(response => {
|
||||
response.json().then(() => {
|
||||
refetchUserProfile().then(() => {
|
||||
// check if redirect url is set in cookie:
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
@@ -69,8 +71,8 @@ const AuthenticationLoading = () => {
|
||||
const handleMFAClose = () => {
|
||||
setMfaModalOpen(false)
|
||||
setMfaSessionToken('')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication was cancelled')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Two-factor authentication was cancelled.')
|
||||
setStatus('error')
|
||||
}
|
||||
|
||||
@@ -83,8 +85,8 @@ const AuthenticationLoading = () => {
|
||||
const storedState = localStorage.getItem('authState')
|
||||
|
||||
if (returnedState !== storedState) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('State does not match')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('The sign-in request could not be verified.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -110,8 +112,8 @@ const AuthenticationLoading = () => {
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Authentication failed')
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Please try again.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -120,22 +122,22 @@ const AuthenticationLoading = () => {
|
||||
|
||||
if (data.mfaRequired) {
|
||||
if (!data.sessionToken) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('MFA session is missing. Please try again')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('The MFA session is missing. Please try again.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
setMfaSessionToken(data.sessionToken)
|
||||
setMfaModalOpen(true)
|
||||
setMessage('Two-Factor Authentication Required')
|
||||
setSubMessage('Please verify your login to continue')
|
||||
setMessage('Two-factor authentication')
|
||||
setSubMessage('Verify your login to continue.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!data.token && !data.access_token) {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('No valid authentication token returned')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('No valid authentication token was returned.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
@@ -156,66 +158,52 @@ const AuthenticationLoading = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication request failed', error)
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Please try again')
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Please try again.')
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className='flex h-full items-center justify-center'>
|
||||
<AuthShell title={message} subtitle={subMessage}>
|
||||
<Box
|
||||
className='flex flex-col items-center justify-center'
|
||||
sx={{
|
||||
minHeight: '80vh',
|
||||
}}
|
||||
role='status'
|
||||
aria-live='polite'
|
||||
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||
>
|
||||
<CircularProgress
|
||||
determinate={status === 'error'}
|
||||
color={status === 'pending' ? 'primary' : 'danger'}
|
||||
sx={{ '--CircularProgress-size': '200px' }}
|
||||
>
|
||||
<Logo />
|
||||
</CircularProgress>
|
||||
<Box
|
||||
className='flex items-center gap-2'
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Box>
|
||||
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
|
||||
{subMessage}
|
||||
</Typography>
|
||||
{status === 'pending' && (
|
||||
<LinearProgress
|
||||
sx={{ width: '60%', '--LinearProgress-radius': '999px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Button
|
||||
component={Link}
|
||||
to='/login'
|
||||
size='lg'
|
||||
variant='outlined'
|
||||
sx={{
|
||||
mt: 4,
|
||||
}}
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
sx={authButtonSx}
|
||||
>
|
||||
<Link to='/login'>Go back Login</Link>
|
||||
Back to sign in
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={() => {
|
||||
setMessage('Authentication failed')
|
||||
setSubMessage('Two-factor authentication failed. Please try again')
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
sessionToken={mfaSessionToken}
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={() => {
|
||||
setMessage('Sign-in failed')
|
||||
setSubMessage('Two-factor authentication failed. Please try again.')
|
||||
}}
|
||||
/>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,38 @@
|
||||
// create boilerplate for ResetPasswordView:
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import MarkEmailReadOutlined from '@mui/icons-material/MarkEmailReadOutlined'
|
||||
import { Box, Button, Link, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ResetPassword } from '../../utils/Fetcher'
|
||||
import { AuthSubmitButton, AuthTextField, LegalLinks } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
|
||||
const isInvalidEmail = email =>
|
||||
!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
||||
|
||||
const ForgotPasswordView = () => {
|
||||
const navigate = useNavigate()
|
||||
const [resetStatusOk, setResetStatusOk] = useState(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailError, setEmailError] = useState(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
const validateEmail = email => {
|
||||
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
|
||||
}
|
||||
const handleSubmit = async e => {
|
||||
e?.preventDefault()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!email) {
|
||||
return setEmailError('Email is required')
|
||||
setEmailError('Email is required')
|
||||
return
|
||||
}
|
||||
|
||||
// validate email:
|
||||
if (validateEmail(email)) {
|
||||
if (isInvalidEmail(email)) {
|
||||
setEmailError('Please enter a valid email address')
|
||||
return
|
||||
}
|
||||
|
||||
if (emailError) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await ResetPassword(email)
|
||||
|
||||
@@ -64,146 +56,104 @@ const ForgotPasswordView = () => {
|
||||
title: 'Reset Failed',
|
||||
message: 'Failed to send reset email, please try again later',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate on blur/submit only; flagging a half-typed address as invalid on
|
||||
// every keystroke reads as the form yelling at you mid-word.
|
||||
const handleEmailChange = e => {
|
||||
setEmail(e.target.value)
|
||||
if (validateEmail(e.target.value)) {
|
||||
setEmailError('Please enter a valid email address')
|
||||
} else {
|
||||
if (emailError) {
|
||||
setEmailError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
const handleEmailBlur = () => {
|
||||
if (email && isInvalidEmail(email)) {
|
||||
setEmailError('Please enter a valid email address')
|
||||
}
|
||||
}
|
||||
|
||||
if (resetStatusOk !== null) {
|
||||
return (
|
||||
<AuthShell
|
||||
title='Check your email'
|
||||
subtitle={`If an account exists for ${email}, we've sent instructions for resetting your password.`}
|
||||
footer={<LegalLinks />}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<MarkEmailReadOutlined
|
||||
sx={{ fontSize: 40, color: 'primary.plainColor', mb: 2 }}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</Box>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
{resetStatusOk === null && (
|
||||
<>
|
||||
<Typography level='body2' sx={{ mb: 3 }}>
|
||||
Enter your email, and we'll send you a link to get into your
|
||||
account.
|
||||
</Typography>
|
||||
return (
|
||||
<AuthShell
|
||||
title='Reset your password'
|
||||
subtitle="Enter your email and we'll send you a link to get back into your account."
|
||||
footer={<LegalLinks />}
|
||||
>
|
||||
<Box
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<AuthTextField
|
||||
label='Email address'
|
||||
id='email'
|
||||
name='email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
placeholder='you@example.com'
|
||||
autoFocus
|
||||
value={email}
|
||||
error={emailError}
|
||||
onChange={handleEmailChange}
|
||||
onBlur={handleEmailBlur}
|
||||
/>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Email Address
|
||||
</Typography>
|
||||
<FormControl
|
||||
error={emailError !== null}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
placeholder='Enter your email address'
|
||||
type='email'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
error={emailError !== null}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormHelperText>{emailError}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Reset Password
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
color='neutral'
|
||||
>
|
||||
Back to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{resetStatusOk != null && (
|
||||
<>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ textAlign: 'center', mt: 2, mb: 3 }}
|
||||
>
|
||||
If there is an account associated with the email you entered,
|
||||
you will receive an email with instructions on how to reset your
|
||||
password.
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant='solid'
|
||||
size='lg'
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Send reset link
|
||||
</AuthSubmitButton>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Remembered it?{' '}
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
fontWeight={600}
|
||||
underline='hover'
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,23 +2,16 @@ import { Preferences } from '@capacitor/preferences'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||
import WifiIcon from '@mui/icons-material/Wifi'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { AuthSubmitButton, AuthTextField } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
@@ -138,7 +131,8 @@ const LoginSettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async e => {
|
||||
e.preventDefault()
|
||||
const trimmedURL = serverURL.trim()
|
||||
|
||||
if (trimmedURL === '') {
|
||||
@@ -192,138 +186,113 @@ const LoginSettings = () => {
|
||||
const isTesting = status === 'testing'
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<AuthShell
|
||||
title='Server settings'
|
||||
subtitle='Point the app at your own self-hosted Donetick server.'
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
component='form'
|
||||
onSubmit={handleSave}
|
||||
sx={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
<AuthTextField
|
||||
label='Server URL'
|
||||
id='serverURL'
|
||||
name='serverURL'
|
||||
inputMode='url'
|
||||
autoCapitalize='none'
|
||||
autoCorrect='off'
|
||||
spellCheck='false'
|
||||
placeholder='https://your-server:2021'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={handleURLChange}
|
||||
disabled={isTesting}
|
||||
color={
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'error'
|
||||
? 'danger'
|
||||
: 'neutral'
|
||||
}
|
||||
endDecorator={
|
||||
status === 'success' ? (
|
||||
<CheckCircleOutlineIcon color='success' fontSize='small' />
|
||||
) : status === 'error' ? (
|
||||
<ErrorOutlineIcon color='error' fontSize='small' />
|
||||
) : null
|
||||
}
|
||||
helper='Include the protocol (http:// or https://) and the port if needed. Donetick defaults to port 2021.'
|
||||
/>
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
color='danger'
|
||||
variant='soft'
|
||||
startDecorator={<ErrorOutlineIcon />}
|
||||
sx={{ mt: 2, borderRadius: '12px', alignItems: 'flex-start' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
color='success'
|
||||
variant='soft'
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, borderRadius: '12px' }}
|
||||
>
|
||||
Connected. Taking you to sign in...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'testing' && (
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
startDecorator={<WifiIcon />}
|
||||
sx={{ mt: 2, borderRadius: '12px' }}
|
||||
>
|
||||
Testing connection to server...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AuthSubmitButton
|
||||
loading={isTesting}
|
||||
disabled={status === 'success'}
|
||||
startDecorator={isTesting ? <CircularProgress size='sm' /> : null}
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{isTesting ? 'Testing connection' : 'Save & connect'}
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
disabled={isTesting}
|
||||
sx={{ ...authButtonSx, mt: 1 }}
|
||||
onClick={async () => {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
Server URL
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='serverURL'
|
||||
name='serverURL'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={handleURLChange}
|
||||
disabled={isTesting}
|
||||
color={
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'error'
|
||||
? 'danger'
|
||||
: 'neutral'
|
||||
}
|
||||
endDecorator={
|
||||
status === 'success' ? (
|
||||
<CheckCircleOutlineIcon color='success' fontSize='small' />
|
||||
) : status === 'error' ? (
|
||||
<ErrorOutlineIcon color='error' fontSize='small' />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Change the server URL to connect to a different server, such as your
|
||||
own self-hosted Donetick server.
|
||||
</Typography>
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Include the protocol (http:// or https://) and port if necessary
|
||||
(default Donetick port is 2021).
|
||||
</Typography>
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
color='danger'
|
||||
variant='soft'
|
||||
startDecorator={<ErrorOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
color='success'
|
||||
variant='soft'
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Connected! Redirecting to login...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'testing' && (
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
startDecorator={<WifiIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Testing connection to server...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
disabled={isTesting || status === 'success'}
|
||||
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
|
||||
onClick={handleSave}
|
||||
startDecorator={
|
||||
isTesting ? <CircularProgress size='sm' /> : undefined
|
||||
}
|
||||
>
|
||||
{isTesting ? 'Testing...' : 'Save & Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
disabled={isTesting}
|
||||
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
|
||||
onClick={async () => {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
}}
|
||||
>
|
||||
Cancel and Reset
|
||||
</Button>
|
||||
</Sheet>
|
||||
Reset to default server
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ mt: 2.5, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Changing the server clears locally cached data on this device.
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,10 @@ import { Capacitor } from '@capacitor/core'
|
||||
import { Device } from '@capacitor/device'
|
||||
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
|
||||
import { SocialLogin } from '@capgo/capacitor-social-login'
|
||||
import { Settings } from '@mui/icons-material'
|
||||
import { SettingsOutlined } from '@mui/icons-material'
|
||||
import AppleIcon from '@mui/icons-material/Apple'
|
||||
import GoogleIcon from '@mui/icons-material/Google'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
Sheet,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Avatar, Box, Button, IconButton, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -28,25 +14,83 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||
import {
|
||||
AuthDivider,
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
AuthTextField,
|
||||
LegalLinks,
|
||||
SocialButton,
|
||||
} from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
import MFAVerificationModal from './MFAVerificationModal'
|
||||
|
||||
const SegmentedControl = ({ value, onChange, options }) => (
|
||||
<Box
|
||||
role='tablist'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
p: 0.5,
|
||||
gap: 0.5,
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
mb: 2.5,
|
||||
}}
|
||||
>
|
||||
{options.map(option => {
|
||||
const selected = option.value === value
|
||||
return (
|
||||
<Box
|
||||
key={option.value}
|
||||
component='button'
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
sx={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '9px',
|
||||
py: 1,
|
||||
fontSize: '0.875rem',
|
||||
fontFamily: 'inherit',
|
||||
fontWeight: 600,
|
||||
color: selected ? 'text.primary' : 'text.secondary',
|
||||
bgcolor: selected ? 'background.surface' : 'transparent',
|
||||
boxShadow: selected ? 'xs' : 'none',
|
||||
transition: 'background-color 180ms ease, color 180ms ease',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.500',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
|
||||
const LoginView = () => {
|
||||
// Use React Query client directly to invalidate the user profile query
|
||||
const queryClient = useQueryClient()
|
||||
// const [userProfile, setUserProfile] = useState(null)
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [mfaModalOpen, setMfaModalOpen] = useState(false)
|
||||
const [mfaSessionToken, setMfaSessionToken] = useState('')
|
||||
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Child login state
|
||||
const [loginType, setLoginType] = useState('primary')
|
||||
@@ -54,7 +98,7 @@ const LoginView = () => {
|
||||
const [childName, setChildName] = useState('')
|
||||
|
||||
// Clear fields when switching login modes
|
||||
const handleLoginModeChange = (event, newValue) => {
|
||||
const handleLoginModeChange = newValue => {
|
||||
setLoginType(newValue)
|
||||
setUsername('')
|
||||
setParentUsername('')
|
||||
@@ -94,7 +138,6 @@ const LoginView = () => {
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
setUserProfile(user)
|
||||
Navigate('/chores')
|
||||
}
|
||||
}, [isAuthenticated, user, Navigate])
|
||||
@@ -141,7 +184,19 @@ const LoginView = () => {
|
||||
? buildChildUsername(parentUsername, childName)
|
||||
: username
|
||||
|
||||
const result = await authLogin({ username: actualUsername, password })
|
||||
setIsSubmitting(true)
|
||||
let result
|
||||
try {
|
||||
result = await authLogin({ username: actualUsername, password })
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Login Failed',
|
||||
message: error?.message || 'An error occurred, please try again',
|
||||
})
|
||||
return
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
if (result.data?.mfaRequired) {
|
||||
@@ -352,473 +407,261 @@ const LoginView = () => {
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
const displayName = userProfile?.displayName || userProfile?.username
|
||||
const showSocialLogin = import.meta.env.VITE_IS_SELF_HOSTED !== 'true'
|
||||
const hasSocialOptions =
|
||||
showSocialLogin || Boolean(resource?.identity_provider?.client_id)
|
||||
|
||||
// make content center in the middle of the page:
|
||||
return (
|
||||
<AuthShell
|
||||
title={userProfile ? 'Welcome back' : 'Sign in'}
|
||||
subtitle={
|
||||
userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
}
|
||||
footer={<LegalLinks />}
|
||||
action={
|
||||
Capacitor.isNativePlatform() ? (
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
aria-label='Server settings'
|
||||
onClick={() => Navigate('/login/settings')}
|
||||
>
|
||||
<SettingsOutlined />
|
||||
</IconButton>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
{userProfile ? (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<IconButton
|
||||
// on top right of the screen:
|
||||
sx={{ position: 'absolute', top: 2, right: 2, color: 'black' }}
|
||||
onClick={() => {
|
||||
Navigate('/login/settings')
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
<Settings />
|
||||
</IconButton>
|
||||
)}
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
|
||||
{userProfile && (
|
||||
<>
|
||||
<Avatar
|
||||
src={userProfile?.image}
|
||||
alt={userProfile?.username}
|
||||
size='lg'
|
||||
sx={{ mt: 2, width: '96px', height: '96px', mb: 1 }}
|
||||
/>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Welcome back,{' '}
|
||||
{userProfile?.displayName || userProfile?.username}
|
||||
{getUserDisplayInfo(userProfile).userType === 'child' && (
|
||||
<Typography
|
||||
component='span'
|
||||
level='body-xs'
|
||||
color='neutral'
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
(Sub Account)
|
||||
</Typography>
|
||||
)}
|
||||
<Avatar
|
||||
src={userProfile?.image}
|
||||
alt={displayName}
|
||||
sx={{ width: 88, height: 88 }}
|
||||
/>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Typography level='title-md'>{displayName}</Typography>
|
||||
{getUserDisplayInfo(userProfile).userType === 'child' && (
|
||||
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
|
||||
Sub Account
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={() => {
|
||||
getUserProfileAndNavigateToHome()
|
||||
}}
|
||||
>
|
||||
Continue as {userProfile.displayName || userProfile.username}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
apiClient.handleLogout()
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!userProfile && (
|
||||
<>
|
||||
<Typography level='body2' sx={{ mb: 3 }}>
|
||||
Sign in to your account to continue
|
||||
</Typography>
|
||||
|
||||
{/* Login Type Tabs */}
|
||||
<Tabs
|
||||
value={loginType}
|
||||
onChange={handleLoginModeChange}
|
||||
sx={{ width: '100%', mb: 3 }}
|
||||
>
|
||||
<TabList
|
||||
sx={{
|
||||
width: '100%',
|
||||
p: 0.5,
|
||||
borderBottom: 'none',
|
||||
boxShadow: 'none',
|
||||
'&::after': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value='primary'
|
||||
variant='plain'
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Primary Account
|
||||
</Tab>
|
||||
<Tab
|
||||
value='sub'
|
||||
variant='plain'
|
||||
sx={{
|
||||
flex: 1,
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Sub Account
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel value='primary' sx={{ p: 0, mt: 2 }}>
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
label='Email Address'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value='sub' sx={{ p: 0, mt: 2 }}>
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Primary Account Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='parentUsername'
|
||||
name='parentUsername'
|
||||
placeholder='Enter primary account username'
|
||||
autoFocus
|
||||
value={parentUsername}
|
||||
onChange={e => {
|
||||
setParentUsername(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Typography level='body2' alignSelf={'start'} mt={1} mb={1}>
|
||||
Sub Account Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='childName'
|
||||
name='childName'
|
||||
placeholder='Enter sub account name'
|
||||
value={childName}
|
||||
onChange={e => {
|
||||
setChildName(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mb={1}>
|
||||
Password:
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='password'
|
||||
label='Password'
|
||||
type='password'
|
||||
id='password'
|
||||
autoComplete='password'
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={handleForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Divider> or </Divider>
|
||||
{import.meta.env.VITE_IS_SELF_HOSTED !== 'true' && (
|
||||
<>
|
||||
{!Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<LoginSocialGoogle
|
||||
client_id={GOOGLE_CLIENT_ID}
|
||||
redirect_uri={REDIRECT_URL}
|
||||
scope='openid profile email'
|
||||
discoveryDocs='claims_supported'
|
||||
access_type='online'
|
||||
isOnlyGetToken={true}
|
||||
onResolve={({ provider, data }) => {
|
||||
loggedWithProvider(provider, data)
|
||||
}}
|
||||
onReject={() => {
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Google, please try again",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
fullWidth
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</div>
|
||||
</Button>
|
||||
</LoginSocialGoogle>
|
||||
|
||||
{/* <Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
backgroundColor: 'black',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button> */}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
const user = await SocialLogin.login({
|
||||
provider: 'google',
|
||||
options: { scopes: ['profile', 'email', 'openid'] },
|
||||
})
|
||||
console.log('Google user', user)
|
||||
loggedWithProvider('google', user.result)
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error)
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: `Couldn't log in with Google, please try again${
|
||||
error?.message ? `: ${error.message}` : ''
|
||||
}`,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<GoogleIcon />
|
||||
Continue with Google
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Apple Sign In Button for Native Platforms */}
|
||||
{isAppleSignInSupported && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
sx={{
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
state: 'random_string',
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message:
|
||||
"Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2'>
|
||||
<AppleIcon />
|
||||
Continue with Apple
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{resource?.identity_provider?.client_id && (
|
||||
<Button
|
||||
fullWidth
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={handleAuthentikLogin}
|
||||
>
|
||||
Continue with {resource?.identity_provider?.name}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!resource?.is_user_creation_disabled && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Navigate('/signup')
|
||||
}}
|
||||
fullWidth
|
||||
variant='soft'
|
||||
size='lg'
|
||||
// sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Create new account
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/privacy', '_blank')
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/terms', '_blank')
|
||||
}}
|
||||
>
|
||||
Terms of Use
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Sheet>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ ...authButtonSx, mt: 1 }}
|
||||
onClick={getUserProfileAndNavigateToHome}
|
||||
>
|
||||
Continue as {displayName}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => apiClient.handleLogout()}
|
||||
>
|
||||
Use a different account
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<SegmentedControl
|
||||
value={loginType}
|
||||
onChange={handleLoginModeChange}
|
||||
options={[
|
||||
{ value: 'primary', label: 'Primary Account' },
|
||||
{ value: 'sub', label: 'Sub Account' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{loginType === 'primary' ? (
|
||||
<AuthTextField
|
||||
label='Username'
|
||||
id='username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
placeholder='Your username'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<AuthTextField
|
||||
label='Primary account username'
|
||||
id='parentUsername'
|
||||
name='parentUsername'
|
||||
autoComplete='username'
|
||||
placeholder='Enter primary account username'
|
||||
autoFocus
|
||||
value={parentUsername}
|
||||
onChange={e => setParentUsername(e.target.value)}
|
||||
/>
|
||||
<AuthTextField
|
||||
label='Sub account name'
|
||||
id='childName'
|
||||
name='childName'
|
||||
placeholder='Enter sub account name'
|
||||
value={childName}
|
||||
onChange={e => setChildName(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<AuthPasswordField
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='current-password'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
underline='hover'
|
||||
onClick={handleForgotPassword}
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 3 }}>
|
||||
{loginType === 'sub' ? 'Sign in as sub account' : 'Sign in'}
|
||||
</AuthSubmitButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasSocialOptions && <AuthDivider>or continue with</AuthDivider>}
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{showSocialLogin && !Capacitor.isNativePlatform() && (
|
||||
<LoginSocialGoogle
|
||||
client_id={GOOGLE_CLIENT_ID}
|
||||
redirect_uri={REDIRECT_URL}
|
||||
scope='openid profile email'
|
||||
discoveryDocs='claims_supported'
|
||||
access_type='online'
|
||||
isOnlyGetToken={true}
|
||||
onResolve={({ provider, data }) => {
|
||||
loggedWithProvider(provider, data)
|
||||
}}
|
||||
onReject={() => {
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: "Couldn't log in with Google, please try again",
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SocialButton icon={<GoogleIcon />}>Google</SocialButton>
|
||||
</LoginSocialGoogle>
|
||||
)}
|
||||
|
||||
{showSocialLogin && Capacitor.isNativePlatform() && (
|
||||
<>
|
||||
<SocialButton
|
||||
icon={<GoogleIcon />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const user = await SocialLogin.login({
|
||||
provider: 'google',
|
||||
options: { scopes: ['profile', 'email', 'openid'] },
|
||||
})
|
||||
console.log('Google user', user)
|
||||
loggedWithProvider('google', user.result)
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error)
|
||||
showError({
|
||||
title: 'Google Login Failed',
|
||||
message: `Couldn't log in with Google, please try again${
|
||||
error?.message ? `: ${error.message}` : ''
|
||||
}`,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Google
|
||||
</SocialButton>
|
||||
|
||||
{isAppleSignInSupported && (
|
||||
<SocialButton
|
||||
icon={<AppleIcon />}
|
||||
onClick={() => {
|
||||
SocialLogin.login({
|
||||
provider: 'apple',
|
||||
options: {
|
||||
scopes: ['email', 'name'],
|
||||
state: 'random_string',
|
||||
},
|
||||
})
|
||||
.then(user => {
|
||||
console.log('Apple user', user)
|
||||
loggedWithProvider('apple', user)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Apple login error:', error)
|
||||
showError({
|
||||
title: 'Apple Login Failed',
|
||||
message: "Couldn't log in with Apple, please try again",
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
Apple
|
||||
</SocialButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{resource?.identity_provider?.client_id && (
|
||||
<SocialButton onClick={handleAuthentikLogin}>
|
||||
{resource?.identity_provider?.name}
|
||||
</SocialButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!userProfile && !resource?.is_user_creation_disabled && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
fontWeight={600}
|
||||
underline='hover'
|
||||
onClick={() => Navigate('/signup')}
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<MFAVerificationModal
|
||||
open={mfaModalOpen}
|
||||
onClose={handleMFAClose}
|
||||
@@ -826,7 +669,7 @@ const LoginView = () => {
|
||||
onSuccess={handleMFASuccess}
|
||||
onError={handleMFAError}
|
||||
/>
|
||||
</Container>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Security, Smartphone } from '@mui/icons-material'
|
||||
import { Alert, Box, Input, Link, Stack, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { VerifyMFA } from '../../utils/Fetcher'
|
||||
import { authInputSx } from './authStyles'
|
||||
|
||||
const MFAVerificationModal = ({
|
||||
open,
|
||||
@@ -18,6 +18,7 @@ const MFAVerificationModal = ({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!verificationCode.trim()) {
|
||||
setError('Please enter a verification code')
|
||||
@@ -41,6 +42,8 @@ const MFAVerificationModal = ({
|
||||
onError?.(message)
|
||||
}
|
||||
} catch (error) {
|
||||
// A wrong code is shown inline; a failed request is escalated to the
|
||||
// caller so it can surface a toast instead of looking like a bad code.
|
||||
const message = 'Failed to verify code. Please try again.'
|
||||
setError(message)
|
||||
onError?.(message)
|
||||
@@ -58,8 +61,9 @@ const MFAVerificationModal = ({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleKeyPress = e => {
|
||||
const handleKeyDown = e => {
|
||||
if (e.key === 'Enter' && !loading) {
|
||||
e.preventDefault()
|
||||
handleVerify()
|
||||
}
|
||||
}
|
||||
@@ -69,8 +73,12 @@ const MFAVerificationModal = ({
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size='md'
|
||||
title='Two-Factor Authentication'
|
||||
description='Enter the verification code from your authenticator app.'
|
||||
title='Two-factor authentication'
|
||||
description={
|
||||
isBackupCode
|
||||
? 'Enter one of the backup codes you saved when setting up two-factor authentication.'
|
||||
: 'Enter the 6-digit code from your authenticator app.'
|
||||
}
|
||||
closeOnBackdrop={!loading}
|
||||
closeOnEscape={!loading}
|
||||
footer={
|
||||
@@ -89,67 +97,79 @@ const MFAVerificationModal = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Box className='mb-4 text-center'>
|
||||
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
|
||||
</Box>
|
||||
|
||||
<Stack spacing={3}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
{isBackupCode ? 'Backup Code' : 'Verification Code'}
|
||||
<Typography
|
||||
component='label'
|
||||
htmlFor='mfa-code'
|
||||
level='body-sm'
|
||||
sx={{ display: 'block', fontWeight: 600, mb: 0.75 }}
|
||||
>
|
||||
{isBackupCode ? 'Backup code' : 'Verification code'}
|
||||
</Typography>
|
||||
<Input
|
||||
placeholder={
|
||||
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
|
||||
}
|
||||
id='mfa-code'
|
||||
size='lg'
|
||||
placeholder={isBackupCode ? 'Enter backup code' : '000000'}
|
||||
value={verificationCode}
|
||||
onChange={e => setVerificationCode(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
onKeyDown={handleKeyDown}
|
||||
error={Boolean(error)}
|
||||
autoFocus
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
fontSize: '1.1em',
|
||||
letterSpacing: isBackupCode ? 'normal' : '0.1em',
|
||||
...authInputSx,
|
||||
// Targets the inner <input>; styling the root leaves the text
|
||||
// itself unaligned.
|
||||
'& input': {
|
||||
textAlign: 'center',
|
||||
letterSpacing: isBackupCode ? 'normal' : '0.4em',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
fontSize: '1.125rem',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
maxLength: isBackupCode ? 50 : 6,
|
||||
inputMode: isBackupCode ? 'text' : 'numeric',
|
||||
pattern: isBackupCode ? undefined : '[0-9]*',
|
||||
autoComplete: isBackupCode ? 'off' : 'one-time-code',
|
||||
},
|
||||
}}
|
||||
startDecorator={<Smartphone />}
|
||||
autoFocus
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert color='danger' size='sm'>
|
||||
<Alert color='danger' variant='soft' sx={{ borderRadius: '12px' }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box className='text-center'>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
underline='hover'
|
||||
onClick={() => {
|
||||
setIsBackupCode(!isBackupCode)
|
||||
setVerificationCode('')
|
||||
setError('')
|
||||
}}
|
||||
sx={{ fontSize: 'sm' }}
|
||||
>
|
||||
{isBackupCode
|
||||
? 'Use authenticator app instead'
|
||||
: "Can't access your authenticator? Use a backup code"}
|
||||
: 'Use a backup code instead'}
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Alert color='neutral' size='sm'>
|
||||
<Typography level='body-xs'>
|
||||
Having trouble? Make sure your authenticator app is synced and try
|
||||
again. Each backup code can only be used once.
|
||||
{isBackupCode && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Each backup code can only be used once.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
import {
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
AuthTextField,
|
||||
LegalLinks,
|
||||
} from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
|
||||
const SignupView = () => {
|
||||
const [username, setUsername] = React.useState('')
|
||||
@@ -27,6 +23,7 @@ const SignupView = () => {
|
||||
const [passwordError, setPasswordError] = React.useState('')
|
||||
const [emailError, setEmailError] = React.useState('')
|
||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const { showError } = useNotification()
|
||||
const handleLogin = (username, password) => {
|
||||
login(username, password).then(response => {
|
||||
@@ -105,208 +102,128 @@ const SignupView = () => {
|
||||
if (!handleSignUpValidation()) {
|
||||
return
|
||||
}
|
||||
signUp(username, password, displayName, email).then(response => {
|
||||
if (response.status === 201) {
|
||||
handleLogin(username, password)
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: 'Signup disabled, please contact admin',
|
||||
})
|
||||
} else {
|
||||
console.log('Signup failed')
|
||||
response.json().then(res => {
|
||||
setIsSubmitting(true)
|
||||
signUp(username, password, displayName, email)
|
||||
.then(response => {
|
||||
if (response.status === 201) {
|
||||
handleLogin(username, password)
|
||||
} else if (response.status === 403) {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: res.error || 'An error occurred during signup',
|
||||
message: 'Signup disabled, please contact admin',
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('Signup failed')
|
||||
response.json().then(res => {
|
||||
showError({
|
||||
title: 'Signup Failed',
|
||||
message: res.error || 'An error occurred during signup',
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(() => setIsSubmitting(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<AuthShell
|
||||
title='Create your account'
|
||||
subtitle='Track chores and tasks together, in one shared place.'
|
||||
footer={<LegalLinks />}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
}}
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
<AuthTextField
|
||||
label='Display name'
|
||||
id='displayName'
|
||||
name='displayName'
|
||||
autoComplete='name'
|
||||
placeholder='How others see your name'
|
||||
autoFocus
|
||||
value={displayName}
|
||||
error={displayNameError}
|
||||
onChange={e => {
|
||||
setDisplayNameError(null)
|
||||
setDisplayName(e.target.value)
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
<Typography level='body2'>
|
||||
Create an account to get started!
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
Username
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='username'
|
||||
label='Username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsernameError(null)
|
||||
setUsername(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
<FormControl error={usernameError}>
|
||||
<FormHelperText c>{usernameError}</FormHelperText>
|
||||
</FormControl>
|
||||
{/* Error message display */}
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
Email
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
id='email'
|
||||
label='email'
|
||||
name='email'
|
||||
autoComplete='email'
|
||||
value={email}
|
||||
onChange={e => {
|
||||
setEmailError(null)
|
||||
setEmail(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
<FormControl error={emailError}>
|
||||
<FormHelperText c>{emailError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
Password:
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='password'
|
||||
label='Password'
|
||||
type='password'
|
||||
id='password'
|
||||
placeholder='Enter password (8-64 characters)'
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPasswordError(null)
|
||||
setPassword(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<FormControl error={passwordError}>
|
||||
<FormHelperText>{passwordError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography level='body2' alignSelf={'start'}>
|
||||
Display Name:
|
||||
</Typography>
|
||||
<Input
|
||||
margin='normal'
|
||||
required
|
||||
fullWidth
|
||||
name='displayName'
|
||||
label='Display Name'
|
||||
id='displayName'
|
||||
placeholder='How others see your name'
|
||||
value={displayName}
|
||||
onChange={e => {
|
||||
setDisplayNameError(null)
|
||||
setDisplayName(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<FormControl error={displayNameError}>
|
||||
<FormHelperText>{displayNameError}</FormHelperText>
|
||||
</FormControl>
|
||||
<Typography
|
||||
level='body2'
|
||||
sx={{ mt: 2, mb: 1, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||
</Typography>
|
||||
<Button
|
||||
// type='submit'
|
||||
size='lg'
|
||||
fullWidth
|
||||
variant='solid'
|
||||
sx={{ mt: 1, mb: 1 }}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
<Divider> or </Divider>
|
||||
<Button
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
Navigate('/login')
|
||||
}}
|
||||
fullWidth
|
||||
variant='soft'
|
||||
// sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
|
||||
>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/privacy-policy', '_blank')
|
||||
}}
|
||||
>
|
||||
Privacy Policy
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
window.open('https://donetick.com/terms', '_blank')
|
||||
}}
|
||||
>
|
||||
Terms of Use
|
||||
</Button>
|
||||
</Box>
|
||||
</Sheet>
|
||||
<AuthTextField
|
||||
label='Username'
|
||||
id='username'
|
||||
name='username'
|
||||
autoComplete='username'
|
||||
placeholder='lowercase letters, dot and dash'
|
||||
value={username}
|
||||
error={usernameError}
|
||||
onChange={e => {
|
||||
setUsernameError(null)
|
||||
setUsername(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthTextField
|
||||
label='Email'
|
||||
id='email'
|
||||
name='email'
|
||||
type='email'
|
||||
autoComplete='email'
|
||||
placeholder='you@example.com'
|
||||
value={email}
|
||||
error={emailError}
|
||||
onChange={e => {
|
||||
setEmailError(null)
|
||||
setEmail(e.target.value.trim())
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthPasswordField
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='new-password'
|
||||
placeholder='At least 8 characters'
|
||||
value={password}
|
||||
error={passwordError}
|
||||
helper='Use 8 to 64 characters.'
|
||||
onChange={e => {
|
||||
setPasswordError(null)
|
||||
setPassword(e.target.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Create account
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
By creating an account you agree to our Terms of Service and Privacy
|
||||
Policy.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ mt: 3, textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
fontWeight={600}
|
||||
underline='hover'
|
||||
onClick={() => Navigate('/login')}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</Typography>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,68 @@
|
||||
// create boilerplate for ResetPasswordView:
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { Box, Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import Logo from '../../Logo'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChangePassword } from '../../utils/Fetcher'
|
||||
import { AuthPasswordField, AuthSubmitButton, LegalLinks } from './AuthFields'
|
||||
import AuthShell from './AuthShell'
|
||||
import { authButtonSx } from './authStyles'
|
||||
|
||||
const UpdatePasswordView = () => {
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [passwordError, setPasswordError] = useState(null)
|
||||
const [passworConfirmationError, setPasswordConfirmationError] =
|
||||
const [passwordConfirmationError, setPasswordConfirmationError] =
|
||||
useState(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const { showError, showNotification } = useNotification()
|
||||
|
||||
const verifiticationCode = searchParams.get('c')
|
||||
const verificationCode = searchParams.get('c')
|
||||
|
||||
const handlePasswordChange = e => {
|
||||
const password = e.target.value
|
||||
setPassword(password)
|
||||
if (password.length < 8 || password.length > 64) {
|
||||
setPasswordError('Password must be between 8 and 64 characters')
|
||||
} else {
|
||||
setPassword(e.target.value)
|
||||
if (passwordError) {
|
||||
setPasswordError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordConfirmChange = e => {
|
||||
setPasswordConfirm(e.target.value)
|
||||
if (e.target.value !== password) {
|
||||
setPasswordConfirmationError('Passwords do not match')
|
||||
} else {
|
||||
if (passwordConfirmationError) {
|
||||
setPasswordConfirmationError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (passwordError != null || passworConfirmationError != null) {
|
||||
const validate = () => {
|
||||
let isValid = true
|
||||
|
||||
if (password.length < 8 || password.length > 64) {
|
||||
setPasswordError('Password must be between 8 and 64 characters')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if (passwordConfirm !== password) {
|
||||
setPasswordConfirmationError('Passwords do not match')
|
||||
isValid = false
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
const handleSubmit = async e => {
|
||||
e?.preventDefault()
|
||||
|
||||
// The old version only bailed when an error was already set, so an
|
||||
// untouched form submitted an empty password.
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const response = await ChangePassword(verifiticationCode, password)
|
||||
const response = await ChangePassword(verificationCode, password)
|
||||
|
||||
if (response.ok) {
|
||||
showNotification({
|
||||
@@ -60,7 +71,6 @@ const UpdatePasswordView = () => {
|
||||
message:
|
||||
'Your password has been updated successfully. Redirecting to login...',
|
||||
})
|
||||
// wait 3 seconds and then redirect to login:
|
||||
setTimeout(() => {
|
||||
navigate('/login')
|
||||
}, 3000)
|
||||
@@ -75,111 +85,95 @@ const UpdatePasswordView = () => {
|
||||
title: 'Password Update Failed',
|
||||
message: 'Failed to update password, please try again later',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
}}
|
||||
|
||||
if (!verificationCode) {
|
||||
return (
|
||||
<AuthShell
|
||||
title='This link is not valid'
|
||||
subtitle='The password reset link is incomplete or has already been used. Request a new one to continue.'
|
||||
footer={<LegalLinks />}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/forgot-password')}
|
||||
>
|
||||
Request a new link
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</Box>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
title='Set a new password'
|
||||
subtitle='Choose a password you have not used on this account before.'
|
||||
footer={<LegalLinks />}
|
||||
>
|
||||
<Box
|
||||
component='form'
|
||||
onSubmit={handleSubmit}
|
||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
|
||||
>
|
||||
<AuthPasswordField
|
||||
label='New password'
|
||||
id='password'
|
||||
name='password'
|
||||
autoComplete='new-password'
|
||||
placeholder='At least 8 characters'
|
||||
autoFocus
|
||||
value={password}
|
||||
error={passwordError}
|
||||
helper='Use 8 to 64 characters.'
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
|
||||
<AuthPasswordField
|
||||
label='Confirm new password'
|
||||
id='passwordConfirm'
|
||||
name='passwordConfirm'
|
||||
autoComplete='new-password'
|
||||
placeholder='Re-enter your password'
|
||||
value={passwordConfirm}
|
||||
error={passwordConfirmationError}
|
||||
onChange={handlePasswordConfirmChange}
|
||||
/>
|
||||
|
||||
<AuthSubmitButton loading={isSubmitting} sx={{ mt: 1 }}>
|
||||
Save password
|
||||
</AuthSubmitButton>
|
||||
|
||||
<Button
|
||||
type='button'
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/login')}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
<Typography level='body2' mb={4}>
|
||||
Please enter your new password below
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl error>
|
||||
<Input
|
||||
placeholder='Password'
|
||||
type='password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
error={passwordError !== null}
|
||||
// onKeyDown={e => {
|
||||
// if (e.key === 'Enter' && validateForm(validateFormInput)) {
|
||||
// handleSubmit(e)
|
||||
// }
|
||||
// }}
|
||||
/>
|
||||
<FormHelperText>{passwordError}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl error>
|
||||
<Input
|
||||
placeholder='Confirm Password'
|
||||
type='password'
|
||||
value={passwordConfirm}
|
||||
onChange={handlePasswordConfirmChange}
|
||||
error={passworConfirmationError !== null}
|
||||
// onKeyDown={e => {
|
||||
// if (e.key === 'Enter' && validateForm(validateFormInput)) {
|
||||
// handleSubmit(e)
|
||||
// }
|
||||
// }}
|
||||
/>
|
||||
<FormHelperText>{passworConfirmationError}</FormHelperText>
|
||||
</FormControl>
|
||||
{/* helper to show password not matching : */}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{
|
||||
mt: 5,
|
||||
mb: 1,
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Save Password
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Sheet>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
13
src/views/Authorization/authStyles.js
Normal file
13
src/views/Authorization/authStyles.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Shared shape for the auth screens so all four stay in one control vocabulary.
|
||||
export const authInputSx = {
|
||||
'--Input-radius': '12px',
|
||||
'--Input-minHeight': '48px',
|
||||
'--Input-focusedThickness': '2px',
|
||||
fontSize: '1rem',
|
||||
}
|
||||
|
||||
export const authButtonSx = {
|
||||
'--Button-radius': '12px',
|
||||
minHeight: 48,
|
||||
fontWeight: 600,
|
||||
}
|
||||
@@ -235,7 +235,7 @@ const MFASettings = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
{/*
|
||||
{/*
|
||||
{mfaEnabled && (
|
||||
<Card variant='outlined'>
|
||||
<Box className='flex items-center justify-between'>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { version } from '../../../package.json'
|
||||
@@ -33,7 +33,6 @@ import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
@@ -100,19 +99,6 @@ const NavBar = () => {
|
||||
]
|
||||
const location = useLocation()
|
||||
const [searchParams] = useSearchParams()
|
||||
useEffect(() => {
|
||||
SafeArea.getSafeAreaInsets().then(data => {
|
||||
const { insets } = data
|
||||
const drawerContent = document.querySelector('.drawer-content')
|
||||
if (drawerContent) {
|
||||
drawerContent.style.paddingTop = `${insets.top}px`
|
||||
drawerContent.style.paddingRight = `${insets.right}px`
|
||||
drawerContent.style.paddingBottom = `${insets.bottom}px`
|
||||
drawerContent.style.paddingLeft = `${insets.left}px`
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const getMenuIcon = () => {
|
||||
const menuRounded = (
|
||||
<IconButton size='md' variant='plain' onClick={() => setDrawerOpen(true)}>
|
||||
@@ -223,7 +209,10 @@ const NavBar = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className='drawer-content'>
|
||||
{/* Safe-area padding comes from the --safe-area-inset-* variables that
|
||||
Capacitor's SystemBars keeps in sync with the live window insets.
|
||||
Top inset is left to the inner List so it isn't applied twice. */}
|
||||
<div className='drawer-content safe-area-x safe-area-bottom'>
|
||||
{/* <div className='align-center flex px-5 pt-4'>
|
||||
<ModalClose size='sm' sx={{ top: 'unset', right: 20 }} />
|
||||
</div> */}
|
||||
|
||||
Reference in New Issue
Block a user