From d41d6bd75837aeb1b44f195d75e1ecfe632fd6b5 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 28 Sep 2025 19:12:26 -0400 Subject: [PATCH] feat: add sub user management features including creation, deletion, and password updates --- src/contexts/RouterContext.jsx | 5 + src/queries/UserQueries.jsx | 33 ++ src/service/AuthenticationService.jsx | 2 +- src/views/Authorization/LoginView.jsx | 194 ++++++++++-- .../Modals/Inputs/CreateChildUserModal.jsx | 227 +++++++++++++ src/views/Settings/ChildUserSettings.jsx | 297 ++++++++++++++++++ src/views/Settings/SettingsOverview.jsx | 32 +- 7 files changed, 767 insertions(+), 23 deletions(-) create mode 100644 src/views/Modals/Inputs/CreateChildUserModal.jsx create mode 100644 src/views/Settings/ChildUserSettings.jsx diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 5782b85..01f85f8 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -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: , }, + { + path: 'subaccounts', + element: , + }, { path: 'notifications', element: , diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index a0162a9..2795b49 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -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']), + } +} diff --git a/src/service/AuthenticationService.jsx b/src/service/AuthenticationService.jsx index 924de11..7b1c700 100644 --- a/src/service/AuthenticationService.jsx +++ b/src/service/AuthenticationService.jsx @@ -1,4 +1,4 @@ -import React, { createContext, useState } from 'react' +import { createContext, useState } from 'react' const AuthenticationContext = createContext({}) diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index b205649..46d8705 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -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 = () => { Welcome back,{' '} {userProfile?.displayName || userProfile?.username} + {getUserDisplayInfo(userProfile).userType === 'child' && ( + + (Sub Account) + + )} + + + + ) +} + +export default CreateChildUserModal diff --git a/src/views/Settings/ChildUserSettings.jsx b/src/views/Settings/ChildUserSettings.jsx new file mode 100644 index 0000000..edda45d --- /dev/null +++ b/src/views/Settings/ChildUserSettings.jsx @@ -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 ( + + + Only primary users can manage sub accounts. + + + ) + } + + return ( + +
+ + Manage sub accounts. Sub account users can log in and complete + assigned tasks. + + {!isPlusAccount(userProfile) && ( + + Sub account limited to 1 on Free plan. Upgrade to Plus to have up to + 5 sub accounts. + + )} + + + Sub Accounts ({childUsers?.length || 0}) + + + + + {isLoading ? ( + Loading sub accounts... + ) : childUsers?.length === 0 ? ( + + + + No Sub Accounts + + + Create sub accounts so team members can log in and complete + their assigned tasks. + + + + + ) : ( +
+ {childUsers?.map(child => ( + + + + + {child.displayName?.[0]?.toUpperCase() || + child.username?.[0]?.toUpperCase()} + + + + + {child.displayName || child.username} + + + Username: {child.username} + + + Created:{' '} + {new Date(child.createdAt).toLocaleDateString()} + + + + + { + setSelectedChildId(child.id) + setPasswordModalOpen(true) + }} + title='Change Password' + > + + + + handleDeleteChild( + child.id, + child.displayName || child.username, + ) + } + loading={deletingChildId === child.id} + title='Delete Account' + > + + + + + + + ))} +
+ )} + + + + + + How Managed Accounts Work + + + • Managed accounts created by the primary user, these specific for + user you want to have ability to delete and reset password. + + + • Sub accounts can log in with their own username and password. + + + • Managed accounts can complete tasks but have limited + administrative permissions + + + • Managed accounts automatically added to your circle + + +
+ + setCreateModalOpen(false)} + onSuccess={handleCreateChild} + /> + + { + if (newPassword) { + handleUpdatePassword(newPassword) + } + setPasswordModalOpen(false) + setSelectedChildId(null) + }} + /> + + +
+ ) +} + +export default ChildUserSettings diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx index 2752fcf..7d7ff02 100644 --- a/src/views/Settings/SettingsOverview.jsx +++ b/src/views/Settings/SettingsOverview.jsx @@ -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: , }, + { + id: 'subaccounts', + title: 'Managed Accounts', + description: + 'Create and manage sub accounts to log in and complete assigned tasks.', + icon: , + }, { 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 ( @@ -257,7 +287,7 @@ const SettingsOverview = () => { '--ListItem-paddingX': '20px', }} > - {settingsCards.map((setting, index) => ( + {getAvailableSettings().map((setting, index) => ( handleCardClick(setting.id)}