frontend_nyla/src/pages/admin/AdminInvitationsPage.tsx

492 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, FaEnvelopeOpenText, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
export const AdminInvitationsPage: React.FC = () => {
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: 'Benutzername',
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'email',
label: '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 ? 'Email wurde gesendet' : 'Email nicht gesendet'}>
{emailText} {emailSent && '✓'}
</span>
);
}
},
{
key: 'roleIds',
label: '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: '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: 'Verwendet',
type: 'string' as const,
sortable: true,
width: 100,
render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`
},
{
key: 'createdAt',
label: 'Erstellt',
type: 'number' as const,
sortable: true,
width: 150,
render: (value: number) => formatDate(value)
},
], [roles]);
// 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: '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]);
// 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}>
<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}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Einladungen</h1>
<p className={styles.pageSubtitle}>Erstellen und verwalten Sie Einladungen für neue Benutzer</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="">-- Mandant wählen --</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}>Kein Mandant ausgewählt</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.
</p>
</div>
) : loading && invitations.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Einladungen...</span>
</div>
) : invitations.length === 0 ? (
<div className={styles.emptyState}>
<FaEnvelopeOpenText className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Einladungen</h3>
<p className={styles.emptyDescription}>
Es gibt noch keine aktiven Einladungen für diesen Mandanten.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Einladung erstellen
</button>
</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: 'Einladung widerrufen',
}
]}
customActions={[
{
id: 'showUrl',
icon: <FaLink />,
onClick: handleShowUrl,
title: 'Einladungs-Link anzeigen',
}
]}
hookData={{
handleDelete: handleDeleteInvitation,
refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
pagination,
}}
emptyMessage="Keine Einladungen gefunden"
/>
</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}>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>Lade Rollen...</span>
</div>
) : createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateInvitation}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Einladung erstellen"
cancelButtonText="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="In Zwischenablage kopieren"
>
<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;