473 lines
16 KiB
TypeScript
473 lines
16 KiB
TypeScript
/**
|
||
* AdminInvitationsPage
|
||
*
|
||
* Admin page for managing invitations within a mandate.
|
||
* Allows creating, viewing, and revoking invitations.
|
||
*/
|
||
|
||
import React, { useState, useEffect, useMemo } from 'react';
|
||
import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations';
|
||
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
|
||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||
import { FaPlus, FaSync, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
||
import { useToast } from '../../contexts/ToastContext';
|
||
import api from '../../api';
|
||
import styles from './Admin.module.css';
|
||
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||
|
||
export const AdminInvitationsPage: React.FC = () => {
|
||
const { t } = useLanguage();
|
||
|
||
const { showError } = useToast();
|
||
const {
|
||
invitations,
|
||
loading,
|
||
error,
|
||
pagination,
|
||
fetchInvitations,
|
||
createInvitation,
|
||
revokeInvitation,
|
||
} = useInvitations();
|
||
|
||
const { fetchMandates, fetchRoles } = useUserMandates();
|
||
|
||
// State
|
||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||
const [roles, setRoles] = useState<Role[]>([]);
|
||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||
const [showUrlModal, setShowUrlModal] = useState<Invitation | null>(null);
|
||
const [showExpired, setShowExpired] = useState(false);
|
||
const [showUsed, setShowUsed] = useState(false);
|
||
const [_isSubmitting, setIsSubmitting] = useState(false); // Prefixed with _ to suppress warning
|
||
const [copySuccess, setCopySuccess] = useState(false);
|
||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||
|
||
// Load mandates and attributes on mount
|
||
useEffect(() => {
|
||
const loadMandates = async () => {
|
||
const data = await fetchMandates();
|
||
setMandates(data);
|
||
if (data.length > 0 && !selectedMandateId) {
|
||
setSelectedMandateId(data[0].id);
|
||
}
|
||
};
|
||
loadMandates();
|
||
// Fetch Invitation attributes from backend
|
||
api.get('/api/attributes/Invitation').then(response => {
|
||
const attrs = response.data?.attributes || response.data || [];
|
||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||
}).catch(() => setBackendAttributes([]));
|
||
}, [fetchMandates]);
|
||
|
||
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
|
||
useEffect(() => {
|
||
if (selectedMandateId) {
|
||
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
||
fetchRoles(selectedMandateId).then(setRoles);
|
||
}
|
||
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
|
||
|
||
// Format timestamp
|
||
const formatDate = (timestamp: number) => {
|
||
if (!timestamp) return '-';
|
||
const date = new Date(timestamp * 1000);
|
||
return date.toLocaleString('de-CH', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
// Table columns
|
||
const columns = useMemo(() => [
|
||
{
|
||
key: 'targetUsername',
|
||
label: t('Benutzername'),
|
||
type: 'string' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
searchable: true,
|
||
width: 150,
|
||
},
|
||
{
|
||
key: 'email',
|
||
label: t('E-Mail'),
|
||
type: 'string' as const,
|
||
sortable: true,
|
||
filterable: true,
|
||
width: 180,
|
||
render: (value: string, row: Invitation) => {
|
||
const emailText = value || '-';
|
||
const emailSent = (row as any).emailSent;
|
||
return (
|
||
<span title={emailSent ? t('E-Mail wurde gesendet') : t('E-Mail nicht gesendet')}>
|
||
{emailText} {emailSent && '✓'}
|
||
</span>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
key: 'roleIds',
|
||
label: t('Rollen'),
|
||
type: 'string', // Array rendered as string
|
||
sortable: false,
|
||
filterable: false,
|
||
width: 150,
|
||
render: (value: string[]) => {
|
||
if (!value || value.length === 0) return '-';
|
||
return value.map(roleId => {
|
||
const role = roles.find(r => r.id === roleId);
|
||
return role?.roleLabel || roleId;
|
||
}).join(', ');
|
||
}
|
||
} as any,
|
||
{
|
||
key: 'expiresAt',
|
||
label: t('Gültig bis'),
|
||
type: 'number' as const,
|
||
sortable: true,
|
||
width: 150,
|
||
render: (value: number) => {
|
||
const text = formatDate(value);
|
||
const isExpired = value < Date.now() / 1000;
|
||
return (
|
||
<span style={{ color: isExpired ? 'var(--danger-color)' : 'inherit' }}>
|
||
{text} {isExpired && '(abgelaufen)'}
|
||
</span>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
key: 'currentUses',
|
||
label: t('Verwendet'),
|
||
type: 'string' as const,
|
||
sortable: true,
|
||
width: 100,
|
||
render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`
|
||
},
|
||
{
|
||
key: 'createdAt',
|
||
label: t('Erstellt'),
|
||
type: 'number' as const,
|
||
sortable: true,
|
||
width: 150,
|
||
render: (value: number) => formatDate(value)
|
||
},
|
||
], [roles, t]);
|
||
|
||
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
|
||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||
const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
|
||
|
||
// Mandate-level roles (user, viewer, admin) - same as when adding mandate members
|
||
const roleOptions = roles
|
||
.filter(r => !r.featureInstanceId)
|
||
.map(r => ({ value: r.id, label: r.roleLabel }));
|
||
|
||
const fields = backendAttributes
|
||
.filter(attr => !excludedFields.includes(attr.name))
|
||
.map(attr => ({
|
||
...attr,
|
||
options: attr.name === 'roleIds' ? roleOptions : attr.options,
|
||
})) as AttributeDefinition[];
|
||
|
||
// Add helper field expiresInHours if not in model but fields exist
|
||
if (fields.length > 0 && !fields.find(f => f.name === 'expiresInHours')) {
|
||
fields.push({ name: 'expiresInHours', label: t('Gültigkeitsdauer (Stunden)'), type: 'number',
|
||
required: true, default: 72 } as any);
|
||
}
|
||
// Override required for targetUsername and email (both required for invitations)
|
||
return fields.map(f => {
|
||
if (f.name === 'targetUsername' || f.name === 'email') {
|
||
return { ...f, required: true };
|
||
}
|
||
return f;
|
||
});
|
||
}, [roles, backendAttributes, t]);
|
||
|
||
// Handle create invitation
|
||
const handleCreateInvitation = async (data: InvitationCreate) => {
|
||
if (!selectedMandateId) return;
|
||
setIsSubmitting(true);
|
||
try {
|
||
const result = await createInvitation(selectedMandateId, data);
|
||
if (result.success && result.data) {
|
||
setShowCreateModal(false);
|
||
setShowUrlModal(result.data);
|
||
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
||
} else {
|
||
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Einladung'));
|
||
}
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
// Handle delete invitation by ID (for DeleteActionButton)
|
||
// Note: DeleteActionButton handles confirmation UI, so no window.confirm here
|
||
const handleDeleteInvitation = async (invitationId: string): Promise<boolean> => {
|
||
if (!selectedMandateId) return false;
|
||
const result = await revokeInvitation(selectedMandateId, invitationId);
|
||
if (!result.success) {
|
||
showError(t('Fehler'), result.error || t('Fehler beim Widerrufen der Einladung'));
|
||
}
|
||
return result.success;
|
||
};
|
||
|
||
// Handle show URL
|
||
const handleShowUrl = (invitation: Invitation) => {
|
||
setShowUrlModal(invitation);
|
||
};
|
||
|
||
// Copy URL to clipboard
|
||
const handleCopyUrl = async (url: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
setCopySuccess(true);
|
||
setTimeout(() => setCopySuccess(false), 2000);
|
||
} catch (err) {
|
||
console.error('Failed to copy:', err);
|
||
}
|
||
};
|
||
|
||
|
||
if (error && !selectedMandateId) {
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.errorContainer}>
|
||
<span className={styles.errorIcon}>⚠️</span>
|
||
<p className={styles.errorMessage}>
|
||
{t('Fehler')}: {error}
|
||
</p>
|
||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||
<FaSync /> {t('Erneut versuchen')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||
<div className={styles.pageHeader}>
|
||
<div>
|
||
<h1 className={styles.pageTitle}>{t('Einladungen')}</h1>
|
||
<p className={styles.pageSubtitle}>{t('Erstellen und verwalten Sie Einladungen')}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mandate Selector and Filters */}
|
||
<div className={styles.filterSection}>
|
||
<div className={styles.filterGroup}>
|
||
<label className={styles.filterLabel}>
|
||
<FaBuilding style={{ marginRight: 8 }} />
|
||
{t('Mandant')}:
|
||
</label>
|
||
<select
|
||
className={styles.filterSelect}
|
||
value={selectedMandateId}
|
||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||
>
|
||
<option value="">{t('Mandant wählen')}</option>
|
||
{mandates.map(m => (
|
||
<option key={m.id} value={m.id}>
|
||
{mandateDisplayLabel(m)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className={styles.filterGroup}>
|
||
<label className={styles.checkboxLabel}>
|
||
<input
|
||
type="checkbox"
|
||
checked={showExpired}
|
||
onChange={(e) => setShowExpired(e.target.checked)}
|
||
/>
|
||
{t('Abgelaufene anzeigen')}
|
||
</label>
|
||
<label className={styles.checkboxLabel}>
|
||
<input
|
||
type="checkbox"
|
||
checked={showUsed}
|
||
onChange={(e) => setShowUsed(e.target.checked)}
|
||
/>
|
||
{t('Verwendete anzeigen')}
|
||
</label>
|
||
</div>
|
||
|
||
{selectedMandateId && (
|
||
<div className={styles.headerActions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed })}
|
||
disabled={loading}
|
||
>
|
||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => setShowCreateModal(true)}
|
||
>
|
||
<FaPlus /> {t('Neue Einladung')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
{!selectedMandateId ? (
|
||
<div className={styles.emptyState}>
|
||
<FaBuilding className={styles.emptyIcon} />
|
||
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
||
<p className={styles.emptyDescription}>
|
||
{t('Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.')}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className={styles.tableContainer}>
|
||
<FormGeneratorTable
|
||
data={invitations}
|
||
columns={columns}
|
||
apiEndpoint="/api/invitations/"
|
||
loading={loading}
|
||
pagination={true}
|
||
pageSize={25}
|
||
searchable={true}
|
||
filterable={true}
|
||
sortable={true}
|
||
selectable={true}
|
||
actionButtons={[
|
||
{
|
||
type: 'delete' as const,
|
||
title: t('Einladung widerrufen'),
|
||
}
|
||
]}
|
||
customActions={[
|
||
{
|
||
id: 'showUrl',
|
||
icon: <FaLink />,
|
||
onClick: handleShowUrl,
|
||
title: t('Einladungslink anzeigen'),
|
||
}
|
||
]}
|
||
hookData={{
|
||
handleDelete: handleDeleteInvitation,
|
||
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
||
pagination,
|
||
}}
|
||
emptyMessage={t('Keine Einladungen gefunden')}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Invitation Modal */}
|
||
{showCreateModal && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{t('Neue Einladung erstellen')}</h2>
|
||
<button
|
||
className={styles.modalClose}
|
||
onClick={() => setShowCreateModal(false)}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalContent}>
|
||
{roles.filter(r => !r.featureInstanceId).length === 0 ? (
|
||
<div className={styles.loadingContainer}>
|
||
<div className={styles.spinner} />
|
||
<span>{t('Rollen laden')}</span>
|
||
</div>
|
||
) : createFields.length === 0 ? (
|
||
<div className={styles.loadingContainer}>
|
||
<div className={styles.spinner} />
|
||
<span>{t('Formular laden')}</span>
|
||
</div>
|
||
) : (
|
||
<FormGeneratorForm
|
||
attributes={createFields}
|
||
mode="create"
|
||
onSubmit={handleCreateInvitation}
|
||
onCancel={() => setShowCreateModal(false)}
|
||
submitButtonText={t('Einladung erstellen')}
|
||
cancelButtonText={t('Abbrechen')}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* URL Display Modal */}
|
||
{showUrlModal && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{t('Einladungs-Link')}</h2>
|
||
<button
|
||
className={styles.modalClose}
|
||
onClick={() => setShowUrlModal(null)}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalContent}>
|
||
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
|
||
{t('Einladung für Benutzer')} <strong>{showUrlModal.targetUsername}</strong>:
|
||
</p>
|
||
<div className={styles.urlBox}>
|
||
<input
|
||
type="text"
|
||
readOnly
|
||
value={showUrlModal.inviteUrl}
|
||
className={styles.urlInput}
|
||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||
/>
|
||
<button
|
||
className={styles.copyButton}
|
||
onClick={() => handleCopyUrl(showUrlModal.inviteUrl)}
|
||
title={t('In Zwischenablage kopieren')}
|
||
>
|
||
<FaCopy />
|
||
{copySuccess ? ` ${t('Kopiert!')}` : ` ${t('Kopieren')}`}
|
||
</button>
|
||
</div>
|
||
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||
{t('Dieser Link kann nur von Benutzer')} <strong>{showUrlModal.targetUsername}</strong>{' '}
|
||
{t('verwendet werden.')}
|
||
</p>
|
||
{showUrlModal.email && (
|
||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSent ? 'var(--success-color)' : 'var(--text-secondary)' }}>
|
||
{showUrlModal.emailSent
|
||
? `✓ ${t('E-Mail wurde an {email} gesendet', { email: showUrlModal.email })}`
|
||
: `${t('E-Mail-Adresse')}: ${showUrlModal.email} (${t('nicht gesendet')})`}
|
||
</p>
|
||
)}
|
||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||
{t('Gültig bis')}: {formatDate(showUrlModal.expiresAt)}
|
||
</p>
|
||
</div>
|
||
<div className={styles.modalFooter}>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => setShowUrlModal(null)}
|
||
>
|
||
{t('Schliessen')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminInvitationsPage;
|