-
- )}
-
- )}
- >
- )}
- {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..f08a7f6 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 => {
@@ -90,10 +87,10 @@ const SignupView = () => {
isValid = false
}
- // username should only contain lowercase letters, dot and dash:
- if (!/^[a-z.-]+$/.test(username)) {
+ // username should only contain lowercase letters, numbers, dot and dash:
+ if (!/^[a-z0-9.-]+$/.test(username)) {
setUsernameError(
- 'Username can only contain lowercase letters, dot and dash',
+ 'Username can only contain lowercase letters, numbers, dot and dash',
)
isValid = false
}
@@ -105,208 +102,129 @@ 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 (
-
+ }
+ logoSize={0}
+ >
- {
+ 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..0122e7c 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,99 @@ const UpdatePasswordView = () => {
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
+ } finally {
+ setIsSubmitting(false)
}
}
- return (
-
- }
+ showLogo
>
-
+ navigate('/forgot-password')}
+ >
+ Request a new link
+
+ navigate('/login')}
+ >
+ Back to sign in
+
+
+
+ )
+ }
+
+ return (
+ }
+ // Reached from an emailed link, usually in a browser: an unbranded page
+ // asking for a new password is the exact shape of a phishing screen.
+ showLogo
+ >
+
+
+
+
+
+
+ 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. */}
+