diff --git a/src/Logo.jsx b/src/Logo.jsx
index 664c607..d7f43a8 100644
--- a/src/Logo.jsx
+++ b/src/Logo.jsx
@@ -1,8 +1,8 @@
import LogoSVG from '@/assets/logo.svg'
-const Logo = () => {
+const Logo = ({ size = '128px' }) => {
return (
-
+
)
}
diff --git a/src/views/Authorization/AuthFields.jsx b/src/views/Authorization/AuthFields.jsx
new file mode 100644
index 0000000..e63ad36
--- /dev/null
+++ b/src/views/Authorization/AuthFields.jsx
@@ -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 }) => (
+
+ {label}
+ {children}
+ {(error || helper) && (
+
+ {error || helper}
+
+ )}
+
+)
+
+export const AuthTextField = ({ label, error, helper, sx, ...inputProps }) => (
+
+
+
+)
+
+export const AuthPasswordField = ({
+ label = 'Password',
+ error,
+ helper,
+ sx,
+ ...inputProps
+}) => {
+ const [visible, setVisible] = useState(false)
+
+ return (
+
+ setVisible(v => !v)}
+ sx={{ borderRadius: '8px' }}
+ >
+ {visible ? (
+
+ ) : (
+
+ )}
+
+ }
+ {...inputProps}
+ />
+
+ )
+}
+
+export const AuthSubmitButton = ({ children, sx, ...props }) => (
+
+ {children}
+
+)
+
+export const SocialButton = ({ icon, children, sx, ...props }) => (
+
+ {children}
+
+)
+
+export const AuthDivider = ({ children = 'or' }) => (
+
+
+ {children}
+
+
+)
+
+export const LegalLinks = () => (
+
+
+ Privacy Policy
+
+ {' ยท '}
+
+ Terms of Use
+
+
+)
diff --git a/src/views/Authorization/AuthShell.jsx b/src/views/Authorization/AuthShell.jsx
new file mode 100644
index 0000000..956bb1e
--- /dev/null
+++ b/src/views/Authorization/AuthShell.jsx
@@ -0,0 +1,120 @@
+import { Box, Sheet, Typography } from '@mui/joy'
+import Logo from '../../Logo'
+
+const Wordmark = () => (
+
+ Done
+
+ tick
+
+
+)
+
+/**
+ * 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 (
+
+ {/* my:auto centers the column without the top-clipping that
+ justify-content:center causes once the form outgrows the viewport. */}
+
+
+ {action && (
+ {action}
+ )}
+
+
+
+
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {children}
+
+
+ {footer && {footer} }
+
+
+ )
+}
+
+export default AuthShell
diff --git a/src/views/Authorization/Authenticating.jsx b/src/views/Authorization/Authenticating.jsx
index b7d53a1..86879fb 100644
--- a/src/views/Authorization/Authenticating.jsx
+++ b/src/views/Authorization/Authenticating.jsx
@@ -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 (
-
+
-
-
-
-
- {message}
-
-
- {subMessage}
-
+ {status === 'pending' && (
+
+ )}
{status === 'error' && (
- Go back Login
+ Back to sign in
)}
-
- {
- setMessage('Authentication failed')
- setSubMessage('Two-factor authentication failed. Please try again')
- }}
- />
-
+
+ {
+ setMessage('Sign-in failed')
+ setSubMessage('Two-factor authentication failed. Please try again.')
+ }}
+ />
+
)
}
diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx
index e2902d0..e9d7a5f 100644
--- a/src/views/Authorization/ForgotPasswordView.jsx
+++ b/src/views/Authorization/ForgotPasswordView.jsx
@@ -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 (
-
- {
+ if (email && isInvalidEmail(email)) {
+ setEmailError('Please enter a valid email address')
+ }
+ }
+
+ if (resetStatusOk !== null) {
+ return (
+ }
>
-
-
+
+ navigate('/login')}
+ >
+ Back to sign in
+
+
+
+ )
+ }
-
- Done
- tick
-
- {resetStatusOk === null && (
- <>
-
- Enter your email, and we'll send you a link to get into your
- account.
-
+ return (
+ }
+ >
+
+
-
- Email Address
-
-
- {
- if (e.key === 'Enter') {
- e.preventDefault()
- handleSubmit()
- }
- }}
- />
- {emailError}
-
-
-
- Reset Password
-
-
- {
- navigate('/login')
- }}
- color='neutral'
- >
- Back to Login
-
- >
- )}
- {resetStatusOk != null && (
- <>
-
- If there is an account associated with the email you entered,
- you will receive an email with instructions on how to reset your
- password.
-
-
- {
- navigate('/login')
- }}
- >
- Go to Login
-
- >
- )}
-
+
+ Send reset link
+
-
+
+
+ Remembered it?{' '}
+ navigate('/login')}
+ >
+ Back to sign in
+
+
+
)
}
diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx
index 1de190c..6cd1d3d 100644
--- a/src/views/Authorization/LoginSettings.jsx
+++ b/src/views/Authorization/LoginSettings.jsx
@@ -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 (
-
+
-
+ ) : status === 'error' ? (
+
+ ) : null
+ }
+ helper='Include the protocol (http:// or https://) and the port if needed. Donetick defaults to port 2021.'
+ />
+
+ {status === 'error' && (
+ }
+ sx={{ mt: 2, borderRadius: '12px', alignItems: 'flex-start' }}
+ >
+ {errorMessage}
+
+ )}
+
+ {status === 'success' && (
+ }
+ sx={{ mt: 2, borderRadius: '12px' }}
+ >
+ Connected. Taking you to sign in...
+
+ )}
+
+ {status === 'testing' && (
+ }
+ sx={{ mt: 2, borderRadius: '12px' }}
+ >
+ Testing connection to server...
+
+ )}
+
+ : null}
+ sx={{ mt: 3 }}
+ >
+ {isTesting ? 'Testing connection' : 'Save & connect'}
+
+
+ {
+ await Preferences.set({ key: 'customServerUrl', value: API_URL })
+ await apiClient.init(true)
+ refetchResource()
+ Navigate('/login')
}}
>
-
-
-
- Done
- tick
-
-
-
- Server URL
-
-
- ) : status === 'error' ? (
-
- ) : null
- }
- />
-
-
- Change the server URL to connect to a different server, such as your
- own self-hosted Donetick server.
-
-
- Include the protocol (http:// or https://) and port if necessary
- (default Donetick port is 2021).
-
-
- {status === 'error' && (
- }
- sx={{ mt: 2, width: '100%' }}
- >
- {errorMessage}
-
- )}
-
- {status === 'success' && (
- }
- sx={{ mt: 2, width: '100%' }}
- >
- Connected! Redirecting to login...
-
- )}
-
- {status === 'testing' && (
- }
- sx={{ mt: 2, width: '100%' }}
- >
- Testing connection to server...
-
- )}
-
- : undefined
- }
- >
- {isTesting ? 'Testing...' : 'Save & Connect'}
-
- {
- await Preferences.set({ key: 'customServerUrl', value: API_URL })
- await apiClient.init(true)
- refetchResource()
- Navigate('/login')
- }}
- >
- Cancel and Reset
-
-
+ Reset to default server
+
-
+
+
+ Changing the server clears locally cached data on this device.
+
+
)
}
diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx
index 3fe8a79..fb61ab4 100644
--- a/src/views/Authorization/LoginView.jsx
+++ b/src/views/Authorization/LoginView.jsx
@@ -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 }) => (
+
+ {options.map(option => {
+ const selected = option.value === value
+ return (
+ 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}
+
+ )
+ })}
+
+)
+
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 (
- }
+ action={
+ Capacitor.isNativePlatform() ? (
+ Navigate('/login/settings')}
+ >
+
+
+ ) : null
+ }
>
-
-
- {Capacitor.isNativePlatform() && (
- {
- Navigate('/login/settings')
- }}
- >
- {' '}
-
-
- )}
-
-
-
- Done
- tick
-
-
- {userProfile && (
- <>
-
-
- Welcome back,{' '}
- {userProfile?.displayName || userProfile?.username}
- {getUserDisplayInfo(userProfile).userType === 'child' && (
-
- (Sub Account)
-
- )}
+
+
+ {displayName}
+ {getUserDisplayInfo(userProfile).userType === 'child' && (
+
+ Sub Account
-
- {
- getUserProfileAndNavigateToHome()
- }}
- >
- Continue as {userProfile.displayName || userProfile.username}
-
- {
- apiClient.handleLogout()
- }}
- >
- Logout
-
- >
- )}
- {!userProfile && (
- <>
-
- Sign in to your account to continue
-
-
- {/* Login Type Tabs */}
-
-
-
- Primary Account
-
-
- Sub Account
-
-
-
-
-
- Username
-
- {
- setUsername(e.target.value)
- }}
- />
-
-
-
-
- Primary Account Username
-
- {
- setParentUsername(e.target.value)
- }}
- />
-
- Sub Account Username
-
- {
- setChildName(e.target.value)
- }}
- />
-
-
-
-
- Password:
-
- {
- setPassword(e.target.value)
- }}
- />
-
-
- {loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
-
-
- Forgot password?
-
- >
- )}
- or
- {import.meta.env.VITE_IS_SELF_HOSTED !== 'true' && (
- <>
- {!Capacitor.isNativePlatform() && (
-
- {
- loggedWithProvider(provider, data)
- }}
- onReject={() => {
- showError({
- title: 'Google Login Failed',
- message:
- "Couldn't log in with Google, please try again",
- })
- }}
- >
-
-
-
- Continue with Google
-
-
-
-
- {/* {
- 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",
- })
- })
- }}
- >
-
-
- Continue with Apple
-
- */}
-
- )}
-
- {Capacitor.isNativePlatform() && (
-
- {
- 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}` : ''
- }`,
- })
- }
- }}
- >
-
-
- Continue with Google
-
-
-
- {/* Apple Sign In Button for Native Platforms */}
- {isAppleSignInSupported && (
- {
- 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",
- })
- })
- }}
- >
-
-
- Continue with Apple
-
-
- )}
-
- )}
- >
- )}
- {resource?.identity_provider?.client_id && (
-
- Continue with {resource?.identity_provider?.name}
-
- )}
-
- {!resource?.is_user_creation_disabled && (
- {
- Navigate('/signup')
- }}
- fullWidth
- variant='soft'
- size='lg'
- // sx={{ mt: 3, mb: 2 }}
- >
- Create new account
-
- )}
-
-
- {
- window.open('https://donetick.com/privacy', '_blank')
- }}
- >
- Privacy Policy
-
- {
- window.open('https://donetick.com/terms', '_blank')
- }}
- >
- Terms of Use
-
+ )}
-
+
+
+ Continue as {displayName}
+
+ apiClient.handleLogout()}
+ >
+ Use a different account
+
+
+ ) : (
+
+
+
+
+ {loginType === 'primary' ? (
+ setUsername(e.target.value)}
+ />
+ ) : (
+ <>
+ setParentUsername(e.target.value)}
+ />
+ setChildName(e.target.value)}
+ />
+ >
+ )}
+
+
+ setPassword(e.target.value)}
+ />
+
+
+ Forgot password?
+
+
+
+
+
+
+ {loginType === 'sub' ? 'Sign in as sub account' : 'Sign in'}
+
+
+ )}
+
+ {hasSocialOptions && or continue with }
+
+
+ {showSocialLogin && !Capacitor.isNativePlatform() && (
+ {
+ loggedWithProvider(provider, data)
+ }}
+ onReject={() => {
+ showError({
+ title: 'Google Login Failed',
+ message: "Couldn't log in with Google, please try again",
+ })
+ }}
+ >
+ }>Google
+
+ )}
+
+ {showSocialLogin && Capacitor.isNativePlatform() && (
+ <>
+ }
+ 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
+
+
+ {isAppleSignInSupported && (
+ }
+ 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
+
+ )}
+ >
+ )}
+
+ {resource?.identity_provider?.client_id && (
+
+ {resource?.identity_provider?.name}
+
+ )}
+ {!userProfile && !resource?.is_user_creation_disabled && (
+
+ Don't have an account?{' '}
+ Navigate('/signup')}
+ >
+ Create one
+
+
+ )}
+
{
onSuccess={handleMFASuccess}
onError={handleMFAError}
/>
-
+
)
}
diff --git a/src/views/Authorization/MFAVerificationModal.jsx b/src/views/Authorization/MFAVerificationModal.jsx
index 235d21a..8261301 100644
--- a/src/views/Authorization/MFAVerificationModal.jsx
+++ b/src/views/Authorization/MFAVerificationModal.jsx
@@ -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 = ({
/>
}
>
-
-
-
-
-
+
-
- {isBackupCode ? 'Backup Code' : 'Verification Code'}
+
+ {isBackupCode ? 'Backup code' : 'Verification code'}
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 ; 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={ }
- autoFocus
/>
{error && (
-
+
{error}
)}
-
+
{
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'}
-
-
- Having trouble? Make sure your authenticator app is synced and try
- again. Each backup code can only be used once.
+ {isBackupCode && (
+
+ Each backup code can only be used once.
-
+ )}
)
diff --git a/src/views/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx
index ceb7c3c..81917a2 100644
--- a/src/views/Authorization/Signup.jsx
+++ b/src/views/Authorization/Signup.jsx
@@ -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 (
-
+ }
+ >
- {
+ setDisplayNameError(null)
+ setDisplayName(e.target.value)
}}
- >
-
-
-
- Done
-
- tick
-
-
-
- Create an account to get started!
-
-
-
- Username
-
- {
- setUsernameError(null)
- setUsername(e.target.value.trim())
- }}
- />
-
- {usernameError}
-
- {/* Error message display */}
-
- Email
-
- {
- setEmailError(null)
- setEmail(e.target.value.trim())
- }}
- />
-
- {emailError}
-
-
- Password:
-
- {
- setPasswordError(null)
- setPassword(e.target.value)
- }}
- />
-
- {passwordError}
-
-
- Display Name:
-
- {
- setDisplayNameError(null)
- setDisplayName(e.target.value)
- }}
- />
-
- {displayNameError}
-
-
- By signing up, you agree to our Terms of Service and Privacy Policy
-
-
- Sign Up
-
- or
- {
- Navigate('/login')
- }}
- fullWidth
- variant='soft'
- // sx={{ mt: 3, mb: 2 }}
- >
- Login
-
+ />
-
- {
- window.open('https://donetick.com/privacy-policy', '_blank')
- }}
- >
- Privacy Policy
-
- {
- window.open('https://donetick.com/terms', '_blank')
- }}
- >
- Terms of Use
-
-
-
+ {
+ setUsernameError(null)
+ setUsername(e.target.value.trim())
+ }}
+ />
+
+ {
+ setEmailError(null)
+ setEmail(e.target.value.trim())
+ }}
+ />
+
+ {
+ setPasswordError(null)
+ setPassword(e.target.value)
+ }}
+ />
+
+
+ Create account
+
+
+
+ By creating an account you agree to our Terms of Service and Privacy
+ Policy.
+
-
+
+
+ Already have an account?{' '}
+ Navigate('/login')}
+ >
+ Sign in
+
+
+
)
}
diff --git a/src/views/Authorization/UpdatePasswordView.jsx b/src/views/Authorization/UpdatePasswordView.jsx
index dbce331..d92a3b5 100644
--- a/src/views/Authorization/UpdatePasswordView.jsx
+++ b/src/views/Authorization/UpdatePasswordView.jsx
@@ -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 (
-
- }
>
-
+ navigate('/forgot-password')}
+ >
+ Request a new link
+
+ navigate('/login')}
+ >
+ Back to sign in
+
+
+
+ )
+ }
+
+ return (
+ }
+ >
+
+
+
+
+
+
+ Save password
+
+
+ navigate('/login')}
>
-
-
-
- Done
-
- tick
-
-
-
- Please enter your new password below
-
-
-
-
- {
- // if (e.key === 'Enter' && validateForm(validateFormInput)) {
- // handleSubmit(e)
- // }
- // }}
- />
- {passwordError}
-
-
-
- {
- // if (e.key === 'Enter' && validateForm(validateFormInput)) {
- // handleSubmit(e)
- // }
- // }}
- />
- {passworConfirmationError}
-
- {/* helper to show password not matching : */}
-
-
- Save Password
-
- {
- navigate('/login')
- }}
- >
- Cancel
-
-
+ Cancel
+
-
+
)
}
diff --git a/src/views/Authorization/authStyles.js b/src/views/Authorization/authStyles.js
new file mode 100644
index 0000000..330960f
--- /dev/null
+++ b/src/views/Authorization/authStyles.js
@@ -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,
+}
diff --git a/src/views/Settings/MFASettings.jsx b/src/views/Settings/MFASettings.jsx
index c7cb7b4..60e5920 100644
--- a/src/views/Settings/MFASettings.jsx
+++ b/src/views/Settings/MFASettings.jsx
@@ -235,7 +235,7 @@ const MFASettings = () => {
- {/*
+ {/*
{mfaEnabled && (
diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx
index c073e9a..0539b2a 100644
--- a/src/views/components/NavBar.jsx
+++ b/src/views/components/NavBar.jsx
@@ -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 = (
setDrawerOpen(true)}>
@@ -223,7 +209,10 @@ const NavBar = () => {
},
}}
>
-
+ {/* 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. */}
+