Skip to content
Open
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
95 changes: 95 additions & 0 deletions PatchNotes.Api/Routes/UserRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,101 @@ public static WebApplication MapUserRoutes(this WebApplication app)
.Produces(StatusCodes.Status404NotFound)
.WithName("UpdateCurrentUser");

// POST /api/users/me/confirm-email-change - Clean up old emails after magic link verification
group.MapPost("/me/confirm-email-change", async (HttpContext httpContext, PatchNotesDbContext db, IStytchClient stytchClient, ILoggerFactory loggerFactory) =>
{
var logger = loggerFactory.CreateLogger("PatchNotes.Api.Routes.UserRoutes");
var stytchUserId = httpContext.Items["StytchUserId"] as string;
var sessionEmail = httpContext.Items["StytchEmail"] as string;

// Fetch user from Stytch to get the full emails array
StytchUser? stytchUser;
try
{
stytchUser = await stytchClient.GetUserAsync(stytchUserId!);
}
catch (Exception ex)
{
logger.LogError(ex, "Stytch API call failed for user {StytchUserId}", stytchUserId);
return Results.Json(new ApiError("Stytch API call failed"), statusCode: 502);
}

if (stytchUser == null)
{
return Results.Json(new ApiError("Could not fetch user from Stytch"), statusCode: 502);
}

// If there's only one email, nothing to clean up
if (stytchUser.Emails.Count <= 1)
{
var existingUser = await db.Users.FirstOrDefaultAsync(u => u.StytchUserId == stytchUserId);
if (existingUser == null) return Results.NotFound(new ApiError("User not found"));

var session = httpContext.Items["StytchSession"] as StytchSessionResult;
return Results.Ok(new UserDto
{
Id = existingUser.Id,
StytchUserId = existingUser.StytchUserId,
Email = existingUser.Email,
Name = existingUser.Name,
CreatedAt = existingUser.CreatedAt,
LastLoginAt = existingUser.LastLoginAt,
IsPro = existingUser.IsPro || (session?.IsAdmin ?? false),
IsAdmin = session?.IsAdmin ?? false
});
}

// The session email is the new email (the one the user just verified via magic link).
// Delete all other emails from Stytch.
var newEmail = sessionEmail ?? stytchUser.Emails.Last().Email;
var emailsToRemove = stytchUser.Emails.Where(e => e.Email != newEmail).ToList();

foreach (var oldEmail in emailsToRemove)
{
try
{
await stytchClient.DeleteEmailAsync(oldEmail.EmailId);
logger.LogInformation("Removed old email {EmailId} ({Email}) from Stytch user {StytchUserId}",
oldEmail.EmailId, oldEmail.Email, stytchUserId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to delete old email {EmailId} from Stytch user {StytchUserId}",
oldEmail.EmailId, stytchUserId);
return Results.Json(new ApiError("Failed to remove old email from Stytch"), statusCode: 502);
}
}

// Update our DB with the new email
var user = await db.Users.FirstOrDefaultAsync(u => u.StytchUserId == stytchUserId);
if (user == null)
{
return Results.NotFound(new ApiError("User not found"));
}

user.Email = newEmail;
await db.SaveChangesAsync();

var sess = httpContext.Items["StytchSession"] as StytchSessionResult;
var isAdmin = sess?.IsAdmin ?? false;

return Results.Ok(new UserDto
{
Id = user.Id,
StytchUserId = user.StytchUserId,
Email = user.Email,
Name = user.Name,
CreatedAt = user.CreatedAt,
LastLoginAt = user.LastLoginAt,
IsPro = user.IsPro || isAdmin,
IsAdmin = isAdmin
});
})
.AddEndpointFilterFactory(RouteUtils.CreateAuthFilter())
.Produces<UserDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.WithName("ConfirmEmailChange");

// GET /api/users/me/email-preferences - Get current email preferences
group.MapGet("/me/email-preferences", async (HttpContext httpContext, PatchNotesDbContext db) =>
{
Expand Down
22 changes: 22 additions & 0 deletions PatchNotes.Api/Stytch/IStytchClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ public interface IStytchClient
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The user info, or null if not found.</returns>
Task<StytchUser?> GetUserAsync(string userId, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes an email address from a Stytch user by email ID.
/// </summary>
/// <param name="emailId">The Stytch email ID to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task DeleteEmailAsync(string emailId, CancellationToken cancellationToken = default);
}

/// <summary>
Expand Down Expand Up @@ -73,6 +80,11 @@ public class StytchUser
/// </summary>
public string? Email { get; set; }

/// <summary>
/// All email addresses associated with the user.
/// </summary>
public List<StytchEmail> Emails { get; set; } = [];

/// <summary>
/// The user's name, if available.
/// </summary>
Expand All @@ -83,3 +95,13 @@ public class StytchUser
/// </summary>
public string? Status { get; set; }
}

/// <summary>
/// A Stytch email address with its ID and verification status.
/// </summary>
public class StytchEmail
{
public required string EmailId { get; set; }
public required string Email { get; set; }
public bool Verified { get; set; }
}
11 changes: 11 additions & 0 deletions PatchNotes.Api/Stytch/StytchClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ public StytchClient(IConfiguration configuration, ILogger<StytchClient> logger)
{
UserId = response.UserId,
Email = response.Emails?.FirstOrDefault()?.Email,
Emails = response.Emails?.Select(e => new StytchEmail
{
EmailId = e.EmailId,
Email = e.Email,
Verified = e.Verified
}).ToList() ?? [],
Name = name,
Status = response.Status
};
Expand All @@ -78,4 +84,9 @@ public StytchClient(IConfiguration configuration, ILogger<StytchClient> logger)
return null;
}
}

public async Task DeleteEmailAsync(string emailId, CancellationToken cancellationToken = default)
{
await _client.Users.DeleteEmail(new UsersDeleteEmailRequest(emailId));
}
}
5 changes: 5 additions & 0 deletions PatchNotes.Tests/PatchNotesApiFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,11 @@ public void RegisterSession(string sessionToken, string userId, string email, Li
_users.TryGetValue(userId, out var user);
return Task.FromResult(user);
}

public Task DeleteEmailAsync(string emailId, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
}

/// <summary>
Expand Down
25 changes: 24 additions & 1 deletion patchnotes-web/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"servers": [
{
"url": "http://localhost:5000/"
"url": "http://localhost:5099/"
}
],
"paths": {
Expand Down Expand Up @@ -578,6 +578,29 @@
}
}
},
"/api/users/me/confirm-email-change": {
"post": {
"tags": [
"Users"
],
"operationId": "ConfirmEmailChange",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserDto"
}
}
}
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/users/me/email-preferences": {
"get": {
"tags": [
Expand Down
82 changes: 82 additions & 0 deletions patchnotes-web/src/api/generated/users/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,88 @@ const {mutation: mutationOptions, request: requestOptions} = options ?
> => {
return useMutation(getLoginUserMutationOptions(options), queryClient);
}
export type confirmEmailChangeResponse200 = {
data: UserDto
status: 200
}

export type confirmEmailChangeResponse404 = {
data: void
status: 404
}

export type confirmEmailChangeResponseSuccess = (confirmEmailChangeResponse200) & {
headers: Headers;
};
export type confirmEmailChangeResponseError = (confirmEmailChangeResponse404) & {
headers: Headers;
};

export type confirmEmailChangeResponse = (confirmEmailChangeResponseSuccess | confirmEmailChangeResponseError)

export const getConfirmEmailChangeUrl = () => {




return `/api/users/me/confirm-email-change`
}

export const confirmEmailChange = async ( options?: RequestInit): Promise<confirmEmailChangeResponse> => {

return customFetch<confirmEmailChangeResponse>(getConfirmEmailChangeUrl(),
{
...options,
method: 'POST'


}
);}




export const getConfirmEmailChangeMutationOptions = <TError = void,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof confirmEmailChange>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof confirmEmailChange>>, TError,void, TContext> => {

const mutationKey = ['confirmEmailChange'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};




const mutationFn: MutationFunction<Awaited<ReturnType<typeof confirmEmailChange>>, void> = () => {


return confirmEmailChange(requestOptions)
}






return { mutationFn, ...mutationOptions }}

export type ConfirmEmailChangeMutationResult = NonNullable<Awaited<ReturnType<typeof confirmEmailChange>>>

export type ConfirmEmailChangeMutationError = void

export const useConfirmEmailChange = <TError = void,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof confirmEmailChange>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof confirmEmailChange>>,
TError,
void,
TContext
> => {
return useMutation(getConfirmEmailChangeMutationOptions(options), queryClient);
}
export type getEmailPreferencesResponse200 = {
data: EmailPreferencesDto
status: 200
Expand Down
11 changes: 11 additions & 0 deletions patchnotes-web/src/api/generated/users/users.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,17 @@ export const LoginUserResponse = zod.object({
"isAdmin": zod.boolean().optional()
})

export const ConfirmEmailChangeResponse = zod.object({
"id": zod.string(),
"stytchUserId": zod.string(),
"email": zod.string().nullish(),
"name": zod.string().nullish(),
"createdAt": zod.iso.datetime({"offset":true}).optional(),
"lastLoginAt": zod.iso.datetime({"offset":true}).nullish(),
"isPro": zod.boolean().optional(),
"isAdmin": zod.boolean().optional()
})

export const GetEmailPreferencesResponse = zod.object({
"emailDigestEnabled": zod.boolean().optional(),
"emailWelcomeSent": zod.boolean().optional(),
Expand Down
Loading