revamp login and signup screens

This commit is contained in:
Mo Tarbin
2026-07-28 20:02:02 -04:00
parent 8dfaa4d8c6
commit 99b467488f
13 changed files with 1165 additions and 1202 deletions

View 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>
)

View 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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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&apos;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>
)
}

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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,
}

View File

@@ -235,7 +235,7 @@ const MFASettings = () => {
</Box>
</Box>
</Card>
{/*
{/*
{mfaEnabled && (
<Card variant='outlined'>
<Box className='flex items-center justify-between'>

View File

@@ -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> */}