Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static class Routes
public const string UsersLockApi = "/admin/users/{id}/lock";
public const string UsersUnlockApi = "/admin/users/{id}/unlock";
public const string UsersForceReverifyApi = "/admin/users/{id}/force-reverify";
public const string UsersForcePhoneReverifyApi = "/admin/users/{id}/force-phone-reverify";
public const string UsersDisable2faApi = "/admin/users/{id}/disable-2fa";
public const string UsersDeactivateApi = "/admin/users/{id}/deactivate";
public const string UsersReactivateApi = "/admin/users/{id}/reactivate";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ async Task<IResult> (string id, IUserAdminContracts userAdmin) =>
}
);

// POST /admin/users/{id}/force-phone-reverify — Force phone re-verification
group.MapPost(
"/{id}/force-phone-reverify",
async Task<IResult> (string id, IUserAdminContracts userAdmin) =>
{
await userAdmin.ForcePhoneReverificationAsync(UserId.From(id));
Comment on lines +178 to +182
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added ForcePhoneReverify_ValidUser_ClearsPhoneNumberConfirmedAndRedirects to AdminUsersEndpointTests. It seeds a user with a confirmed phone, posts to /admin/users/{id}/force-phone-reverify, and asserts both the 302 redirect and that PhoneNumberConfirmed flips to false while the number itself is preserved.


Generated by Claude Code


return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security");
}
);

// POST /admin/users/{id}/disable-2fa — Disable two-factor authentication
group.MapPost(
"/{id}/disable-2fa",
Expand Down
9 changes: 9 additions & 0 deletions modules/Admin/src/SimpleModule.Admin/Locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@
"UsersEdit.EmailVerified": "Verified",
"UsersEdit.EmailNotVerified": "Not verified",
"UsersEdit.ForceReverifyButton": "Force Re-verification",
"UsersEdit.PhoneVerificationTitle": "Phone Verification",
"UsersEdit.PhoneVerificationStatus": "Status: {status}",
"UsersEdit.PhoneVerified": "Verified",
"UsersEdit.PhoneNotVerified": "Not verified",
"UsersEdit.PhoneNotSet": "No phone number set",
"UsersEdit.ForceReverifyPhoneButton": "Force Phone Re-verification",
"UsersEdit.TwoFactorTitle": "Two-Factor Authentication",
"UsersEdit.TwoFactorStatus": "Status: {status}",
"UsersEdit.TwoFactorEnabled": "Enabled",
Expand Down Expand Up @@ -104,6 +110,9 @@
"UsersEdit.ConfirmReverifyTitle": "Force Re-verification",
"UsersEdit.ConfirmReverifyDescription": "This will require the user to re-verify their email address. Are you sure?",
"UsersEdit.ConfirmReverifyAction": "Force Re-verification",
"UsersEdit.ConfirmReverifyPhoneTitle": "Force Phone Re-verification",
"UsersEdit.ConfirmReverifyPhoneDescription": "This will require the user to re-verify their phone number. Are you sure?",
"UsersEdit.ConfirmReverifyPhoneAction": "Force Phone Re-verification",
"UsersEdit.ConfirmDisable2faTitle": "Disable Two-Factor Authentication",
"UsersEdit.ConfirmDisable2faDescription": "This will disable 2FA and reset the authenticator for this user. Are you sure?",
"UsersEdit.ConfirmDisable2faAction": "Disable 2FA",
Expand Down
9 changes: 9 additions & 0 deletions modules/Admin/src/SimpleModule.Admin/Locales/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export const AdminKeys = {
ConfirmReverifyAction: 'UsersEdit.ConfirmReverifyAction',
ConfirmReverifyDescription: 'UsersEdit.ConfirmReverifyDescription',
ConfirmReverifyTitle: 'UsersEdit.ConfirmReverifyTitle',
ConfirmReverifyPhoneAction: 'UsersEdit.ConfirmReverifyPhoneAction',
ConfirmReverifyPhoneDescription: 'UsersEdit.ConfirmReverifyPhoneDescription',
ConfirmReverifyPhoneTitle: 'UsersEdit.ConfirmReverifyPhoneTitle',
ConfirmRevokeAllAction: 'UsersEdit.ConfirmRevokeAllAction',
ConfirmRevokeAllDescription: 'UsersEdit.ConfirmRevokeAllDescription',
ConfirmRevokeAllTitle: 'UsersEdit.ConfirmRevokeAllTitle',
Expand All @@ -126,6 +129,11 @@ export const AdminKeys = {
EmailVerificationStatus: 'UsersEdit.EmailVerificationStatus',
EmailVerificationTitle: 'UsersEdit.EmailVerificationTitle',
EmailVerified: 'UsersEdit.EmailVerified',
PhoneVerificationTitle: 'UsersEdit.PhoneVerificationTitle',
PhoneVerificationStatus: 'UsersEdit.PhoneVerificationStatus',
PhoneVerified: 'UsersEdit.PhoneVerified',
PhoneNotVerified: 'UsersEdit.PhoneNotVerified',
PhoneNotSet: 'UsersEdit.PhoneNotSet',
ErrorPasswordMismatch: 'UsersEdit.ErrorPasswordMismatch',
FailedLoginAttempts: 'UsersEdit.FailedLoginAttempts',
FieldConfirmPassword: 'UsersEdit.FieldConfirmPassword',
Expand All @@ -134,6 +142,7 @@ export const AdminKeys = {
FieldEmailConfirmed: 'UsersEdit.FieldEmailConfirmed',
FieldNewPassword: 'UsersEdit.FieldNewPassword',
ForceReverifyButton: 'UsersEdit.ForceReverifyButton',
ForceReverifyPhoneButton: 'UsersEdit.ForceReverifyPhoneButton',
LastLogin: 'UsersEdit.LastLogin',
LastLoginNever: 'UsersEdit.LastLoginNever',
LockButton: 'UsersEdit.LockButton',
Expand Down
19 changes: 18 additions & 1 deletion modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ interface UserDetail {
displayName: string;
email: string;
emailConfirmed: boolean;
phoneNumber: string | null;
phoneNumberConfirmed: boolean;
twoFactorEnabled: boolean;
roles: string[];
isLockedOut: boolean;
Expand Down Expand Up @@ -63,7 +65,13 @@ interface Props {
currentUserId: string;
}

type ConfirmAction = 'deactivate' | 'reverify' | 'disable2fa' | 'revokeAll' | null;
type ConfirmAction =
| 'deactivate'
| 'reverify'
| 'reverifyPhone'
| 'disable2fa'
| 'revokeAll'
| null;

export default function UsersEdit({
user,
Expand Down Expand Up @@ -94,6 +102,9 @@ export default function UsersEdit({
case 'reverify':
router.post(`/admin/users/${user.id}/force-reverify`);
break;
case 'reverifyPhone':
router.post(`/admin/users/${user.id}/force-phone-reverify`);
break;
case 'disable2fa':
router.post(`/admin/users/${user.id}/disable-2fa`);
break;
Expand All @@ -118,6 +129,11 @@ export default function UsersEdit({
description: t(AdminKeys.UsersEdit.ConfirmReverifyDescription),
action: t(AdminKeys.UsersEdit.ConfirmReverifyAction),
},
reverifyPhone: {
title: t(AdminKeys.UsersEdit.ConfirmReverifyPhoneTitle),
description: t(AdminKeys.UsersEdit.ConfirmReverifyPhoneDescription),
action: t(AdminKeys.UsersEdit.ConfirmReverifyPhoneAction),
},
disable2fa: {
title: t(AdminKeys.UsersEdit.ConfirmDisable2faTitle),
description: t(AdminKeys.UsersEdit.ConfirmDisable2faDescription),
Expand Down Expand Up @@ -183,6 +199,7 @@ export default function UsersEdit({
user={user}
isSelf={isSelf}
onReverify={() => setConfirmAction('reverify')}
onReverifyPhone={() => setConfirmAction('reverifyPhone')}
onDisable2fa={() => setConfirmAction('disable2fa')}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { AdminKeys } from '@/Locales/keys';
interface UserDetail {
id: string;
emailConfirmed: boolean;
phoneNumber: string | null;
phoneNumberConfirmed: boolean;
twoFactorEnabled: boolean;
isLockedOut: boolean;
accessFailedCount: number;
Expand All @@ -28,10 +30,17 @@ interface Props {
user: UserDetail;
isSelf: boolean;
onReverify: () => void;
onReverifyPhone: () => void;
onDisable2fa: () => void;
}

export function UserSecurityTab({ user, isSelf, onReverify, onDisable2fa }: Props) {
export function UserSecurityTab({
user,
isSelf,
onReverify,
onReverifyPhone,
onDisable2fa,
}: Props) {
const { t } = useTranslation('Admin');
const [passwordError, setPasswordError] = useState<string | null>(null);

Expand Down Expand Up @@ -128,6 +137,33 @@ export function UserSecurityTab({ user, isSelf, onReverify, onDisable2fa }: Prop
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>{t(AdminKeys.UsersEdit.PhoneVerificationTitle)}</CardTitle>
</CardHeader>
<CardContent>
{user.phoneNumber ? (
<>
<p className="text-sm text-text-muted mb-1">{user.phoneNumber}</p>
<p className="text-sm text-text-muted mb-3">
{t(AdminKeys.UsersEdit.PhoneVerificationStatus, {
status: user.phoneNumberConfirmed
? t(AdminKeys.UsersEdit.PhoneVerified)
: t(AdminKeys.UsersEdit.PhoneNotVerified),
})}
</p>
{user.phoneNumberConfirmed && (
<Button variant="outline" onClick={onReverifyPhone}>
{t(AdminKeys.UsersEdit.ForceReverifyPhoneButton)}
</Button>
)}
</>
) : (
<p className="text-sm text-text-muted">{t(AdminKeys.UsersEdit.PhoneNotSet)}</p>
)}
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>{t(AdminKeys.UsersEdit.TwoFactorTitle)}</CardTitle>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,33 @@ public async Task DeactivateUser_Self_ReturnsBadRequest()

response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

[Fact]
public async Task ForcePhoneReverify_ValidUser_ClearsPhoneNumberConfirmedAndRedirects()
{
var userId = await SeedTestUserAsync();
using (var scope = _factory.Services.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<
UserManager<ApplicationUser>
>();
var user = await userManager.FindByIdAsync(userId);
await userManager.SetPhoneNumberAsync(user!, "+15550009999");
user!.PhoneNumberConfirmed = true;
await userManager.UpdateAsync(user);
}

var client = CreateAdminClient();
var response = await client.PostAsync($"/admin/users/{userId}/force-phone-reverify", null);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

using var scope2 = _factory.Services.CreateScope();
var userManager2 = scope2.ServiceProvider.GetRequiredService<
UserManager<ApplicationUser>
>();
var after = await userManager2.FindByIdAsync(userId);
after!.PhoneNumberConfirmed.Should().BeFalse();
after.PhoneNumber.Should().Be("+15550009999", "phone number itself should be preserved");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public class AdminUserDto
public string DisplayName { get; set; } = string.Empty;
public string? Email { get; set; }
public bool EmailConfirmed { get; set; }
public string? PhoneNumber { get; set; }
public bool PhoneNumberConfirmed { get; set; }
public bool TwoFactorEnabled { get; set; }
public List<string> Roles { get; set; } = [];
public bool IsLockedOut { get; set; }
Expand Down
11 changes: 11 additions & 0 deletions modules/Users/src/SimpleModule.Users.Contracts/ISmsSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace SimpleModule.Users.Contracts;

public interface ISmsSender
{
Task SendVerificationCodeAsync(
ApplicationUser user,
string phoneNumber,
string code,
CancellationToken cancellationToken = default
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ Task<PagedResult<AdminUserDto>> GetUsersPagedAsync(
Task DeactivateAsync(UserId id);
Task ReactivateAsync(UserId id);
Task ForceEmailReverificationAsync(UserId id);
Task ForcePhoneReverificationAsync(UserId id);
Task DisableTwoFactorAsync(UserId id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public static class Routes
public const string ResetAuthenticator = "/Manage/ResetAuthenticator";
public const string GenerateRecoveryCodes = "/Manage/GenerateRecoveryCodes";
public const string SignOutEverywhere = "/Manage/SignOutEverywhere";
public const string SendPhoneVerificationCode = "/Manage/SendPhoneVerificationCode";
public const string ConfirmPhoneNumber = "/Manage/ConfirmPhoneNumber";
public const string RemovePhoneNumber = "/Manage/RemovePhoneNumber";
}

public static class TokenPurposes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using SimpleModule.Core;
using SimpleModule.Core.Inertia;
using SimpleModule.Users.Contracts;

namespace SimpleModule.Users.Pages.Account.Manage;

public class ConfirmPhoneNumberEndpoint : IViewEndpoint
{
public const string Route = UsersConstants.Routes.ConfirmPhoneNumber;

public void Map(IEndpointRouteBuilder app)
{
app.MapPost(
Route,
async (
[FromForm] string? phoneNumber,
[FromForm] string? code,
ClaimsPrincipal principal,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager
) =>
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return TypedResults.Redirect("/Identity/Account/Login");
}

var username = await userManager.GetUserNameAsync(user);

if (string.IsNullOrWhiteSpace(phoneNumber) || string.IsNullOrWhiteSpace(code))
{
return Inertia.Render(
"Users/Account/Manage/Index",
new
{
username,
phoneNumber = await userManager.GetPhoneNumberAsync(user),
isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync(
user
),
pendingPhoneNumber = phoneNumber,
statusMessage = "Error: Phone number and verification code are required.",
}
);
}

var result = await userManager.ChangePhoneNumberAsync(user, phoneNumber, code);
if (!result.Succeeded)
Comment on lines +54 to +55
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — 6 digits is too narrow a search space to leave unthrottled. Tracking in #199 alongside the SMS resend cooldown, so we add per-user attempt counters once for both email and phone code submission. Out of scope for this PR (phone parity to the existing untrottled email flow).


Generated by Claude Code

{
return Inertia.Render(
"Users/Account/Manage/Index",
new
{
username,
phoneNumber = await userManager.GetPhoneNumberAsync(user),
isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync(
user
),
pendingPhoneNumber = phoneNumber,
statusMessage = "Error: Invalid or expired verification code.",
}
);
}

await signInManager.RefreshSignInAsync(user);

return Inertia.Render(
"Users/Account/Manage/Index",
new
{
username,
phoneNumber = await userManager.GetPhoneNumberAsync(user),
isPhoneNumberConfirmed = await userManager.IsPhoneNumberConfirmedAsync(
user
),
statusMessage = "Your phone number has been verified.",
}
);
}
)
.RequireAuthorization()
.DisableAntiforgery();
}
}
Loading
Loading