Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion src/shared/Jordnaer.Shared/Database/UserProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
namespace Jordnaer.Shared;

[Index(nameof(ZipCode))]
[Index(nameof(UserName))]
[Index(nameof(UserName), IsUnique = true)]
[Index(nameof(SearchableName))]
public class UserProfile
{
Expand Down
24 changes: 21 additions & 3 deletions src/web/Jordnaer/Components/Account/Pages/ConfirmEmail.razor
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

@using System.Text
@using Microsoft.AspNetCore.WebUtilities
@using Jordnaer.Features.Profile

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@inject IProfileService ProfileService
@inject IdentityRedirectManager RedirectManager

<MetadataComponent Title="Bekræft email" />
Expand Down Expand Up @@ -54,9 +56,25 @@

await SignInManager.SignInAsync(user, isPersistent: true);

var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;

RedirectManager.RedirectTo(returnUrl, forceLoad: true);
// Check if profile is complete and redirect accordingly
try
{
var isProfileComplete = await ProfileService.IsProfileCompleteAsync(user.Id);
if (!isProfileComplete)
{
RedirectManager.RedirectTo("/CompleteProfile", forceLoad: true);
}
else
{
var redirectUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
RedirectManager.RedirectTo(redirectUrl, forceLoad: true);
}
}
catch
{
// Fall back to home page if profile check fails
RedirectManager.RedirectTo("/", forceLoad: true);
}
Comment thread
NielsPilgaard marked this conversation as resolved.
}
else
{
Expand Down
53 changes: 51 additions & 2 deletions src/web/Jordnaer/Components/Account/Pages/ExternalLogin.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@using System.Text.Encodings.Web
@using Mediator
@using Microsoft.AspNetCore.WebUtilities
@using Jordnaer.Features.Profile

@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
Expand All @@ -13,6 +14,7 @@
@inject ILogger<ExternalLogin> Logger
@inject JordnaerDbContext Context
@inject IMediator Mediator
@inject IProfileService ProfileService

<MetadataComponent Title="Registrér" />

Expand Down Expand Up @@ -129,7 +131,25 @@
_externalLoginInfo.Principal.Identity?.Name,
_externalLoginInfo.LoginProvider);

RedirectManager.RedirectTo(FirstLoginReturnUrl);
// Check if profile is complete and redirect accordingly
var isProfileComplete = await ProfileService.IsProfileCompleteAsync(user.Id);
if (!isProfileComplete)
{
if (!string.IsNullOrWhiteSpace(ReturnUrl))
{
RedirectManager.RedirectTo(
"/CompleteProfile",
queryParameters: new Dictionary<string, object?> { ["returnUrl"] = ReturnUrl });
}
else
{
RedirectManager.RedirectTo("/CompleteProfile");
}
}
else
{
RedirectManager.RedirectTo(FirstLoginReturnUrl);
}
Comment thread
NielsPilgaard marked this conversation as resolved.
Outdated
}
else if (result.IsLockedOut)
{
Expand Down Expand Up @@ -165,6 +185,24 @@
Logger.LogInformation("User created an account using {Name} provider.", _externalLoginInfo!.LoginProvider);

var userProfile = _externalLoginInfo!.Principal.ToUserProfile(user.Id);

// Generate unique username
var usernameResult = await ProfileService.GenerateUniqueUsernameAsync(
userProfile.FirstName ?? "",
userProfile.LastName ?? "");

if (usernameResult.IsT1)
{
Logger.LogWarning("Failed to generate unique username for user {Email}: {Error}",
Input.Email, usernameResult.AsT1.Value);
_message = new AlertMessage(
"Kunne ikke generere brugernavn. Du skal fuldføre din profil for at fortsætte.",
true);
return;
}

userProfile.UserName = usernameResult.AsT0.Value;

Context.UserProfiles.Add(userProfile);
await Context.SaveChangesAsync();

Expand All @@ -186,7 +224,18 @@
JordnaerMetrics.ExternalLoginCounter.Add(1, new KeyValuePair<string, object?>("provider", _externalLoginInfo.LoginProvider));

await SignInManager.SignInAsync(user, isPersistent: false, _externalLoginInfo.LoginProvider);
RedirectManager.RedirectTo(FirstLoginReturnUrl);

// Redirect new external users to profile completion
if (!string.IsNullOrWhiteSpace(ReturnUrl))
{
RedirectManager.RedirectTo(
"/CompleteProfile",
queryParameters: new Dictionary<string, object?> { ["returnUrl"] = ReturnUrl });
}
else
{
RedirectManager.RedirectTo("/CompleteProfile");
}
}
}

Expand Down
12 changes: 11 additions & 1 deletion src/web/Jordnaer/Components/Account/Pages/Register.razor
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,17 @@
}

await SignInManager.SignInAsync(user, isPersistent: false);
RedirectManager.RedirectTo(ReturnUrl);
// Redirect to profile completion, passing ReturnUrl through
if (!string.IsNullOrWhiteSpace(ReturnUrl))
{
RedirectManager.RedirectTo(
"/CompleteProfile",
queryParameters: new Dictionary<string, object?> { ["returnUrl"] = ReturnUrl });
}
else
{
RedirectManager.RedirectTo("/CompleteProfile");
}
}

private sealed class InputModel
Expand Down
8 changes: 5 additions & 3 deletions src/web/Jordnaer/Database/DatabaseInitialyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,20 @@ public static async Task InsertFakeUsersAsync(
.RuleFor(u => u.DateOfBirth, f => f.Date.Between(DateTime.UtcNow.AddYears(-70), DateTime.UtcNow.AddYears(-16)))
.RuleFor(u => u.ProfilePictureUrl, f => f.Internet.Avatar());

Console.WriteLine("Generated {0} UserProfiles for testing.", usersToGenerate);

var users = userFaker.Generate(usersToGenerate);

users = users.DistinctBy(u => u.UserName).ToList();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Console.WriteLine("Generated {0} UserProfiles for testing.", users.Count);

context.AddRange(users);
}

public static async Task<List<Category>> InsertCategoriesAsync(this JordnaerDbContext context)
{
if (await context.Categories.AnyAsync())
{
return [];
return await context.Categories.AsNoTracking().ToListAsync();
}

var categories = new List<Category>
Expand Down
81 changes: 81 additions & 0 deletions src/web/Jordnaer/Features/Profile/ProfileService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,23 @@ public interface IProfileService
/// <param name="updatedUserProfile">The user profile.</param>
/// <param name="cancellationToken"></param>
ValueTask<OneOf<Success<UserProfile>, Error>> UpdateUserProfile(UserProfile updatedUserProfile, CancellationToken cancellationToken = default);

/// <summary>
/// Checks if the user profile is complete (has required fields).
/// </summary>
/// <param name="userId">The user ID to check.</param>
/// <param name="cancellationToken"></param>
/// <returns>True if profile is complete, false otherwise.</returns>
Task<bool> IsProfileCompleteAsync(string userId, CancellationToken cancellationToken = default);

/// <summary>
/// Generates a unique username from first and last name.
/// </summary>
/// <param name="firstName">First name.</param>
/// <param name="lastName">Last name.</param>
/// <param name="cancellationToken"></param>
/// <returns>A unique username or error message.</returns>
Task<OneOf<Success<string>, Error<string>>> GenerateUniqueUsernameAsync(string firstName, string lastName, CancellationToken cancellationToken = default);
}

public sealed class ProfileService(
Expand Down Expand Up @@ -134,4 +151,68 @@ internal static async Task UpdateExistingUserProfileAsync(UserProfile currentUse
}
}
}

public async Task<bool> IsProfileCompleteAsync(string userId, CancellationToken cancellationToken = default)
{
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var profile = await context.UserProfiles
.AsNoTracking()
.FirstOrDefaultAsync(user => user.Id == userId, cancellationToken);

if (profile is null)
{
return false;
}

// Basic completeness check
return !string.IsNullOrWhiteSpace(profile.FirstName) &&
!string.IsNullOrWhiteSpace(profile.LastName) &&
profile.Location is not null &&
(profile.ZipCode.HasValue || !string.IsNullOrWhiteSpace(profile.Address));
}

public async Task<OneOf<Success<string>, Error<string>>> GenerateUniqueUsernameAsync(string firstName, string lastName, CancellationToken cancellationToken = default)
{
var baseUsername = $"{firstName}{lastName}".ToLowerInvariant()
.Replace(" ", "")
.Replace("æ", "ae")
.Replace("ø", "oe")
.Replace("å", "aa");

// Remove non-alphanumeric characters
baseUsername = new string(baseUsername.Where(c => char.IsLetterOrDigit(c)).ToArray());

if (string.IsNullOrWhiteSpace(baseUsername))
{
return new Error<string>("Kunne ikke generere brugernavn");
}

await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);

// Fetch all existing usernames that start with baseUsername in one query (case-insensitive)
var pattern = $"{baseUsername}%";
var existingUsernames = await context.UserProfiles
.AsNoTracking()
.Where(p => p.UserName != null && EF.Functions.Like(p.UserName, pattern))
.Select(p => p.UserName)
.ToHashSetAsync(cancellationToken);

var username = baseUsername;
if (!existingUsernames.Any(u => string.Equals(u, username, StringComparison.OrdinalIgnoreCase)))
{
return new Success<string>(username);
}

// Find first available counter
for (var counter = 2; counter <= 1000; counter++)
{
username = $"{baseUsername}{counter}";
if (!existingUsernames.Contains(username, StringComparer.OrdinalIgnoreCase))
{
return new Success<string>(username);
}
}

return new Error<string>("Kunne ikke finde et unikt brugernavn");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
1 change: 0 additions & 1 deletion src/web/Jordnaer/Features/Profile/UserProfileCard.razor
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
{
<MudItem xs="12">
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" Class="mb-2">Om mig</MudText>
<MudText Typo="Typo.body1">
@MarkdownRenderer.SanitizeAndRenderMarkupString(Profile.Description)
</MudText>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ public async Task<HealthCheckResult> CheckHealthAsync(
logger.LogDebug("Internal healthcheck rate limit has been reached.");
return HealthCheckResult.Degraded(exception.Message);
}
catch (Exception exception)
{
logger.LogError(exception, "An error occurred while pinging DataForsyningen.");
return HealthCheckResult.Unhealthy(exception.Message, exception);
}

if (pingResult.IsSuccessful)
{
Expand Down
Loading