Merge branch 'V2' into V2-CBR-port

This commit is contained in:
Balázs Szücs 2025-11-14 18:37:17 +01:00 committed by GitHub
commit 6bd6d91f3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 802 additions and 760 deletions

View File

@ -3658,8 +3658,10 @@
"help": "Help", "help": "Help",
"account": "Account", "account": "Account",
"config": "Config", "config": "Config",
"settings": "Settings",
"adminSettings": "Admin Settings", "adminSettings": "Admin Settings",
"allTools": "All Tools", "allTools": "Tools",
"reader": "Reader",
"helpMenu": { "helpMenu": {
"toolsTour": "Tools Tour", "toolsTour": "Tools Tour",
"toolsTourDesc": "Learn what the tools can do", "toolsTourDesc": "Learn what the tools can do",
@ -4800,7 +4802,7 @@
"maybeLater": "Maybe Later", "maybeLater": "Maybe Later",
"dontShowAgain": "Don't Show Again" "dontShowAgain": "Don't Show Again"
}, },
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.", "allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.", "selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.", "toolInterface": "This is the <strong>Crop</strong> 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 <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.", "filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
@ -4860,7 +4862,8 @@
"admin": "Admin", "admin": "Admin",
"roleDescriptions": { "roleDescriptions": {
"admin": "Can manage settings and invite members, with full administrative access.", "admin": "Can manage settings and invite members, with full administrative access.",
"member": "Can view and edit shared files, but cannot manage workspace settings or members." "member": "Can view and edit shared files, but cannot manage workspace settings or members.",
"user": "User"
}, },
"editRole": "Edit Role", "editRole": "Edit Role",
"enable": "Enable", "enable": "Enable",
@ -4941,6 +4944,7 @@
"copied": "Link copied to clipboard", "copied": "Link copied to clipboard",
"success": "Invite link generated successfully", "success": "Invite link generated successfully",
"successWithEmail": "Invite link generated and sent via email", "successWithEmail": "Invite link generated and sent via email",
"emailSent": "Invite link generated and sent via email",
"emailFailed": "Invite link generated, but email failed", "emailFailed": "Invite link generated, but email failed",
"emailFailedDetails": "Error: {0}. Please share the invite link manually.", "emailFailedDetails": "Error: {0}. Please share the invite link manually.",
"error": "Failed to generate invite link", "error": "Failed to generate invite link",

View File

@ -3967,7 +3967,7 @@
"account": "Account", "account": "Account",
"config": "Config", "config": "Config",
"adminSettings": "Admin Settings", "adminSettings": "Admin Settings",
"allTools": "All Tools" "allTools": "Tools"
}, },
"admin": { "admin": {
"error": "Error", "error": "Error",

View File

@ -68,11 +68,11 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PageEditorProvider> <PageEditorProvider>
<SignatureProvider> <SignatureProvider>
<RightRailProvider> <RightRailProvider>
<TourOrchestrationProvider> <TourOrchestrationProvider>
<AdminTourOrchestrationProvider> <AdminTourOrchestrationProvider>
{children} {children}
</AdminTourOrchestrationProvider> </AdminTourOrchestrationProvider>
</TourOrchestrationProvider> </TourOrchestrationProvider>
</RightRailProvider> </RightRailProvider>
</SignatureProvider> </SignatureProvider>
</PageEditorProvider> </PageEditorProvider>

View File

@ -123,7 +123,7 @@ export default function OnboardingTour() {
const stepsConfig: Record<TourStep, StepType> = { const stepsConfig: Record<TourStep, StepType> = {
[TourStep.ALL_TOOLS]: { [TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]', selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'), content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center', position: 'center',
padding: 0, padding: 0,
action: () => { action: () => {

View File

@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
const iconNode = ( const iconNode = (
<span className="iconContainer"> <span className="iconContainer">
<AppsIcon sx={{ fontSize: '2rem' }} /> <AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
</span> </span>
); );
return ( return (
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}> <Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2"> <div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon <ActionIcon
component="a" component="a"
href={navProps.href} href={navProps.href}
onClick={handleNavClick} onClick={handleNavClick}
size={'lg'} size={isActive ? 'lg' : 'md'}
variant="subtle" variant="subtle"
aria-label={t("quickAccess.allTools", "All Tools")} aria-label={t("quickAccess.allTools", "Tools")}
style={{ style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)', backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)', color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
@ -61,7 +61,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
{iconNode} {iconNode}
</ActionIcon> </ActionIcon>
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}> <span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
{t("quickAccess.allTools", "All Tools")} {t("quickAccess.allTools", "Tools")}
</span> </span>
</div> </div>
</Tooltip> </Tooltip>

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core'; import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon'; import LocalIcon from '@app/components/shared/LocalIcon';
@ -7,6 +7,7 @@ import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { BASE_PATH } from '@app/constants/app'; import { BASE_PATH } from '@app/constants/app';
import { useLogoPath } from '@app/hooks/useLogoPath'; import { useLogoPath } from '@app/hooks/useLogoPath';
import { useFileManager } from '@app/hooks/useFileManager';
const LandingPage = () => { const LandingPage = () => {
const { addFiles } = useFileHandler(); const { addFiles } = useFileHandler();
@ -16,6 +17,8 @@ const LandingPage = () => {
const { openFilesModal } = useFilesModalContext(); const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false); const [isUploadHover, setIsUploadHover] = React.useState(false);
const logoPath = useLogoPath(); const logoPath = useLogoPath();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const handleFileDrop = async (files: File[]) => { const handleFileDrop = async (files: File[]) => {
await addFiles(files); await addFiles(files);
@ -38,6 +41,22 @@ const LandingPage = () => {
event.target.value = ''; event.target.value = '';
}; };
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
(async () => {
try {
const files = await loadRecentFiles();
if (isMounted) {
setHasRecents((files?.length || 0) > 0);
}
} catch (_err) {
if (isMounted) setHasRecents(false);
}
})();
return () => { isMounted = false; };
}, [loadRecentFiles]);
return ( return (
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}> <Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
{/* White PDF Page Background */} {/* White PDF Page Background */}
@ -119,59 +138,89 @@ const LandingPage = () => {
}} }}
onMouseLeave={() => setIsUploadHover(false)} onMouseLeave={() => setIsUploadHover(false)}
> >
<Button {/* Show both buttons only when recents exist; otherwise show a single Upload button */}
style={{ {hasRecents && (
backgroundColor: 'var(--landing-button-bg)', <>
color: 'var(--landing-button-color)', <Button
border: '1px solid var(--landing-button-border)', style={{
borderRadius: '2rem', backgroundColor: 'var(--landing-button-bg)',
height: '38px', color: 'var(--landing-button-color)',
paddingLeft: isUploadHover ? 0 : '1rem', border: '1px solid var(--landing-button-border)',
paddingRight: isUploadHover ? 0 : '1rem', borderRadius: '2rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)', height: '38px',
minWidth: isUploadHover ? '58px' : undefined, paddingLeft: isUploadHover ? 0 : '1rem',
display: 'flex', paddingRight: isUploadHover ? 0 : '1rem',
alignItems: 'center', width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
justifyContent: 'center', minWidth: isUploadHover ? '58px' : undefined,
transition: 'width .5s ease, padding .5s ease' display: 'flex',
}} alignItems: 'center',
onClick={handleOpenFilesModal} justifyContent: 'center',
onMouseEnter={() => setIsUploadHover(false)} transition: 'width .5s ease, padding .5s ease'
> }}
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" /> onClick={handleOpenFilesModal}
{!isUploadHover && ( onMouseEnter={() => setIsUploadHover(false)}
<span> >
{t('landing.addFiles', 'Add Files')} <LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
</span> {!isUploadHover && (
)} <span>
</Button> {t('landing.addFiles', 'Add Files')}
<Button </span>
aria-label="Upload" )}
style={{ </Button>
backgroundColor: 'var(--landing-button-bg)', <Button
color: 'var(--landing-button-color)', aria-label="Upload"
border: '1px solid var(--landing-button-border)', style={{
borderRadius: '1rem', backgroundColor: 'var(--landing-button-bg)',
height: '38px', color: 'var(--landing-button-color)',
width: isUploadHover ? 'calc(100% - 50px)' : '58px', border: '1px solid var(--landing-button-border)',
minWidth: '58px', borderRadius: '1rem',
paddingLeft: isUploadHover ? '1rem' : 0, height: '38px',
paddingRight: isUploadHover ? '1rem' : 0, width: isUploadHover ? 'calc(100% - 50px)' : '58px',
display: 'flex', minWidth: '58px',
alignItems: 'center', paddingLeft: isUploadHover ? '1rem' : 0,
justifyContent: 'center', paddingRight: isUploadHover ? '1rem' : 0,
transition: 'width .5s ease, padding .5s ease' display: 'flex',
}} alignItems: 'center',
onClick={handleNativeUploadClick} justifyContent: 'center',
onMouseEnter={() => setIsUploadHover(true)} transition: 'width .5s ease, padding .5s ease'
> }}
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} /> onClick={handleNativeUploadClick}
{isUploadHover && ( onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<span style={{ marginLeft: '.5rem' }}> <span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')} {t('landing.uploadFromComputer', 'Upload from computer')}
</span> </span>
)} </Button>
</Button> )}
</div> </div>
{/* Hidden file input for native file picker */} {/* Hidden file input for native file picker */}

View File

@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
import AppConfigModal from '@app/components/shared/AppConfigModal'; import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext'; import { useOnboarding } from '@app/contexts/OnboardingContext';
import { import {
isNavButtonActive, isNavButtonActive,
getNavButtonStyle, getNavButtonStyle,
@ -88,7 +89,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: () => handleClick(), onClick: () => handleClick(),
'aria-label': config.name 'aria-label': config.name
})} })}
size={isActive ? (config.size || 'lg') : 'lg'} size={isActive ? 'lg' : 'md'}
variant="subtle" variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''} className={isActive ? 'activeIconScale' : ''}
@ -108,9 +109,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const mainButtons: ButtonConfig[] = [ const mainButtons: ButtonConfig[] = [
{ {
id: 'read', id: 'read',
name: t("quickAccess.read", "Read"), name: t("quickAccess.reader", "Reader"),
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg', size: 'md',
isRound: false, isRound: false,
type: 'navigation', type: 'navigation',
onClick: () => { onClick: () => {
@ -118,23 +119,11 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
handleReaderToggle(); handleReaderToggle();
} }
}, },
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
{ {
id: 'automate', id: 'automate',
name: t("quickAccess.automate", "Automate"), name: t("quickAccess.automate", "Automate"),
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />, icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
size: 'lg', size: 'md',
isRound: false, isRound: false,
type: 'navigation', type: 'navigation',
onClick: () => { onClick: () => {
@ -147,37 +136,36 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
} }
} }
}, },
];
const middleButtons: ButtonConfig[] = [
{ {
id: 'files', id: 'files',
name: t("quickAccess.files", "Files"), name: t("quickAccess.files", "Files"),
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />, icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
isRound: true, isRound: true,
size: 'lg', size: 'md',
type: 'modal', type: 'modal',
onClick: handleFilesButtonClick onClick: handleFilesButtonClick
}, },
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
]; ];
const middleButtons: ButtonConfig[] = [];
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
const bottomButtons: ButtonConfig[] = [ const bottomButtons: ButtonConfig[] = [
{ {
id: 'help', id: 'help',
name: t("quickAccess.help", "Help"), name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
isRound: true, isRound: true,
size: 'lg', size: 'md',
type: 'action', type: 'action',
onClick: () => { onClick: () => {
// This will be overridden by the wrapper logic // This will be overridden by the wrapper logic
@ -185,9 +173,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}, },
{ {
id: 'config', id: 'config',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"), name: t("quickAccess.settings", "Settings"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />, icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg', size: 'md',
type: 'modal', type: 'modal',
onClick: () => { onClick: () => {
navigate('/settings/overview'); navigate('/settings/overview');
@ -200,7 +188,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div <div
ref={ref} ref={ref}
data-sidebar="quick-access" data-sidebar="quick-access"
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`} className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
style={{ style={{
borderRight: '1px solid var(--border-default)' borderRight: '1px solid var(--border-default)'
}} }}
@ -239,20 +227,30 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
))} ))}
</Stack> </Stack>
{/* Divider after main buttons */} {/* Divider after main buttons (creates gap) */}
<Divider {middleButtons.length === 0 && (
size="xs" <Divider
className="content-divider" size="xs"
/> className="content-divider"
/>
)}
{/* Middle section */} {/* Middle section */}
<Stack gap="lg" align="center"> {middleButtons.length > 0 && (
{middleButtons.map((config, index) => ( <>
<React.Fragment key={config.id}> <Divider
{renderNavButton(config, index)} size="xs"
</React.Fragment> className="content-divider"
))} />
</Stack> <Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
</>
)}
{/* Spacer to push bottom buttons to bottom */} {/* Spacer to push bottom buttons to bottom */}
<div className="spacer" /> <div className="spacer" />

View File

@ -14,6 +14,8 @@ import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext'; import { useSignature } from '@app/contexts/SignatureContext';
import LocalIcon from '@app/components/shared/LocalIcon'; import LocalIcon from '@app/components/shared/LocalIcon';
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions'; import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import { useSidebarContext } from '@app/contexts/SidebarContext'; import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail'; import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
@ -39,7 +41,7 @@ export default function RightRail() {
const { sidebarRefs } = useSidebarContext(); const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation(); const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext); const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext(); const { toggleTheme, themeMode } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail(); const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
@ -195,7 +197,11 @@ export default function RightRail() {
className="right-rail-icon" className="right-rail-icon"
onClick={toggleTheme} onClick={toggleTheme}
> >
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" /> {themeMode === 'dark' ? (
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
) : (
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
)}
</ActionIcon>, </ActionIcon>,
t('rightRail.toggleTheme', 'Toggle Theme') t('rightRail.toggleTheme', 'Toggle Theme')
)} )}

View File

@ -2,10 +2,10 @@ import React, { useState, useCallback, useMemo } from "react";
import { SegmentedControl, Loader } from "@mantine/core"; import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import rainbowStyles from '@app/styles/rainbow.module.css'; import rainbowStyles from '@app/styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility"; import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import GridViewIcon from "@mui/icons-material/GridView";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { LocalIcon } from '@app/components/shared/LocalIcon';
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench'; import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown'; import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext'; import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
@ -55,7 +55,7 @@ const createViewOptions = (
{switchingTo === "viewer" ? ( {switchingTo === "viewer" ? (
<Loader size="sm" /> <Loader size="sm" />
) : ( ) : (
<VisibilityIcon fontSize="medium" /> <InsertDriveFileIcon fontSize="medium" />
)} )}
</div> </div>
), ),
@ -84,7 +84,7 @@ const createViewOptions = (
{switchingTo === "pageEditor" ? ( {switchingTo === "pageEditor" ? (
<Loader size="sm" /> <Loader size="sm" />
) : ( ) : (
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" /> <GridViewIcon fontSize="medium" />
)} )}
</div> </div>
), ),

View File

@ -1,13 +1,13 @@
/** /**
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar * ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
* *
* When a user selects a tool from the All Tools list, this component displays the tool's * When a user selects a tool from the Tools list, this component displays the tool's
* icon and name at the top of the navigation bar. It provides a quick way to see which * icon and name at the top of the navigation bar. It provides a quick way to see which
* tool is currently active and offers a back button to return to the All Tools list. * tool is currently active and offers a back button to return to the Tools list.
* *
* Features: * Features:
* - Shows tool icon and name when a tool is selected * - Shows tool icon and name when a tool is selected
* - Hover to reveal back arrow for returning to All Tools * - Hover to reveal back arrow for returning to Tools
* - Smooth slide-down/slide-up animations * - Smooth slide-down/slide-up animations
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate) * - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
*/ */
@ -149,7 +149,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
handleBackToTools(); handleBackToTools();
}); });
}} }}
size={'xl'} size={'lg'}
variant="subtle" variant="subtle"
onMouseEnter={() => setIsBackHover(true)} onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)} onMouseLeave={() => setIsBackHover(false)}
@ -165,7 +165,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
> >
<span className="iconContainer"> <span className="iconContainer">
{isBackHover ? ( {isBackHover ? (
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} /> <ArrowBackRoundedIcon sx={{ fontSize: '1.875rem' }} />
) : ( ) : (
indicatorTool.icon indicatorTool.icon
)} )}

View File

@ -1,15 +1,29 @@
.activeIconScale { .activeIconScale {
transform: scale(1.3); transform: scale(1);
transition: transform 0.2s; transition: transform 0.2s;
z-index: 1; z-index: 1;
width: calc(1.75rem + 10px) !important;
height: calc(1.75rem + 10px) !important;
}
.activeIconScale .iconContainer {
width: calc(1.5rem + 10px);
height: calc(1.5rem + 10px);
}
.activeIconScale .iconContainer svg,
.activeIconScale .iconContainer img {
width: calc(1.25rem + 10px) !important;
height: calc(1.25rem + 10px) !important;
} }
.iconContainer { .iconContainer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2rem; width: 1.5rem;
height: 2rem; height: 1.5rem;
transition: width 0.2s, height 0.2s;
} }
/* Action icon styles */ /* Action icon styles */
@ -24,9 +38,9 @@
/* Main container styles */ /* Main container styles */
.quick-access-bar-main { .quick-access-bar-main {
background-color: var(--bg-muted); background-color: var(--bg-muted);
width: 5rem; width: 4rem;
min-width: 5rem; min-width: 4rem;
max-width: 5rem; max-width: 4rem;
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
@ -34,9 +48,9 @@
/* Rainbow mode container */ /* Rainbow mode container */
.quick-access-bar-main.rainbow-mode { .quick-access-bar-main.rainbow-mode {
background-color: var(--bg-muted); background-color: var(--bg-muted);
width: 5rem; width: 4rem;
min-width: 5rem; min-width: 4rem;
max-width: 5rem; max-width: 4rem;
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
@ -57,7 +71,7 @@
/* Nav header divider */ /* Nav header divider */
.nav-header-divider { .nav-header-divider {
width: 3.75rem; width: 3rem;
border-color: var(--color-gray-300); border-color: var(--color-gray-300);
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@ -85,7 +99,7 @@
/* Overflow divider */ /* Overflow divider */
.overflow-divider { .overflow-divider {
width: 3.75rem; width: 3rem;
border-color: var(--color-gray-300); border-color: var(--color-gray-300);
margin: 0 0.5rem; margin: 0 0.5rem;
} }
@ -143,7 +157,7 @@
/* Content divider */ /* Content divider */
.content-divider { .content-divider {
width: 3.75rem; width: 3rem;
border-color: var(--color-gray-300); border-color: var(--color-gray-300);
margin: 1rem 0; margin: 1rem 0;
} }
@ -241,7 +255,7 @@
/* Divider that animates growing from top */ /* Divider that animates growing from top */
.current-tool-divider { .current-tool-divider {
width: 3.75rem; width: 3rem;
border-color: var(--color-gray-300); border-color: var(--color-gray-300);
margin: 0.5rem auto 0.5rem auto; margin: 0.5rem auto 0.5rem auto;
transform-origin: top; transform-origin: top;

View File

@ -1,4 +1,4 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from "react"; import React, { useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core"; import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
@ -8,11 +8,10 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems"; import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import NoToolsFound from "@app/components/tools/shared/NoToolsFound"; import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons"; import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
import Badge from "@app/components/shared/Badge";
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
import ToolButton from "@app/components/tools/toolPicker/ToolButton"; import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId"; import { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
interface ToolPickerProps { interface ToolPickerProps {
selectedToolKey: string | null; selectedToolKey: string | null;
@ -23,49 +22,8 @@ interface ToolPickerProps {
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => { const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
const scrollableRef = useRef<HTMLDivElement>(null); const scrollableRef = useRef<HTMLDivElement>(null);
const quickHeaderRef = useRef<HTMLDivElement>(null);
const allHeaderRef = useRef<HTMLDivElement>(null);
const quickAccessRef = useRef<HTMLDivElement>(null);
const allToolsRef = useRef<HTMLDivElement>(null);
// Keep header heights in sync with any dynamic size changes
useLayoutEffect(() => {
const update = () => {
if (quickHeaderRef.current) {
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
}
if (allHeaderRef.current) {
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
}
};
update();
// Update on window resize
window.addEventListener("resize", update);
// Update on element resize (e.g., font load, badge count change, zoom)
const observers: ResizeObserver[] = [];
if (typeof ResizeObserver !== "undefined") {
const observe = (el: HTMLDivElement | null, cb: () => void) => {
if (!el) return;
const ro = new ResizeObserver(() => cb());
ro.observe(el);
observers.push(ro);
};
observe(quickHeaderRef.current, update);
observe(allHeaderRef.current, update);
}
return () => {
window.removeEventListener("resize", update);
observers.forEach(o => o.disconnect());
};
}, []);
const { sections: visibleSections } = useToolSections(filteredTools); const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow(); const { favoriteTools, toolRegistry } = useToolWorkflow();
@ -84,31 +42,25 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
return items; return items;
}, [quickSection]); }, [quickSection]);
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
const allSection = useMemo( const allSection = useMemo(
() => visibleSections.find(s => s.key === 'all'), () => visibleSections.find(s => s.key === 'all'),
[visibleSections] [visibleSections]
); );
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
const container = scrollableRef.current;
const target = ref.current;
if (container && target) {
const stackedOffset = ref === allToolsRef
? (quickHeaderHeight + allHeaderHeight)
: quickHeaderHeight;
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
container.scrollTo({
top: Math.max(0, top),
behavior: "smooth"
});
}
};
// Build flat list by subcategory for search mode // Build flat list by subcategory for search mode
const emptyFilteredTools: ToolPickerProps['filteredTools'] = []; const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools; const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch); const { searchGroups } = useToolSections(effectiveFilteredForSearch);
const headerTextStyle: React.CSSProperties = {
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.5rem 0 0.25rem 0.5rem",
textTransform: "none",
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
opacity: 0.7
};
const toTitleCase = (s: string) =>
s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
return ( return (
<Box <Box
@ -141,110 +93,55 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</Stack> </Stack>
) : ( ) : (
<> <>
{quickSection && ( {/* Flat list: favorites and recommended first, then all subcategories */}
<> <Stack p="sm" gap="xs">
<div {favoriteToolItems.length > 0 && (
ref={quickHeaderRef} <Box w="100%">
style={{ <div style={headerTextStyle}>
position: "sticky", {t('toolPanel.fullscreen.favorites', 'Favourites')}
top: 0, </div>
zIndex: 2, <div>
borderTop: `0.0625rem solid var(--tool-header-border)`, {favoriteToolItems.map(({ id, tool }) => (
borderBottom: `0.0625rem solid var(--tool-header-border)`, <ToolButton
padding: "0.5rem 1rem", key={`fav-${id}`}
fontWeight: 600, id={id}
background: "var(--tool-header-bg)", tool={tool}
color: "var(--tool-header-text)", isSelected={selectedToolKey === id}
cursor: "pointer", onSelect={onSelect}
display: "flex", hasStars
alignItems: "center", />
justifyContent: "space-between", ))}
}} </div>
onClick={() => scrollTo(quickAccessRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<Badge>
{recommendedCount}
</Badge>
</div>
<Box ref={quickAccessRef} w="100%" my="sm">
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
</Stack>
</Box> </Box>
</> )}
)} {recommendedItems.length > 0 && (
<Box w="100%">
{allSection && ( <div style={headerTextStyle}>
<> {t('toolPanel.fullscreen.recommended', 'Recommended')}
<div </div>
ref={allHeaderRef} <div>
style={{ {recommendedItems.map(({ id, tool }) => (
position: "sticky", <ToolButton
top: quickSection ? quickHeaderHeight -1 : 0, key={`rec-${id}`}
zIndex: 2, id={id as ToolId}
borderTop: `0.0625rem solid var(--tool-header-border)`, tool={tool}
borderBottom: `0.0625rem solid var(--tool-header-border)`, isSelected={selectedToolKey === id}
padding: "0.5rem 1rem", onSelect={onSelect}
fontWeight: 600, hasStars
background: "var(--tool-header-bg)", />
color: "var(--tool-header-text)", ))}
cursor: "pointer", </div>
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(allToolsRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<Badge>
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</Badge>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
)}
</Stack>
</Box> </Box>
</> )}
)} {allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}>
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
</div>
{renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)}
</Box>
))}
</Stack>
{!quickSection && !allSection && <NoToolsFound />} {!quickSection && !allSection && <NoToolsFound />}

View File

@ -80,7 +80,7 @@ export default function ToolSelector({
// If no sections, create a simple group from filtered tools // If no sections, create a simple group from filtered tools
if (baseFilteredTools.length > 0) { if (baseFilteredTools.length > 0) {
return [{ return [{
name: 'All Tools', name: 'Tools',
subcategoryId: 'all' as any, subcategoryId: 'all' as any,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool })) tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
}]; }];

View File

@ -68,7 +68,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
const restoreWorkbenchState = useCallback(async () => { const restoreWorkbenchState = useCallback(async () => {
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length); console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
// Go back to All Tools // Go back to Tools
handleBackToTools(); handleBackToTools();
// Clear all files (including tour sample) // Clear all files (including tour sample)

View File

@ -64,8 +64,12 @@ export function useToolSections(
Object.entries(subs).forEach(([s, tools]) => { Object.entries(subs).forEach(([s, tools]) => {
const subcategoryId = s as SubcategoryId; const subcategoryId = s as SubcategoryId;
if (!all[subcategoryId]) all[subcategoryId] = []; // Build the 'all' collection without duplicating recommended tools
all[subcategoryId].push(...tools); // Recommended tools are shown in the Quick section only
if (categoryId !== ToolCategoryId.RECOMMENDED_TOOLS) {
if (!all[subcategoryId]) all[subcategoryId] = [];
all[subcategoryId].push(...tools);
}
}); });
if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) { if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) {

View File

@ -218,7 +218,7 @@ export default function HomePage() {
<div className="mobile-bottom-bar"> <div className="mobile-bottom-bar">
<button <button
className="mobile-bottom-button" className="mobile-bottom-button"
aria-label={t('quickAccess.allTools', 'All Tools')} aria-label={t('quickAccess.allTools', 'Tools')}
onClick={() => { onClick={() => {
handleBackToTools(); handleBackToTools();
if (isMobile) { if (isMobile) {
@ -227,7 +227,7 @@ export default function HomePage() {
}} }}
> >
<AppsIcon sx={{ fontSize: '1.5rem' }} /> <AppsIcon sx={{ fontSize: '1.5rem' }} />
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'All Tools')}</span> <span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'Tools')}</span>
</button> </button>
<button <button
className="mobile-bottom-button" className="mobile-bottom-button"

View File

@ -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<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(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 (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={handleClose}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers.label', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{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')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
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
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</Box>
</Modal>
);
}

View File

@ -13,10 +13,6 @@ import {
Group, Group,
Modal, Modal,
Select, Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip, Tooltip,
CloseButton, CloseButton,
Avatar, Avatar,
@ -28,6 +24,7 @@ import { userManagementService, User } from '@app/services/userManagementService
import { teamService, Team } from '@app/services/teamService'; import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired'; import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
@ -43,8 +40,6 @@ export default function PeopleSection() {
const [editUserModalOpened, setEditUserModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
// License information // License information
const [licenseInfo, setLicenseInfo] = useState<{ const [licenseInfo, setLicenseInfo] = useState<{
@ -56,31 +51,6 @@ export default function PeopleSection() {
totalUsers: number; totalUsers: number;
} | null>(null); } | 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,
});
// Form state for edit user modal // Form state for edit user modal
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
role: 'ROLE_USER', role: 'ROLE_USER',
@ -205,143 +175,6 @@ export default function PeopleSection() {
} }
}; };
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') });
setInviteModalOpened(false);
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
fetchData();
} catch (error: any) {
console.error('Failed to create 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', 'At least one email address is required') });
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails,
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', `${response.successCount} user(s) invited successfully`)
});
if (response.failureCount > 0 && response.errors) {
alert({
alertType: 'warning',
title: t('workspace.people.emailInvite.partialSuccess', 'Some invites failed'),
body: response.errors
});
}
setInviteModalOpened(false);
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
fetchData();
} 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,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
});
// Construct full frontend URL
const frontendUrl = `${window.location.origin}/invite/${response.token}`;
setGeneratedInviteLink(frontendUrl);
if (response.emailSent) {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and email sent!')
});
} else if (inviteLinkForm.sendEmail && response.emailError) {
// Email was requested but failed
alert({
alertType: 'warning',
title: t('workspace.people.inviteLink.emailFailed', 'Invite link generated, but email failed'),
body: t('workspace.people.inviteLink.emailFailedDetails', 'Error: {0}. Please share the invite link manually.').replace('{0}', response.emailError)
});
} else {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.success', 'Invite link generated successfully!')
});
}
} 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 handleUpdateUserRole = async () => { const handleUpdateUserRole = async () => {
if (!selectedUser) return; if (!selectedUser) return;
@ -420,18 +253,6 @@ export default function PeopleSection() {
}); });
}; };
const closeInviteModal = () => {
setInviteModalOpened(false);
setGeneratedInviteLink(null);
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
};
const filteredUsers = users.filter((user) => const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase()) user.username.toLowerCase().includes(searchQuery.toLowerCase())
); );
@ -720,277 +541,11 @@ export default function PeopleSection() {
</Table.Tbody> </Table.Tbody>
</Table> </Table>
{/* Add Member Modal */} {/* Invite Members Modal (reusable) */}
<Modal <InviteMembersModal
opened={inviteModalOpened} opened={inviteModalOpened}
onClose={closeInviteModal} onClose={() => setInviteModalOpened(false)}
size="md" />
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={closeInviteModal}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers.label', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{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')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
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
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</Box>
</Modal>
{/* Edit User Modal */} {/* Edit User Modal */}
<Modal <Modal

View File

@ -69,7 +69,7 @@ export interface InviteUsersResponse {
} }
export interface InviteLinkRequest { export interface InviteLinkRequest {
email: string; email?: string;
role: string; role: string;
teamId?: number; teamId?: number;
expiryHours?: number; expiryHours?: number;