Skip to content

Commit 8f0b2e8

Browse files
Merge branch 'develop' into 80-scripts-sql
2 parents 474fb9b + 45dfe47 commit 8f0b2e8

File tree

14 files changed

+335
-1
lines changed

14 files changed

+335
-1
lines changed

src/backend/src/FantasyRealm.Api/Controllers/EmployeeManagementController.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,33 @@ public async Task<IActionResult> UpdateStatus(int id, [FromBody] UpdateUserStatu
120120
return Ok(result.Value);
121121
}
122122

123+
/// <summary>
124+
/// Resets an employee's password with a generated temporary password.
125+
/// Sets the MustChangePassword flag and sends the new credentials by email.
126+
/// </summary>
127+
/// <param name="id">The employee identifier.</param>
128+
/// <param name="cancellationToken">Cancellation token.</param>
129+
/// <returns>No content on success.</returns>
130+
/// <response code="204">Password reset successfully.</response>
131+
/// <response code="403">Target user is not an employee.</response>
132+
/// <response code="404">Employee not found.</response>
133+
[HttpPost("{id:int}/reset-password")]
134+
[ProducesResponseType(StatusCodes.Status204NoContent)]
135+
[ProducesResponseType(StatusCodes.Status403Forbidden)]
136+
[ProducesResponseType(StatusCodes.Status404NotFound)]
137+
public async Task<IActionResult> ResetPassword(int id, CancellationToken cancellationToken)
138+
{
139+
if (!TryGetUserId(out var adminId))
140+
return Unauthorized(new { message = "Utilisateur non identifié." });
141+
142+
var result = await employeeManagementService.ResetPasswordAsync(id, adminId, cancellationToken);
143+
144+
if (result.IsFailure)
145+
return StatusCode(result.ErrorCode ?? 400, new { message = result.Error });
146+
147+
return NoContent();
148+
}
149+
123150
/// <summary>
124151
/// Permanently deletes an employee account.
125152
/// Sends a notification email before deletion.

src/backend/src/FantasyRealm.Application/Interfaces/IEmployeeManagementService.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ Task<Result<EmployeeManagementResponse>> ReactivateAsync(
6767
int adminId,
6868
CancellationToken cancellationToken);
6969

70+
/// <summary>
71+
/// Resets an employee's password with a generated temporary password.
72+
/// Sets the MustChangePassword flag and sends the new credentials by email.
73+
/// </summary>
74+
/// <param name="employeeId">The employee identifier.</param>
75+
/// <param name="adminId">The administrator performing the action.</param>
76+
/// <param name="cancellationToken">Cancellation token.</param>
77+
Task<Result<Unit>> ResetPasswordAsync(
78+
int employeeId,
79+
int adminId,
80+
CancellationToken cancellationToken);
81+
7082
/// <summary>
7183
/// Permanently deletes an employee account.
7284
/// Sends a notification email before deletion.

src/backend/src/FantasyRealm.Application/Services/EmployeeManagementService.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ namespace FantasyRealm.Application.Services
1414
public sealed class EmployeeManagementService(
1515
IUserRepository userRepository,
1616
IPasswordHasher passwordHasher,
17+
IPasswordGenerator passwordGenerator,
1718
IEmailService emailService,
1819
IActivityLogService activityLogService,
1920
ILogger<EmployeeManagementService> logger) : IEmployeeManagementService
@@ -182,6 +183,44 @@ await emailService.SendAccountReactivatedEmailAsync(
182183
return Result<EmployeeManagementResponse>.Success(UserMapper.ToEmployeeResponse(updated));
183184
}
184185

186+
/// <inheritdoc />
187+
public async Task<Result<Unit>> ResetPasswordAsync(
188+
int employeeId,
189+
int adminId,
190+
CancellationToken cancellationToken)
191+
{
192+
var employee = await userRepository.GetByIdWithRoleAsync(employeeId, cancellationToken);
193+
194+
if (employee is null)
195+
return Result<Unit>.Failure("Employé introuvable.", 404);
196+
197+
if (employee.Role.Label != EmployeeRoleLabel)
198+
return Result<Unit>.Failure("Seuls les mots de passe des comptes employés peuvent être réinitialisés depuis cet espace.", 403);
199+
200+
var temporaryPassword = passwordGenerator.GenerateSecurePassword();
201+
employee.PasswordHash = passwordHasher.Hash(temporaryPassword);
202+
employee.MustChangePassword = true;
203+
204+
await userRepository.UpdateAsync(employee, cancellationToken);
205+
206+
logger.LogInformation("Employee {EmployeeId} password reset by admin {AdminId}", employeeId, adminId);
207+
208+
try { await activityLogService.LogAsync(ActivityAction.EmployeePasswordReset, "User", employeeId, employee.Pseudo, null, cancellationToken); }
209+
catch (Exception ex) { logger.LogWarning(ex, "Failed to log activity for employee password reset {EmployeeId}", employeeId); }
210+
211+
try
212+
{
213+
await emailService.SendTemporaryPasswordEmailAsync(
214+
employee.Email, employee.Pseudo, temporaryPassword, cancellationToken);
215+
}
216+
catch (Exception ex)
217+
{
218+
logger.LogWarning(ex, "Failed to send temporary password email for employee {EmployeeId}", employeeId);
219+
}
220+
221+
return Result<Unit>.Success(Unit.Value);
222+
}
223+
185224
/// <inheritdoc />
186225
public async Task<Result<Unit>> DeleteAsync(
187226
int employeeId,

src/backend/src/FantasyRealm.Domain/Enums/ActivityAction.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public enum ActivityAction
1919
EmployeeSuspended,
2020
EmployeeReactivated,
2121
EmployeeDeleted,
22+
EmployeePasswordReset,
2223
PasswordChanged
2324
}
2425
}

src/backend/tests/FantasyRealm.Tests.Unit/Services/EmployeeManagementServiceTests.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class EmployeeManagementServiceTests
1717
{
1818
private readonly Mock<IUserRepository> _userRepoMock = new();
1919
private readonly Mock<IPasswordHasher> _passwordHasherMock = new();
20+
private readonly Mock<IPasswordGenerator> _passwordGeneratorMock = new();
2021
private readonly Mock<IEmailService> _emailServiceMock = new();
2122
private readonly Mock<IActivityLogService> _activityLogServiceMock = new();
2223
private readonly Mock<ILogger<EmployeeManagementService>> _loggerMock = new();
@@ -29,6 +30,7 @@ public EmployeeManagementServiceTests()
2930
_sut = new EmployeeManagementService(
3031
_userRepoMock.Object,
3132
_passwordHasherMock.Object,
33+
_passwordGeneratorMock.Object,
3234
_emailServiceMock.Object,
3335
_activityLogServiceMock.Object,
3436
_loggerMock.Object);
@@ -318,6 +320,86 @@ public async Task ReactivateAsync_WhenNotEmployeeRole_ReturnsFailure403()
318320
result.ErrorCode.Should().Be(403);
319321
}
320322

323+
// -- ResetPasswordAsync ---------------------------------------------------
324+
325+
[Fact]
326+
public async Task ResetPasswordAsync_WhenFound_SetsHashAndMustChangePassword()
327+
{
328+
var employee = ActiveEmployee();
329+
_userRepoMock
330+
.Setup(r => r.GetByIdWithRoleAsync(10, It.IsAny<CancellationToken>()))
331+
.ReturnsAsync(employee);
332+
_passwordGeneratorMock
333+
.Setup(g => g.GenerateSecurePassword(It.IsAny<int>()))
334+
.Returns("TempP@ss2025!xyz");
335+
_passwordHasherMock
336+
.Setup(h => h.Hash("TempP@ss2025!xyz"))
337+
.Returns("hashed_temp");
338+
_userRepoMock
339+
.Setup(r => r.UpdateAsync(employee, It.IsAny<CancellationToken>()))
340+
.ReturnsAsync(employee);
341+
342+
var result = await _sut.ResetPasswordAsync(10, AdminId, CancellationToken.None);
343+
344+
result.IsSuccess.Should().BeTrue();
345+
employee.PasswordHash.Should().Be("hashed_temp");
346+
employee.MustChangePassword.Should().BeTrue();
347+
_userRepoMock.Verify(r => r.UpdateAsync(employee, It.IsAny<CancellationToken>()), Times.Once);
348+
}
349+
350+
[Fact]
351+
public async Task ResetPasswordAsync_WhenFound_SendsTemporaryPasswordEmail()
352+
{
353+
var employee = ActiveEmployee();
354+
_userRepoMock
355+
.Setup(r => r.GetByIdWithRoleAsync(10, It.IsAny<CancellationToken>()))
356+
.ReturnsAsync(employee);
357+
_passwordGeneratorMock
358+
.Setup(g => g.GenerateSecurePassword(It.IsAny<int>()))
359+
.Returns("TempP@ss2025!xyz");
360+
_passwordHasherMock
361+
.Setup(h => h.Hash(It.IsAny<string>()))
362+
.Returns("hashed_temp");
363+
_userRepoMock
364+
.Setup(r => r.UpdateAsync(employee, It.IsAny<CancellationToken>()))
365+
.ReturnsAsync(employee);
366+
367+
await _sut.ResetPasswordAsync(10, AdminId, CancellationToken.None);
368+
369+
_emailServiceMock.Verify(
370+
e => e.SendTemporaryPasswordEmailAsync(
371+
"legolas@example.com", "Legolas", "TempP@ss2025!xyz",
372+
It.IsAny<CancellationToken>()),
373+
Times.Once);
374+
}
375+
376+
[Fact]
377+
public async Task ResetPasswordAsync_WhenNotFound_ReturnsFailure404()
378+
{
379+
_userRepoMock
380+
.Setup(r => r.GetByIdWithRoleAsync(999, It.IsAny<CancellationToken>()))
381+
.ReturnsAsync((User?)null);
382+
383+
var result = await _sut.ResetPasswordAsync(999, AdminId, CancellationToken.None);
384+
385+
result.IsFailure.Should().BeTrue();
386+
result.ErrorCode.Should().Be(404);
387+
}
388+
389+
[Fact]
390+
public async Task ResetPasswordAsync_WhenNotEmployeeRole_ReturnsFailure403()
391+
{
392+
var user = RegularUser();
393+
_userRepoMock
394+
.Setup(r => r.GetByIdWithRoleAsync(20, It.IsAny<CancellationToken>()))
395+
.ReturnsAsync(user);
396+
397+
var result = await _sut.ResetPasswordAsync(20, AdminId, CancellationToken.None);
398+
399+
result.IsFailure.Should().BeTrue();
400+
result.ErrorCode.Should().Be(403);
401+
}
402+
321403
// -- DeleteAsync ---------------------------------------------------------
322404

323405
[Fact]

src/frontend/src/components/admin/ActivityLogFilters.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const ACTION_OPTIONS: { value: ActivityAction; label: string }[] = [
66
{ value: 'EmployeeSuspended', label: 'Employé suspendu' },
77
{ value: 'EmployeeReactivated', label: 'Employé réactivé' },
88
{ value: 'EmployeeDeleted', label: 'Employé supprimé' },
9+
{ value: 'EmployeePasswordReset', label: 'Mot de passe employé réinitialisé' },
910
{ value: 'UserSuspended', label: 'Utilisateur suspendu' },
1011
{ value: 'UserReactivated', label: 'Utilisateur réactivé' },
1112
{ value: 'UserDeleted', label: 'Utilisateur supprimé' },

src/frontend/src/components/admin/ActivityLogTable.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const ACTION_LABELS: Record<string, string> = {
66
EmployeeSuspended: 'Employé suspendu',
77
EmployeeReactivated: 'Employé réactivé',
88
EmployeeDeleted: 'Employé supprimé',
9+
EmployeePasswordReset: 'Mot de passe réinitialisé',
910
UserSuspended: 'Utilisateur suspendu',
1011
UserReactivated: 'Utilisateur réactivé',
1112
UserDeleted: 'Utilisateur supprimé',
@@ -24,6 +25,7 @@ const ACTION_COLORS: Record<string, string> = {
2425
EmployeeSuspended: 'bg-orange-500/20 text-orange-400',
2526
EmployeeReactivated: 'bg-green-500/20 text-green-400',
2627
EmployeeDeleted: 'bg-red-500/20 text-red-400',
28+
EmployeePasswordReset: 'bg-blue-500/20 text-blue-400',
2729
UserSuspended: 'bg-orange-500/20 text-orange-400',
2830
UserReactivated: 'bg-green-500/20 text-green-400',
2931
UserDeleted: 'bg-red-500/20 text-red-400',

src/frontend/src/components/admin/EmployeeCard.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface EmployeeCardProps {
77
employee: EmployeeManagement;
88
onSuspend: (id: number) => void;
99
onReactivate: (id: number) => void;
10+
onResetPassword: (id: number) => void;
1011
onDelete: (id: number) => void;
1112
isProcessing: boolean;
1213
}
@@ -15,6 +16,7 @@ export const EmployeeCard = memo(function EmployeeCard({
1516
employee,
1617
onSuspend,
1718
onReactivate,
19+
onResetPassword,
1820
onDelete,
1921
isProcessing,
2022
}: EmployeeCardProps) {
@@ -55,6 +57,9 @@ export const EmployeeCard = memo(function EmployeeCard({
5557
aria-label={`Réactiver le compte de ${employee.pseudo}`}
5658
className="flex-1"
5759
>
60+
<svg className="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
61+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
62+
</svg>
5863
Réactiver
5964
</Button>
6065
) : (
@@ -66,9 +71,25 @@ export const EmployeeCard = memo(function EmployeeCard({
6671
aria-label={`Suspendre le compte de ${employee.pseudo}`}
6772
className="flex-1"
6873
>
74+
<svg className="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
75+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
76+
</svg>
6977
Suspendre
7078
</Button>
7179
)}
80+
<Button
81+
variant="secondary"
82+
size="sm"
83+
onClick={() => onResetPassword(employee.id)}
84+
isLoading={isProcessing}
85+
aria-label={`Réinitialiser le mot de passe de ${employee.pseudo}`}
86+
className="flex-1"
87+
>
88+
<svg className="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
89+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
90+
</svg>
91+
Mot de passe
92+
</Button>
7293
<Button
7394
variant="danger"
7495
size="sm"
@@ -77,6 +98,9 @@ export const EmployeeCard = memo(function EmployeeCard({
7798
aria-label={`Supprimer le compte de ${employee.pseudo}`}
7899
className="flex-1"
79100
>
101+
<svg className="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
102+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
103+
</svg>
80104
Supprimer
81105
</Button>
82106
</div>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from '../ui';
2+
3+
interface ResetPasswordModalProps {
4+
isOpen: boolean;
5+
targetName: string;
6+
onConfirm: () => void;
7+
onClose: () => void;
8+
isSubmitting: boolean;
9+
result: 'idle' | 'success' | 'error';
10+
}
11+
12+
export function ResetPasswordModal({
13+
isOpen,
14+
targetName,
15+
onConfirm,
16+
onClose,
17+
isSubmitting,
18+
result,
19+
}: ResetPasswordModalProps) {
20+
return (
21+
<Modal isOpen={isOpen} onClose={onClose} size="sm">
22+
<ModalHeader onClose={onClose}>
23+
Réinitialiser le mot de passe
24+
</ModalHeader>
25+
<ModalBody>
26+
{result === 'idle' && (
27+
<p className="text-cream-300">
28+
Réinitialiser le mot de passe de{' '}
29+
<strong className="text-cream-100">{targetName}</strong> ?
30+
<br />
31+
<span className="text-cream-500 text-sm mt-2 block">
32+
Un mot de passe temporaire sera généré et envoyé par email.
33+
L'employé devra le modifier à sa prochaine connexion.
34+
</span>
35+
</p>
36+
)}
37+
{result === 'success' && (
38+
<div className="flex items-start gap-3">
39+
<svg className="w-6 h-6 text-success-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
40+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
41+
</svg>
42+
<p className="text-cream-300">
43+
Le mot de passe de <strong className="text-cream-100">{targetName}</strong> a
44+
été réinitialisé avec succès. Un email contenant le mot de passe temporaire
45+
lui a été envoyé.
46+
</p>
47+
</div>
48+
)}
49+
{result === 'error' && (
50+
<div className="flex items-start gap-3">
51+
<svg className="w-6 h-6 text-error-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
52+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
53+
</svg>
54+
<p className="text-cream-300">
55+
Une erreur est survenue lors de la réinitialisation du mot de passe.
56+
Veuillez réessayer.
57+
</p>
58+
</div>
59+
)}
60+
</ModalBody>
61+
<ModalFooter>
62+
{result === 'idle' && (
63+
<>
64+
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
65+
Annuler
66+
</Button>
67+
<Button variant="primary" size="sm" onClick={onConfirm} isLoading={isSubmitting}>
68+
<svg className="w-3.5 h-3.5 mr-1 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
69+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
70+
</svg>
71+
Réinitialiser
72+
</Button>
73+
</>
74+
)}
75+
{(result === 'success' || result === 'error') && (
76+
<Button variant="outline" size="sm" onClick={onClose}>
77+
Fermer
78+
</Button>
79+
)}
80+
</ModalFooter>
81+
</Modal>
82+
);
83+
}

src/frontend/src/components/admin/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { ActivityLogTable } from './ActivityLogTable';
33
export { AdminDashboardStats } from './AdminDashboardStats';
44
export { CreateEmployeeModal } from './CreateEmployeeModal';
55
export { EmployeeCard } from './EmployeeCard';
6+
export { ResetPasswordModal } from './ResetPasswordModal';

0 commit comments

Comments
 (0)