diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2665b3420..7504f00d3 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -3655,7 +3655,8 @@ "account": "Account", "config": "Config", "adminSettings": "Admin Settings", - "allTools": "All Tools", + "allTools": "Tools", + "reader": "Reader", "helpMenu": { "toolsTour": "Tools Tour", "toolsTourDesc": "Learn what the tools can do", @@ -4778,7 +4779,7 @@ "maybeLater": "Maybe Later", "dontShowAgain": "Don't Show Again" }, - "allTools": "This is the All Tools panel, where you can browse and select from all available PDF tools.", + "allTools": "This is the Tools panel, where you can browse and select from all available PDF tools.", "selectCropTool": "Let's select the Crop tool to demonstrate how to use one of the tools.", "toolInterface": "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.", "filesButton": "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on.", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 10e923c90..76f89ff4a 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -3967,7 +3967,7 @@ "account": "Account", "config": "Config", "adminSettings": "Admin Settings", - "allTools": "All Tools" + "allTools": "Tools" }, "admin": { "error": "Error", diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 24f793188..c8a7c99f8 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext"; import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext"; import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext"; import { PageEditorProvider } from "@app/contexts/PageEditorContext"; +import { InviteModalProvider } from "@app/contexts/InviteModalContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; @@ -68,11 +69,13 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - {children} - - + + + + {children} + + + diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index 8f7608b14..503a246dc 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -123,7 +123,7 @@ export default function OnboardingTour() { const stepsConfig: Record = { [TourStep.ALL_TOOLS]: { selector: '[data-tour="tool-panel"]', - content: t('onboarding.allTools', 'This is the All Tools panel, where you can browse and select from all available PDF tools.'), + content: t('onboarding.allTools', 'This is the Tools panel, where you can browse and select from all available PDF tools.'), position: 'center', padding: 0, action: () => { diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx index 9a333878a..f5ba4a97c 100644 --- a/frontend/src/core/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx @@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC = ({ activeButton, set const iconNode = ( - + ); return ( - +
= ({ activeButton, set {iconNode} - {t("quickAccess.allTools", "All Tools")} + {t("quickAccess.allTools", "Tools")}
diff --git a/frontend/src/core/components/shared/InviteMembersModal.tsx b/frontend/src/core/components/shared/InviteMembersModal.tsx new file mode 100644 index 000000000..a52fe39f2 --- /dev/null +++ b/frontend/src/core/components/shared/InviteMembersModal.tsx @@ -0,0 +1,515 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Modal, + Stack, + Text, + Button, + TextInput, + Select, + Paper, + Checkbox, + Textarea, + SegmentedControl, + Tooltip, + CloseButton, + Box, + Group, +} from '@mantine/core'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { alert } from '@app/components/toast'; +import { userManagementService } from '@app/services/userManagementService'; +import { teamService, Team } from '@app/services/teamService'; +import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +interface InviteMembersModalProps { + opened: boolean; + onClose: () => void; +} + +export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const [teams, setTeams] = useState([]); + const [processing, setProcessing] = useState(false); + const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct'); + const [generatedInviteLink, setGeneratedInviteLink] = useState(null); + + // License information + const [licenseInfo, setLicenseInfo] = useState<{ + maxAllowedUsers: number; + availableSlots: number; + grandfatheredUserCount: number; + licenseMaxUsers: number; + premiumEnabled: boolean; + totalUsers: number; + } | null>(null); + + // Form state for direct invite + const [inviteForm, setInviteForm] = useState({ + username: '', + password: '', + role: 'ROLE_USER', + teamId: undefined as number | undefined, + forceChange: false, + }); + + // Form state for email invite + const [emailInviteForm, setEmailInviteForm] = useState({ + emails: '', + role: 'ROLE_USER', + teamId: undefined as number | undefined, + }); + + // Form state for invite link + const [inviteLinkForm, setInviteLinkForm] = useState({ + email: '', + role: 'ROLE_USER', + teamId: undefined as number | undefined, + expiryHours: 72, + sendEmail: false, + }); + + // Fetch teams and license info + useEffect(() => { + if (opened) { + const fetchData = async () => { + try { + const [adminData, teamsData] = await Promise.all([ + userManagementService.getUsers(), + teamService.getTeams(), + ]); + + setTeams(teamsData); + + setLicenseInfo({ + maxAllowedUsers: adminData.maxAllowedUsers, + availableSlots: adminData.availableSlots, + grandfatheredUserCount: adminData.grandfatheredUserCount, + licenseMaxUsers: adminData.licenseMaxUsers, + premiumEnabled: adminData.premiumEnabled, + totalUsers: adminData.totalUsers, + }); + } catch (error) { + console.error('Failed to fetch data:', error); + } + }; + fetchData(); + } + }, [opened]); + + const roleOptions = [ + { + value: 'ROLE_USER', + label: t('workspace.people.roleDescriptions.user', 'User'), + }, + { + value: 'ROLE_ADMIN', + label: t('workspace.people.roleDescriptions.admin', 'Admin'), + }, + ]; + + const teamOptions = teams.map((team) => ({ + value: team.id.toString(), + label: team.name, + })); + + const handleInviteUser = async () => { + if (!inviteForm.username || !inviteForm.password) { + alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') }); + return; + } + + try { + setProcessing(true); + await userManagementService.createUser({ + username: inviteForm.username, + password: inviteForm.password, + role: inviteForm.role, + teamId: inviteForm.teamId, + authType: 'password', + forceChange: inviteForm.forceChange, + }); + alert({ alertType: 'success', title: t('workspace.people.addMember.success') }); + onClose(); + // Reset form + setInviteForm({ + username: '', + password: '', + role: 'ROLE_USER', + teamId: undefined, + forceChange: false, + }); + } catch (error: any) { + console.error('Failed to invite user:', error); + const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.addMember.error'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + + const handleEmailInvite = async () => { + if (!emailInviteForm.emails.trim()) { + alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'Email addresses are required') }); + return; + } + + try { + setProcessing(true); + const response = await userManagementService.inviteUsers({ + emails: emailInviteForm.emails, // comma-separated string as required by API + role: emailInviteForm.role, + teamId: emailInviteForm.teamId, + }); + + if (response.successCount > 0) { + alert({ + alertType: 'success', + title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` }) + }); + onClose(); + setEmailInviteForm({ + emails: '', + role: 'ROLE_USER', + teamId: undefined, + }); + } else { + alert({ + alertType: 'error', + title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'), + body: response.errors || response.error + }); + } + } catch (error: any) { + console.error('Failed to invite users:', error); + const errorMessage = error.response?.data?.message || + error.response?.data?.error || + error.message || + t('workspace.people.emailInvite.error', 'Failed to send invites'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + + const handleGenerateInviteLink = async () => { + try { + setProcessing(true); + const response = await userManagementService.generateInviteLink({ + email: inviteLinkForm.email || undefined, + role: inviteLinkForm.role, + teamId: inviteLinkForm.teamId, + expiryHours: inviteLinkForm.expiryHours, + sendEmail: inviteLinkForm.sendEmail, + }); + setGeneratedInviteLink(response.inviteUrl); + if (inviteLinkForm.sendEmail && inviteLinkForm.email) { + alert({ alertType: 'success', title: t('workspace.people.inviteLink.emailSent', 'Invite link generated and sent via email') }); + } + } catch (error: any) { + console.error('Failed to generate invite link:', error); + const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.inviteLink.error', 'Failed to generate invite link'); + alert({ alertType: 'error', title: errorMessage }); + } finally { + setProcessing(false); + } + }; + + const handleClose = () => { + setGeneratedInviteLink(null); + setInviteMode('direct'); + setInviteForm({ + username: '', + password: '', + role: 'ROLE_USER', + teamId: undefined, + forceChange: false, + }); + setEmailInviteForm({ + emails: '', + role: 'ROLE_USER', + teamId: undefined, + }); + setInviteLinkForm({ + email: '', + role: 'ROLE_USER', + teamId: undefined, + expiryHours: 72, + sendEmail: false, + }); + onClose(); + }; + + return ( + + + + + {/* Header with Icon */} + + + + {t('workspace.people.inviteMembers.label', 'Invite Members')} + + {inviteMode === 'email' && ( + + {t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')} + + )} + + + {/* License Warning/Info */} + {licenseInfo && ( + + + + 0 ? 'info' : 'warning'} width="1rem" height="1rem" /> + + {licenseInfo.availableSlots > 0 + ? t('workspace.people.license.slotsAvailable', { + count: licenseInfo.availableSlots, + defaultValue: `${licenseInfo.availableSlots} user slot(s) available` + }) + : t('workspace.people.license.noSlotsAvailable', 'No user slots available')} + + + + {t('workspace.people.license.currentUsage', { + current: licenseInfo.totalUsers, + max: licenseInfo.maxAllowedUsers, + defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses` + })} + + + + )} + + {/* Mode Toggle */} + +
+ { + setInviteMode(value as 'email' | 'direct' | 'link'); + setGeneratedInviteLink(null); + }} + data={[ + { + label: t('workspace.people.inviteMode.username', 'Username'), + value: 'direct', + }, + { + label: t('workspace.people.inviteMode.link', 'Link'), + value: 'link', + }, + { + label: t('workspace.people.inviteMode.email', 'Email'), + value: 'email', + disabled: !config?.enableEmailInvites, + }, + ]} + fullWidth + /> +
+
+ + {/* Link Mode */} + {inviteMode === 'link' && ( + <> + setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })} + description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')} + /> + setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })} + clearable + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })} + min={1} + max={720} + /> + {inviteLinkForm.email && ( + setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })} + /> + )} + + {/* Display generated link */} + {generatedInviteLink && ( + + + {t('workspace.people.inviteLink.generated', 'Invite Link Generated')} + + + + + + + )} + + )} + + {/* Email Mode */} + {inviteMode === 'email' && config?.enableEmailInvites && ( + <> +