Skip to content

Commit

Permalink
Implement resend code functionality in signup verification
Browse files Browse the repository at this point in the history
  • Loading branch information
tjementum committed Jan 6, 2025
1 parent 3617e80 commit c3a976e
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 50 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
group.MapPost("{id}/complete", async Task<ApiResult> (SignupId id, CompleteSignupCommand command, IMediator mediator)
=> await mediator.Send(command with { Id = id })
).AllowAnonymous();

group.MapPost("{id}/resend-code", async Task<ApiResult<ResendSignupCodeResponse>> (SignupId id, IMediator mediator)
=> await mediator.Send(new ResendSignupCodeCommand(id))
).Produces<ResendSignupCodeResponse>().AllowAnonymous();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Identity;
using PlatformPlatform.AccountManagement.Features.Signups.Domain;
using PlatformPlatform.SharedKernel.Authentication;
using PlatformPlatform.SharedKernel.Cqrs;
using PlatformPlatform.SharedKernel.Integrations.Email;
using PlatformPlatform.SharedKernel.Telemetry;

namespace PlatformPlatform.AccountManagement.Features.Signups.Commands;

[PublicAPI]
public sealed record ResendSignupCodeCommand(SignupId Id) : ICommand, IRequest<Result<ResendSignupCodeResponse>>;

[PublicAPI]
public record ResendSignupCodeResponse(int ValidForSeconds);

public sealed class ResendSignupCodeCommandHandler(
ISignupRepository signupRepository,
IEmailClient emailClient,
IPasswordHasher<object> passwordHasher,
ITelemetryEventsCollector events
) : IRequestHandler<ResendSignupCodeCommand, Result<ResendSignupCodeResponse>>
{
public async Task<Result<ResendSignupCodeResponse>> Handle(ResendSignupCodeCommand command, CancellationToken cancellationToken)
{
var signup = await signupRepository.GetByIdAsync(command.Id, cancellationToken);
if (signup is null)
{
return Result<ResendSignupCodeResponse>.NotFound($"Signup with id '{command.Id}' not found.");
}

if (signup.ModifiedAt > TimeProvider.System.GetUtcNow().AddSeconds(-30))
{
return Result<ResendSignupCodeResponse>.BadRequest("You must wait at least 30 seconds before requesting a new code.");
}

// Generate new verification code and update expiration
var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6);
var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword);
signup.UpdateVerificationCode(oneTimePasswordHash);
signupRepository.Update(signup);

await emailClient.SendAsync(signup.Email, "Confirm your email address",
$"""
<h1 style="text-align:center;font-family=sans-serif;font-size:20px">Your confirmation code is below</h1>
<p style="text-align:center;font-family=sans-serif;font-size:16px">Enter it in your open browser window. It is only valid for a few minutes.</p>
<p style="text-align:center;font-family=sans-serif;font-size:40px;background:#f5f4f5">{oneTimePassword}</p>
""",
cancellationToken
);

var secondsSinceSignupStarted = (TimeProvider.System.GetUtcNow() - signup.CreatedAt).TotalSeconds;

events.CollectEvent(new SignupCodeResend(signup.TenantId, (int)secondsSinceSignupStarted));

return new ResendSignupCodeResponse(Signup.ValidForSeconds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public TenantId GetTenantId()
}

[PublicAPI]
public sealed record StartSignupResponse(string SignupId, int ValidForSeconds);
public sealed record StartSignupResponse(SignupId SignupId, int ValidForSeconds);

public sealed class StartSignupValidator : AbstractValidator<StartSignupCommand>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ public void MarkAsCompleted()

Completed = true;
}

public void UpdateVerificationCode(string oneTimePasswordHash)
{
if (Completed)
{
throw new UnreachableException("Cannot regenerate verification code for completed signup");
}

ValidUntil = TimeProvider.System.GetUtcNow().AddSeconds(ValidForSeconds);
OneTimePasswordHash = oneTimePasswordHash;
}
}

[PublicAPI]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public sealed class SignupBlocked(TenantId tenantId, int retryCount)
public sealed class SignupCompleted(TenantId tenantId, int signupTimeInSeconds)
: TelemetryEvent(("tenant_id", tenantId), ("signup_time_in_seconds", signupTimeInSeconds));

public sealed class SignupCodeResend(TenantId tenantId, int secondsSinceSignupStarted)
: TelemetryEvent(("tenant_id", tenantId), ("seconds_since_signup_started", secondsSinceSignupStarted));

public sealed class SignupExpired(TenantId tenantId, int secondsFromCreation)
: TelemetryEvent(("tenant_id", tenantId), ("seconds_from_creation", secondsFromCreation));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task StartSignup_WhenSubdomainIsFreeAndEmailIsValid_ShouldReturnSuc
response.EnsureSuccessStatusCode();
var responseBody = await response.DeserializeResponse<StartSignupResponse>();
responseBody.Should().NotBeNull();
responseBody!.SignupId.Should().NotBeNullOrEmpty();
responseBody!.SignupId.ToString().Should().NotBeNullOrEmpty();
responseBody.ValidForSeconds.Should().Be(300);

TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface SignupState {
signupId: Schemas["SignupId"];
email: string;
expireAt: Date;
subdomain: string;
region: string;
}

let currentSignupState: SignupState | undefined;
Expand Down
10 changes: 7 additions & 3 deletions application/account-management/WebApp/routes/signup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,14 @@ export const Route = createFileRoute("/signup/")({

export function StartSignupForm() {
const [email, setEmail] = useState("");
const [subdomain, setSubdomain] = useState("");
const [region, setRegion] = useState("europe");

const [{ success, errors, data, title, message }, action, isPending] = useActionState(
api.actionPost("/api/account-management/signups/start"),
{ success: null }
);

const [subdomain, setSubdomain] = useState("");
const { data: isSubdomainFree } = useApi(
"/api/account-management/signups/is-subdomain-free",
{
Expand All @@ -69,7 +70,9 @@ export function StartSignupForm() {
setSignupState({
signupId: signupId,
email,
expireAt: new Date(Date.now() + validForSeconds * 1000)
expireAt: new Date(Date.now() + validForSeconds * 1000),
subdomain,
region
});

return <Navigate to="/signup/verify" />;
Expand Down Expand Up @@ -116,10 +119,11 @@ export function StartSignupForm() {
/>
<Select
name="region"
selectedKey="europe"
selectedKey={region}
label={t`Region`}
description={t`This is the region where your data is stored`}
isRequired
onChange={(value) => setRegion(value)}
className="flex w-full flex-col"
>
<SelectItem id="europe">
Expand Down
84 changes: 52 additions & 32 deletions application/account-management/WebApp/routes/signup/verify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { OneTimeCodeInput } from "@repo/ui/components/OneTimeCodeInput";
import { useExpirationTimeout } from "@repo/ui/hooks/useExpiration";
import logoMarkUrl from "@/shared/images/logo-mark.svg";
import poweredByUrl from "@/shared/images/powered-by.svg";
import { getSignupState } from "./-shared/signupState";
import { getSignupState, setSignupState } from "./-shared/signupState";
import { api } from "@/shared/lib/api/client";
import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage";
import { loggedInPath, signedUpPath } from "@repo/infrastructure/auth/constants";
Expand Down Expand Up @@ -50,6 +50,20 @@ export function CompleteSignupForm() {
}
);

const [{ success: resendSuccess, data: resendData }, resendAction] = useActionState(
api.actionPost("/api/account-management/signups/{id}/resend-code"),
{ success: null }
);

useEffect(() => {
if (resendSuccess && resendData) {
setSignupState({
...getSignupState(),
expireAt: new Date(Date.now() + resendData.validForSeconds * 1000)
});
}
}, [resendSuccess, resendData]);

useEffect(() => {
if (success) {
window.location.href = signedUpPath;
Expand All @@ -63,42 +77,48 @@ export function CompleteSignupForm() {
}, [isExpired]);

return (
<Form action={action} validationErrors={errors} validationBehavior="aria" className="w-full max-w-sm space-y-3">
<input type="hidden" name="id" value={signupId} />
<div className="flex w-full flex-col gap-4 rounded-lg px-6 pt-8 pb-4">
<div className="flex justify-center">
<Link href="/">
<img src={logoMarkUrl} className="h-12 w-12" alt={t`Logo`} />
</Link>
</div>
<h1 className="mb-3 w-full text-center text-2xl">
<Trans>Enter your verification code</Trans>
</h1>
<div className="text-center text-gray-500 text-sm">
<Trans>
Please check your email for a verification code sent to <span className="font-semibold">{email}</span>
</Trans>
</div>
<div className="flex w-full flex-col gap-4">
<OneTimeCodeInput name="oneTimePassword" digitPattern={DigitPattern.DigitsAndChars} length={6} autoFocus />
<div className="text-center text-neutral-500 text-xs">
<div className="w-full max-w-sm space-y-3">
<Form action={action} validationErrors={errors} validationBehavior="aria">
<input type="hidden" name="id" value={signupId} />
<div className="flex w-full flex-col gap-4 rounded-lg px-6 pt-8 pb-4">
<div className="flex justify-center">
<Link href="/">
<Trans>Didn't receive the code? Resend</Trans>
<img src={logoMarkUrl} className="h-12 w-12" alt={t`Logo`} />
</Link>
<span className="font-normal tabular-nums leading-none">({expiresInString})</span>
</div>
<h1 className="mb-3 w-full text-center text-2xl">
<Trans>Enter your verification code</Trans>
</h1>
<div className="text-center text-gray-500 text-sm">
<Trans>
Please check your email for a verification code sent to <span className="font-semibold">{email}</span>
</Trans>
</div>
<div className="flex w-full flex-col gap-4">
<OneTimeCodeInput name="oneTimePassword" digitPattern={DigitPattern.DigitsAndChars} length={6} autoFocus />
</div>
<FormErrorMessage title={title} message={message} />
<Button type="submit" className="mt-4 w-full text-center">
<Trans>Verify</Trans>
</Button>
</div>
<FormErrorMessage title={title} message={message} />
<Button type="submit" className="mt-4 w-full text-center">
<Trans>Verify</Trans>
</Button>
<div className="flex flex-col items-center gap-6 text-neutral-500">
<p className="text-xs ">
<Trans>Can't find your code? Check your spam folder.</Trans>
</p>
<img src={poweredByUrl} alt={t`Powered by`} />
</Form>

<div className="flex flex-col items-center gap-6 text-neutral-500 px-6">
<div className="text-center text-neutral-500 text-xs">
<Form action={resendAction} className="inline">
<input type="hidden" name="id" value={signupId} />
<Button type="submit" variant="link" className="text-xs p-0 h-auto">
<Trans>Didn't receive the code? Resend</Trans>
</Button>
</Form>
<span className="font-normal tabular-nums leading-none ml-1">({expiresInString})</span>
</div>
<p className="text-xs">
<Trans>Can't find your code? Check your spam folder.</Trans>
</p>
<img src={poweredByUrl} alt={t`Powered by`} />
</div>
</Form>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,37 @@
}
}
},
"/api/account-management/signups/{id}/resend-code": {
"post": {
"tags": [
"Signups"
],
"operationId": "PostApiAccountManagementSignupsResendCode",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"$ref": "#/components/schemas/SignupId"
},
"x-position": 1
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ResendSignupCodeResponse"
}
}
}
}
}
}
},
"/api/account-management/tenants/{id}": {
"get": {
"tags": [
Expand Down Expand Up @@ -744,26 +775,14 @@
"additionalProperties": false,
"properties": {
"signupId": {
"type": "string"
"$ref": "#/components/schemas/SignupId"
},
"validForSeconds": {
"type": "integer",
"format": "int32"
}
}
},
"StartSignupCommand": {
"type": "object",
"additionalProperties": false,
"properties": {
"subdomain": {
"type": "string"
},
"email": {
"type": "string"
}
}
},
"SignupId": {
"type": "string",
"format": "signup_{string}"
Expand Down Expand Up @@ -791,6 +810,18 @@
}
}
},
"StartSignupCommand": {
"type": "object",
"additionalProperties": false,
"properties": {
"subdomain": {
"type": "string"
},
"email": {
"type": "string"
}
}
},
"CompleteSignupCommand": {
"type": "object",
"additionalProperties": false,
Expand All @@ -800,6 +831,16 @@
}
}
},
"ResendSignupCodeResponse": {
"type": "object",
"additionalProperties": false,
"properties": {
"validForSeconds": {
"type": "integer",
"format": "int32"
}
}
},
"TenantResponse": {
"type": "object",
"additionalProperties": false,
Expand Down

0 comments on commit c3a976e

Please sign in to comment.