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 (
- logo + logo
) } 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 }) => ( + +) + +export const SocialButton = ({ icon, children, sx, ...props }) => ( + +) + +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..4b613de --- /dev/null +++ b/src/views/Authorization/AuthShell.jsx @@ -0,0 +1,117 @@ +import { Capacitor } from '@capacitor/core' +import { Box, Sheet, Typography } from '@mui/joy' +import Logo from '../../Logo' + +/** + * 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 = 48, + // In the app the user already came through the app icon and the Get Started + // mark, so repeating it here is noise. On the web these routes are the first + // thing a visitor sees — often on a self-hosted domain, and with no navbar — + // so the mark is the only thing identifying the app. Views reached from an + // emailed link override this to always show it. + showLogo = !Capacitor.isNativePlatform(), +}) => { + return ( + + {/* my:auto centers the column without the top-clipping that + justify-content:center causes once the form outgrows the viewport. */} + + + {action && ( + {action} + )} + + {/* Mark only: the wordmark sat at nearly the same size and weight as + the title below it, so the two competed instead of forming a + hierarchy. */} + {showLogo && ( + + + + )} + + {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' && ( )} - - { - 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..6bd73a9 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,106 @@ 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 ( + } + logoSize={0} > - - + + + + + ) + } - - Done - tick - - {resetStatusOk === null && ( - <> - - Enter your email, and we'll send you a link to get into your - account. - + return ( + } + logoSize={0} + > + + - - Email Address - - - { - if (e.key === 'Enter') { - e.preventDefault() - handleSubmit() - } - }} - /> - {emailError} - - - - - - - )} - {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. - - - - - )} - + + 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'} + + + - - + 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..1b0959d 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,262 @@ 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 - - - - - )} - {!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) - }} - /> - - - - - )} - 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", - }) - }} - > - - - - {/* */} - - )} - - {Capacitor.isNativePlatform() && ( - - - - {/* Apple Sign In Button for Native Platforms */} - {isAppleSignInSupported && ( - - )} - - )} - - )} - {resource?.identity_provider?.client_id && ( - - )} - - {!resource?.is_user_creation_disabled && ( - - )} - - - - + )} - + + + + + ) : ( + + + + + {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 - - - or - + /> - - - - - + { + 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 > - + + + + + ) + } + + 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 + + + - - + 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. */} +
{/*
*/}