mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Add audit system, invite links, and usage analytics (#4749)
# Description of Changes New Features Audit System: Complete audit logging with dashboard, event tracking, and export capabilities Invite Links: Secure invite system with email notifications and expiration Usage Analytics: Endpoint usage statistics and visualization License Management: User counting with grandfathering and license enforcement ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
f5c67a3239
commit
ac3e10eb99
@@ -6,6 +6,7 @@ import Landing from "@app/routes/Landing";
|
||||
import Login from "@app/routes/Login";
|
||||
import Signup from "@app/routes/Signup";
|
||||
import AuthCallback from "@app/routes/AuthCallback";
|
||||
import InviteAccept from "@app/routes/InviteAccept";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
|
||||
// Import global styles
|
||||
@@ -26,6 +27,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
||||
|
||||
{/* Main app routes - Landing handles auth logic */}
|
||||
<Route path="/*" element={<Landing />} />
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { createConfigNavSections as createCoreConfigNavSections, ConfigNavSection } from '@core/components/shared/config/configNavSections';
|
||||
import PeopleSection from '@app/components/shared/config/configSections/PeopleSection';
|
||||
import TeamsSection from '@app/components/shared/config/configSections/TeamsSection';
|
||||
|
||||
/**
|
||||
* Proprietary extension of createConfigNavSections that adds workspace sections
|
||||
*/
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
// Get the core sections
|
||||
const sections = createCoreConfigNavSections(isAdmin, runningEE);
|
||||
|
||||
// Add Workspace section if user is admin
|
||||
if (isAdmin) {
|
||||
const workspaceSection: ConfigNavSection = {
|
||||
title: 'Workspace',
|
||||
items: [
|
||||
{
|
||||
key: 'people',
|
||||
label: 'People',
|
||||
icon: 'group-rounded',
|
||||
component: <PeopleSection />
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
label: 'Teams',
|
||||
icon: 'groups-rounded',
|
||||
component: <TeamsSection />
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Insert workspace section after Preferences (at index 1)
|
||||
sections.splice(1, 0, workspaceSection);
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { ConfigNavSection, ConfigNavItem, ConfigColors } from '@core/components/shared/config/configNavSections';
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
|
||||
|
||||
/**
|
||||
* Proprietary extension of GeneralSection that adds account management
|
||||
*/
|
||||
const GeneralSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { signOut, user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
navigate('/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.description', 'Configure general application preferences.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<Stack gap="xs" align="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.user', 'User')}: <strong>{user.email || user.username}</strong>
|
||||
</Text>
|
||||
<Button color="red" variant="outline" size="xs" onClick={handleLogout}>
|
||||
{t('settings.general.logout', 'Log out')}
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Render core general section preferences (without title since we show it above) */}
|
||||
<CoreGeneralSection hideTitle />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSection;
|
||||
@@ -0,0 +1,974 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
TextInput,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Badge,
|
||||
Loader,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Paper,
|
||||
Checkbox,
|
||||
Textarea,
|
||||
SegmentedControl,
|
||||
Tooltip,
|
||||
CloseButton,
|
||||
Avatar,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { userManagementService, User } 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';
|
||||
|
||||
export default function PeopleSection() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [inviteModalOpened, setInviteModalOpened] = useState(false);
|
||||
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
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,
|
||||
});
|
||||
|
||||
// Form state for edit user modal
|
||||
const [editForm, setEditForm] = useState({
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined as number | undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
console.log('[PeopleSection] Email invites enabled:', config.enableEmailInvites);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [adminData, teamsData] = await Promise.all([
|
||||
userManagementService.getUsers(),
|
||||
teamService.getTeams(),
|
||||
]);
|
||||
|
||||
// Enrich users with session data
|
||||
const enrichedUsers = adminData.users.map(user => ({
|
||||
...user,
|
||||
isActive: adminData.userSessions[user.username] || false,
|
||||
lastRequest: adminData.userLastRequest[user.username] || undefined,
|
||||
}));
|
||||
|
||||
setUsers(enrichedUsers);
|
||||
setTeams(teamsData);
|
||||
|
||||
// Store license information
|
||||
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 people data:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load people data' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await userManagementService.updateUserRole({
|
||||
username: selectedUser.username,
|
||||
role: editForm.role,
|
||||
teamId: editForm.teamId,
|
||||
});
|
||||
alert({ alertType: 'success', title: t('workspace.people.editMember.success') });
|
||||
closeEditModal();
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.editMember.error');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (user: User) => {
|
||||
try {
|
||||
await userManagementService.toggleUserEnabled(user.username, !user.enabled);
|
||||
alert({ alertType: 'success', title: t('workspace.people.toggleEnabled.success') });
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to toggle user status:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.toggleEnabled.error');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: User) => {
|
||||
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
|
||||
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userManagementService.deleteUser(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.deleteUserError', 'Failed to delete user');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setEditForm({
|
||||
role: user.roleName,
|
||||
teamId: user.team?.id,
|
||||
});
|
||||
setEditUserModalOpened(true);
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditUserModalOpened(false);
|
||||
setSelectedUser(null);
|
||||
setEditForm({
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const closeInviteModal = () => {
|
||||
setInviteModalOpened(false);
|
||||
setGeneratedInviteLink(null);
|
||||
setInviteLinkForm({
|
||||
email: '',
|
||||
role: 'ROLE_USER',
|
||||
teamId: undefined,
|
||||
expiryHours: 72,
|
||||
sendEmail: false,
|
||||
});
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter((user) =>
|
||||
user.username.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: 'ROLE_ADMIN',
|
||||
label: t('workspace.people.admin'),
|
||||
description: t('workspace.people.roleDescriptions.admin', 'Can manage settings and invite members, with full administrative access.'),
|
||||
icon: 'admin-panel-settings'
|
||||
},
|
||||
{
|
||||
value: 'ROLE_USER',
|
||||
label: t('workspace.people.member'),
|
||||
description: t('workspace.people.roleDescriptions.member', 'Can view and edit shared files, but cannot manage workspace settings or members.'),
|
||||
icon: 'person'
|
||||
},
|
||||
];
|
||||
|
||||
const renderRoleOption = ({ option }: { option: any }) => (
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<LocalIcon icon={option.icon} width="1.25rem" height="1.25rem" style={{ flexShrink: 0 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>{option.label}</Text>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const teamOptions = teams.map((team) => ({
|
||||
value: team.id.toString(),
|
||||
label: team.name,
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.people.loading', 'Loading people...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('workspace.people.title')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.people.description')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* License Information - Compact */}
|
||||
{licenseInfo && (
|
||||
<Group gap="md" c="dimmed" style={{ fontSize: '0.875rem' }}>
|
||||
<Text size="sm" span>
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.totalUsers}</Text>
|
||||
<Text component="span" c="dimmed"> / </Text>
|
||||
<Text component="span" fw={600} c="inherit">{licenseInfo.maxAllowedUsers}</Text>
|
||||
<Text component="span" c="dimmed" ml={4}>{t('workspace.people.license.users', 'users')}</Text>
|
||||
</Text>
|
||||
|
||||
{licenseInfo.availableSlots === 0 && (
|
||||
<Badge color="red" variant="light" size="sm">
|
||||
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{licenseInfo.grandfatheredUserCount > 0 && (
|
||||
<Text size="sm" c="dimmed" span>
|
||||
•
|
||||
<Text component="span" ml={4}>
|
||||
{t('workspace.people.license.grandfatheredShort', '{{count}} grandfathered', { count: licenseInfo.grandfatheredUserCount })}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && (
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Header Actions */}
|
||||
<Group justify="space-between">
|
||||
<TextInput
|
||||
placeholder={t('workspace.people.searchMembers')}
|
||||
leftSection={<LocalIcon icon="search" width="1rem" height="1rem" />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
style={{ maxWidth: 300 }}
|
||||
/>
|
||||
<Tooltip
|
||||
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
|
||||
position="bottom"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setInviteModalOpened(true)}
|
||||
disabled={licenseInfo ? licenseInfo.availableSlots === 0 : false}
|
||||
>
|
||||
{t('workspace.people.addMembers')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Members Table */}
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.user')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
|
||||
{t('workspace.people.role')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.team')}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.people.noMembersFound')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!user.enabled
|
||||
? t('workspace.people.disabled', 'Disabled')
|
||||
: user.isActive
|
||||
? t('workspace.people.activeSession', 'Active session')
|
||||
: t('workspace.people.active', 'Active')
|
||||
}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? 'blue' : 'gray'}
|
||||
styles={{
|
||||
root: {
|
||||
border: user.isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin', 'Admin')
|
||||
: t('workspace.people.member', 'Member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{user.team?.name ? (
|
||||
<Tooltip label={user.team.name} disabled={user.team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
maw={150}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.team.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">—</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
|
||||
<Text size="xs">
|
||||
Last Activity: {user.lastRequest
|
||||
? new Date(user.lastRequest).toLocaleString()
|
||||
: 'Never'}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
multiline
|
||||
w={220}
|
||||
position="left"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<LocalIcon icon="info" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions menu */}
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
|
||||
<Menu.Item onClick={() => openEditModal(user)}>{t('workspace.people.editRole')}</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={user.enabled ? <LocalIcon icon="person-off" width="1rem" height="1rem" /> : <LocalIcon icon="person-check" width="1rem" height="1rem" />}
|
||||
onClick={() => handleToggleEnabled(user)}
|
||||
>
|
||||
{user.enabled ? t('workspace.people.disable') : t('workspace.people.enable')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />} onClick={() => handleDeleteUser(user)}>
|
||||
{t('workspace.people.deleteUser')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={inviteModalOpened}
|
||||
onClose={closeInviteModal}
|
||||
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', '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', '[email protected]')}
|
||||
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', '[email protected], [email protected]')}
|
||||
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 */}
|
||||
<Modal
|
||||
opened={editUserModalOpened}
|
||||
onClose={closeEditModal}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<Box pos="relative">
|
||||
<CloseButton
|
||||
onClick={closeEditModal}
|
||||
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="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t('workspace.people.editMember.title')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.people.editMember.editing')} <strong>{selectedUser?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
<Select
|
||||
label={t('workspace.people.editMember.role')}
|
||||
data={roleOptions}
|
||||
value={editForm.role}
|
||||
onChange={(value) => setEditForm({ ...editForm, role: value || 'ROLE_USER' })}
|
||||
renderOption={renderRoleOption}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Select
|
||||
label={t('workspace.people.editMember.team')}
|
||||
placeholder={t('workspace.people.editMember.teamPlaceholder')}
|
||||
data={teamOptions}
|
||||
value={editForm.teamId?.toString()}
|
||||
onChange={(value) => setEditForm({ ...editForm, teamId: value ? parseInt(value) : undefined })}
|
||||
clearable
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
<Button onClick={handleUpdateUserRole} loading={processing} fullWidth size="md" mt="md">
|
||||
{t('workspace.people.editMember.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+545
@@ -0,0 +1,545 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Loader,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
CloseButton,
|
||||
Tooltip,
|
||||
Menu,
|
||||
Avatar,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { teamService, Team } from '@app/services/teamService';
|
||||
import { User, userManagementService } from '@app/services/userManagementService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
interface TeamDetailsSectionProps {
|
||||
teamId: number;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [team, setTeam] = useState<Team | null>(null);
|
||||
const [teamUsers, setTeamUsers] = useState<User[]>([]);
|
||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
||||
const [allTeams, setAllTeams] = useState<Team[]>([]);
|
||||
const [userLastRequest, setUserLastRequest] = useState<Record<string, number>>({});
|
||||
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
|
||||
const [changeTeamModalOpened, setChangeTeamModalOpened] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string>('');
|
||||
const [processing, setProcessing] = useState(false);
|
||||
|
||||
// License information
|
||||
const [licenseInfo, setLicenseInfo] = useState<{
|
||||
availableSlots: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeamDetails();
|
||||
fetchAllTeams();
|
||||
}, [teamId]);
|
||||
|
||||
const fetchTeamDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [data, adminData] = await Promise.all([
|
||||
teamService.getTeamDetails(teamId),
|
||||
userManagementService.getUsers(),
|
||||
]);
|
||||
console.log('[TeamDetailsSection] Raw data:', data);
|
||||
setTeam(data.team);
|
||||
setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []);
|
||||
setAvailableUsers(Array.isArray(data.availableUsers) ? data.availableUsers : []);
|
||||
setUserLastRequest(data.userLastRequest || {});
|
||||
|
||||
// Store license information
|
||||
setLicenseInfo({
|
||||
availableSlots: adminData.availableSlots,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch team details:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load team details' });
|
||||
onBack();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllTeams = async () => {
|
||||
try {
|
||||
const teams = await teamService.getTeams();
|
||||
setAllTeams(teams);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch teams:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedUserId) {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.selectUserRequired', 'Please select a user') });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.addUserToTeam(teamId, parseInt(selectedUserId));
|
||||
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success', 'User added to team successfully') });
|
||||
setAddMemberModalOpened(false);
|
||||
setSelectedUserId('');
|
||||
fetchTeamDetails();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to add member:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.teams.addMemberToTeam.error', 'Failed to add user to team');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (user: User) => {
|
||||
if (!window.confirm(t('workspace.teams.confirmRemove', `Remove ${user.username} from this team?`))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
// Find the Default team ID
|
||||
const defaultTeam = allTeams.find(t => t.name === 'Default');
|
||||
|
||||
if (!defaultTeam) {
|
||||
throw new Error('Default team not found');
|
||||
}
|
||||
|
||||
// Move user to Default team by updating their role with the Default team ID
|
||||
await teamService.moveUserToTeam(user.username, user.rolesAsString || 'ROLE_USER', defaultTeam.id);
|
||||
alert({ alertType: 'success', title: t('workspace.teams.removeMemberSuccess', 'User removed from team') });
|
||||
fetchTeamDetails();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to remove member:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.teams.removeMemberError', 'Failed to remove user from team');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (user: User) => {
|
||||
const confirmMessage = t('workspace.people.confirmDelete', 'Are you sure you want to delete this user? This action cannot be undone.');
|
||||
if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await userManagementService.deleteUser(user.username);
|
||||
alert({ alertType: 'success', title: t('workspace.people.deleteUserSuccess', 'User deleted successfully') });
|
||||
fetchTeamDetails();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete user:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.people.deleteUserError', 'Failed to delete user');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openChangeTeamModal = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setSelectedTeamId(user.team?.id?.toString() || '');
|
||||
setChangeTeamModalOpened(true);
|
||||
};
|
||||
|
||||
const handleChangeTeam = async () => {
|
||||
if (!selectedUser || !selectedTeamId) {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.changeTeam.selectTeamRequired', 'Please select a team') });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.moveUserToTeam(selectedUser.username, selectedUser.rolesAsString || 'ROLE_USER', parseInt(selectedTeamId));
|
||||
alert({ alertType: 'success', title: t('workspace.teams.changeTeam.success', 'Team changed successfully') });
|
||||
setChangeTeamModalOpened(false);
|
||||
setSelectedUser(null);
|
||||
setSelectedTeamId('');
|
||||
fetchTeamDetails();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to change team:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.teams.changeTeam.error', 'Failed to change team');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.teams.loadingDetails', 'Loading team details...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<Stack align="center" py="xl">
|
||||
<Text size="sm" c="red">
|
||||
{t('workspace.teams.teamNotFound', 'Team not found')}
|
||||
</Text>
|
||||
<Button variant="light" onClick={onBack}>
|
||||
{t('workspace.teams.backToTeams', 'Back to Teams')}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header with back button */}
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" onClick={onBack}>
|
||||
<LocalIcon icon="arrow-back" width="1.2rem" height="1.2rem" />
|
||||
</ActionIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text fw={600} size="lg">
|
||||
{team.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.teams.memberCount', { count: teamUsers.length })} {teamUsers.length === 1 ? 'member' : 'members'}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Add Member Button */}
|
||||
<Group justify="flex-end">
|
||||
<Tooltip
|
||||
label={t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
|
||||
disabled={!licenseInfo || licenseInfo.availableSlots > 0}
|
||||
position="bottom"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
|
||||
onClick={() => setAddMemberModalOpened(true)}
|
||||
disabled={team.name === 'Internal' || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
|
||||
>
|
||||
{t('workspace.teams.addMember')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Members Table */}
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm">
|
||||
{t('workspace.people.user')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, color: 'var(--mantine-color-gray-7)' }} fz="sm" w={100}>
|
||||
{t('workspace.people.role')}
|
||||
</Table.Th>
|
||||
<Table.Th w={50}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teamUsers.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.teams.noMembers', 'No members in this team')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
teamUsers.map((user) => {
|
||||
const isActive = userLastRequest[user.username] &&
|
||||
(Date.now() - userLastRequest[user.username]) < 5 * 60 * 1000; // Active within last 5 minutes
|
||||
|
||||
return (
|
||||
<Table.Tr key={user.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label={
|
||||
!user.enabled
|
||||
? t('workspace.people.disabled', 'Disabled')
|
||||
: isActive
|
||||
? t('workspace.people.activeSession', 'Active session')
|
||||
: t('workspace.people.active', 'Active')
|
||||
}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<Avatar
|
||||
size={32}
|
||||
color={user.enabled ? 'blue' : 'gray'}
|
||||
styles={{
|
||||
root: {
|
||||
border: isActive ? '2px solid var(--mantine-color-green-6)' : 'none',
|
||||
opacity: user.enabled ? 1 : 0.5,
|
||||
}
|
||||
}}
|
||||
>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip label={user.username} disabled={user.username.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
maw={200}
|
||||
style={{
|
||||
lineHeight: 1.3,
|
||||
opacity: user.enabled ? 1 : 0.6,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{user.username}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{user.email && (
|
||||
<Text size="xs" c="dimmed" truncate style={{ lineHeight: 1.3 }}>
|
||||
{user.email}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td w={100}>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={(user.rolesAsString || '').includes('ROLE_ADMIN') ? 'blue' : 'gray'}
|
||||
variant="light"
|
||||
>
|
||||
{(user.rolesAsString || '').includes('ROLE_ADMIN')
|
||||
? t('workspace.people.admin')
|
||||
: t('workspace.people.member')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Info icon with tooltip */}
|
||||
<Tooltip
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={500}>
|
||||
Authentication: {user.authenticationType || 'Unknown'}
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
Last Activity:{' '}
|
||||
{userLastRequest[user.username]
|
||||
? new Date(userLastRequest[user.username]).toLocaleString()
|
||||
: 'Never'}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
multiline
|
||||
w={220}
|
||||
position="left"
|
||||
withArrow
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 10}
|
||||
>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm">
|
||||
<LocalIcon icon="info" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Actions menu */}
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="swap-horiz" width="1rem" height="1rem" />}
|
||||
onClick={() => openChangeTeamModal(user)}
|
||||
disabled={processing || team.name === 'Internal'}
|
||||
>
|
||||
{t('workspace.teams.changeTeam.label', 'Change Team')}
|
||||
</Menu.Item>
|
||||
{team.name !== 'Internal' && team.name !== 'Default' && (
|
||||
<Menu.Item
|
||||
leftSection={<LocalIcon icon="person-remove" width="1rem" height="1rem" />}
|
||||
onClick={() => handleRemoveMember(user)}
|
||||
disabled={processing}
|
||||
>
|
||||
{t('workspace.teams.removeMember', 'Remove from team')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
|
||||
onClick={() => handleDeleteUser(user)}
|
||||
disabled={processing || team.name === 'Internal'}
|
||||
>
|
||||
{t('workspace.people.deleteUser', 'Delete User')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={addMemberModalOpened}
|
||||
onClose={() => setAddMemberModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={() => setAddMemberModalOpened(false)}
|
||||
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.teams.addMemberToTeam.title')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{team.name}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Select
|
||||
label={t('workspace.teams.addMemberToTeam.selectUser')}
|
||||
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
|
||||
data={availableUsers.map((user) => ({
|
||||
value: user.id.toString(),
|
||||
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
|
||||
}))}
|
||||
value={selectedUserId}
|
||||
onChange={(value) => setSelectedUserId(value || '')}
|
||||
searchable
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
|
||||
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
{t('workspace.teams.addMemberToTeam.willBeMoved')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
|
||||
{t('workspace.teams.addMemberToTeam.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Change Team Modal */}
|
||||
<Modal
|
||||
opened={changeTeamModalOpened}
|
||||
onClose={() => setChangeTeamModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={() => setChangeTeamModalOpened(false)}
|
||||
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="swap-horiz" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t('workspace.teams.changeTeam.title', 'Change Team')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.teams.changeTeam.changing', 'Moving')} <strong>{selectedUser?.username}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Select
|
||||
label={t('workspace.teams.changeTeam.selectTeam', 'Select Team')}
|
||||
placeholder={t('workspace.teams.changeTeam.selectTeamPlaceholder', 'Choose a team')}
|
||||
data={allTeams
|
||||
.filter((t) => t.name !== 'Internal')
|
||||
.map((team) => ({
|
||||
value: team.id.toString(),
|
||||
label: team.name,
|
||||
}))}
|
||||
value={selectedTeamId}
|
||||
onChange={(value) => setSelectedTeamId(value || '')}
|
||||
searchable
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
|
||||
<Button onClick={handleChangeTeam} loading={processing} fullWidth size="md" mt="md">
|
||||
{t('workspace.teams.changeTeam.submit', 'Change Team')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Button,
|
||||
TextInput,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Badge,
|
||||
Loader,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
CloseButton,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { teamService, Team } from '@app/services/teamService';
|
||||
import { userManagementService, User } from '@app/services/userManagementService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import TeamDetailsSection from '@app/components/shared/config/configSections/TeamDetailsSection';
|
||||
|
||||
export default function TeamsSection() {
|
||||
const { t } = useTranslation();
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createModalOpened, setCreateModalOpened] = useState(false);
|
||||
const [renameModalOpened, setRenameModalOpened] = useState(false);
|
||||
const [addMemberModalOpened, setAddMemberModalOpened] = useState(false);
|
||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [viewingTeamId, setViewingTeamId] = useState<number | null>(null);
|
||||
|
||||
// Form states
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [renameTeamName, setRenameTeamName] = useState('');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeams();
|
||||
}, []);
|
||||
|
||||
const fetchTeams = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const teamsData = await teamService.getTeams();
|
||||
setTeams(teamsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch teams:', error);
|
||||
alert({ alertType: 'error', title: 'Failed to load teams' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTeam = async () => {
|
||||
if (!newTeamName.trim()) {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.createTeam.nameRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.createTeam(newTeamName);
|
||||
alert({ alertType: 'success', title: t('workspace.teams.createTeam.success') });
|
||||
setCreateModalOpened(false);
|
||||
setNewTeamName('');
|
||||
fetchTeams();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create team:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.teams.createTeam.error');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameTeam = async () => {
|
||||
if (!selectedTeam || !renameTeamName.trim()) {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.renameTeam.nameRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.renameTeam(selectedTeam.id, renameTeamName);
|
||||
alert({ alertType: 'success', title: t('workspace.teams.renameTeam.success') });
|
||||
setRenameModalOpened(false);
|
||||
setSelectedTeam(null);
|
||||
setRenameTeamName('');
|
||||
fetchTeams();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to rename team:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.teams.renameTeam.error');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (team: Team) => {
|
||||
if (team.name === 'Internal') {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.cannotDeleteInternal') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(t('workspace.teams.confirmDelete'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await teamService.deleteTeam(team.id);
|
||||
alert({ alertType: 'success', title: t('workspace.teams.deleteTeam.success') });
|
||||
fetchTeams();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete team:', error);
|
||||
const errorMessage = error.response?.data?.message ||
|
||||
error.response?.data?.error ||
|
||||
error.message ||
|
||||
t('workspace.teams.deleteTeam.error');
|
||||
alert({ alertType: 'error', title: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const openRenameModal = (team: Team) => {
|
||||
if (team.name === 'Internal') {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.cannotRenameInternal') });
|
||||
return;
|
||||
}
|
||||
setSelectedTeam(team);
|
||||
setRenameTeamName(team.name);
|
||||
setRenameModalOpened(true);
|
||||
};
|
||||
|
||||
const openAddMemberModal = async (team: Team) => {
|
||||
if (team.name === 'Internal') {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.cannotAddToInternal') });
|
||||
return;
|
||||
}
|
||||
setSelectedTeam(team);
|
||||
try {
|
||||
// Fetch all users to show in dropdown
|
||||
const adminData = await userManagementService.getUsers();
|
||||
setAvailableUsers(adminData.users);
|
||||
setAddMemberModalOpened(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedTeam || !selectedUserId) {
|
||||
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.userRequired') });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true);
|
||||
await teamService.addUserToTeam(selectedTeam.id, parseInt(selectedUserId));
|
||||
alert({ alertType: 'success', title: t('workspace.teams.addMemberToTeam.success') });
|
||||
setAddMemberModalOpened(false);
|
||||
setSelectedTeam(null);
|
||||
setSelectedUserId('');
|
||||
fetchTeams();
|
||||
} catch (error) {
|
||||
console.error('Failed to add member to team:', error);
|
||||
alert({ alertType: 'error', title: t('workspace.teams.addMemberToTeam.error') });
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// If viewing team details, render TeamDetailsSection
|
||||
if (viewingTeamId !== null) {
|
||||
return (
|
||||
<TeamDetailsSection
|
||||
teamId={viewingTeamId}
|
||||
onBack={() => {
|
||||
setViewingTeamId(null);
|
||||
fetchTeams(); // Refresh teams list
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.teams.loading', 'Loading teams...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('workspace.teams.title')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('workspace.teams.description')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Header Actions */}
|
||||
<Group justify="flex-end">
|
||||
<Button leftSection={<LocalIcon icon="add" width="1rem" height="1rem" />} onClick={() => setCreateModalOpened(true)}>
|
||||
{t('workspace.teams.createNewTeam')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Teams Table */}
|
||||
<Table
|
||||
horizontalSpacing="md"
|
||||
verticalSpacing="sm"
|
||||
withRowBorders
|
||||
highlightOnHover
|
||||
style={{
|
||||
'--table-border-color': 'var(--mantine-color-gray-3)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr style={{ backgroundColor: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
|
||||
{t('workspace.teams.teamName')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ fontWeight: 600, fontSize: '0.875rem', color: 'var(--mantine-color-gray-7)' }}>
|
||||
{t('workspace.teams.totalMembers')}
|
||||
</Table.Th>
|
||||
<Table.Th style={{ width: 50 }}></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{teams.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text ta="center" c="dimmed" py="xl">
|
||||
{t('workspace.teams.noTeamsFound')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<Table.Tr
|
||||
key={team.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setViewingTeamId(team.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={team.name} disabled={team.name.length <= 20} zIndex={Z_INDEX_OVER_CONFIG_MODAL}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="dark"
|
||||
maw={200}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
{team.name === 'Internal' && (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
{t('workspace.teams.system')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">{team.userCount || 0}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<LocalIcon icon="more-vert" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ zIndex: Z_INDEX_OVER_CONFIG_MODAL }}>
|
||||
<Menu.Item leftSection={<LocalIcon icon="visibility" width="1rem" height="1rem" />} onClick={() => setViewingTeamId(team.id)}>
|
||||
{t('workspace.teams.viewTeam', 'View Team')}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<LocalIcon icon="group" width="1rem" height="1rem" />} onClick={() => openAddMemberModal(team)}>
|
||||
{t('workspace.teams.addMember')}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<LocalIcon icon="edit" width="1rem" height="1rem" />} onClick={() => openRenameModal(team)}>
|
||||
{t('workspace.teams.renameTeamLabel')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<LocalIcon icon="delete" width="1rem" height="1rem" />}
|
||||
onClick={() => handleDeleteTeam(team)}
|
||||
disabled={team.name === 'Internal'}
|
||||
>
|
||||
{t('workspace.teams.deleteTeamLabel')}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{/* Create Team Modal */}
|
||||
<Modal
|
||||
opened={createModalOpened}
|
||||
onClose={() => setCreateModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={() => setCreateModalOpened(false)}
|
||||
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="group-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t('workspace.teams.createTeam.title')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<TextInput
|
||||
label={t('workspace.teams.createTeam.teamName')}
|
||||
placeholder={t('workspace.teams.createTeam.teamNamePlaceholder')}
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button onClick={handleCreateTeam} loading={processing} fullWidth size="md" mt="md">
|
||||
{t('workspace.teams.createTeam.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Rename Team Modal */}
|
||||
<Modal
|
||||
opened={renameModalOpened}
|
||||
onClose={() => setRenameModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={() => setRenameModalOpened(false)}
|
||||
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="edit" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
|
||||
<Text size="xl" fw={600} ta="center">
|
||||
{t('workspace.teams.renameTeam.title')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.teams.renameTeam.renaming')} <strong>{selectedTeam?.name}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<TextInput
|
||||
label={t('workspace.teams.renameTeam.newTeamName')}
|
||||
placeholder={t('workspace.teams.renameTeam.newTeamNamePlaceholder')}
|
||||
value={renameTeamName}
|
||||
onChange={(e) => setRenameTeamName(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Button onClick={handleRenameTeam} loading={processing} fullWidth size="md" mt="md">
|
||||
{t('workspace.teams.renameTeam.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Add Member Modal */}
|
||||
<Modal
|
||||
opened={addMemberModalOpened}
|
||||
onClose={() => setAddMemberModalOpened(false)}
|
||||
size="md"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
centered
|
||||
padding="xl"
|
||||
withCloseButton={false}
|
||||
>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseButton
|
||||
onClick={() => setAddMemberModalOpened(false)}
|
||||
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.teams.addMemberToTeam.title')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t('workspace.teams.addMemberToTeam.addingTo')} <strong>{selectedTeam?.name}</strong>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Select
|
||||
label={t('workspace.teams.addMemberToTeam.selectUser')}
|
||||
placeholder={t('workspace.teams.addMemberToTeam.selectUserPlaceholder')}
|
||||
data={availableUsers.map((user) => ({
|
||||
value: user.id.toString(),
|
||||
label: `${user.username}${user.team ? ` (${t('workspace.teams.addMemberToTeam.currentlyIn')} ${user.team.name})` : ''}`,
|
||||
}))}
|
||||
value={selectedUserId}
|
||||
onChange={(value) => setSelectedUserId(value || '')}
|
||||
searchable
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
|
||||
{selectedUserId && availableUsers.find((u) => u.id.toString() === selectedUserId)?.team && (
|
||||
<Text size="xs" c="orange">
|
||||
{t('workspace.teams.addMemberToTeam.willBeMoved')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button onClick={handleAddMember} loading={processing} fullWidth size="md" mt="md">
|
||||
{t('workspace.teams.addMemberToTeam.submit')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Paper, Center, Loader, TextInput, PasswordInput, Anchor } from '@mantine/core';
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
interface InviteData {
|
||||
email: string | null;
|
||||
role: string;
|
||||
expiresAt: string;
|
||||
emailRequired: boolean;
|
||||
}
|
||||
|
||||
export default function InviteAccept() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [inviteData, setInviteData] = useState<InviteData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
// Set document meta
|
||||
useDocumentMeta({
|
||||
title: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
|
||||
description: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogTitle: `${t('invite.welcome', 'Welcome to Stirling PDF')} - Stirling PDF`,
|
||||
ogDescription: t('app.description', 'The Free Adobe Acrobat alternative (10M+ Downloads)'),
|
||||
ogImage: `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: `${window.location.origin}${window.location.pathname}`
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError(t('invite.invalidToken', 'Invalid invitation link'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
validateToken();
|
||||
}, [token]);
|
||||
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiClient.get<InviteData>(`/api/v1/invite/validate/${token}`, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
setInviteData(response.data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
t('invite.validationError', 'Failed to validate invitation link');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccept = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate email if required
|
||||
if (inviteData?.emailRequired) {
|
||||
if (!email || email.trim().length === 0) {
|
||||
setError(t('invite.emailRequired', 'Email address is required'));
|
||||
return;
|
||||
}
|
||||
if (!email.includes('@')) {
|
||||
setError(t('invite.invalidEmail', 'Invalid email address'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate passwords
|
||||
if (!password) {
|
||||
setError(t('invite.passwordRequired', 'Password is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('invite.passwordMismatch', 'Passwords do not match'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
if (inviteData?.emailRequired) {
|
||||
formData.append('email', email.trim().toLowerCase());
|
||||
}
|
||||
formData.append('password', password);
|
||||
|
||||
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
|
||||
// Success - redirect to login
|
||||
navigate('/login?messageType=accountCreated');
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.error ||
|
||||
err.message ||
|
||||
t('invite.acceptError', 'Failed to create account');
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('invite.validating', 'Validating invitation...')} />
|
||||
<Center py="xl">
|
||||
<Loader size="md" />
|
||||
</Center>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !inviteData) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('invite.invalidInvitation', 'Invalid Invitation')} />
|
||||
<ErrorMessage error={error} />
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login')}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
|
||||
>
|
||||
{t('invite.goToLogin', 'Go to Login')}
|
||||
</button>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginHeader
|
||||
title={t('invite.welcomeTitle', "You've been invited!")}
|
||||
subtitle={t('invite.welcomeSubtitle', 'Complete your account setup to get started')}
|
||||
/>
|
||||
|
||||
{inviteData && !inviteData.emailRequired && (
|
||||
<Paper withBorder p="md" mb="lg" bg="blue.0" style={{ borderColor: 'var(--mantine-color-blue-3)' }}>
|
||||
<Stack gap="xs" align="center">
|
||||
<Text size="xs" tt="uppercase" c="dimmed" fw={500} style={{ letterSpacing: '0.05em' }}>
|
||||
{t('invite.accountFor', 'Creating account for')}
|
||||
</Text>
|
||||
<Text size="lg" fw={600}>
|
||||
{inviteData.email}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('invite.linkExpires', 'Link expires')}: {new Date(inviteData.expiresAt).toLocaleDateString()} at {new Date(inviteData.expiresAt).toLocaleTimeString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<form onSubmit={handleAccept}>
|
||||
<Stack gap="md">
|
||||
{inviteData?.emailRequired && (
|
||||
<TextInput
|
||||
label={t('invite.email', 'Email address')}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t('invite.emailPlaceholder', 'Enter your email address')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
label={t('invite.choosePassword', 'Choose a password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('invite.passwordPlaceholder', 'Enter your password')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t('invite.confirmPassword', 'Confirm password')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('invite.confirmPasswordPlaceholder', 'Re-enter your password')}
|
||||
disabled={submitting}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{submitting ? t('invite.creating', 'Creating Account...') : t('invite.createAccount', 'Create Account')}
|
||||
</button>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Center mt="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('invite.alreadyHaveAccount', 'Already have an account?')}{' '}
|
||||
<Anchor component="button" type="button" onClick={() => navigate('/login')} c="dark">
|
||||
{t('invite.signIn', 'Sign in')}
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Center>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -17,26 +17,42 @@ import { BASE_PATH } from '@app/constants/app';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { session, loading } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
// Prefill email from query param (e.g. after password reset)
|
||||
// Handle query params (email prefill and success messages)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const emailFromQuery = url.searchParams.get('email');
|
||||
const emailFromQuery = searchParams.get('email');
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery);
|
||||
}
|
||||
|
||||
const messageType = searchParams.get('messageType')
|
||||
if (messageType) {
|
||||
switch (messageType) {
|
||||
case 'accountCreated':
|
||||
setSuccessMessage(t('login.accountCreatedSuccess', 'Account created successfully! You can now sign in.'))
|
||||
break
|
||||
case 'passwordChanged':
|
||||
setSuccessMessage(t('login.passwordChangedSuccess', 'Password changed successfully! Please sign in with your new password.'))
|
||||
break
|
||||
case 'credsUpdated':
|
||||
setSuccessMessage(t('login.credentialsUpdated', 'Your credentials have been updated. Please sign in again.'))
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
}, [searchParams, t]);
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
@@ -121,6 +137,22 @@ export default function Login() {
|
||||
<AuthLayout>
|
||||
<LoginHeader title={t('login.login') || 'Sign in'} />
|
||||
|
||||
{/* Success message */}
|
||||
{successMessage && (
|
||||
<div style={{
|
||||
padding: '1rem',
|
||||
marginBottom: '1rem',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#16a34a'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '0.875rem', textAlign: 'center' }}>
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface Team {
|
||||
id: number;
|
||||
name: string;
|
||||
userCount?: number;
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
roleName: string;
|
||||
enabled: boolean;
|
||||
team?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
lastRequest?: Date | null;
|
||||
}
|
||||
|
||||
export interface TeamDetailsResponse {
|
||||
team: Team;
|
||||
members: TeamMember[];
|
||||
availableUsers: TeamMember[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Team Management Service
|
||||
* Provides functions to interact with team-related backend APIs
|
||||
*/
|
||||
export const teamService = {
|
||||
/**
|
||||
* Get all teams with user counts
|
||||
*/
|
||||
async getTeams(): Promise<Team[]> {
|
||||
const response = await apiClient.get<{ teamsWithCounts: Team[] }>('/api/v1/proprietary/ui-data/teams');
|
||||
return response.data.teamsWithCounts;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get team details including members
|
||||
*/
|
||||
async getTeamDetails(teamId: number): Promise<any> {
|
||||
const response = await apiClient.get(`/api/v1/proprietary/ui-data/teams/${teamId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new team
|
||||
*/
|
||||
async createTeam(name: string): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
await apiClient.post('/api/v1/team/create', formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Rename an existing team
|
||||
*/
|
||||
async renameTeam(teamId: number, newName: string): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('teamId', teamId.toString());
|
||||
formData.append('newName', newName);
|
||||
await apiClient.post('/api/v1/team/rename', formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a team (only if it has no members)
|
||||
*/
|
||||
async deleteTeam(teamId: number): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('teamId', teamId.toString());
|
||||
await apiClient.post('/api/v1/team/delete', formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a user to a team
|
||||
*/
|
||||
async addUserToTeam(teamId: number, userId: number): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('teamId', teamId.toString());
|
||||
formData.append('userId', userId.toString());
|
||||
await apiClient.post('/api/v1/team/addUser', formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
|
||||
/**
|
||||
* Move a user to a specific team (used when "removing" from a team - moves to Default)
|
||||
*/
|
||||
async moveUserToTeam(username: string, currentRole: string, teamId: number): Promise<void> {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('role', currentRole);
|
||||
formData.append('teamId', teamId.toString());
|
||||
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user