Files
Classeo/frontend/src/routes/admin/users/+page.svelte
Mathias STRASSER 4005c70082 feat: Gestion des utilisateurs (invitation, blocage, déblocage)
Permet aux administrateurs d'un établissement de gérer le cycle de vie
des comptes utilisateurs : inviter de nouveaux membres, bloquer/débloquer
des comptes actifs, et renvoyer des invitations en attente.

Chaque mutation vérifie l'appartenance au tenant courant pour empêcher
les accès cross-tenant. Le blocage est restreint aux comptes actifs
uniquement et un administrateur ne peut pas bloquer son propre compte.

Les comptes suspendus reçoivent une erreur 403 spécifique au login
(sans déclencher l'escalade du rate limiting) et les tentatives sont
tracées dans les métriques Prometheus.
2026-02-07 16:47:22 +01:00

1215 lines
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch, getCurrentUserId } from '$lib/auth';
// Types
interface User {
id: string;
email: string;
role: string;
roleLabel: string;
firstName: string;
lastName: string;
statut: string;
createdAt: string;
invitedAt: string | null;
activatedAt: string | null;
blockedAt: string | null;
blockedReason: string | null;
invitationExpiree: boolean;
}
// Role options (admin can assign these roles)
const ROLE_OPTIONS = [
{ value: 'ROLE_ADMIN', label: 'Directeur' },
{ value: 'ROLE_PROF', label: 'Enseignant' },
{ value: 'ROLE_VIE_SCOLAIRE', label: 'Vie Scolaire' },
{ value: 'ROLE_SECRETARIAT', label: 'Secrétariat' },
{ value: 'ROLE_PARENT', label: 'Parent' },
{ value: 'ROLE_ELEVE', label: 'Élève' }
];
// Statut options for filter
const STATUT_OPTIONS = [
{ value: 'pending', label: 'En attente' },
{ value: 'consent', label: 'Consentement requis' },
{ value: 'active', label: 'Actif' },
{ value: 'suspended', label: 'Suspendu' },
{ value: 'archived', label: 'Archivé' }
];
// State
let users = $state<User[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let showCreateModal = $state(false);
// Filters
let filterRole = $state<string>('');
let filterStatut = $state<string>('');
// Form state
let newFirstName = $state('');
let newLastName = $state('');
let newEmail = $state('');
let newRole = $state('');
let isSubmitting = $state(false);
let isResending = $state<string | null>(null);
// Block modal state
let showBlockModal = $state(false);
let blockTargetUser = $state<User | null>(null);
let blockReason = $state('');
let isBlocking = $state(false);
let isUnblocking = $state<string | null>(null);
// Load users on mount
$effect(() => {
loadUsers();
});
async function loadUsers() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
if (filterRole) params.set('role', filterRole);
if (filterStatut) params.set('statut', filterStatut);
const queryString = params.toString();
const url = `${apiUrl}/users${queryString ? `?${queryString}` : ''}`;
const response = await authenticatedFetch(url);
if (!response.ok) {
throw new Error('Erreur lors du chargement des utilisateurs');
}
const data = await response.json();
users = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
users = [];
} finally {
isLoading = false;
}
}
function applyFilters() {
loadUsers();
}
function resetFilters() {
filterRole = '';
filterStatut = '';
loadUsers();
}
async function handleCreateUser() {
if (!newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || !newRole) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
firstName: newFirstName.trim(),
lastName: newLastName.trim(),
email: newEmail.trim().toLowerCase(),
role: newRole
})
});
if (!response.ok) {
let errorMessage = `Erreur lors de la création (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
successMessage = `L'invitation a été envoyée à ${newEmail.trim()}.`;
await loadUsers();
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
} finally {
isSubmitting = false;
}
}
async function handleResendInvitation(userId: string) {
try {
isResending = userId;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/users/${userId}/resend-invitation`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!response.ok) {
let errorMessage = `Erreur lors du renvoi (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
successMessage = 'L\'invitation a été renvoyée avec succès.';
await loadUsers();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors du renvoi';
} finally {
isResending = null;
}
}
function openCreateModal() {
showCreateModal = true;
newFirstName = '';
newLastName = '';
newEmail = '';
newRole = '';
error = null;
}
function closeModal() {
showCreateModal = false;
}
function getStatutLabel(statut: string): string {
switch (statut) {
case 'pending':
return 'En attente';
case 'consent':
return 'Consentement requis';
case 'active':
return 'Actif';
case 'suspended':
return 'Suspendu';
case 'archived':
return 'Archivé';
default:
return statut;
}
}
function getStatutClass(statut: string, invitationExpiree: boolean): string {
if (invitationExpiree) return 'status-expired';
switch (statut) {
case 'active':
return 'status-active';
case 'pending':
case 'consent':
return 'status-pending';
case 'suspended':
return 'status-blocked';
case 'archived':
return 'status-archived';
default:
return '';
}
}
function getStatutDisplay(statut: string, invitationExpiree: boolean): string {
if (invitationExpiree) return 'Invitation expirée';
return getStatutLabel(statut);
}
function canResendInvitation(user: User): boolean {
return user.statut === 'pending' || user.statut === 'consent';
}
function canBlockUser(user: User): boolean {
return user.statut !== 'suspended' && user.statut !== 'archived' && user.id !== getCurrentUserId();
}
function openBlockModal(user: User) {
blockTargetUser = user;
blockReason = '';
showBlockModal = true;
}
function closeBlockModal() {
showBlockModal = false;
blockTargetUser = null;
blockReason = '';
}
async function handleBlockUser() {
if (!blockTargetUser || !blockReason.trim()) return;
try {
isBlocking = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/users/${blockTargetUser.id}/block`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
reason: blockReason.trim()
})
});
if (!response.ok) {
let errorMessage = `Erreur lors du blocage (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
successMessage = `L'utilisateur ${blockTargetUser.firstName} ${blockTargetUser.lastName} a été bloqué.`;
closeBlockModal();
await loadUsers();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors du blocage';
} finally {
isBlocking = false;
}
}
async function handleUnblockUser(user: User) {
try {
isUnblocking = user.id;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/users/${user.id}/unblock`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!response.ok) {
let errorMessage = `Erreur lors du déblocage (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
successMessage = `L'utilisateur ${user.firstName} ${user.lastName} a été débloqué.`;
await loadUsers();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors du déblocage';
} finally {
isUnblocking = null;
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
</script>
<svelte:head>
<title>Gestion des utilisateurs - Classeo</title>
</svelte:head>
<div class="users-page">
<header class="page-header">
<div class="header-content">
<h1>Gestion des utilisateurs</h1>
<p class="subtitle">Invitez et gérez les utilisateurs de votre établissement</p>
</div>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Inviter un utilisateur
</button>
</header>
{#if error}
<div class="alert alert-error">
{error}
<button class="alert-close" onclick={() => (error = null)}>×</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success">
{successMessage}
<button class="alert-close" onclick={() => (successMessage = null)}>×</button>
</div>
{/if}
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
<label for="filter-role">Rôle</label>
<select id="filter-role" bind:value={filterRole}>
<option value="">Tous les rôles</option>
{#each ROLE_OPTIONS as role}
<option value={role.value}>{role.label}</option>
{/each}
</select>
</div>
<div class="filter-group">
<label for="filter-statut">Statut</label>
<select id="filter-statut" bind:value={filterStatut}>
<option value="">Tous les statuts</option>
{#each STATUT_OPTIONS as statut}
<option value={statut.value}>{statut.label}</option>
{/each}
</select>
</div>
<div class="filter-actions">
<button class="btn-secondary btn-sm" onclick={applyFilters}>Filtrer</button>
<button class="btn-text btn-sm" onclick={resetFilters}>Réinitialiser</button>
</div>
</div>
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement des utilisateurs...</p>
</div>
{:else if users.length === 0}
<div class="empty-state">
<span class="empty-icon">👥</span>
<h2>Aucun utilisateur</h2>
<p>Commencez par inviter votre premier utilisateur</p>
<button class="btn-primary" onclick={openCreateModal}>Inviter un utilisateur</button>
</div>
{:else}
<div class="users-table-container">
<table class="users-table">
<thead>
<tr>
<th>Nom</th>
<th>Email</th>
<th>Rôle</th>
<th>Statut</th>
<th>Date d'invitation</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each users as user (user.id)}
<tr>
<td class="user-name-cell">
<span class="user-fullname">{user.firstName} {user.lastName}</span>
</td>
<td class="user-email">{user.email}</td>
<td>
<span class="role-badge">{user.roleLabel}</span>
</td>
<td>
<span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}">
{getStatutDisplay(user.statut, user.invitationExpiree)}
</span>
{#if user.statut === 'suspended' && user.blockedReason}
<span class="blocked-reason" title={user.blockedReason}>
{user.blockedReason}
</span>
{/if}
</td>
<td class="date-cell">{formatDate(user.invitedAt)}</td>
<td class="actions-cell">
{#if canResendInvitation(user)}
<button
class="btn-secondary btn-sm"
onclick={() => handleResendInvitation(user.id)}
disabled={isResending === user.id}
>
{#if isResending === user.id}
Envoi...
{:else}
Renvoyer l'invitation
{/if}
</button>
{/if}
{#if canBlockUser(user)}
<button
class="btn-danger btn-sm"
onclick={() => openBlockModal(user)}
>
Bloquer
</button>
{/if}
{#if user.statut === 'suspended'}
<button
class="btn-success btn-sm"
onclick={() => handleUnblockUser(user)}
disabled={isUnblocking === user.id}
>
{#if isUnblocking === user.id}
Déblocage...
{:else}
Débloquer
{/if}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Create User Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }}
>
<header class="modal-header">
<h2 id="modal-title">Inviter un utilisateur</h2>
<button class="modal-close" onclick={closeModal} aria-label="Fermer">×</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreateUser();
}}
>
<div class="form-row">
<div class="form-group">
<label for="user-firstname">Prénom *</label>
<input
type="text"
id="user-firstname"
bind:value={newFirstName}
placeholder="ex: Marie"
required
minlength="2"
maxlength="100"
/>
</div>
<div class="form-group">
<label for="user-lastname">Nom *</label>
<input
type="text"
id="user-lastname"
bind:value={newLastName}
placeholder="ex: Dupont"
required
minlength="2"
maxlength="100"
/>
</div>
</div>
<div class="form-group">
<label for="user-email">Email *</label>
<input
type="email"
id="user-email"
bind:value={newEmail}
placeholder="ex: marie.dupont@email.com"
required
/>
</div>
<div class="form-group">
<label for="user-role">Rôle *</label>
<select id="user-role" bind:value={newRole} required>
<option value="">-- Sélectionner un rôle --</option>
{#each ROLE_OPTIONS as role}
<option value={role.value}>{role.label}</option>
{/each}
</select>
</div>
<div class="form-hint-block">
Un email d'invitation sera automatiquement envoyé à l'utilisateur.
L'invitation expire au bout de 7 jours.
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModal} disabled={isSubmitting}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !newFirstName.trim() || !newLastName.trim() || !newEmail.trim() || !newRole}
>
{#if isSubmitting}
Envoi de l'invitation...
{:else}
Envoyer l'invitation
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Block User Modal -->
{#if showBlockModal && blockTargetUser}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeBlockModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="block-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeBlockModal(); }}
>
<header class="modal-header">
<h2 id="block-modal-title">Bloquer un utilisateur</h2>
<button class="modal-close" onclick={closeBlockModal} aria-label="Fermer">×</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleBlockUser();
}}
>
<div class="block-warning">
Vous allez bloquer <strong>{blockTargetUser.firstName} {blockTargetUser.lastName}</strong> ({blockTargetUser.email}).
L'utilisateur ne pourra plus se connecter.
</div>
<div class="form-group">
<label for="block-reason">Raison du blocage *</label>
<textarea
id="block-reason"
bind:value={blockReason}
placeholder="ex: Comportement inapproprié, compte compromis..."
required
rows="3"
></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeBlockModal} disabled={isBlocking}>
Annuler
</button>
<button
type="submit"
class="btn-danger"
disabled={isBlocking || !blockReason.trim()}
>
{#if isBlocking}
Blocage en cours...
{:else}
Confirmer le blocage
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.users-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
/* Buttons */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-secondary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-success {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #16a34a;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-success:hover:not(:disabled) {
background: #15803d;
}
.btn-success:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-text {
padding: 0.5rem 1rem;
background: none;
color: #6b7280;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: color 0.2s;
}
.btn-text:hover {
color: #374151;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-icon {
font-size: 1.25rem;
line-height: 1;
}
/* Alerts */
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.alert-close:hover {
opacity: 1;
}
/* Filters */
.filters-bar {
display: flex;
align-items: flex-end;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-group select {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
min-width: 180px;
background: white;
}
.filter-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.filter-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Loading & Empty */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #1f2937;
}
.empty-state p {
margin: 0 0 1.5rem;
color: #6b7280;
}
/* Table */
.users-table-container {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.users-table td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #374151;
border-bottom: 1px solid #f3f4f6;
}
.users-table tr:last-child td {
border-bottom: none;
}
.users-table tr:hover td {
background: #f9fafb;
}
.user-name-cell {
white-space: nowrap;
}
.user-fullname {
font-weight: 500;
color: #1f2937;
}
.user-email {
color: #6b7280;
}
.date-cell {
white-space: nowrap;
color: #6b7280;
}
.actions-cell {
white-space: nowrap;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Badges */
.role-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #eff6ff;
color: #3b82f6;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-active {
background: #f0fdf4;
color: #16a34a;
}
.status-pending {
background: #fffbeb;
color: #d97706;
}
.status-expired {
background: #fef2f2;
color: #dc2626;
}
.status-blocked {
background: #fef2f2;
color: #dc2626;
}
.status-archived {
background: #f3f4f6;
color: #6b7280;
}
.blocked-reason {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 32rem;
max-height: 90vh;
overflow: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.block-warning {
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #dc2626;
margin-bottom: 1.25rem;
line-height: 1.5;
}
.form-group textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint-block {
padding: 0.75rem 1rem;
background: #eff6ff;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #3b82f6;
margin-bottom: 1.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
@media (max-width: 768px) {
.filters-bar {
flex-direction: column;
align-items: stretch;
}
.filter-group select {
min-width: auto;
width: 100%;
}
.form-row {
grid-template-columns: 1fr;
}
.users-table th:nth-child(5),
.users-table td:nth-child(5) {
display: none;
}
}
</style>