Skip to content

Commit

Permalink
signup done
Browse files Browse the repository at this point in the history
  • Loading branch information
neozhu committed Nov 19, 2024
1 parent 15bb32b commit 023d1b4
Show file tree
Hide file tree
Showing 25 changed files with 1,328 additions and 86 deletions.
1 change: 1 addition & 0 deletions src/CleanAspire.Api/CleanAspire.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="9.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="1.2.39" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CleanAspire.ServiceDefaults\CleanAspire.ServiceDefaults.csproj" />
Expand Down
111 changes: 107 additions & 4 deletions src/CleanAspire.Api/CleanAspire.Api.json
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,56 @@
"Authentication",
"Identity Management"
],
"summary": "Retrieve the profile information",
"description": "This endpoint fetches the profile information of the currently authenticated user based on their claims. If the user is not found in the system, it returns a 404 Not Found status. The endpoint requires authorization and utilizes Identity Management for user retrieval and profile generation.",
"summary": "Retrieve the user's profile",
"description": "Fetches the profile information of the authenticated user. Returns 404 if the user is not found. Requires authorization.",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProfileResponse"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/HttpValidationProblemDetails"
}
}
}
},
"404": {
"description": "Not Found"
}
},
"security": [
{
"Identity.Application": [ ]
}
]
},
"post": {
"tags": [
"Authentication",
"Identity Management"
],
"summary": "Update user profile information.",
"description": "Allows users to update their profile, including username, email, nickname, avatar, time zone, and language code.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProfileRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
Expand Down Expand Up @@ -593,6 +641,11 @@
}
}
},
"IFormFile": {
"type": "string",
"format": "binary",
"nullable": true
},
"InfoRequest": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -652,6 +705,56 @@
"password": "P@ssw0rd!"
}
},
"ProfileRequest": {
"required": [
"username",
"email"
],
"type": "object",
"properties": {
"nickname": {
"maxLength": 50,
"pattern": "^[a-zA-Z0-9_]*$",
"type": "string",
"description": "User's preferred nickname.",
"nullable": true
},
"username": {
"maxLength": 50,
"pattern": "^[a-zA-Z0-9_]*$",
"type": "string",
"description": "Unique username for the user."
},
"email": {
"maxLength": 80,
"pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"type": "string",
"description": "User's email address. Must be in a valid email format."
},
"avatar": {
"$ref": "#/components/schemas/IFormFile"
},
"timeZoneId": {
"maxLength": 50,
"type": "string",
"description": "User's time zone identifier, e.g., 'UTC', 'America/New_York'.",
"nullable": true
},
"languageCode": {
"maxLength": 10,
"pattern": "^[a-z]{2,3}(-[A-Z]{2})?$",
"type": "string",
"description": "User's preferred language code, e.g., 'en-US'.",
"nullable": true
},
"superiorId": {
"maxLength": 50,
"type": "string",
"description": "Tenant identifier for multi-tenant systems. Must be a GUID in version 7 format.",
"nullable": true
}
}
},
"ProfileResponse": {
"required": [
"userId",
Expand Down Expand Up @@ -729,7 +832,7 @@
}
},
"example": {
"email": "Eddie.Torp73@hotmail.com",
"email": "Coleman.Lang@yahoo.com",
"password": "P@ssw0rd!"
}
},
Expand Down Expand Up @@ -816,7 +919,7 @@
}
},
"example": {
"Email": "Matteo_Breitenberg46@hotmail.com",
"Email": "Lavonne.Erdman@hotmail.com",
"Password": "P@ssw0rd!",
"Nickname": "exampleNickname",
"Provider": "Local",
Expand Down
101 changes: 96 additions & 5 deletions src/CleanAspire.Api/IdentityApiAdditionalEndpointsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@
using Microsoft.AspNetCore.WebUtilities;
using System.Text.Encodings.Web;
using System.Text;
using CleanAspire.Application.Common.Interfaces;
using CleanAspire.Infrastructure.Services;
using static System.Net.Mime.MediaTypeNames;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace CleanAspire.Api;

public static class IdentityApiAdditionalEndpointsExtensions
{
// Validate the email address using DataAnnotations like the UserValidator does when RequireUniqueEmail = true.
private static readonly EmailAddressAttribute _emailAddressAttribute = new();

public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(this IEndpointRouteBuilder endpoints)
where TUser : class, new()
{
Expand All @@ -44,11 +53,64 @@ public static IEndpointRouteBuilder MapIdentityApiAdditionalEndpoints<TUser>(thi
return TypedResults.NotFound();
}
return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
}).RequireAuthorization()
.WithSummary("Retrieve the profile information")
.WithDescription("This endpoint fetches the profile information of the currently authenticated user based on their claims. " +
"If the user is not found in the system, it returns a 404 Not Found status. " +
"The endpoint requires authorization and utilizes Identity Management for user retrieval and profile generation.");
})
.WithSummary("Retrieve the user's profile")
.WithDescription("Fetches the profile information of the authenticated user. " +
"Returns 404 if the user is not found. Requires authorization.");
identityGroup.MapPost("/profile", async Task<Results<Ok<ProfileResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromBody] ProfileRequest request, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
}

if (!string.IsNullOrEmpty(request.Email) && !_emailAddressAttribute.IsValid(request.Email))
{
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(request.Email)));
}
if (user is not ApplicationUser appUser)
throw new InvalidCastException($"The provided user must be of type {nameof(ApplicationUser)}.");

appUser.UserName = request.Username;
appUser.Nickname = request.Nickname;
appUser.TimeZoneId = request.TimeZoneId;
appUser.LanguageCode = request.LanguageCode;
appUser.SuperiorId = request.SuperiorId;
if (request.Avatar != null)
{
var avatarUrl = string.Empty;
var uploadService = sp.GetRequiredService<IUploadService>();
var filestream = request.Avatar.OpenReadStream();
var imgstream = new MemoryStream();
await filestream.CopyToAsync(imgstream);
imgstream.Position = 0;
using (var outStream = new MemoryStream())
{
using (var image = SixLabors.ImageSharp.Image.Load(imgstream))
{
image.Mutate(i => i.Resize(new ResizeOptions { Mode = ResizeMode.Crop, Size = new Size(128, 128) }));
image.Save(outStream, PngFormat.Instance);
avatarUrl = await uploadService.UploadAsync(new UploadRequest($"{appUser.Id}_{DateTime.UtcNow.Ticks}.png", UploadType.ProfilePicture, outStream.ToArray(), true));
}
}
appUser.AvatarUrl = avatarUrl;
}
await userManager.UpdateAsync(user).ConfigureAwait(false);
if (!string.IsNullOrEmpty(request.Email))
{
var email = await userManager.GetEmailAsync(user);

if (email != request.Email)
{
await SendConfirmationEmailAsync(user, userManager, context, request.Email, isChange: true);
}
}

return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
}).WithSummary("Update user profile information.")
.WithDescription("Allows users to update their profile, including username, email, nickname, avatar, time zone, and language code.");



Expand Down Expand Up @@ -213,6 +275,35 @@ private static ValidationProblem CreateValidationProblem(IdentityResult result)
}
}

public sealed class ProfileRequest
{
[Description("User's preferred nickname.")]
[MaxLength(50, ErrorMessage = "Nickname cannot exceed 50 characters.")]
[RegularExpression("^[a-zA-Z0-9_]*$", ErrorMessage = "Nickname can only contain letters, numbers, and underscores.")]
public string? Nickname { get; init; }

[Description("Unique username for the user.")]
[MaxLength(50, ErrorMessage = "Username cannot exceed 50 characters.")]
[RegularExpression("^[a-zA-Z0-9_]*$", ErrorMessage = "Username can only contain letters, numbers, and underscores.")]
public required string Username { get; init; }
[Required]
[Description("User's email address. Must be in a valid email format.")]
[MaxLength(80, ErrorMessage = "Email cannot exceed 80 characters.")]
[RegularExpression("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", ErrorMessage = "Invalid email format.")]
public required string Email { get; init; }
public IFormFile? Avatar { get; init; }
[Description("User's time zone identifier, e.g., 'UTC', 'America/New_York'.")]
[MaxLength(50, ErrorMessage = "TimeZoneId cannot exceed 50 characters.")]
public string? TimeZoneId { get; set; }

[Description("User's preferred language code, e.g., 'en-US'.")]
[MaxLength(10, ErrorMessage = "LanguageCode cannot exceed 10 characters.")]
[RegularExpression("^[a-z]{2,3}(-[A-Z]{2})?$", ErrorMessage = "Invalid language code format.")]
public string? LanguageCode { get; set; }
[Description("Tenant identifier for multi-tenant systems. Must be a GUID in version 7 format.")]
[MaxLength(50, ErrorMessage = "Nickname cannot exceed 50 characters.")]
public string? SuperiorId { get; init; }
}
public sealed class ProfileResponse
{
public string? Nickname { get; init; }
Expand Down
26 changes: 26 additions & 0 deletions src/CleanAspire.Application/Common/Interfaces/IUploadService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;

namespace CleanAspire.Application.Common.Interfaces;

public interface IUploadService
{
Task<string> UploadAsync(UploadRequest request);
void Remove(string filename);
}
public record UploadRequest(
string FileName,
UploadType UploadType,
byte[] Data,
bool Overwrite = false,
string? Extension = null,
string? Folder = null
);
public enum UploadType : byte
{
[Description(@"Products")] Product,
[Description(@"ProfilePictures")] ProfilePicture,
[Description(@"Documents")] Document
}
47 changes: 47 additions & 0 deletions src/CleanAspire.ClientApp/Client/Account/AccountRequestBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// <auto-generated/>
#pragma warning disable CS0618
using CleanAspire.Api.Client.Account.ConfirmEmail;
using CleanAspire.Api.Client.Account.Signup;
using Microsoft.Kiota.Abstractions.Extensions;
using Microsoft.Kiota.Abstractions;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System;
namespace CleanAspire.Api.Client.Account
{
/// <summary>
/// Builds and executes requests for operations under \account
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class AccountRequestBuilder : BaseRequestBuilder
{
/// <summary>The confirmEmail property</summary>
public global::CleanAspire.Api.Client.Account.ConfirmEmail.ConfirmEmailRequestBuilder ConfirmEmail
{
get => new global::CleanAspire.Api.Client.Account.ConfirmEmail.ConfirmEmailRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>The signup property</summary>
public global::CleanAspire.Api.Client.Account.Signup.SignupRequestBuilder Signup
{
get => new global::CleanAspire.Api.Client.Account.Signup.SignupRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>
/// Instantiates a new <see cref="global::CleanAspire.Api.Client.Account.AccountRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public AccountRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/account", pathParameters)
{
}
/// <summary>
/// Instantiates a new <see cref="global::CleanAspire.Api.Client.Account.AccountRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public AccountRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/account", rawUrl)
{
}
}
}
#pragma warning restore CS0618
Loading

0 comments on commit 023d1b4

Please sign in to comment.