ui-nyla/src/pages/admin/AdminInvitationsPage.tsx
2026-04-09 00:11:35 +02:00

477 lines
17 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 api from '../../api';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
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('adminInvitations.benutzername'),
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'email',
label: t('adminInvitations.email'),
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('adminInvitations.emailWurdeGesendet') : t('adminInvitations.emailNichtGesendet')}>
{emailText} {emailSent && '✓'}
</span>
);
}
},
{
key: 'roleIds',
label: t('adminInvitations.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('adminInvitations.gueltigBis'),
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('adminInvitations.verwendet'),
type: 'string' as const,
sortable: true,
width: 100,
render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`
},
{
key: 'createdAt',
label: t('adminInvitations.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('adminInvitationsPage.gueltigkeitsdauerStunden'), 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]);
// 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('Fehler', result.error || '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('Fehler', result.error || '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);
}
};
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
return mandate.name || mandate.id;
};
if (error && !selectedMandateId) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('adminInvitations.einladungen')}</h1>
<p className={styles.pageSubtitle}>{t('adminInvitations.erstellenUndVerwaltenSieEinladungen')}</p>
</div>
</div>
{/* Mandate Selector and Filters */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
Mandant:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('adminInvitations.mandantWaehlen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showExpired}
onChange={(e) => setShowExpired(e.target.checked)}
/>
Abgelaufene anzeigen
</label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={showUsed}
onChange={(e) => setShowUsed(e.target.checked)}
/>
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' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Einladung
</button>
</div>
)}
</div>
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('adminInvitations.keinMandantAusgewaehlt')}</h3>
<p className={styles.emptyDescription}>
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={false}
actionButtons={[
{
type: 'delete' as const,
title: t('adminInvitationsPage.revokeInvitation'),
}
]}
customActions={[
{
id: 'showUrl',
icon: <FaLink />,
onClick: handleShowUrl,
title: t('adminInvitationsPage.showInvitationLink'),
}
]}
hookData={{
handleDelete: handleDeleteInvitation,
refetch: (params?: any) => fetchInvitations(params || selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
pagination,
}}
emptyMessage={t('adminInvitations.keineEinladungenGefunden')}
/>
</div>
)}
{/* Create Invitation Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('adminInvitations.neueEinladungErstellen')}</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('adminInvitations.ladeRollen')}</span>
</div>
) : createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('adminInvitations.ladeFormular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateInvitation}
onCancel={() => setShowCreateModal(false)}
submitButtonText={t('adminInvitations.einladungErstellen')}
cancelButtonText={t('adminInvitations.abbrechen')}
/>
)}
</div>
</div>
</div>
)}
{/* URL Display Modal */}
{showUrlModal && (
<div className={styles.modalOverlay} onClick={() => setShowUrlModal(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Einladungs-Link</h2>
<button
className={styles.modalClose}
onClick={() => setShowUrlModal(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
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('adminInvitations.inZwischenablageKopieren')}
>
<FaCopy />
{copySuccess ? ' Kopiert!' : ' Kopieren'}
</button>
</div>
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Dieser Link kann nur von Benutzer <strong>{showUrlModal.targetUsername}</strong> verwendet werden.
</p>
{showUrlModal.email && (
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: showUrlModal.emailSent ? 'var(--success-color)' : 'var(--text-secondary)' }}>
{showUrlModal.emailSent
? `✓ Email wurde an ${showUrlModal.email} gesendet`
: `Email-Adresse: ${showUrlModal.email} (nicht gesendet)`}
</p>
)}
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
Gültig bis: {formatDate(showUrlModal.expiresAt)}
</p>
</div>
<div className={styles.modalFooter}>
<button
className={styles.primaryButton}
onClick={() => setShowUrlModal(null)}
>
Schliessen
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default AdminInvitationsPage;