feat: add sub user management features including creation, deletion, and password updates

This commit is contained in:
Mo Tarbin
2025-09-28 19:12:26 -04:00
parent 86b8bbfb5f
commit d41d6bd758
7 changed files with 767 additions and 23 deletions

View File

@@ -3,6 +3,7 @@ import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import Error from '@/views/Error'
import AccountSettings from '@/views/Settings/AccountSettings'
import AdvancedSettings from '@/views/Settings/AdvancedSettings'
import ChildUserSettings from '@/views/Settings/ChildUserSettings'
import CircleSettings from '@/views/Settings/CircleSettings'
import Settings from '@/views/Settings/Settings'
import SettingsOverview from '@/views/Settings/SettingsOverview'
@@ -83,6 +84,10 @@ const Router = createBrowserRouter([
path: 'account',
element: <AccountSettings />,
},
{
path: 'subaccounts',
element: <ChildUserSettings />,
},
{
path: 'notifications',
element: <NotificationSetting />,

View File

@@ -2,6 +2,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
GetAllCircleMembers,
GetAllUsers,
GetChildUsers,
GetDeviceTokens,
GetUserProfile,
} from '../utils/Fetcher'
@@ -40,6 +41,13 @@ export const useUserProfile = () => {
}
const resp = await GetUserProfile()
const result = await resp.json()
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
if (resp.status === 403) {
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
window.location.href = '/login'
return null
}
return result.res // Return the actual user profile data
},
@@ -78,3 +86,28 @@ export const useDeviceTokens = () => {
refetch: () => queryClient.invalidateQueries(['deviceTokens']),
}
}
export const useChildUsers = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['childUsers'],
queryFn: async () => {
if (!isTokenValid()) {
return null
}
const resp = await GetChildUsers()
const result = await resp.json()
return result.res || []
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['childUsers']),
}
}

View File

@@ -1,4 +1,4 @@
import React, { createContext, useState } from 'react'
import { createContext, useState } from 'react'
const AuthenticationContext = createContext({})

View File

@@ -15,6 +15,10 @@ import {
IconButton,
Input,
Sheet,
Tab,
TabList,
TabPanel,
Tabs,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
@@ -28,6 +32,7 @@ import { useResource } from '../../queries/ResourceQueries'
import { useNotification } from '../../service/NotificationProvider'
import { GetUserProfile, login } from '../../utils/Fetcher'
import { apiManager, isTokenValid } from '../../utils/TokenManager'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import MFAVerificationModal from './MFAVerificationModal'
const LoginView = () => {
@@ -39,6 +44,20 @@ const LoginView = () => {
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
// Child login state
const [loginType, setLoginType] = useState('primary')
const [parentUsername, setParentUsername] = useState('')
const [childName, setChildName] = useState('')
// Clear fields when switching login modes
const handleLoginModeChange = (event, newValue) => {
setLoginType(newValue)
setUsername('')
setParentUsername('')
setChildName('')
setPassword('')
}
const { data: resource } = useResource()
const { showError } = useNotification()
const Navigate = useNavigate()
@@ -84,7 +103,48 @@ const LoginView = () => {
}, [])
const handleSubmit = async e => {
e.preventDefault()
login(username, password)
// Validation for child login
if (loginType === 'sub') {
if (!parentUsername.trim()) {
showError({
title: 'Validation Error',
message: 'Primary username is required for sub account login',
})
return
}
if (!childName.trim()) {
showError({
title: 'Validation Error',
message: 'Sub account name is required for sub account login',
})
return
}
} else {
if (!username.trim()) {
showError({
title: 'Validation Error',
message: 'Username is required',
})
return
}
}
if (!password) {
showError({
title: 'Validation Error',
message: 'Password is required',
})
return
}
// Determine the actual username to send
const actualUsername =
loginType === 'sub'
? buildChildUsername(parentUsername, childName)
: username
login(actualUsername, password)
.then(response => {
if (response.status === 200) {
return response.json().then(data => {
@@ -358,6 +418,16 @@ const LoginView = () => {
<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>
)}
</Typography>
<Button
@@ -394,27 +464,109 @@ const LoginView = () => {
)}
{!userProfile && (
<>
<Typography level='body2'>
<Typography level='body2' sx={{ mb: 3 }}>
Sign in to your account to continue
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4}>
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)
}}
/>
<Typography level='body2' alignSelf={'start'}>
{/* 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
@@ -446,7 +598,7 @@ const LoginView = () => {
}}
onClick={handleSubmit}
>
Sign In
{loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
</Button>
<Button
type='submit'

View File

@@ -0,0 +1,227 @@
import {
Box,
Button,
FormControl,
FormHelperText,
Input,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
function CreateChildUserModal({ isOpen, onClose, onSuccess }) {
const { ResponsiveModal } = useResponsiveModal()
const [childName, setChildName] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [errors, setErrors] = useState({})
const [touched, setTouched] = useState({})
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
const newErrors = {}
if (touched.childName) {
if (!childName.trim()) {
newErrors.childName = 'Sub account name is required'
} else if (childName.length < 2) {
newErrors.childName = 'Sub account name must be at least 2 characters'
} else if (childName.length > 20) {
newErrors.childName = 'Sub account name must be less than 20 characters'
} else if (!/^[a-zA-Z0-9_]+$/.test(childName)) {
newErrors.childName =
'Sub account name can only contain letters, numbers, and underscores'
}
}
if (touched.password) {
if (!password) {
newErrors.password = 'Password is required'
} else if (password.length < 8) {
newErrors.password = 'Password must be at least 8 characters'
} else if (password.length > 45) {
newErrors.password = 'Password must be less than 45 characters'
}
}
if (touched.confirmPassword) {
if (password !== confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match'
}
}
if (touched.displayName && displayName.length > 50) {
newErrors.displayName = 'Display name must be less than 50 characters'
}
setErrors(newErrors)
}, [childName, displayName, password, confirmPassword, touched])
const handleSubmit = async () => {
setTouched({
childName: true,
password: true,
confirmPassword: true,
displayName: true,
})
if (Object.keys(errors).length > 0) {
return
}
setIsSubmitting(true)
try {
await onSuccess({
childName: childName.trim(),
displayName: displayName.trim() || childName.trim(),
password,
})
handleClose()
} catch (error) {
console.error('Failed to create child user:', error)
} finally {
setIsSubmitting(false)
}
}
const handleClose = () => {
setChildName('')
setDisplayName('')
setPassword('')
setConfirmPassword('')
setErrors({})
setTouched({})
setIsSubmitting(false)
onClose()
}
const isValid =
Object.keys(errors).length === 0 &&
childName.trim() &&
password &&
password === confirmPassword
return (
<ResponsiveModal open={isOpen} onClose={handleClose}>
<Typography level='h4' mb={2}>
Create Sub Account
</Typography>
<Typography level='body-md' mb={3}>
Create a new sub account. The user will be able to log in using their
combined username and complete tasks assigned to them.
</Typography>
<FormControl error={!!errors.childName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Sub Account Name *
</Typography>
<Input
required
fullWidth
id='childName'
name='childName'
placeholder='Enter sub account name (e.g., sarah)'
value={childName}
onChange={e => {
setChildName(e.target.value)
setTouched(prev => ({ ...prev, childName: true }))
}}
/>
{errors.childName && (
<FormHelperText>{errors.childName}</FormHelperText>
)}
<FormHelperText>
This will create a username like: primaryname_subaccountname
</FormHelperText>
</FormControl>
<FormControl error={!!errors.displayName} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Display Name
</Typography>
<Input
fullWidth
id='displayName'
name='displayName'
placeholder='Display name (optional, defaults to sub account name)'
value={displayName}
onChange={e => {
setDisplayName(e.target.value)
setTouched(prev => ({ ...prev, displayName: true }))
}}
/>
{errors.displayName && (
<FormHelperText>{errors.displayName}</FormHelperText>
)}
</FormControl>
<FormControl error={!!errors.password} sx={{ mb: 2 }}>
<Typography level='body2' mb={1}>
Password *
</Typography>
<Input
required
fullWidth
name='password'
type='password'
id='password'
placeholder='Enter password (8-45 characters)'
value={password}
onChange={e => {
setPassword(e.target.value)
setTouched(prev => ({ ...prev, password: true }))
}}
/>
{errors.password && <FormHelperText>{errors.password}</FormHelperText>}
</FormControl>
<FormControl error={!!errors.confirmPassword} sx={{ mb: 3 }}>
<Typography level='body2' mb={1}>
Confirm Password *
</Typography>
<Input
required
fullWidth
name='confirmPassword'
type='password'
id='confirmPassword'
placeholder='Confirm password'
value={confirmPassword}
onChange={e => {
setConfirmPassword(e.target.value)
setTouched(prev => ({ ...prev, confirmPassword: true }))
}}
/>
{errors.confirmPassword && (
<FormHelperText>{errors.confirmPassword}</FormHelperText>
)}
</FormControl>
<Box display='flex' justifyContent='space-between' gap={2}>
<Button
size='lg'
variant='outlined'
onClick={handleClose}
disabled={isSubmitting}
sx={{ flex: 1 }}
>
Cancel
</Button>
<Button
size='lg'
onClick={handleSubmit}
disabled={!isValid || isSubmitting}
loading={isSubmitting}
sx={{ flex: 1 }}
>
Create Account
</Button>
</Box>
</ResponsiveModal>
)
}
export default CreateChildUserModal

View File

@@ -0,0 +1,297 @@
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import PersonAddIcon from '@mui/icons-material/PersonAdd'
import {
Avatar,
Box,
Button,
Card,
CardContent,
Divider,
IconButton,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useChildUsers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
CreateChildUser,
DeleteChildUser,
UpdateChildPassword,
} from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import CreateChildUserModal from '../Modals/Inputs/CreateChildUserModal'
import PasswordChangeModal from '../Modals/Inputs/PasswordChangeModal'
import SettingsLayout from './SettingsLayout'
const ChildUserSettings = () => {
const { data: userProfile } = useUserProfile()
const { data: childUsers, isLoading, refetch } = useChildUsers()
const { showNotification } = useNotification()
const queryClient = useQueryClient()
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const [createModalOpen, setCreateModalOpen] = useState(false)
const [passwordModalOpen, setPasswordModalOpen] = useState(false)
const [selectedChildId, setSelectedChildId] = useState(null)
const [deletingChildId, setDeletingChildId] = useState(null)
// Check if user is a parent (not a child user)
const isParentUser = userProfile?.userType === 0 && !userProfile?.parentUserId
const handleCreateChild = async childData => {
try {
const response = await CreateChildUser(
childData.childName,
childData.displayName,
childData.password,
)
if (response.ok) {
const result = await response.json()
showNotification({
type: 'success',
message: `Child account "${result.res.displayName}" created successfully!`,
})
refetch()
queryClient.invalidateQueries(['childUsers'])
} else {
const error = await response.json()
throw new Error(error.error || 'Failed to create child user')
}
} catch (error) {
showNotification({
type: 'error',
message: `Failed to create child account: ${error.message}`,
})
throw error
}
}
const handleUpdatePassword = async newPassword => {
if (!selectedChildId || !newPassword) return
try {
const response = await UpdateChildPassword(selectedChildId, newPassword)
if (response.ok) {
showNotification({
type: 'success',
message: 'Child password updated successfully',
})
} else {
const error = await response.json()
throw new Error(error.error || 'Failed to update password')
}
} catch (error) {
showNotification({
type: 'error',
message: `Failed to update password: ${error.message}`,
})
}
}
const handleDeleteChild = async (childId, childName) => {
showConfirmation(
`Are you sure you want to delete the child account "${childName}"? This action cannot be undone.`,
'Delete Sub Account',
async () => {
setDeletingChildId(childId)
try {
const response = await DeleteChildUser(childId)
if (response.ok) {
showNotification({
type: 'success',
message: `Sub account "${childName}" deleted successfully`,
})
refetch()
queryClient.invalidateQueries(['childUsers'])
} else {
const error = await response.json()
throw new Error(error.error || 'Failed to delete Sub user')
}
} catch (error) {
showNotification({
type: 'error',
message: `Failed to delete Sub account: ${error.message}`,
})
} finally {
setDeletingChildId(null)
}
},
'Delete',
'Cancel',
'danger',
)
}
if (!isParentUser) {
return (
<SettingsLayout title='Sub Account Management'>
<Typography level='body-md' color='warning'>
Only primary users can manage sub accounts.
</Typography>
</SettingsLayout>
)
}
return (
<SettingsLayout title='Managed Accounts'>
<div className='grid gap-4'>
<Typography level='body-md'>
Manage sub accounts. Sub account users can log in and complete
assigned tasks.
</Typography>
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Sub account limited to 1 on Free plan. Upgrade to Plus to have up to
5 sub accounts.
</Typography>
)}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography level='title-lg'>
Sub Accounts ({childUsers?.length || 0})
</Typography>
<Button
startDecorator={<PersonAddIcon />}
onClick={() => setCreateModalOpen(true)}
>
Add Sub Account
</Button>
</Box>
{isLoading ? (
<Typography>Loading sub accounts...</Typography>
) : childUsers?.length === 0 ? (
<Card variant='soft' sx={{ textAlign: 'center', py: 4 }}>
<CardContent>
<Typography level='title-md' mb={1}>
No Sub Accounts
</Typography>
<Typography level='body-sm' mb={3}>
Create sub accounts so team members can log in and complete
their assigned tasks.
</Typography>
<Button
startDecorator={<PersonAddIcon />}
onClick={() => setCreateModalOpen(true)}
>
Add Your First Sub Account
</Button>
</CardContent>
</Card>
) : (
<div className='grid gap-3'>
{childUsers?.map(child => (
<Card key={child.id} variant='outlined'>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar size='lg'>
{child.displayName?.[0]?.toUpperCase() ||
child.username?.[0]?.toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography level='title-md'>
{child.displayName || child.username}
</Typography>
<Typography level='body-sm' color='neutral'>
Username: {child.username}
</Typography>
<Typography level='body-xs' color='neutral'>
Created:{' '}
{new Date(child.createdAt).toLocaleDateString()}
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton
size='sm'
variant='soft'
onClick={() => {
setSelectedChildId(child.id)
setPasswordModalOpen(true)
}}
title='Change Password'
>
<EditIcon />
</IconButton>
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={() =>
handleDeleteChild(
child.id,
child.displayName || child.username,
)
}
loading={deletingChildId === child.id}
title='Delete Account'
>
<DeleteIcon />
</IconButton>
</Box>
</Box>
</CardContent>
</Card>
))}
</div>
)}
<Divider sx={{ my: 2 }} />
<Box>
<Typography level='title-md' mb={2}>
How Managed Accounts Work
</Typography>
<Typography level='body-sm' mb={1}>
Managed accounts created by the primary user, these specific for
user you want to have ability to delete and reset password.
</Typography>
<Typography level='body-sm' mb={1}>
Sub accounts can log in with their own username and password.
</Typography>
<Typography level='body-sm' mb={1}>
Managed accounts can complete tasks but have limited
administrative permissions
</Typography>
<Typography level='body-sm'>
Managed accounts automatically added to your circle
</Typography>
</Box>
</div>
<CreateChildUserModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSuccess={handleCreateChild}
/>
<PasswordChangeModal
isOpen={passwordModalOpen}
onClose={newPassword => {
if (newPassword) {
handleUpdatePassword(newPassword)
}
setPasswordModalOpen(false)
setSelectedChildId(null)
}}
/>
<ConfirmationModal config={confirmModalConfig} />
</SettingsLayout>
)
}
export default ChildUserSettings

View File

@@ -3,6 +3,7 @@ import {
Api,
ChevronRight,
Circle,
FamilyRestroom,
Notifications,
Palette,
Person,
@@ -30,6 +31,7 @@ import {
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers'
const SettingsOverview = () => {
const navigate = useNavigate()
@@ -57,6 +59,13 @@ const SettingsOverview = () => {
'Manage your subscription, change password, and account deletion options.',
icon: <AccountCircle />,
},
{
id: 'subaccounts',
title: 'Managed Accounts',
description:
'Create and manage sub accounts to log in and complete assigned tasks.',
icon: <FamilyRestroom />,
},
{
id: 'notifications',
title: 'Notifications',
@@ -112,6 +121,27 @@ const SettingsOverview = () => {
navigate(`/settings/${settingId}`)
}
// Filter settings based on user type
const getAvailableSettings = () => {
const parentOnlySettings = [
'children',
'mfa',
'apitokens',
'circle',
'account',
]
if (isParentUser(userProfile)) {
// Parent users can access all settings
return settingsCards
} else {
// Child users can only access basic settings
return settingsCards.filter(
setting => !parentOnlySettings.includes(setting.id),
)
}
}
return (
<Container maxWidth='lg' sx={{ py: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
@@ -257,7 +287,7 @@ const SettingsOverview = () => {
'--ListItem-paddingX': '20px',
}}
>
{settingsCards.map((setting, index) => (
{getAvailableSettings().map((setting, index) => (
<ListItem key={setting.id} sx={{ p: 0 }}>
<ListItemButton
onClick={() => handleCardClick(setting.id)}