WIP
This commit is contained in:
@@ -78,6 +78,32 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/auth/me (dev-only diagnostic — remove before production)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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
|
// POST /api/auth/logout
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,26 +3,26 @@ namespace ROLAC.API.DTOs.Members;
|
|||||||
|
|
||||||
public class CreateMemberRequest
|
public class CreateMemberRequest
|
||||||
{
|
{
|
||||||
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
||||||
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
||||||
[MaxLength(100)] public string? NickName { get; set; }
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
[MaxLength(10)] public string? Gender { get; set; }
|
[MaxLength(10)] public string? Gender { get; set; }
|
||||||
public DateOnly? DateOfBirth { get; set; }
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
public DateOnly? BaptismDate { get; set; }
|
public DateOnly? BaptismDate { get; set; }
|
||||||
[MaxLength(200)] public string? BaptismChurch { get; set; }
|
[MaxLength(200)] public string? BaptismChurch { get; set; }
|
||||||
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||||
[MaxLength(30)] public string? PhoneHome { get; set; }
|
[MaxLength(30)] public string? PhoneHome { get; set; }
|
||||||
[MaxLength(500)] public string? Address { get; set; }
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
[MaxLength(100)] public string? City { get; set; }
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
[MaxLength(50)] public string? State { get; set; }
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
[MaxLength(20)] public string? ZipCode { get; set; }
|
[MaxLength(20)] public string? ZipCode { get; set; }
|
||||||
[MaxLength(100)] public string Country { get; set; } = "USA";
|
[MaxLength(100)] public string Country { get; set; } = "USA";
|
||||||
[MaxLength(20)] public string Status { get; set; } = "Member";
|
[MaxLength(20)] public string Status { get; set; } = "Member";
|
||||||
[MaxLength(10)] public string LanguagePreference { get; set; } = "en";
|
[MaxLength(10)] public string LanguagePreference { get; set; } = "en";
|
||||||
public DateOnly? JoinDate { get; set; }
|
public DateOnly? JoinDate { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public int? FamilyUnitId { get; set; }
|
public int? FamilyUnitId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace ROLAC.API.DTOs.Users;
|
|||||||
|
|
||||||
public class CreateUserRequest
|
public class CreateUserRequest
|
||||||
{
|
{
|
||||||
[Required] public int MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
[Required, EmailAddress] public string Email { get; set; } = "";
|
[Required, EmailAddress] public string Email { get; set; } = "";
|
||||||
[Required, MinLength(1)] public List<string> Roles { get; set; } = [];
|
[Required, MinLength(1)] public List<string> Roles { get; set; } = [];
|
||||||
public string LanguagePreference { get; set; } = "en";
|
public string LanguagePreference { get; set; } = "en";
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Json;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads <see cref="DateOnly"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TolerantDateOnlyConverter : JsonConverter<DateOnly>
|
||||||
|
{
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -6,6 +8,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.Data.Interceptors;
|
using ROLAC.API.Data.Interceptors;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Json;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -47,6 +50,11 @@ builder.Services
|
|||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(opt =>
|
.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
|
opt.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
@@ -56,9 +64,37 @@ builder.Services
|
|||||||
ValidIssuer = config["Jwt:Issuer"],
|
ValidIssuer = config["Jwt:Issuer"],
|
||||||
ValidAudience = config["Jwt:Audience"],
|
ValidAudience = config["Jwt:Audience"],
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
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",
|
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<IUserManagementService, UserManagementService>();
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Swagger / MVC
|
// 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.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen(opt =>
|
builder.Services.AddSwaggerGen(opt =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.5.2" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.0.1" />
|
||||||
|
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -118,15 +118,17 @@ public class UserManagementService : IUserManagementService
|
|||||||
|
|
||||||
public async Task<CreateUserResult> CreateAsync(CreateUserRequest request)
|
public async Task<CreateUserResult> CreateAsync(CreateUserRequest request)
|
||||||
{
|
{
|
||||||
// Validate Member exists (FindAsync bypasses query filter — intentional)
|
// Validate member and uniqueness only when a MemberId is provided
|
||||||
var member = await _db.Members.FindAsync(request.MemberId)
|
if (request.MemberId.HasValue)
|
||||||
?? throw new InvalidOperationException(
|
{
|
||||||
$"Member {request.MemberId} does not exist.");
|
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))
|
||||||
if (_userManager.Users.Any(u => u.MemberId == request.MemberId))
|
throw new InvalidOperationException(
|
||||||
throw new InvalidOperationException(
|
"This member already has a user account.");
|
||||||
"This member already has a user account.");
|
}
|
||||||
|
|
||||||
// Unique email
|
// Unique email
|
||||||
if (await _userManager.FindByEmailAsync(request.Email) is not null)
|
if (await _userManager.FindByEmailAsync(request.Email) is not null)
|
||||||
|
|||||||
+2
-1
@@ -26,7 +26,8 @@
|
|||||||
<kendo-label text="Language"></kendo-label>
|
<kendo-label text="Language"></kendo-label>
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist
|
||||||
formControlName="languagePreference"
|
formControlName="languagePreference"
|
||||||
[data]="langOptions" textField="text" valueField="value">
|
[data]="langOptions" textField="text" valueField="value"
|
||||||
|
[valuePrimitive]="true">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</kendo-formfield>
|
</kendo-formfield>
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -38,6 +38,7 @@
|
|||||||
formControlName="gender"
|
formControlName="gender"
|
||||||
[data]="genderOptions"
|
[data]="genderOptions"
|
||||||
textField="text" valueField="value"
|
textField="text" valueField="value"
|
||||||
|
[valuePrimitive]="true"
|
||||||
[defaultItem]="{ text: '-- Select --', value: null }">
|
[defaultItem]="{ text: '-- Select --', value: null }">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</kendo-formfield>
|
</kendo-formfield>
|
||||||
@@ -57,7 +58,8 @@
|
|||||||
<kendo-label text="Language"></kendo-label>
|
<kendo-label text="Language"></kendo-label>
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist
|
||||||
formControlName="languagePreference"
|
formControlName="languagePreference"
|
||||||
[data]="langOptions" textField="text" valueField="value">
|
[data]="langOptions" textField="text" valueField="value"
|
||||||
|
[valuePrimitive]="true">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</kendo-formfield>
|
</kendo-formfield>
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -76,10 +76,28 @@ export class MemberFormDialogComponent implements OnInit {
|
|||||||
|
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
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 {
|
onCancel(): void {
|
||||||
this.cancelled.emit();
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<kendo-dialog title="Add New User" (close)="onCancel()" [minWidth]="460" [width]="500">
|
||||||
|
<form [formGroup]="form" class="k-form k-form-vertical k-p-2">
|
||||||
|
|
||||||
|
<kendo-formfield>
|
||||||
|
<kendo-label text="Email *"></kendo-label>
|
||||||
|
<kendo-textbox formControlName="email"></kendo-textbox>
|
||||||
|
<kendo-formerror *ngIf="form.get('email')?.errors?.['required']">Required.</kendo-formerror>
|
||||||
|
<kendo-formerror *ngIf="form.get('email')?.errors?.['email']">Invalid email.</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<kendo-formfield class="k-mt-3">
|
||||||
|
<kendo-label text="Roles *"></kendo-label>
|
||||||
|
<kendo-multiselect formControlName="roles" [data]="roleOptions"
|
||||||
|
placeholder="Select roles"></kendo-multiselect>
|
||||||
|
<kendo-formerror>At least one role is required.</kendo-formerror>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<kendo-formfield class="k-mt-3">
|
||||||
|
<kendo-label text="Language"></kendo-label>
|
||||||
|
<kendo-dropdownlist formControlName="languagePreference"
|
||||||
|
[data]="langOptions" textField="text" valueField="value"
|
||||||
|
[valuePrimitive]="true">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
<kendo-formfield class="k-mt-3">
|
||||||
|
<kendo-label text="Member ID (optional)"></kendo-label>
|
||||||
|
<kendo-numerictextbox formControlName="memberId" [format]="'0'"
|
||||||
|
placeholder="Link to a member record"></kendo-numerictextbox>
|
||||||
|
</kendo-formfield>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="onCancel()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" (click)="onSubmit()" [disabled]="form.invalid">
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
+52
@@ -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<CreateUserRequest>();
|
||||||
|
@Output() cancelled = new EventEmitter<void>();
|
||||||
|
|
||||||
|
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(); }
|
||||||
|
}
|
||||||
+2
-1
@@ -17,7 +17,8 @@
|
|||||||
<kendo-formfield class="k-mt-3">
|
<kendo-formfield class="k-mt-3">
|
||||||
<kendo-label text="Language"></kendo-label>
|
<kendo-label text="Language"></kendo-label>
|
||||||
<kendo-dropdownlist formControlName="languagePreference"
|
<kendo-dropdownlist formControlName="languagePreference"
|
||||||
[data]="langOptions" textField="text" valueField="value">
|
[data]="langOptions" textField="text" valueField="value"
|
||||||
|
[valuePrimitive]="true">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</kendo-formfield>
|
</kendo-formfield>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface UserListItemDto {
|
|||||||
export type UserDto = UserListItemDto;
|
export type UserDto = UserListItemDto;
|
||||||
|
|
||||||
export interface CreateUserRequest {
|
export interface CreateUserRequest {
|
||||||
memberId: number;
|
memberId: number | null;
|
||||||
email: string;
|
email: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
languagePreference: string;
|
languagePreference: string;
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<div class="k-p-4">
|
<div class="k-p-4">
|
||||||
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4">
|
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4">
|
||||||
<h2 class="k-m-0">User Management</h2>
|
<h2 class="k-m-0">User Management</h2>
|
||||||
|
<div class="k-d-flex k-gap-2">
|
||||||
|
<button kendoButton themeColor="primary" (click)="openCreateDialog()">+ Add New User</button>
|
||||||
|
<button kendoButton (click)="testAuth()">Test Auth</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth test result (dev only) -->
|
||||||
|
<div *ngIf="authTestResult" class="k-mb-3 k-p-2" style="background:#f0f4ff;border-radius:4px;font-size:12px">
|
||||||
|
<strong>Auth test:</strong> <pre style="margin:0;white-space:pre-wrap">{{ authTestResult }}</pre>
|
||||||
|
<button kendoButton size="small" (click)="authTestResult=null">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reset password result banner -->
|
<!-- Reset password result banner -->
|
||||||
@@ -69,6 +79,13 @@
|
|||||||
</kendo-grid>
|
</kendo-grid>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create User Dialog -->
|
||||||
|
<app-create-user-dialog
|
||||||
|
*ngIf="showCreateDialog"
|
||||||
|
(saved)="onUserCreated($event)"
|
||||||
|
(cancelled)="closeCreateDialog()">
|
||||||
|
</app-create-user-dialog>
|
||||||
|
|
||||||
<!-- Edit User Dialog -->
|
<!-- Edit User Dialog -->
|
||||||
<app-edit-user-dialog
|
<app-edit-user-dialog
|
||||||
*ngIf="showEditDialog && editingUser"
|
*ngIf="showEditDialog && editingUser"
|
||||||
|
|||||||
@@ -5,18 +5,21 @@ import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
|
|||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { UserApiService } from '../../services/user-api.service';
|
import { UserApiService } from '../../services/user-api.service';
|
||||||
import { EditUserDialogComponent } from '../../components/edit-user-dialog/edit-user-dialog.component';
|
import { EditUserDialogComponent } from '../../components/edit-user-dialog/edit-user-dialog.component';
|
||||||
|
import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component';
|
||||||
import {
|
import {
|
||||||
UserListItemDto, UserDto, UpdateUserRequest, PagedResult
|
UserListItemDto, UserDto, CreateUserRequest, UpdateUserRequest, PagedResult
|
||||||
} from '../../models/user.model';
|
} from '../../models/user.model';
|
||||||
|
import { ApiConfigService } from '../../../../core/services/api-config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-users-page',
|
selector: 'app-users-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, GridModule, InputsModule,
|
CommonModule, FormsModule, GridModule, InputsModule,
|
||||||
ButtonsModule, IndicatorsModule, EditUserDialogComponent,
|
ButtonsModule, IndicatorsModule, EditUserDialogComponent, CreateUserDialogComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './users-page.component.html',
|
templateUrl: './users-page.component.html',
|
||||||
styleUrls: ['./users-page.component.scss'],
|
styleUrls: ['./users-page.component.scss'],
|
||||||
@@ -29,6 +32,9 @@ export class UsersPageComponent implements OnInit {
|
|||||||
isLoading = false;
|
isLoading = false;
|
||||||
searchText = '';
|
searchText = '';
|
||||||
|
|
||||||
|
// Create dialog
|
||||||
|
showCreateDialog = false;
|
||||||
|
|
||||||
// Edit dialog
|
// Edit dialog
|
||||||
showEditDialog = false;
|
showEditDialog = false;
|
||||||
editingUser: UserDto | null = null;
|
editingUser: UserDto | null = null;
|
||||||
@@ -36,7 +42,14 @@ export class UsersPageComponent implements OnInit {
|
|||||||
// Reset password result
|
// Reset password result
|
||||||
resetPasswordResult: { userId: string; tempPassword: string } | null = null;
|
resetPasswordResult: { userId: string; tempPassword: string } | null = null;
|
||||||
|
|
||||||
constructor(private userApi: UserApiService) {}
|
// Auth test result (dev only)
|
||||||
|
authTestResult: string | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private userApi: UserApiService,
|
||||||
|
private http: HttpClient,
|
||||||
|
private apiConfig: ApiConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void { this.loadData(); }
|
ngOnInit(): void { this.loadData(); }
|
||||||
|
|
||||||
@@ -61,6 +74,25 @@ export class UsersPageComponent implements OnInit {
|
|||||||
|
|
||||||
onSearch(): void { this.page = 1; this.loadData(); }
|
onSearch(): void { this.page = 1; this.loadData(); }
|
||||||
|
|
||||||
|
/** Dev-only: hits /api/auth/me and shows the parsed claims so we can see what the JWT contains. */
|
||||||
|
testAuth(): void {
|
||||||
|
this.http.get(`${this.apiConfig.authUrl}/me`).subscribe({
|
||||||
|
next: (result) => { 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 {
|
openEditDialog(user: UserListItemDto): void {
|
||||||
this.userApi.getById(user.id).subscribe(dto => {
|
this.userApi.getById(user.id).subscribe(dto => {
|
||||||
this.editingUser = dto;
|
this.editingUser = dto;
|
||||||
|
|||||||
@@ -33,36 +33,46 @@
|
|||||||
|
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<h4 *ngIf="!sidebarCollapsed">Overview</h4>
|
<h4 *ngIf="!sidebarCollapsed">Main</h4>
|
||||||
<a routerLink="/user-portal/dashboard" routerLinkActive="active" class="nav-item"
|
<a *ngFor="let item of mainNavItems" class="nav-item" [class.active]="item.active"
|
||||||
(click)="onNavigationClick()">
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
<rect x="3" y="3" width="7" height="7"></rect>
|
|
||||||
<rect x="14" y="3" width="7" height="7"></rect>
|
|
||||||
<rect x="14" y="14" width="7" height="7"></rect>
|
|
||||||
<rect x="3" y="14" width="7" height="7"></rect>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<span *ngIf="!sidebarCollapsed">Dashboard</span>
|
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-section" *ngIf="showAdminSection">
|
<div class="nav-section">
|
||||||
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
<h4 *ngIf="!sidebarCollapsed">Management</h4>
|
||||||
<a routerLink="/user-portal/admin/users" routerLinkActive="active" class="nav-item"
|
<a *ngFor="let item of managementNavItems" class="nav-item" [class.active]="item.active"
|
||||||
(click)="onNavigationClick()">
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
|
|
||||||
<circle cx="9" cy="7" r="4"></circle>
|
|
||||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"></path>
|
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<span *ngIf="!sidebarCollapsed">User Management</span>
|
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
|
||||||
|
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
||||||
|
<a *ngFor="let item of memberAdminNavItems" class="nav-item" [class.active]="item.active"
|
||||||
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
|
</div>
|
||||||
|
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||||
|
</a>
|
||||||
|
<ng-container *ngIf="showUserAdminSection">
|
||||||
|
<a *ngFor="let item of userAdminNavItems" class="nav-item" [class.active]="item.active"
|
||||||
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
|
<div class="nav-icon">
|
||||||
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
|
</div>
|
||||||
|
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-footer" *ngIf="!sidebarCollapsed">
|
<div class="sidebar-footer" *ngIf="!sidebarCollapsed">
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, NavigationEnd, RouterModule, RouterLink, RouterLinkActive } from '@angular/router';
|
import { Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { IconsModule } from '@progress/kendo-angular-icons';
|
||||||
|
import { SVGIcon, homeIcon, calendarIcon, userIcon, groupIcon } from '@progress/kendo-svg-icons';
|
||||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
||||||
import { Subject, takeUntil, filter } from 'rxjs';
|
import { Subject, takeUntil, filter } from 'rxjs';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
text: string;
|
||||||
|
icon: SVGIcon;
|
||||||
|
path: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-portal',
|
selector: 'app-user-portal',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
RouterLink,
|
RouterOutlet,
|
||||||
RouterLinkActive,
|
IconsModule,
|
||||||
RouterOutlet
|
|
||||||
],
|
],
|
||||||
templateUrl: './user-portal.component.html',
|
templateUrl: './user-portal.component.html',
|
||||||
styleUrls: ['./user-portal.component.scss']
|
styleUrls: ['./user-portal.component.scss']
|
||||||
@@ -23,10 +30,49 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
isMobile = false;
|
isMobile = false;
|
||||||
currentUser: UserInfo | null = null;
|
currentUser: UserInfo | null = null;
|
||||||
currentPageTitle = 'Dashboard';
|
currentPageTitle = 'Dashboard';
|
||||||
showAdminSection = false;
|
|
||||||
unreadMessages = 3;
|
unreadMessages = 3;
|
||||||
unreadNotifications = 2;
|
unreadNotifications = 2;
|
||||||
|
|
||||||
|
public homeIcon: SVGIcon = homeIcon;
|
||||||
|
public calendarIcon: SVGIcon = calendarIcon;
|
||||||
|
public peopleIcon: SVGIcon = userIcon;
|
||||||
|
public bedIcon: SVGIcon = userIcon;
|
||||||
|
public userIcon: SVGIcon = userIcon;
|
||||||
|
public pillIcon: SVGIcon = userIcon;
|
||||||
|
public chartIcon: SVGIcon = userIcon;
|
||||||
|
public buildingIcon: SVGIcon = userIcon;
|
||||||
|
public creditCardIcon: SVGIcon = userIcon;
|
||||||
|
public supportIcon: SVGIcon = userIcon;
|
||||||
|
|
||||||
|
public mainNavItems: NavItem[] = [
|
||||||
|
{ text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' },
|
||||||
|
// { text: 'Schedule', icon: this.calendarIcon, path: '/user-portal/schedule' },
|
||||||
|
// { text: 'Patients', icon: this.peopleIcon, path: '/user-portal/patients' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public managementNavItems: NavItem[] = [
|
||||||
|
// { text: 'Staff', icon: this.userIcon, path: '/user-portal/staff' },
|
||||||
|
// { text: 'Pharmacy', icon: this.pillIcon, path: '/user-portal/pharmacy' },
|
||||||
|
// { text: 'Reports', icon: this.chartIcon, path: '/user-portal/reports' },1124
|
||||||
|
// { text: 'Departments', icon: this.buildingIcon, path: '/user-portal/departments' },
|
||||||
|
// { text: 'Payments', icon: this.creditCardIcon, path: '/user-portal/payments' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public supportNavItems: NavItem[] = [
|
||||||
|
{ text: 'Support', icon: this.supportIcon, path: '/user-portal/support' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public memberAdminNavItems: NavItem[] = [
|
||||||
|
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public userAdminNavItems: NavItem[] = [
|
||||||
|
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' },
|
||||||
|
];
|
||||||
|
|
||||||
|
public showMemberAdminSection = false;
|
||||||
|
public showUserAdminSection = false;
|
||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -65,7 +111,9 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(user => {
|
.subscribe(user => {
|
||||||
this.currentUser = user;
|
this.currentUser = user;
|
||||||
this.showAdminSection = !!user?.roles?.includes('super_admin');
|
const roles = user?.roles ?? [];
|
||||||
|
this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary');
|
||||||
|
this.showUserAdminSection = roles.includes('super_admin');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,17 +123,40 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
filter(event => event instanceof NavigationEnd),
|
filter(event => event instanceof NavigationEnd),
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe((event: NavigationEnd) => {
|
||||||
|
this.updateActiveStates(event.url);
|
||||||
this.updatePageTitle();
|
this.updatePageTitle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.updateActiveStates(this.router.url);
|
||||||
this.updatePageTitle();
|
this.updatePageTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public navigateTo(path: string): void {
|
||||||
|
this.router.navigate([path]);
|
||||||
|
this.onNavigationClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateActiveStates(currentUrl: string): void {
|
||||||
|
const allItems = [
|
||||||
|
...this.mainNavItems,
|
||||||
|
...this.managementNavItems,
|
||||||
|
...this.supportNavItems,
|
||||||
|
...this.memberAdminNavItems,
|
||||||
|
...this.userAdminNavItems,
|
||||||
|
];
|
||||||
|
allItems.forEach(item => (item.active = false));
|
||||||
|
|
||||||
|
const activeItem = allItems.find(item => currentUrl.startsWith(item.path));
|
||||||
|
if (activeItem) {
|
||||||
|
activeItem.active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private updatePageTitle(): void {
|
private updatePageTitle(): void {
|
||||||
const url = this.router.url;
|
const url = this.router.url;
|
||||||
const segments = url.split('/').filter(s => s);
|
const segments = url.split('/').filter(s => s);
|
||||||
const key = segments.length >= 3
|
const key = segments.length >= 3
|
||||||
? `${segments[1]}/${segments[2]}` // e.g. 'admin/members'
|
? `${segments[1]}/${segments[2]}` // e.g. 'admin/members'
|
||||||
: segments[1] ?? '';
|
: segments[1] ?? '';
|
||||||
this.currentPageTitle = this.getPageTitle(key);
|
this.currentPageTitle = this.getPageTitle(key);
|
||||||
@@ -93,15 +164,24 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private getPageTitle(page: string): string {
|
private getPageTitle(page: string): string {
|
||||||
const titles: { [key: string]: string } = {
|
const titles: { [key: string]: string } = {
|
||||||
'dashboard': 'Dashboard',
|
'dashboard': 'Dashboard',
|
||||||
'transactions': 'Escrow Transactions',
|
'schedule': 'Schedule',
|
||||||
'tasks': 'Tasks & Todos',
|
'patients': 'Patients',
|
||||||
'contacts': 'Contacts',
|
'bed-management': 'Bed Management',
|
||||||
'documents': 'Documents',
|
'staff': 'Staff',
|
||||||
'messages': 'Messages',
|
'pharmacy': 'Pharmacy',
|
||||||
'settings': 'Settings',
|
'reports': 'Reports',
|
||||||
'admin/members': 'Member Management',
|
'departments': 'Departments',
|
||||||
'admin/users': 'User Management',
|
'payments': 'Payments',
|
||||||
|
'support': 'Support',
|
||||||
|
'transactions': 'Escrow Transactions',
|
||||||
|
'tasks': 'Tasks & Todos',
|
||||||
|
'contacts': 'Contacts',
|
||||||
|
'documents': 'Documents',
|
||||||
|
'messages': 'Messages',
|
||||||
|
'settings': 'Settings',
|
||||||
|
'admin/members': 'Member Management',
|
||||||
|
'admin/users': 'User Management',
|
||||||
};
|
};
|
||||||
return titles[page] ?? 'Dashboard';
|
return titles[page] ?? 'Dashboard';
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -17,4 +17,4 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.spec.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+21
-26
@@ -1,31 +1,26 @@
|
|||||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
|
||||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
|
||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
|
"module": "es2020",
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"experimentalDecorators": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "preserve"
|
"typeRoots": [
|
||||||
},
|
"node_modules/@types"
|
||||||
"angularCompilerOptions": {
|
],
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"lib": [
|
||||||
"strictInjectionParameters": true,
|
"es2017",
|
||||||
"strictInputAccessModifiers": true,
|
"dom"
|
||||||
"typeCheckHostBindings": true,
|
],
|
||||||
"strictTemplates": true
|
"plugins": [
|
||||||
},
|
{
|
||||||
"files": [],
|
"name": "tslint-language-service"
|
||||||
"references": [
|
}
|
||||||
{
|
],
|
||||||
"path": "./tsconfig.app.json"
|
"useDefineForClassFields": false
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user