diff --git a/API/ROLAC.API/Controllers/AuthController.cs b/API/ROLAC.API/Controllers/AuthController.cs index 2609fd7..71a17a5 100644 --- a/API/ROLAC.API/Controllers/AuthController.cs +++ b/API/ROLAC.API/Controllers/AuthController.cs @@ -78,6 +78,32 @@ public class AuthController : ControllerBase } } + // ------------------------------------------------------------------------- + // GET /api/auth/me (dev-only diagnostic — remove before production) + // ------------------------------------------------------------------------- + + /// + /// Returns the claims ASP.NET Core parsed from the Bearer token. + /// Use this to debug 401 vs 403: if you get 200 here, the JWT validates + /// fine; if you then get 403 on /api/users the role claim isn't matching. + /// + [HttpGet("me")] + [Authorize] // no role restriction — just needs a valid JWT + public IActionResult GetMe() + { + var claims = User.Claims + .Select(c => new { c.Type, c.Value }) + .ToList(); + + return Ok(new + { + isAuthenticated = User.Identity?.IsAuthenticated, + authenticationType = User.Identity?.AuthenticationType, + name = User.Identity?.Name, + claims, + }); + } + // ------------------------------------------------------------------------- // POST /api/auth/logout // ------------------------------------------------------------------------- diff --git a/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs b/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs index 4762b08..2863642 100644 --- a/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs +++ b/API/ROLAC.API/DTOs/Members/CreateMemberRequest.cs @@ -3,26 +3,26 @@ namespace ROLAC.API.DTOs.Members; public class CreateMemberRequest { - [Required, MaxLength(100)] public string FirstName_en { get; set; } = ""; - [Required, MaxLength(100)] public string LastName_en { get; set; } = ""; - [MaxLength(100)] public string? NickName { get; set; } - [MaxLength(100)] public string? FirstName_zh { get; set; } - [MaxLength(100)] public string? LastName_zh { get; set; } - [MaxLength(10)] public string? Gender { get; set; } - public DateOnly? DateOfBirth { get; set; } - public DateOnly? BaptismDate { get; set; } - [MaxLength(200)] public string? BaptismChurch { get; set; } - [MaxLength(200), EmailAddress] public string? Email { get; set; } - [MaxLength(30)] public string? PhoneCell { get; set; } - [MaxLength(30)] public string? PhoneHome { get; set; } - [MaxLength(500)] public string? Address { get; set; } - [MaxLength(100)] public string? City { get; set; } - [MaxLength(50)] public string? State { get; set; } - [MaxLength(20)] public string? ZipCode { get; set; } - [MaxLength(100)] public string Country { get; set; } = "USA"; - [MaxLength(20)] public string Status { get; set; } = "Member"; - [MaxLength(10)] public string LanguagePreference { get; set; } = "en"; - public DateOnly? JoinDate { get; set; } - public string? Notes { get; set; } - public int? FamilyUnitId { get; set; } + [Required, MaxLength(100)] public string FirstName_en { get; set; } = ""; + [Required, MaxLength(100)] public string LastName_en { get; set; } = ""; + [MaxLength(100)] public string? NickName { get; set; } + [MaxLength(100)] public string? FirstName_zh { get; set; } + [MaxLength(100)] public string? LastName_zh { get; set; } + [MaxLength(10)] public string? Gender { get; set; } + public DateOnly? DateOfBirth { get; set; } + public DateOnly? BaptismDate { get; set; } + [MaxLength(200)] public string? BaptismChurch { get; set; } + [MaxLength(200), EmailAddress] public string? Email { get; set; } + [MaxLength(30)] public string? PhoneCell { get; set; } + [MaxLength(30)] public string? PhoneHome { get; set; } + [MaxLength(500)] public string? Address { get; set; } + [MaxLength(100)] public string? City { get; set; } + [MaxLength(50)] public string? State { get; set; } + [MaxLength(20)] public string? ZipCode { get; set; } + [MaxLength(100)] public string Country { get; set; } = "USA"; + [MaxLength(20)] public string Status { get; set; } = "Member"; + [MaxLength(10)] public string LanguagePreference { get; set; } = "en"; + public DateOnly? JoinDate { get; set; } + public string? Notes { get; set; } + public int? FamilyUnitId { get; set; } } diff --git a/API/ROLAC.API/DTOs/Users/CreateUserRequest.cs b/API/ROLAC.API/DTOs/Users/CreateUserRequest.cs index fac16a4..afe54f4 100644 --- a/API/ROLAC.API/DTOs/Users/CreateUserRequest.cs +++ b/API/ROLAC.API/DTOs/Users/CreateUserRequest.cs @@ -3,7 +3,7 @@ namespace ROLAC.API.DTOs.Users; public class CreateUserRequest { - [Required] public int MemberId { get; set; } + public int? MemberId { get; set; } [Required, EmailAddress] public string Email { get; set; } = ""; [Required, MinLength(1)] public List Roles { get; set; } = []; public string LanguagePreference { get; set; } = "en"; diff --git a/API/ROLAC.API/Json/TolerantDateOnlyConverter.cs b/API/ROLAC.API/Json/TolerantDateOnlyConverter.cs new file mode 100644 index 0000000..d904e9f --- /dev/null +++ b/API/ROLAC.API/Json/TolerantDateOnlyConverter.cs @@ -0,0 +1,33 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ROLAC.API.Json; + +/// +/// Reads from either "yyyy-MM-dd" or any ISO 8601 date-time +/// (the date portion is taken). Writes as "yyyy-MM-dd". Lets JS clients send a Date +/// without first formatting it. +/// +public sealed class TolerantDateOnlyConverter : JsonConverter +{ + private const string Format = "yyyy-MM-dd"; + + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var s = reader.GetString(); + if (string.IsNullOrEmpty(s)) + throw new JsonException("Expected a date string for DateOnly."); + + if (DateOnly.TryParseExact(s, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d)) + return d; + + if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto)) + return DateOnly.FromDateTime(dto.DateTime); + + throw new JsonException($"Unable to parse '{s}' as DateOnly."); + } + + public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) + => writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture)); +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 6651f8f..c5086fb 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -1,4 +1,6 @@ using System.Text; +using System.Text.Json; +using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -6,6 +8,7 @@ using Microsoft.IdentityModel.Tokens; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.Entities; +using ROLAC.API.Json; using ROLAC.API.Services; var builder = WebApplication.CreateBuilder(args); @@ -47,6 +50,11 @@ builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(opt => { + // Keep JWT claim names exactly as written ("role", "sub", "email"). + // Without this, .NET 8's JsonWebTokenHandler may remap "role" to the + // long ClaimTypes.Role URI, which conflicts with RoleClaimType = "role". + opt.MapInboundClaims = false; + opt.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -56,9 +64,37 @@ builder.Services ValidIssuer = config["Jwt:Issuer"], ValidAudience = config["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), - // Roles were written as JWT short name "role"; map to ClaimTypes.Role for [Authorize]. + NameClaimType = "sub", RoleClaimType = "role", - ClockSkew = TimeSpan.Zero, + ClockSkew = TimeSpan.FromMinutes(1), + }; + + // Diagnostic events — visible in the API console while debugging 401s. + opt.Events = new JwtBearerEvents + { + OnAuthenticationFailed = ctx => + { + Console.WriteLine( + $"[JWT] Auth failed: {ctx.Exception.GetType().Name} — {ctx.Exception.Message}"); + return Task.CompletedTask; + }, + OnChallenge = ctx => + { + // Fires when a 401 challenge is about to be sent. + Console.WriteLine( + $"[JWT] Challenge: error={ctx.Error}, description={ctx.ErrorDescription}"); + return Task.CompletedTask; + }, + OnForbidden = ctx => + { + // Fires when user IS authenticated but lacks the required role (403). + Console.WriteLine( + $"[JWT] Forbidden: user={ctx.HttpContext.User.Identity?.Name}, " + + $"roles=[{string.Join(',', ctx.HttpContext.User.Claims + .Where(c => c.Type == "role") + .Select(c => c.Value))}]"); + return Task.CompletedTask; + }, }; }); @@ -86,7 +122,16 @@ builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Swagger / MVC // --------------------------------------------------------------------------- -builder.Services.AddControllers(); +builder.Services + .AddControllers() + .AddJsonOptions(opt => + { + // camelCase in/out + tolerant DateOnly (accepts "yyyy-MM-dd" or full ISO datetime). + opt.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + opt.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true; + opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter()); + }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(opt => { diff --git a/API/ROLAC.API/ROLAC.API.csproj b/API/ROLAC.API/ROLAC.API.csproj index 1988f44..432453e 100644 --- a/API/ROLAC.API/ROLAC.API.csproj +++ b/API/ROLAC.API/ROLAC.API.csproj @@ -1,21 +1,25 @@ - - net8.0 - enable - enable - + + net8.0 + enable + enable + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - + + + - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + \ No newline at end of file diff --git a/API/ROLAC.API/Services/UserManagementService.cs b/API/ROLAC.API/Services/UserManagementService.cs index 3e0040d..0354cb0 100644 --- a/API/ROLAC.API/Services/UserManagementService.cs +++ b/API/ROLAC.API/Services/UserManagementService.cs @@ -118,15 +118,17 @@ public class UserManagementService : IUserManagementService public async Task CreateAsync(CreateUserRequest request) { - // Validate Member exists (FindAsync bypasses query filter — intentional) - var member = await _db.Members.FindAsync(request.MemberId) - ?? throw new InvalidOperationException( - $"Member {request.MemberId} does not exist."); + // Validate member and uniqueness only when a MemberId is provided + if (request.MemberId.HasValue) + { + var member = await _db.Members.FindAsync(request.MemberId.Value) + ?? throw new InvalidOperationException( + $"Member {request.MemberId} does not exist."); - // One user per member - if (_userManager.Users.Any(u => u.MemberId == request.MemberId)) - throw new InvalidOperationException( - "This member already has a user account."); + if (_userManager.Users.Any(u => u.MemberId == request.MemberId)) + throw new InvalidOperationException( + "This member already has a user account."); + } // Unique email if (await _userManager.FindByEmailAsync(request.Email) is not null) diff --git a/APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.html b/APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.html index fbfc1a2..6296093 100644 --- a/APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.html +++ b/APP/src/app/features/members/components/create-user-dialog/create-user-dialog.component.html @@ -26,7 +26,8 @@ + [data]="langOptions" textField="text" valueField="value" + [valuePrimitive]="true"> diff --git a/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html b/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html index 0ee6d62..53d2206 100644 --- a/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html +++ b/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.html @@ -38,6 +38,7 @@ formControlName="gender" [data]="genderOptions" textField="text" valueField="value" + [valuePrimitive]="true" [defaultItem]="{ text: '-- Select --', value: null }"> @@ -57,7 +58,8 @@ + [data]="langOptions" textField="text" valueField="value" + [valuePrimitive]="true"> diff --git a/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts b/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts index 116e516..98a0605 100644 --- a/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts +++ b/APP/src/app/features/members/components/member-form-dialog/member-form-dialog.component.ts @@ -76,10 +76,28 @@ export class MemberFormDialogComponent implements OnInit { onSubmit(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; } - this.saved.emit(this.form.value as CreateMemberRequest); + const v = this.form.value; + const payload: CreateMemberRequest = { + ...v, + dateOfBirth: toDateOnlyString(v.dateOfBirth), + baptismDate: toDateOnlyString(v.baptismDate), + joinDate: toDateOnlyString(v.joinDate), + }; + this.saved.emit(payload); } onCancel(): void { this.cancelled.emit(); } } + +// Kendo DatePicker emits a JS Date; the API expects DateOnly ("yyyy-MM-dd"). +// Use local components so the date the user picked isn't shifted by UTC offset. +function toDateOnlyString(d: Date | string | null | undefined): string | null { + if (d == null || d === '') return null; + if (typeof d === 'string') return d.length >= 10 ? d.substring(0, 10) : d; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} diff --git a/APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.html b/APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.html new file mode 100644 index 0000000..df4b468 --- /dev/null +++ b/APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.html @@ -0,0 +1,40 @@ + + + + + + + Required. + Invalid email. + + + + + + At least one role is required. + + + + + + + + + + + + + + + + + Cancel + + Create User + + + diff --git a/APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.ts b/APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.ts new file mode 100644 index 0000000..30f4074 --- /dev/null +++ b/APP/src/app/features/users/components/create-user-dialog/create-user-dialog.component.ts @@ -0,0 +1,52 @@ +import { Component, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { CreateUserRequest, ALL_ROLES } from '../../models/user.model'; + +@Component({ + selector: 'app-create-user-dialog', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, DialogsModule, + InputsModule, LabelModule, DropDownsModule, ButtonsModule + ], + templateUrl: './create-user-dialog.component.html', +}) +export class CreateUserDialogComponent { + @Output() saved = new EventEmitter(); + @Output() cancelled = new EventEmitter(); + + form: FormGroup; + readonly roleOptions: string[] = [...ALL_ROLES]; + readonly langOptions = [ + { text: 'English', value: 'en' }, + { text: '中文', value: 'zh-TW' }, + ]; + + constructor(private fb: FormBuilder) { + this.form = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + roles: [[], Validators.required], + languagePreference: ['en'], + memberId: [null], + }); + } + + onSubmit(): void { + if (this.form.invalid) { this.form.markAllAsTouched(); return; } + const val = this.form.value; + this.saved.emit({ + email: val.email, + roles: val.roles, + languagePreference: val.languagePreference, + memberId: val.memberId, + }); + } + + onCancel(): void { this.cancelled.emit(); } +} diff --git a/APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.html b/APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.html index 39fd4d3..07c6aab 100644 --- a/APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.html +++ b/APP/src/app/features/users/components/edit-user-dialog/edit-user-dialog.component.html @@ -17,7 +17,8 @@ + [data]="langOptions" textField="text" valueField="value" + [valuePrimitive]="true"> diff --git a/APP/src/app/features/users/models/user.model.ts b/APP/src/app/features/users/models/user.model.ts index 80b6e82..b505a08 100644 --- a/APP/src/app/features/users/models/user.model.ts +++ b/APP/src/app/features/users/models/user.model.ts @@ -13,7 +13,7 @@ export interface UserListItemDto { export type UserDto = UserListItemDto; export interface CreateUserRequest { - memberId: number; + memberId: number | null; email: string; roles: string[]; languagePreference: string; diff --git a/APP/src/app/features/users/pages/users-page/users-page.component.html b/APP/src/app/features/users/pages/users-page/users-page.component.html index e0d24d1..bd3fade 100644 --- a/APP/src/app/features/users/pages/users-page/users-page.component.html +++ b/APP/src/app/features/users/pages/users-page/users-page.component.html @@ -1,6 +1,16 @@ User Management + + + Add New User + Test Auth + + + + + + Auth test: {{ authTestResult }} + Clear @@ -69,6 +79,13 @@ + + + + { this.authTestResult = JSON.stringify(result, null, 2); }, + error: (err) => { this.authTestResult = `ERROR ${err.status}: ${JSON.stringify(err.error)}`; }, + }); + } + + openCreateDialog(): void { this.showCreateDialog = true; } + closeCreateDialog(): void { this.showCreateDialog = false; } + + onUserCreated(request: CreateUserRequest): void { + this.userApi.createUser(request).subscribe(result => { + this.closeCreateDialog(); + this.resetPasswordResult = { userId: result.userId, tempPassword: result.tempPassword }; + this.loadData(); + }); + } + openEditDialog(user: UserListItemDto): void { this.userApi.getById(user.id).subscribe(dto => { this.editingUser = dto; diff --git a/APP/src/app/portals/user-portal/user-portal.component.html b/APP/src/app/portals/user-portal/user-portal.component.html index a521562..045af41 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.html +++ b/APP/src/app/portals/user-portal/user-portal.component.html @@ -33,36 +33,46 @@ - Overview - + Main + - - - - - - + - Dashboard + {{ item.text }} - - Administration - + + Management + - - - - - - + - User Management + {{ item.text }} + + + Administration + + + + + {{ item.text }} + + + + + + + {{ item.text }} + + +
{{ authTestResult }}