frontend_nyla/src/pages/admin/AdminInvitationsPage.tsx
2026-04-26 22:53:39 +02:00

434 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
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 { request } = useApiRequest();
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();
fetchAttributes(request, 'Invitation')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
// 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 (used by URL modal only).
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'
});
};
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'targetUsername', sortable: true, filterable: true, searchable: true, width: 150 },
{ key: 'email', sortable: true, filterable: true, width: 180 },
{ key: 'emailSentFlag', sortable: true, filterable: true, width: 90 },
{ key: 'emailSentAt', sortable: true, filterable: true, width: 150 },
{
key: 'roleIds',
sortable: false,
filterable: false,
width: 150,
formatter: (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(', ');
},
},
{ key: 'expiresAt', sortable: true, filterable: true, width: 150 },
{ key: 'expiredFlag', sortable: true, filterable: true, width: 90 },
{
key: 'currentUses',
sortable: true,
filterable: true,
width: 100,
formatter: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`,
},
{ key: 'usedUpFlag', sortable: true, filterable: true, width: 90 },
{ key: 'sysCreatedAt', sortable: true, filterable: true, width: 150 },
], [roles]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', '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.emailSentFlag ? 'var(--success-color)' : 'var(--text-secondary)' }}>
{showUrlModal.emailSentFlag
? `${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;