Compare commits

..

19 Commits

Author SHA1 Message Date
Chris Chen 60405ef0aa WIP 2026-05-27 07:49:26 -07:00
Chris Chen 62428cd2d4 feat: rewrite AuthService to use ROLAC auth API with in-memory token storage
- Replace GET /api/Token/Create (Basic Auth) with POST /api/Auth/login
- Add refresh() method using HttpOnly cookie (POST /api/Auth/refresh)
- Add initializeFromRefreshToken() for APP_INITIALIZER support
- logout() now fires POST /api/Auth/logout (fire-and-forget)
- Rename User interface to UserInfo (matches C# DTO: id, email, roles, languagePreference)
- All auth state is in-memory only (no localStorage)
- Fix downstream consumers: app.ts, header components, mfa-dialog, token-verification
- Fix tsconfig.spec.json: exclude legacy src/components and src/directives
- Add stub enums.model.ts and fix models/index.ts for pre-existing build errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:47:43 -07:00
Chris Chen 4874f2a0a3 test: fix spec issues from code review (logout assertion order, refresh 5xx test) 2026-05-26 20:38:58 -07:00
Chris Chen dc7909e247 test: add failing specs for AuthService login API integration 2026-05-26 20:34:49 -07:00
Chris Chen aa0c5403a1 docs: login API integration implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:29:33 -07:00
Chris Chen 98965274b8 docs: fix TokenVerificationResult type in login integration spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:22:24 -07:00
Chris Chen d1f342e3d0 docs: login API integration design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:21:43 -07:00
Chris Chen 2aa095c158 Task 11: Smoke test fixes (all 5 scenarios pass)
TokenService.GenerateRefreshToken():
  - Switched to URL-safe Base64 (RFC 4648 §5): +→-, /→_, no = padding.
  - Characters are unreserved per RFC 6265, so Response.Cookies.Append
    does NOT percent-encode the value.  Request.Cookies reads back exact value.

AuthController:
  - CookieOptions.Secure = !env.IsDevelopment()
    Plain HTTP in local dev works; HTTPS-only in staging/production.
  - Inject IWebHostEnvironment for environment-aware Secure flag.

TokenServiceTests:
  - Updated GenerateRefreshToken test: 86-char URL-safe Base64 instead
    of 64-byte standard Base64.  16/16 tests pass.

Smoke test results (http://localhost:5209):
  1. POST /api/auth/login       → 200 + rolac_rt cookie + JWT
  2. POST /api/auth/refresh     → 200 + new token (rotation)
  3. POST /api/auth/logout      → 204 + cookie cleared
  4. Refresh with revoked token → 401
  5. Wrong password             → 401

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:28:20 -07:00
Chris Chen ef0098d5cc Task 10: EF Core InitialAuth migration
Applied against ChurchCRM database on 192.168.68.55:49154.
Creates Identity tables (AspNetUsers, AspNetRoles, AspNetUserRoles, etc.)
+ RefreshTokens table with unique index on TokenHash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:13:30 -07:00
Chris Chen 8b86bd573e Tasks 7-9: AuthController, appsettings, Program.cs
Task 7 – AuthController (POST /api/auth/login|refresh|logout)
  - Refresh token in HttpOnly; Secure; SameSite=Strict cookie (rolac_rt)
  - Cookie Path scoped to /api/auth; cleared on logout/invalid refresh

Task 8 – appsettings.json (non-secret JWT values + CORS origins)
  - appsettings.Development.json carries connection string + JWT secret
    (file is gitignored)

Task 9 – Program.cs wiring
  - EF Core + Npgsql, ASP.NET Core Identity, JWT Bearer auth
  - RoleClaimType=role matches the short JWT claim name written by TokenService
  - CORS: AllowCredentials for Angular app
  - Swagger UI with Bearer security definition
  - Startup: MigrateAsync + DbSeeder.SeedAsync (roles + dev admin)
  - DbSeeder: added SeedAsync(IServiceProvider) entry point

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:40:52 -07:00
Chris Chen 9db8b34181 Task 6: AuthService + 9 unit tests (16/16 pass)
- IAuthService: LoginAsync / RefreshAsync / LogoutAsync
- AuthService: refresh-token rotation, hashed storage, LastLoginAt update
- AuthServiceTests: 5 login + 3 refresh + 1 logout tests via Moq + EF InMemory

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:38:56 -07:00
Chris Chen f74563bb36 Task 5: TokenService + unit tests (7/7 pass)
- ITokenService: GenerateAccessToken / GenerateRefreshToken / HashToken
- TokenService: JWT (HS256, 15-min), 64-byte CSPRNG refresh, SHA-256 hex hash
  - Role claims use short JWT name role (v7.x JsonWebTokenHandler compatible)
- TokenServiceTests: 7 xUnit tests, payload decoded via Base64Url+System.Text.Json
  to avoid Microsoft.IdentityModel 7.1.2/7.5.2 version-mismatch issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 17:34:56 -07:00
Chris Chen b335867b30 feat: add LoginRequest and LoginResponse DTOs 2026-05-25 19:07:36 -07:00
Chris Chen a66a3f7cb0 feat: add AppDbContext (Identity + RefreshTokens) and DbSeeder (13 roles + dev admin) 2026-05-25 19:05:02 -07:00
Chris Chen 40d740d6e0 feat: add AppUser, AppRole, RefreshToken entities 2026-05-25 19:02:22 -07:00
Chris Chen 5a789fb0c2 chore: add Identity, EF Core PostgreSQL, JWT Bearer packages 2026-05-25 19:00:30 -07:00
Chris Chen cab4c6778f docs: add Login API implementation plan (JWT + ASP.NET Identity) 2026-05-25 18:57:18 -07:00
Chris Chen 4da8806bfc Init API 2026-05-25 17:38:23 -07:00
Chris Chen d5648315a0 WIP 2026-05-25 17:32:18 -07:00
294 changed files with 37421 additions and 0 deletions
+1
View File
@@ -91,3 +91,4 @@ logs/
*.log *.log
*.tmp *.tmp
*.temp *.temp
/.claude
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ROLAC.API\ROLAC.API.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,258 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class AuthServiceTests
{
// -----------------------------------------------------------------------
// Factory helpers
// -----------------------------------------------------------------------
private static AppDbContext BuildDb() =>
new(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options);
private static IConfiguration BuildConfig() =>
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Jwt:RefreshTokenExpiryDays", "30" },
})
.Build();
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
private static Mock<UserManager<AppUser>> BuildUserManager(
AppUser? findResult = null,
bool passwordOk = true,
IList<string>? roles = null)
{
var store = new Mock<IUserStore<AppUser>>();
// Remaining ctor params are all optional; Moq passes them via reflection.
#pragma warning disable CS8625
var mgr = new Mock<UserManager<AppUser>>(
store.Object, null, null, null, null, null, null, null, null);
#pragma warning restore CS8625
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync(findResult);
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
.ReturnsAsync(findResult);
mgr.Setup(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
.ReturnsAsync(passwordOk);
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
.ReturnsAsync(roles ?? new List<string> { "member" });
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
.ReturnsAsync(IdentityResult.Success);
return mgr;
}
/// <summary>
/// ITokenService mock: GenerateAccessToken → "access-token",
/// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}".
/// </summary>
private static Mock<ITokenService> BuildTokenService()
{
var svc = new Mock<ITokenService>();
svc.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>()))
.Returns("access-token");
svc.Setup(t => t.GenerateRefreshToken())
.Returns("raw-refresh");
svc.Setup(t => t.HashToken(It.IsAny<string>()))
.Returns<string>(s => $"hash:{s}");
return svc;
}
private static AuthService BuildSut(
Mock<UserManager<AppUser>> umMock,
Mock<ITokenService> tsMock,
AppDbContext db)
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
// -----------------------------------------------------------------------
// Login tests
// -----------------------------------------------------------------------
[Fact]
public async Task Login_ValidCredentials_ReturnsAccessTokenAndRefreshToken()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user, roles: new[] { "member" });
var ts = BuildTokenService();
var db = BuildDb();
var sut = BuildSut(um, ts, db);
var (response, raw) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.Equal("access-token", response.AccessToken);
Assert.Equal(900, response.ExpiresIn);
Assert.Equal("u1", response.User.Id);
Assert.Equal("a@b.com", response.User.Email);
Assert.Contains("member", response.User.Roles);
Assert.Equal("raw-refresh", raw);
// Persisted in DB
var stored = db.RefreshTokens.Single();
Assert.Equal("hash:raw-refresh", stored.TokenHash);
Assert.Equal("u1", stored.UserId);
}
[Fact]
public async Task Login_UnknownEmail_ThrowsUnauthorized()
{
var um = BuildUserManager(findResult: null);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.LoginAsync(new LoginRequest { Email = "nope@b.com", Password = "P@ssw0rd!" }));
}
[Fact]
public async Task Login_WrongPassword_ThrowsUnauthorized()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user, passwordOk: false);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "wrong" }));
}
[Fact]
public async Task Login_InactiveAccount_ThrowsUnauthorized()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = false };
var um = BuildUserManager(findResult: user, passwordOk: true);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }));
}
[Fact]
public async Task Login_Success_UpdatesLastLoginAt()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
}
// -----------------------------------------------------------------------
// Refresh tests
// -----------------------------------------------------------------------
[Fact]
public async Task Refresh_ValidToken_RotatesRefreshToken()
{
const string rawOld = "old-raw";
const string rawNew = "raw-refresh"; // what BuildTokenService returns
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var db = BuildDb();
// Seed an active token
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:old-raw",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
await db.SaveChangesAsync();
var sut = BuildSut(um, ts, db);
var (response, newRaw) = await sut.RefreshAsync(rawOld);
Assert.Equal("access-token", response.AccessToken);
Assert.Equal(rawNew, newRaw);
// Old token revoked
var old = db.RefreshTokens.First(rt => rt.TokenHash == "hash:old-raw");
Assert.NotNull(old.RevokedAt);
Assert.Equal("hash:raw-refresh", old.ReplacedByHash);
// New token stored
var newTok = db.RefreshTokens.First(rt => rt.TokenHash == "hash:raw-refresh");
Assert.Null(newTok.RevokedAt);
}
[Fact]
public async Task Refresh_UnknownToken_ThrowsUnauthorized()
{
var um = BuildUserManager();
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.RefreshAsync("no-such-token"));
}
[Fact]
public async Task Refresh_ExpiredToken_ThrowsUnauthorized()
{
var db = BuildDb();
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:expired-raw",
ExpiresAt = DateTime.UtcNow.AddDays(-1), // already expired
CreatedAt = DateTime.UtcNow.AddDays(-31),
});
await db.SaveChangesAsync();
var um = BuildUserManager();
var ts = BuildTokenService();
var sut = BuildSut(um, ts, db);
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => sut.RefreshAsync("expired-raw"));
}
// -----------------------------------------------------------------------
// Logout tests
// -----------------------------------------------------------------------
[Fact]
public async Task Logout_ValidToken_RevokesRefreshToken()
{
const string raw = "my-raw-token";
var db = BuildDb();
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:my-raw-token",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
await db.SaveChangesAsync();
var um = BuildUserManager();
var ts = BuildTokenService();
var sut = BuildSut(um, ts, db);
await sut.LogoutAsync(raw);
var token = db.RefreshTokens.Single();
Assert.NotNull(token.RevokedAt);
}
}
@@ -0,0 +1,154 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class TokenServiceTests
{
private readonly TokenService _sut;
public TokenServiceTests()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Jwt:SecretKey", "test-secret-key-that-is-at-least-32-characters-long!!" },
{ "Jwt:Issuer", "rolac-api-test" },
{ "Jwt:Audience", "rolac-client-test" },
{ "Jwt:AccessTokenExpiryMinutes", "15" },
{ "Jwt:RefreshTokenExpiryDays", "30" },
})
.Build();
_sut = new TokenService(config);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// <summary>
/// Base64Url-decodes the payload segment of a JWT and returns it as a
/// <see cref="JsonDocument"/>. Caller is responsible for disposing.
/// </summary>
private static JsonDocument ReadJwtPayload(string token)
{
var b64 = token.Split('.')[1];
var mod4 = b64.Length % 4;
if (mod4 > 0) b64 += new string('=', 4 - mod4);
var bytes = Convert.FromBase64String(b64.Replace('-', '+').Replace('_', '/'));
return JsonDocument.Parse(bytes);
}
// ---------------------------------------------------------------------------
// GenerateAccessToken
// ---------------------------------------------------------------------------
[Fact]
public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
{
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
var roles = new List<string> { "member", "finance" };
var token = _sut.GenerateAccessToken(user, roles);
Assert.NotEmpty(token);
// Decode payload without touching any Microsoft.IdentityModel API so that
// library version mismatches (7.1.2 vs 7.5.2) cannot affect this test.
using var doc = ReadJwtPayload(token);
var root = doc.RootElement;
Assert.Equal("user-123", root.GetProperty("sub").GetString());
Assert.Equal("test@rolac.org", root.GetProperty("email").GetString());
// JwtSecurityTokenHandler's DefaultOutboundClaimTypeMap maps
// ClaimTypes.Role → "role", so roles land under the "role" key.
// When multiple claims share a type they are serialised as a JSON array.
var roleEl = root.GetProperty("role");
var roleClaims = roleEl.ValueKind == JsonValueKind.Array
? roleEl.EnumerateArray().Select(e => e.GetString()!).ToList()
: new List<string> { roleEl.GetString()! };
Assert.Contains("member", roleClaims);
Assert.Contains("finance", roleClaims);
}
[Fact]
public void GenerateAccessToken_ExpiresIn15Minutes()
{
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
var token = _sut.GenerateAccessToken(user, new List<string>());
using var doc = ReadJwtPayload(token);
var expUnix = doc.RootElement.GetProperty("exp").GetInt64();
var expectedUnix = DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeSeconds();
// Allow ±2 s for test execution time.
Assert.InRange(expUnix, expectedUnix - 2, expectedUnix + 2);
}
// ---------------------------------------------------------------------------
// GenerateRefreshToken
// ---------------------------------------------------------------------------
[Fact]
public void GenerateRefreshToken_Returns86CharUrlSafeBase64()
{
var token = _sut.GenerateRefreshToken();
Assert.NotEmpty(token);
// 64 bytes → 88 standard Base64 chars → 86 URL-safe chars (no '==' padding,
// '+' → '-', '/' → '_'). All chars must be unreserved in RFC 6265 cookie-values.
Assert.Equal(86, token.Length);
Assert.True(
token.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'),
"Token must use URL-safe Base64 alphabet (A-Z a-z 0-9 - _)");
}
[Fact]
public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
{
var token1 = _sut.GenerateRefreshToken();
var token2 = _sut.GenerateRefreshToken();
Assert.NotEqual(token1, token2);
}
// ---------------------------------------------------------------------------
// HashToken
// ---------------------------------------------------------------------------
[Fact]
public void HashToken_SameInputProducesSameHash()
{
const string raw = "some-raw-token-value";
var hash1 = _sut.HashToken(raw);
var hash2 = _sut.HashToken(raw);
Assert.Equal(hash1, hash2);
}
[Fact]
public void HashToken_DifferentInputsProduceDifferentHashes()
{
var hash1 = _sut.HashToken("token-a");
var hash2 = _sut.HashToken("token-b");
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void HashToken_Returns64CharLowercaseHexString()
{
var hash = _sut.HashToken("any-token");
Assert.Equal(64, hash.Length);
Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
}
}
+126
View File
@@ -0,0 +1,126 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
private const string CookieName = "rolac_rt";
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService;
private readonly IWebHostEnvironment _env;
public AuthController(IAuthService authService, IWebHostEnvironment env)
{
_authService = authService;
_env = env;
}
// -------------------------------------------------------------------------
// POST /api/auth/login
// -------------------------------------------------------------------------
/// <summary>Authenticates a user and returns an access token.</summary>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
try
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var device = Request.Headers.UserAgent.FirstOrDefault();
var (response, raw) = await _authService.LoginAsync(request, ip, device);
SetRefreshCookie(raw);
return Ok(response);
}
catch (UnauthorizedAccessException ex)
{
return Unauthorized(new { message = ex.Message });
}
}
// -------------------------------------------------------------------------
// POST /api/auth/refresh
// -------------------------------------------------------------------------
/// <summary>
/// Rotates the refresh token (read from the HttpOnly cookie) and returns a
/// new access token.
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh()
{
var raw = Request.Cookies[CookieName];
if (string.IsNullOrEmpty(raw))
return Unauthorized(new { message = "Refresh token not found." });
try
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var (response, newRaw) = await _authService.RefreshAsync(raw, ip);
SetRefreshCookie(newRaw);
return Ok(response);
}
catch (UnauthorizedAccessException ex)
{
ClearRefreshCookie();
return Unauthorized(new { message = ex.Message });
}
}
// -------------------------------------------------------------------------
// POST /api/auth/logout
// -------------------------------------------------------------------------
/// <summary>Revokes the current refresh token and clears the cookie.</summary>
[HttpPost("logout")]
[AllowAnonymous]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Logout()
{
var raw = Request.Cookies[CookieName];
if (!string.IsNullOrEmpty(raw))
await _authService.LogoutAsync(raw);
ClearRefreshCookie();
return NoContent();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/// <summary>
/// <c>Secure</c> is set to <c>true</c> everywhere except in the local
/// Development environment so that the cookie works over plain HTTP during
/// dev and smoke-test runs, but is always HTTPS-only in staging/production.
/// </summary>
private void SetRefreshCookie(string rawToken)
=> Response.Cookies.Append(CookieName, rawToken, new CookieOptions
{
HttpOnly = true,
Secure = !_env.IsDevelopment(),
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromSeconds(CookieMaxAge),
Path = "/api/auth",
});
private void ClearRefreshCookie()
=> Response.Cookies.Delete(CookieName, new CookieOptions
{
HttpOnly = true,
Secure = !_env.IsDevelopment(),
SameSite = SameSiteMode.Strict,
Path = "/api/auth",
});
}
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Mvc;
namespace ROLAC.API.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}
+16
View File
@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Auth;
public class LoginRequest
{
[Required]
[EmailAddress]
[MaxLength(256)]
public string Email { get; set; } = null!;
[Required]
[MinLength(8)]
[MaxLength(128)]
public string Password { get; set; } = null!;
}
+20
View File
@@ -0,0 +1,20 @@
namespace ROLAC.API.DTOs.Auth;
public class LoginResponse
{
/// <summary>Short-lived JWT (15 min). Store in memory — never in localStorage.</summary>
public string AccessToken { get; set; } = null!;
/// <summary>Seconds until the access token expires. Always 900 (15 × 60).</summary>
public int ExpiresIn { get; set; }
public UserInfo User { get; set; } = null!;
}
public class UserInfo
{
public string Id { get; set; } = null!;
public string Email { get; set; } = null!;
public IList<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en";
}
+51
View File
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities;
namespace ROLAC.API.Data;
public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<RefreshToken>(entity =>
{
entity.HasKey(e => e.Id);
// Unique index on hash — enables fast lookup and prevents duplicate tokens
entity.HasIndex(e => e.TokenHash).IsUnique();
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
entity.Property(e => e.DeviceInfo).HasMaxLength(200);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.ReplacedByHash).HasMaxLength(64);
entity.HasOne(e => e.User)
.WithMany(u => u.RefreshTokens)
.HasForeignKey(e => e.UserId)
.OnDelete(DeleteBehavior.Cascade);
// Computed properties are not DB columns
entity.Ignore(e => e.IsExpired);
entity.Ignore(e => e.IsRevoked);
entity.Ignore(e => e.IsActive);
});
builder.Entity<AppUser>(entity =>
{
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
});
builder.Entity<AppRole>(entity =>
{
entity.Property(e => e.Description).HasMaxLength(500);
});
}
}
+83
View File
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Identity;
using ROLAC.API.Entities;
namespace ROLAC.API.Data;
public static class DbSeeder
{
private static readonly (string Name, string Description)[] Roles =
[
("super_admin", "System administrator — full access"),
("pastor", "Pastor — full member and financial overview"),
("board_member", "Board member — church governance"),
("coworker_chair", "Coworker chair — coordinates ministry leaders"),
("ministry_leader", "Ministry leader — scoped to own ministry"),
("district_leader", "District leader — manages multiple cell groups"),
("cell_leader", "Cell leader — scoped to own cell group"),
("coworker", "Coworker — general worker in assigned ministry"),
("finance", "Finance — manages giving and expense reports"),
("secretary", "Secretary — manages member data and scheduling"),
("worship_leader", "Worship leader — manages song library and setlists (Phase deferred)"),
("member", "Member — views own profile and service roster"),
("visitor", "Visitor — public pages only"),
];
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
{
foreach (var (name, description) in Roles)
{
if (!await roleManager.RoleExistsAsync(name))
{
await roleManager.CreateAsync(new AppRole
{
Name = name,
Description = description,
});
}
}
}
/// <summary>
/// Seeds roles and (in Development) the default admin account.
/// Called once on application startup after migrations have been applied.
/// </summary>
public static async Task SeedAsync(IServiceProvider services)
{
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
var userManager = services.GetRequiredService<UserManager<AppUser>>();
var env = services.GetRequiredService<IWebHostEnvironment>();
await SeedRolesAsync(roleManager);
if (env.IsDevelopment())
await SeedAdminUserAsync(userManager);
}
/// <summary>
/// Creates a super_admin test account for local development.
/// DO NOT call this in production — remove or guard with IsDevelopment().
/// Credentials: admin@rolac.org / Admin1234!
/// </summary>
public static async Task SeedAdminUserAsync(UserManager<AppUser> userManager)
{
const string adminEmail = "admin@rolac.org";
const string adminPassword = "Admin1234!";
if (await userManager.FindByEmailAsync(adminEmail) is null)
{
var admin = new AppUser
{
UserName = adminEmail,
Email = adminEmail,
EmailConfirmed = true,
IsActive = true,
LanguagePreference = "en",
CreatedAt = DateTime.UtcNow,
};
var result = await userManager.CreateAsync(admin, adminPassword);
if (result.Succeeded)
await userManager.AddToRoleAsync(admin, "super_admin");
}
}
}
+8
View File
@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity;
namespace ROLAC.API.Entities;
public class AppRole : IdentityRole
{
public string? Description { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Identity;
namespace ROLAC.API.Entities;
public class AppUser : IdentityUser
{
/// <summary>Links this login account to a church member record. Null for admin-only accounts.</summary>
public int? MemberId { get; set; }
/// <summary>UI language preference: 'en' or 'zh-TW'.</summary>
public string LanguagePreference { get; set; } = "en";
/// <summary>False = account suspended (returns 403 even with correct password).</summary>
public bool IsActive { get; set; } = true;
public DateTime? LastLoginAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}
+29
View File
@@ -0,0 +1,29 @@
namespace ROLAC.API.Entities;
public class RefreshToken
{
public int Id { get; set; }
public string UserId { get; set; } = null!;
public AppUser User { get; set; } = null!;
/// <summary>SHA-256 hex of the raw token sent to the client. Never store raw tokens.</summary>
public string TokenHash { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Set when this token is revoked (logout or rotation).</summary>
public DateTime? RevokedAt { get; set; }
/// <summary>Points to the hash of the token that replaced this one during rotation.</summary>
public string? ReplacedByHash { get; set; }
public string? DeviceInfo { get; set; }
public string? IpAddress { get; set; }
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
public bool IsRevoked => RevokedAt.HasValue;
public bool IsActive => !IsRevoked && !IsExpired;
}
@@ -0,0 +1,365 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using ROLAC.API.Data;
#nullable disable
namespace ROLAC.API.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260527021212_InitialAuth")]
partial class InitialAuth
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.AppRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LanguagePreference")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("en");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceInfo")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<string>("ReplacedByHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,269 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class InitialAuth : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
MemberId = table.Column<int>(type: "integer", nullable: true),
LanguagePreference = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "en"),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
LastLoginAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "text", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
RoleId = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "text", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ReplacedByHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
DeviceInfo = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IpAddress = table.Column<string>(type: "character varying(45)", maxLength: 45, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
table.ForeignKey(
name: "FK_RefreshTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "RefreshTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}
@@ -0,0 +1,362 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using ROLAC.API.Data;
#nullable disable
namespace ROLAC.API.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("text");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("RoleId")
.HasColumnType("text");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("text");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.AppRole", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LanguagePreference")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasDefaultValue("en");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeviceInfo")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<string>("ReplacedByHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
+137
View File
@@ -0,0 +1,137 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using ROLAC.API.Data;
using ROLAC.API.Entities;
using ROLAC.API.Services;
var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;
// ---------------------------------------------------------------------------
// Database
// ---------------------------------------------------------------------------
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("DefaultConnection")));
// ---------------------------------------------------------------------------
// Identity
// ---------------------------------------------------------------------------
builder.Services
.AddIdentity<AppUser, AppRole>(opt =>
{
opt.Password.RequiredLength = 8;
opt.Password.RequireDigit = true;
opt.Password.RequireUppercase = true;
opt.Password.RequireLowercase = true;
opt.Password.RequireNonAlphanumeric = true;
opt.User.RequireUniqueEmail = true;
opt.SignIn.RequireConfirmedAccount = false;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
// ---------------------------------------------------------------------------
// JWT Authentication
// ---------------------------------------------------------------------------
var jwtKey = config["Jwt:SecretKey"]
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
builder.Services
.AddAuthentication(opt =>
{
opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
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].
RoleClaimType = "role",
ClockSkew = TimeSpan.Zero,
};
});
// ---------------------------------------------------------------------------
// CORS
// ---------------------------------------------------------------------------
var allowedOrigins = config.GetSection("Cors:AllowedOrigins").Get<string[]>()
?? ["http://localhost:4200"];
builder.Services.AddCors(opt =>
opt.AddPolicy("Angular", policy =>
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials()));
// ---------------------------------------------------------------------------
// Application services
// ---------------------------------------------------------------------------
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
// ---------------------------------------------------------------------------
// Swagger / MVC
// ---------------------------------------------------------------------------
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(opt =>
{
opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" });
// Enable JWT in Swagger UI
opt.AddSecurityDefinition("Bearer", new()
{
Name = "Authorization",
Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Description = "Enter your JWT access token.",
});
opt.AddSecurityRequirement(new()
{
{
new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "Bearer" } },
[]
}
});
});
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
var app = builder.Build();
// Apply migrations + seed on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
await DbSeeder.SeedAsync(scope.ServiceProvider);
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors("Angular");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
@@ -0,0 +1,31 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:42019",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5208",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" 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>
+6
View File
@@ -0,0 +1,6 @@
@ROLAC.API_HostAddress = http://localhost:5208
GET {{ROLAC.API_HostAddress}}/weatherforecast/
Accept: application/json
###
+51
View File
@@ -0,0 +1,51 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36623.8
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ROLAC.API", "ROLAC.API.csproj", "{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ROLAC.API.Tests", "..\ROLAC.API.Tests\ROLAC.API.Tests.csproj", "{2065B769-3D62-48F5-9456-96BED5FAF659}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Debug|x64.ActiveCfg = Debug|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Debug|x64.Build.0 = Debug|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Debug|x86.ActiveCfg = Debug|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Debug|x86.Build.0 = Debug|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Release|Any CPU.Build.0 = Release|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Release|x64.ActiveCfg = Release|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Release|x64.Build.0 = Release|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Release|x86.ActiveCfg = Release|Any CPU
{1F8C6494-4B18-4AD5-B63E-4958A8E2C21A}.Release|x86.Build.0 = Release|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Debug|x64.ActiveCfg = Debug|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Debug|x64.Build.0 = Debug|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Debug|x86.ActiveCfg = Debug|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Debug|x86.Build.0 = Debug|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Release|Any CPU.Build.0 = Release|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Release|x64.ActiveCfg = Release|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Release|x64.Build.0 = Release|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Release|x86.ActiveCfg = Release|Any CPU
{2065B769-3D62-48F5-9456-96BED5FAF659}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3ABEE002-E4F9-4D1C-A7FA-2E0FAE97514D}
EndGlobalSection
EndGlobal
+145
View File
@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class AuthService : IAuthService
{
private readonly UserManager<AppUser> _userManager;
private readonly ITokenService _tokenService;
private readonly AppDbContext _db;
private readonly int _refreshTokenExpiryDays;
public AuthService(
UserManager<AppUser> userManager,
ITokenService tokenService,
AppDbContext db,
IConfiguration config)
{
_userManager = userManager;
_tokenService = tokenService;
_db = db;
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
}
// -------------------------------------------------------------------------
// Login
// -------------------------------------------------------------------------
public async Task<(LoginResponse Response, string RawRefreshToken)> LoginAsync(
LoginRequest request, string? ipAddress = null, string? deviceInfo = null)
{
var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null)
throw new UnauthorizedAccessException("Invalid credentials.");
if (!await _userManager.CheckPasswordAsync(user, request.Password))
throw new UnauthorizedAccessException("Invalid credentials.");
if (!user.IsActive)
throw new UnauthorizedAccessException("Account is inactive.");
var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles);
var rawRefresh = _tokenService.GenerateRefreshToken();
var tokenHash = _tokenService.HashToken(rawRefresh);
_db.RefreshTokens.Add(new RefreshToken
{
UserId = user.Id,
TokenHash = tokenHash,
ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenExpiryDays),
CreatedAt = DateTime.UtcNow,
IpAddress = ipAddress,
DeviceInfo = deviceInfo,
});
user.LastLoginAt = DateTime.UtcNow;
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
return (BuildResponse(accessToken, user, roles), rawRefresh);
}
// -------------------------------------------------------------------------
// Refresh
// -------------------------------------------------------------------------
public async Task<(LoginResponse Response, string RawRefreshToken)> RefreshAsync(
string rawRefreshToken, string? ipAddress = null)
{
var hash = _tokenService.HashToken(rawRefreshToken);
var token = await _db.RefreshTokens
.FirstOrDefaultAsync(rt => rt.TokenHash == hash);
if (token is null || !token.IsActive)
throw new UnauthorizedAccessException("Invalid or expired refresh token.");
var user = await _userManager.FindByIdAsync(token.UserId);
if (user is null)
throw new UnauthorizedAccessException("User not found.");
var roles = await _userManager.GetRolesAsync(user);
var newAccess = _tokenService.GenerateAccessToken(user, roles);
var newRaw = _tokenService.GenerateRefreshToken();
var newHash = _tokenService.HashToken(newRaw);
// Rotate: mark old token replaced+revoked, create new token
token.RevokedAt = DateTime.UtcNow;
token.ReplacedByHash = newHash;
_db.RefreshTokens.Add(new RefreshToken
{
UserId = user.Id,
TokenHash = newHash,
ExpiresAt = DateTime.UtcNow.AddDays(_refreshTokenExpiryDays),
CreatedAt = DateTime.UtcNow,
IpAddress = ipAddress,
DeviceInfo = token.DeviceInfo,
});
await _db.SaveChangesAsync();
return (BuildResponse(newAccess, user, roles), newRaw);
}
// -------------------------------------------------------------------------
// Logout
// -------------------------------------------------------------------------
public async Task LogoutAsync(string rawRefreshToken)
{
var hash = _tokenService.HashToken(rawRefreshToken);
var token = await _db.RefreshTokens
.FirstOrDefaultAsync(rt => rt.TokenHash == hash);
if (token is not null && token.IsActive)
{
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static LoginResponse BuildResponse(
string accessToken, AppUser user, IList<string> roles)
=> new()
{
AccessToken = accessToken,
ExpiresIn = 15 * 60,
User = new UserInfo
{
Id = user.Id,
Email = user.Email!,
Roles = roles,
LanguagePreference = user.LanguagePreference,
},
};
}
+31
View File
@@ -0,0 +1,31 @@
using ROLAC.API.DTOs.Auth;
namespace ROLAC.API.Services;
public interface IAuthService
{
/// <summary>
/// Validates credentials and returns a new access token plus the raw refresh token
/// that must be stored in an HttpOnly cookie by the caller.
/// Throws <see cref="UnauthorizedAccessException"/> on any auth failure.
/// </summary>
Task<(LoginResponse Response, string RawRefreshToken)> LoginAsync(
LoginRequest request,
string? ipAddress = null,
string? deviceInfo = null);
/// <summary>
/// Validates a raw refresh token, revokes it, and issues a new token pair (rotation).
/// Throws <see cref="UnauthorizedAccessException"/> if the token is not found,
/// expired, or already revoked.
/// </summary>
Task<(LoginResponse Response, string RawRefreshToken)> RefreshAsync(
string rawRefreshToken,
string? ipAddress = null);
/// <summary>
/// Revokes the refresh token identified by its raw value.
/// Silently succeeds if the token is not found.
/// </summary>
Task LogoutAsync(string rawRefreshToken);
}
+15
View File
@@ -0,0 +1,15 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public interface ITokenService
{
/// <summary>Generates a signed HS256 JWT containing userId, email, and roles claims.</summary>
string GenerateAccessToken(AppUser user, IList<string> roles);
/// <summary>Generates a cryptographically-random 64-byte base64 string (the raw token value).</summary>
string GenerateRefreshToken();
/// <summary>Returns the SHA-256 hex hash of the raw token. Always hash before storing to DB.</summary>
string HashToken(string rawToken);
}
+73
View File
@@ -0,0 +1,73 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class TokenService : ITokenService
{
private readonly IConfiguration _config;
public TokenService(IConfiguration config)
{
_config = config;
}
public string GenerateAccessToken(AppUser user, IList<string> roles)
{
var secretKey = _config["Jwt:SecretKey"]!;
var issuer = _config["Jwt:Issuer"]!;
var audience = _config["Jwt:Audience"]!;
var expiryMin = int.Parse(_config["Jwt:AccessTokenExpiryMinutes"]!);
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
// Use the short JWT claim name "role" so the payload is clean and
// JsonWebTokenHandler (the v7.x default validator) can read it without
// needing an inbound claim-type map applied.
foreach (var role in roles)
claims.Add(new Claim("role", role));
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: issuer,
audience: audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiryMin),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string GenerateRefreshToken()
{
var bytes = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(bytes);
// Use URL-safe Base64 (RFC 4648 §5) with no padding.
// Standard Base64 '+' → '-', '/' → '_', '=' stripped.
// All resulting characters are unreserved in RFC 6265 cookie-values,
// so Response.Cookies.Append will NOT percent-encode the token —
// meaning Request.Cookies[name] returns the exact string we stored.
return Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
public string HashToken(string rawToken)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
return Convert.ToHexString(bytes).ToLower();
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace ROLAC.API
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Jwt": {
"Issuer": "rolac-api",
"Audience": "rolac-client",
"AccessTokenExpiryMinutes": "15",
"RefreshTokenExpiryDays": "30"
},
"Cors": {
"AllowedOrigins": [ "http://localhost:4200", "https://localhost:4200" ]
}
}
+11
View File
@@ -0,0 +1,11 @@
# Page Templates and Building Blocks
This package is part of the [Telerik and Kendo UI Accelerator](https://www.telerik.com/page-templates-and-ui-blocks) add-on.
## License
This is commercial software. To use it, you need to agree to the [**End User License Agreement for Progress® Telerik and Kendo UI Accelerator**](https://www.telerik.com/purchase/license-agreement/ui-accelerator).
All available Progress® Telerik and Kendo UI Accelerator commercial licenses may be obtained at the [Progress® Telerik and Kendo UI Accelerator website](https://www.telerik.com/purchase.aspx?filter=ui-accelerator#individual-products).
*Copyright © 2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.*
+116
View File
@@ -0,0 +1,116 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"client-bridge": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js",
"@angular/localize/init"
],
"tsConfig": "tsconfig.app.json",
"assets": [
"src/assets"
],
"styles": [
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "1mb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"buildTarget": "client-bridge:build"
},
"configurations": {
"production": {
"buildTarget": "client-bridge:build:production"
},
"development": {
"buildTarget": "client-bridge:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing",
"@angular/localize/init"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
"src/assets"
],
"styles": [
"src/styles.scss"
]
}
}
}
}
}
}
@@ -0,0 +1,974 @@
# Login API Integration — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Wire the Angular login page to the ROLAC C# auth API (`POST /api/auth/login`, `/refresh`, `/logout`) using secure in-memory token storage and an auto-refreshing HTTP interceptor.
**Architecture:** `AuthService` owns all auth state in two `BehaviorSubject`s (access token + user); it never touches `localStorage`. An `HttpInterceptorFn` attaches the Bearer token and silently retries on `401` via `/api/auth/refresh`. An `APP_INITIALIZER` calls `refresh()` on startup to restore sessions from the HttpOnly `rolac_rt` cookie.
**Tech Stack:** Angular 20 standalone, Karma/Jasmine, `HttpClientTestingModule`, `HttpTestingController`
---
## File Map
| File | Action |
|------|--------|
| `src/app/shared/services/auth.service.ts` | Full rewrite |
| `src/app/shared/services/auth.service.spec.ts` | Create (new tests) |
| `src/app/core/interceptors/auth.interceptor.ts` | Update |
| `src/app/core/interceptors/auth.interceptor.spec.ts` | Create (new tests) |
| `src/app/app.config.ts` | Add `APP_INITIALIZER` |
---
## Task 1: Write failing tests for `AuthService`
**Files:**
- Create: `src/app/shared/services/auth.service.spec.ts`
- [ ] **Step 1.1 — Create the spec file**
Create `src/app/shared/services/auth.service.spec.ts` with the full contents below. Every test will fail (or error) because the current service has the wrong interfaces and API calls.
```typescript
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController
} from '@angular/common/http/testing';
import {
AuthService,
LoginResultType,
UserInfo
} from './auth.service';
import { ApiConfigService } from '../../core/services/api-config.service';
const MOCK_USER: UserInfo = {
id: 'user-123',
email: 'test@example.com',
roles: ['Admin'],
languagePreference: 'en'
};
const MOCK_API_RESPONSE = {
accessToken: 'mock-access-token',
expiresIn: 900,
user: MOCK_USER
};
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
let apiConfig: ApiConfigService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService, ApiConfigService]
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
apiConfig = TestBed.inject(ApiConfigService);
});
afterEach(() => {
httpMock.verify();
});
// ── login() ────────────────────────────────────────────────────────────────
describe('login()', () => {
it('should POST to /api/auth/login with email and password', () => {
service.login({ email: 'test@example.com', password: 'secret' }).subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/login`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ email: 'test@example.com', password: 'secret' });
expect(req.request.withCredentials).toBeTrue();
req.flush(MOCK_API_RESPONSE);
});
it('should return LoginResultType.Success and store token + user on 200', () => {
let result: any;
service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(MOCK_API_RESPONSE);
expect(result.result).toBe(LoginResultType.Success);
expect(result.responseData).toEqual(MOCK_USER);
expect(service.getToken()).toBe('mock-access-token');
expect(service.getCurrentUser()).toEqual(MOCK_USER);
expect(service.isAuthenticated()).toBeTrue();
});
it('should return LoginResultType.InvalidCredentials on 401', () => {
let result: any;
service.login({ email: 'bad@example.com', password: 'wrong' }).subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(
{ message: 'Invalid credentials' },
{ status: 401, statusText: 'Unauthorized' }
);
expect(result.result).toBe(LoginResultType.InvalidCredentials);
expect(service.getToken()).toBeNull();
expect(service.isAuthenticated()).toBeFalse();
});
it('should return LoginResultType.Error on non-401 HTTP error', () => {
let result: any;
service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(
{ message: 'Server error' },
{ status: 500, statusText: 'Internal Server Error' }
);
expect(result.result).toBe(LoginResultType.Error);
});
});
// ── refresh() ──────────────────────────────────────────────────────────────
describe('refresh()', () => {
it('should POST to /api/auth/refresh with withCredentials', () => {
service.refresh().subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/refresh`);
expect(req.request.method).toBe('POST');
expect(req.request.withCredentials).toBeTrue();
req.flush(MOCK_API_RESPONSE);
});
it('should return true and update token + user on 200', () => {
let result: boolean | undefined;
service.refresh().subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE);
expect(result).toBeTrue();
expect(service.getToken()).toBe('mock-access-token');
expect(service.getCurrentUser()).toEqual(MOCK_USER);
});
it('should return false and leave state unchanged on 401', () => {
let result: boolean | undefined;
service.refresh().subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
{ message: 'Refresh token expired' },
{ status: 401, statusText: 'Unauthorized' }
);
expect(result).toBeFalse();
expect(service.getToken()).toBeNull();
expect(service.isAuthenticated()).toBeFalse();
});
});
// ── logout() ───────────────────────────────────────────────────────────────
describe('logout()', () => {
it('should clear token and user from memory immediately', () => {
// Seed state
service['accessToken$'].next('some-token');
service['currentUser$'].next(MOCK_USER);
service.logout();
httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(null, { status: 204, statusText: 'No Content' });
expect(service.getToken()).toBeNull();
expect(service.getCurrentUser()).toBeNull();
expect(service.isAuthenticated()).toBeFalse();
});
it('should POST to /api/auth/logout with withCredentials', () => {
service.logout();
const req = httpMock.expectOne(`${apiConfig.authUrl}/logout`);
expect(req.request.method).toBe('POST');
expect(req.request.withCredentials).toBeTrue();
req.flush(null, { status: 204, statusText: 'No Content' });
});
it('should not throw if the logout API call fails', () => {
expect(() => {
service.logout();
httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(
{ message: 'Server error' },
{ status: 500, statusText: 'Internal Server Error' }
);
}).not.toThrow();
});
});
// ── initializeFromRefreshToken() ───────────────────────────────────────────
describe('initializeFromRefreshToken()', () => {
it('should resolve even when refresh returns 401 (does not block bootstrap)', async () => {
const promise = service.initializeFromRefreshToken();
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
{ message: 'No cookie' },
{ status: 401, statusText: 'Unauthorized' }
);
await expectAsync(promise).toBeResolved();
});
it('should resolve and authenticate user when refresh succeeds', async () => {
const promise = service.initializeFromRefreshToken();
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE);
await expectAsync(promise).toBeResolved();
expect(service.isAuthenticated()).toBeTrue();
});
});
// ── setCurrentUser() / getCurrentUser() ────────────────────────────────────
describe('setCurrentUser()', () => {
it('should update currentUser$ and mark authenticated', () => {
service.setCurrentUser(MOCK_USER);
expect(service.getCurrentUser()).toEqual(MOCK_USER);
expect(service.isAuthenticated()).toBeTrue();
});
});
// ── redirect URL helpers ────────────────────────────────────────────────────
describe('redirect URL helpers', () => {
it('should default redirect to /dashboard', () => {
expect(service.getRedirectUrl()).toBe('/dashboard');
});
it('should store and return a custom redirect URL', () => {
service.setRedirectUrl('/members');
expect(service.getRedirectUrl()).toBe('/members');
});
});
});
```
- [ ] **Step 1.2 — Run tests to confirm they fail**
```
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/shared/services/auth.service.spec.ts --watch=false
```
Expected output: Multiple FAILED specs — type errors and/or wrong API URLs. If the test runner starts and reports failures (not compilation errors), proceed. If there are import errors for `UserInfo` or `ApiLoginResponse`, that is expected and confirms the tests are ahead of the implementation.
- [ ] **Step 1.3 — Commit the failing tests**
```
git add src/app/shared/services/auth.service.spec.ts
git commit -m "test: add failing specs for AuthService login API integration"
```
---
## Task 2: Rewrite `auth.service.ts`
**Files:**
- Modify: `src/app/shared/services/auth.service.ts`
- [ ] **Step 2.1 — Replace the entire file**
Replace `src/app/shared/services/auth.service.ts` with:
```typescript
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { ApiConfigService } from '../../core/services/api-config.service';
// ── Public interfaces ─────────────────────────────────────────────────────────
/** Matches the C# UserInfo DTO exactly. */
export interface UserInfo {
id: string;
email: string;
roles: string[];
languagePreference: string;
}
/** Matches the C# LoginResponse DTO exactly. */
export interface ApiLoginResponse {
accessToken: string;
expiresIn: number;
user: UserInfo;
}
export interface LoginCredentials {
email: string;
password: string;
/** Reserved for future MFA support — ignored by the current API. */
mfaCode?: string;
}
export enum LoginResultType {
Success = 'Success',
/** Kept dormant — the current API has no MFA endpoint. */
MfaRequired = 'MfaRequired',
InvalidCredentials = 'InvalidCredentials',
Error = 'Error'
}
export interface LoginResult {
result: LoginResultType;
responseData?: UserInfo;
message?: string;
}
export interface TokenVerificationResult {
isValid: boolean;
/** Constructed from JWT claims when using secret-link login. */
user?: UserInfo;
message?: string;
expiresAt?: Date;
requiresMfa?: boolean;
}
// ── Service ───────────────────────────────────────────────────────────────────
@Injectable({ providedIn: 'root' })
export class AuthService {
/** In-memory only — never written to localStorage. */
private accessToken$ = new BehaviorSubject<string | null>(null);
private currentUser$ = new BehaviorSubject<UserInfo | null>(null);
/** Observable stream of the current user (null = not authenticated). */
public currentUser = this.currentUser$.asObservable();
private redirectUrl = '/dashboard';
constructor(
private http: HttpClient,
private apiConfig: ApiConfigService
) {}
// ── Auth API calls ──────────────────────────────────────────────────────
/**
* Authenticate with email + password.
* On success, stores the access token and user in memory and returns
* LoginResultType.Success. Never throws — errors are mapped to LoginResult.
*/
login(credentials: LoginCredentials): Observable<LoginResult> {
return this.http.post<ApiLoginResponse>(
`${this.apiConfig.authUrl}/login`,
{ email: credentials.email, password: credentials.password },
{ withCredentials: true }
).pipe(
tap(response => {
this.accessToken$.next(response.accessToken);
this.currentUser$.next(response.user);
}),
map(response => ({
result: LoginResultType.Success,
responseData: response.user
} as LoginResult)),
catchError(error => {
if (error.status === 401) {
return of({
result: LoginResultType.InvalidCredentials,
message: error.error?.message || 'Invalid email or password'
} as LoginResult);
}
return of({
result: LoginResultType.Error,
message: error.error?.message || 'An error occurred during login'
} as LoginResult);
})
);
}
/**
* Silently exchange the HttpOnly `rolac_rt` cookie for a new access token.
* Returns true on success, false if the cookie is absent or expired.
* Never throws.
*/
refresh(): Observable<boolean> {
return this.http.post<ApiLoginResponse>(
`${this.apiConfig.authUrl}/refresh`,
{},
{ withCredentials: true }
).pipe(
tap(response => {
this.accessToken$.next(response.accessToken);
this.currentUser$.next(response.user);
}),
map(() => true),
catchError(() => of(false))
);
}
/**
* Clears in-memory auth state immediately, then fires a fire-and-forget
* POST to revoke the server-side refresh token cookie.
*/
logout(): void {
this.accessToken$.next(null);
this.currentUser$.next(null);
this.http.post(
`${this.apiConfig.authUrl}/logout`,
{},
{ withCredentials: true }
).pipe(
catchError(() => of(null))
).subscribe();
}
/**
* Called by APP_INITIALIZER on every page load.
* Attempts to restore the session via the refresh token cookie.
* Always resolves — never rejects — so it cannot block app bootstrap.
*/
initializeFromRefreshToken(): Promise<void> {
return new Promise(resolve => {
this.refresh().subscribe(() => resolve());
});
}
// ── State accessors ─────────────────────────────────────────────────────
getToken(): string | null {
return this.accessToken$.value;
}
isAuthenticated(): boolean {
return this.currentUser$.value !== null;
}
getCurrentUser(): UserInfo | null {
return this.currentUser$.value;
}
/**
* Manually set the current user — used by the MFA success callback
* and the secret-link token flow.
*/
setCurrentUser(user: UserInfo): void {
this.currentUser$.next(user);
}
setRedirectUrl(url: string): void {
this.redirectUrl = url;
}
getRedirectUrl(): string {
return this.redirectUrl;
}
// ── Secret-link token helpers (unchanged logic, updated types) ───────────
/**
* Verifies a JWT token received as a URL parameter (secret-link login).
* Performs local verification only — no API call.
* Constructs a UserInfo from the JWT claims (id, email, roles,
* languagePreference).
*/
verifySecretLinkToken(token: string): Observable<TokenVerificationResult> {
try {
const tokenData = this.parseJwtToken(token);
if (!tokenData) {
return of({ isValid: false, message: 'Invalid token format' });
}
if (this.isTokenExpired(token)) {
return of({
isValid: false,
message: 'This link has expired. Please request a new one.'
});
}
const user: UserInfo = {
id: tokenData.userId || tokenData.sub || tokenData.id || '',
email: tokenData.email || tokenData.email_address || '',
roles: Array.isArray(tokenData.roles)
? tokenData.roles
: tokenData.role ? [tokenData.role] : [],
languagePreference: tokenData.languagePreference || 'en'
};
return of({
isValid: true,
user,
message: 'Token verified successfully. MFA required.',
expiresAt: tokenData.exp ? new Date(tokenData.exp * 1000) : undefined,
requiresMfa: true
});
} catch (error) {
return of({ isValid: false, message: 'Invalid or corrupted token' });
}
}
/** Returns true if the JWT's `exp` claim is in the past. */
isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp < Math.floor(Date.now() / 1000);
} catch {
return true;
}
}
private parseJwtToken(token: string): any | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const decoded = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded);
} catch {
return null;
}
}
}
```
- [ ] **Step 2.2 — Run the `AuthService` tests and confirm they all pass**
```
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/shared/services/auth.service.spec.ts --watch=false
```
Expected: All specs PASS. Fix any failures before moving on.
- [ ] **Step 2.3 — Commit**
```
git add src/app/shared/services/auth.service.ts
git commit -m "feat: rewrite AuthService to use ROLAC auth API with in-memory token storage"
```
---
## Task 3: Write failing tests for `AuthInterceptor`
**Files:**
- Create: `src/app/core/interceptors/auth.interceptor.spec.ts`
- [ ] **Step 3.1 — Create the spec file**
Create `src/app/core/interceptors/auth.interceptor.spec.ts`:
```typescript
import { TestBed } from '@angular/core/testing';
import {
HttpClient,
provideHttpClient,
withInterceptors
} from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting
} from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from '../../shared/services/auth.service';
import { ApiConfigService } from '../services/api-config.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let authServiceSpy: jasmine.SpyObj<AuthService>;
let routerSpy: jasmine.SpyObj<Router>;
let apiConfig: ApiConfigService;
beforeEach(() => {
authServiceSpy = jasmine.createSpyObj('AuthService', [
'getToken',
'refresh',
'logout'
]);
routerSpy = jasmine.createSpyObj('Router', ['navigate']);
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authServiceSpy },
{ provide: Router, useValue: routerSpy },
ApiConfigService
]
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
apiConfig = TestBed.inject(ApiConfigService);
});
afterEach(() => httpMock.verify());
// ── Token attachment ────────────────────────────────────────────────────────
describe('token attachment', () => {
it('should add Authorization header when token exists', () => {
authServiceSpy.getToken.and.returnValue('test-token');
http.get(`${apiConfig.getBaseUrl()}/members`).subscribe();
const req = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
req.flush({});
});
it('should NOT add Authorization header when no token', () => {
authServiceSpy.getToken.and.returnValue(null);
http.get(`${apiConfig.getBaseUrl()}/members`).subscribe();
const req = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush({});
});
it('should NOT add Authorization header to /auth/login', () => {
authServiceSpy.getToken.and.returnValue('test-token');
http.post(`${apiConfig.authUrl}/login`, {}).subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/login`);
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush({});
});
it('should NOT add Authorization header to /auth/refresh', () => {
authServiceSpy.getToken.and.returnValue('test-token');
http.post(`${apiConfig.authUrl}/refresh`, {}).subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/refresh`);
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush({});
});
it('should NOT add Authorization header to /auth/logout', () => {
authServiceSpy.getToken.and.returnValue('test-token');
http.post(`${apiConfig.authUrl}/logout`, {}).subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/logout`);
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush({});
});
it('should NOT add Authorization header to external URLs', () => {
authServiceSpy.getToken.and.returnValue('test-token');
http.get('https://other-domain.com/api/data').subscribe();
const req = httpMock.expectOne('https://other-domain.com/api/data');
expect(req.request.headers.has('Authorization')).toBeFalse();
req.flush({});
});
});
// ── 401 auto-refresh ────────────────────────────────────────────────────────
describe('401 auto-refresh', () => {
it('should refresh and retry original request on first 401', () => {
// First call returns old token (initial request); second returns new token (retry)
authServiceSpy.getToken.and.returnValues('old-token', 'new-token');
authServiceSpy.refresh.and.returnValue(of(true));
let responseData: any;
http.get(`${apiConfig.getBaseUrl()}/members`).subscribe(r => responseData = r);
// First attempt → 401
const first = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
first.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
// Retry after refresh
const retry = httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`);
expect(retry.request.headers.get('Authorization')).toBe('Bearer new-token');
expect(retry.request.headers.get('X-Retry')).toBe('true');
retry.flush({ data: 'ok' });
expect(responseData).toEqual({ data: 'ok' });
expect(authServiceSpy.refresh).toHaveBeenCalledTimes(1);
expect(authServiceSpy.logout).not.toHaveBeenCalled();
});
it('should logout and navigate to /login when refresh fails on 401', () => {
authServiceSpy.getToken.and.returnValue('old-token');
authServiceSpy.refresh.and.returnValue(of(false));
http.get(`${apiConfig.getBaseUrl()}/members`).subscribe({
error: () => {} // swallow expected error
});
httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush(
{ message: 'Unauthorized' },
{ status: 401, statusText: 'Unauthorized' }
);
expect(authServiceSpy.logout).toHaveBeenCalledTimes(1);
expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']);
});
it('should NOT retry again if the retry request also gets 401 (prevents loop)', () => {
authServiceSpy.getToken.and.returnValue('token');
authServiceSpy.refresh.and.returnValue(of(true));
http.get(`${apiConfig.getBaseUrl()}/members`).subscribe({
error: () => {}
});
// First request → 401
httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush(
{ message: 'Unauthorized' },
{ status: 401, statusText: 'Unauthorized' }
);
// Retry also gets 401 — should not spawn another request
httpMock.expectOne(`${apiConfig.getBaseUrl()}/members`).flush(
{ message: 'Still unauthorized' },
{ status: 401, statusText: 'Unauthorized' }
);
// No third request
httpMock.expectNone(`${apiConfig.getBaseUrl()}/members`);
expect(authServiceSpy.logout).toHaveBeenCalledTimes(1);
expect(routerSpy.navigate).toHaveBeenCalledWith(['/login']);
});
});
});
```
- [ ] **Step 3.2 — Run tests to confirm they fail**
```
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/core/interceptors/auth.interceptor.spec.ts --watch=false
```
Expected: Multiple failures — the current interceptor uses old paths (`/Auth/login`, `/Token/Create`) and has no auto-refresh logic. Proceed once failures are confirmed.
- [ ] **Step 3.3 — Commit the failing tests**
```
git add src/app/core/interceptors/auth.interceptor.spec.ts
git commit -m "test: add failing specs for authInterceptor auto-refresh and path matching"
```
---
## Task 4: Update `auth.interceptor.ts`
**Files:**
- Modify: `src/app/core/interceptors/auth.interceptor.ts`
- [ ] **Step 4.1 — Replace the entire file**
Replace `src/app/core/interceptors/auth.interceptor.ts` with:
```typescript
import { HttpInterceptorFn, HttpErrorResponse, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service';
import { Router } from '@angular/router';
import { ApiConfigService } from '../services/api-config.service';
/**
* Functional HTTP interceptor that:
* 1. Attaches `Authorization: Bearer <token>` to every request destined for
* the ROLAC API (except public auth endpoints).
* 2. On a 401 response, silently calls POST /api/auth/refresh and retries
* the original request once with the new token.
* 3. If the refresh also fails, logs the user out and redirects to /login.
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const authService = inject(AuthService);
const apiConfig = inject(ApiConfigService);
const router = inject(Router);
// Attach token to qualifying requests
const request = attachToken(req, authService, apiConfig);
return next(request).pipe(
catchError((error: HttpErrorResponse) => {
// Only intercept 401s — and only for the first attempt (no X-Retry header)
if (error.status === 401 && !req.headers.has('X-Retry')) {
return authService.refresh().pipe(
switchMap(success => {
if (success) {
// Retry with the fresh token; mark as retry to prevent loops
const retryReq = req.clone({
setHeaders: {
Authorization: `Bearer ${authService.getToken()}`,
'X-Retry': 'true'
}
});
return next(retryReq);
}
// Refresh failed — session is gone
authService.logout();
router.navigate(['/login']);
return throwError(() => error);
})
);
}
// Second 401 (retry was already attempted) or non-401 error
if (error.status === 401) {
authService.logout();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
/**
* Returns a cloned request with the Bearer token header if:
* - The request URL targets the ROLAC API base URL, AND
* - The endpoint is not a public auth endpoint, AND
* - A token is currently held in memory.
*/
function attachToken(
req: HttpRequest<unknown>,
authService: AuthService,
apiConfig: ApiConfigService
): HttpRequest<unknown> {
const token = authService.getToken();
if (!token || !shouldAddToken(apiConfig, req)) return req;
return req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
/** Public auth paths that must never carry an access token. */
const PUBLIC_AUTH_PATHS = ['/auth/login', '/auth/refresh', '/auth/logout'];
function shouldAddToken(apiConfig: ApiConfigService, req: HttpRequest<unknown>): boolean {
if (!req.url.startsWith(apiConfig.getBaseUrl())) return false;
return !PUBLIC_AUTH_PATHS.some(path => req.url.includes(path));
}
```
- [ ] **Step 4.2 — Run the interceptor tests and confirm they all pass**
```
cd E:\VSProject\ROLAC\APP
ng test --include=src/app/core/interceptors/auth.interceptor.spec.ts --watch=false
```
Expected: All specs PASS. Fix any failures before moving on.
- [ ] **Step 4.3 — Run all tests to check for regressions**
```
cd E:\VSProject\ROLAC\APP
ng test --watch=false
```
Expected: All specs PASS across both spec files.
- [ ] **Step 4.4 — Commit**
```
git add src/app/core/interceptors/auth.interceptor.ts
git commit -m "feat: update authInterceptor with correct auth paths and auto-refresh on 401"
```
---
## Task 5: Add `APP_INITIALIZER` to `app.config.ts`
**Files:**
- Modify: `src/app/app.config.ts`
- [ ] **Step 5.1 — Update `app.config.ts`**
Replace `src/app/app.config.ts` with:
```typescript
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { AuthService } from './shared/services/auth.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor])),
{
provide: APP_INITIALIZER,
useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(),
deps: [AuthService],
multi: true
}
]
};
```
- [ ] **Step 5.2 — Build to check for compile errors**
```
cd E:\VSProject\ROLAC\APP
ng build --configuration=development
```
Expected: Build succeeds with no errors (warnings about bundle size are OK).
- [ ] **Step 5.3 — Run all tests one final time**
```
cd E:\VSProject\ROLAC\APP
ng test --watch=false
```
Expected: All specs PASS.
- [ ] **Step 5.4 — Commit**
```
git add src/app/app.config.ts
git commit -m "feat: add APP_INITIALIZER to restore session from refresh token cookie on page load"
```
---
## Task 6: Smoke-test against the running API
> This task is manual and requires both the Angular dev server and the ROLAC API to be running.
- [ ] **Step 6.1 — Start the API and Angular dev server**
```
# Terminal 1 — API (adjust path if needed)
cd E:\VSProject\ROLAC\API
dotnet run --project ROLAC.API
# Terminal 2 — Angular
cd E:\VSProject\ROLAC\APP
ng serve
```
- [ ] **Step 6.2 — Test happy-path login**
1. Open `http://localhost:4200/login`
2. Click the login button to show the form
3. Enter valid credentials and submit
4. ✅ Should redirect to `/dashboard`
5. Open DevTools → Application → Cookies: confirm `rolac_rt` HttpOnly cookie is present
6. Open DevTools → Application → Local Storage: confirm **no** `currentUser` key (we removed localStorage)
- [ ] **Step 6.3 — Test invalid credentials**
1. Enter wrong email/password
2. ✅ Should show "Invalid email or password" error inline — no redirect
- [ ] **Step 6.4 — Test session restore on page reload**
1. While logged in, hard-reload the page (`Ctrl+Shift+R`)
2. ✅ Should stay on `/dashboard` (not redirect to `/login`)
3. ✅ Token is back in memory (check Network tab — subsequent API calls carry `Authorization: Bearer ...`)
- [ ] **Step 6.5 — Commit smoke-test confirmation note**
```
git commit --allow-empty -m "chore: smoke-tested login API integration against live API"
```
---
## Summary of changes
| File | What changed |
|------|-------------|
| `auth.service.ts` | Full rewrite: new interfaces (`UserInfo`, `ApiLoginResponse`), POSTs JSON to `/api/auth/login`, `refresh()`, `logout()` API calls, in-memory BehaviorSubject storage |
| `auth.service.spec.ts` | New: full test coverage for login/refresh/logout/init |
| `auth.interceptor.ts` | Fixed public paths, added `switchMap` auto-refresh on 401 with X-Retry sentinel |
| `auth.interceptor.spec.ts` | New: coverage for token attachment, refresh flow, loop prevention |
| `app.config.ts` | Added `APP_INITIALIZER` for session restore on page load |
@@ -0,0 +1,262 @@
# Login API Integration — Design Spec
*Date: 2026-05-26 | Project: ROLAC Angular App*
## Overview
Connect the Angular login page to the ROLAC C# API authentication endpoints. The login page component (`login-page.ts`) is already complete; the work is updating the service layer underneath it to talk to the correct API with a secure, memory-safe token strategy.
---
## Scope
**In scope:**
- Update `AuthService` to call new ROLAC API auth endpoints
- Update Angular interfaces to match API DTO shapes exactly
- Replace `localStorage` token storage with in-memory `BehaviorSubject`
- Update `AuthInterceptor` to fix path matching and add silent auto-refresh on 401
- Add `APP_INITIALIZER` session restore via the refresh token cookie
**Out of scope:**
- MFA API integration (no MFA endpoint exists in current ROLAC API — code path kept dormant)
- Secret-link token login flow (existing `verifySecretLinkToken()` kept as-is)
- UI/template changes to `login-page.component.html`
---
## API Contracts
### `POST /api/auth/login`
**Request body:**
```json
{ "email": "user@example.com", "password": "secret" }
```
**Success response (200):**
```json
{
"accessToken": "<JWT>",
"expiresIn": 900,
"user": { "id": "...", "email": "...", "roles": ["Admin"], "languagePreference": "en" }
}
```
**Failure response (401):** `{ "message": "..." }`
Sets `rolac_rt` HttpOnly cookie (30-day refresh token).
### `POST /api/auth/refresh`
No body. Uses `rolac_rt` HttpOnly cookie.
**Success (200):** Same shape as login response. Rotates the refresh token cookie.
**Failure (401):** Cookie expired or revoked.
### `POST /api/auth/logout`
No body. Uses `rolac_rt` HttpOnly cookie.
**Response:** 204 No Content. Clears the cookie.
---
## Architecture
### File changes
| File | Change type |
|------|-------------|
| `src/app/shared/services/auth.service.ts` | Full rewrite |
| `src/app/core/interceptors/auth.interceptor.ts` | Update |
| `src/app/app.config.ts` | Add APP_INITIALIZER |
### Updated interfaces in `auth.service.ts`
**Removed:** `User`, `UserDto`, `AccessDto`, `TokenCreateResponse`, `LoginResponse` (old).
**Added/updated:**
```typescript
// Matches API UserInfo DTO exactly
export interface UserInfo {
id: string;
email: string;
roles: string[];
languagePreference: string;
}
// Matches API LoginResponse DTO exactly
export interface ApiLoginResponse {
accessToken: string;
expiresIn: number;
user: UserInfo;
}
// LoginCredentials — kept; mfaCode kept for future MFA support
export interface LoginCredentials {
email: string;
password: string;
mfaCode?: string;
}
// LoginResultType — kept; MfaRequired kept dormant
export enum LoginResultType {
Success = 'Success',
MfaRequired = 'MfaRequired',
InvalidCredentials = 'InvalidCredentials',
Error = 'Error'
}
// LoginResult — kept; responseData type changes from User to UserInfo
export interface LoginResult {
result: LoginResultType;
responseData?: UserInfo;
message?: string;
}
// TokenVerificationResult — updated: user field changes from User → UserInfo
export interface TokenVerificationResult {
isValid: boolean;
user?: UserInfo; // was User (old); now UserInfo — verifySecretLinkToken extracts
// id, email, roles[], languagePreference from the JWT payload
message?: string;
expiresAt?: Date;
requiresMfa?: boolean;
}
```
### `AuthService` — methods
```
login(credentials: LoginCredentials): Observable<LoginResult>
POST /api/auth/login with JSON body
On 200 → store accessToken + user in BehaviorSubjects → return LoginResultType.Success
On 401 → return LoginResultType.InvalidCredentials
On other error → return LoginResultType.Error
refresh(): Observable<boolean>
POST /api/auth/refresh (no body; browser sends rolac_rt cookie automatically)
On 200 → update accessToken$ BehaviorSubject → return true
On 401/error → return false
logout(): void
POST /api/auth/logout (fire-and-forget)
Clear accessToken$ and currentUser$ BehaviorSubjects
initializeFromRefreshToken(): Promise<void>
Calls refresh(); resolves regardless of outcome (used as APP_INITIALIZER)
getToken(): string | null
Snapshot of accessToken$.value
isAuthenticated(): boolean
currentUser$.value !== null
getCurrentUser(): UserInfo | null
currentUser$.value
setCurrentUser(user: UserInfo): void
Update currentUser$ (used by MFA dialog success callback)
// Kept (logic unchanged, type updated):
getRedirectUrl(): string
setRedirectUrl(url: string): void
verifySecretLinkToken(token: string): Observable<TokenVerificationResult>
// Constructs UserInfo from JWT payload: id, email, roles, languagePreference
// (username/firstName/lastName/branchIds are no longer extracted)
isTokenExpired(token: string): boolean
```
**Token storage:** In-memory `BehaviorSubject` only. No `localStorage`. The refresh token lives exclusively in the HttpOnly `rolac_rt` cookie managed by the browser.
### `AuthInterceptor` — updates
**Fix 1 — Skip list for `shouldAddToken()`:**
Replace old paths (`/Auth/login`, `/Token/Create`) with:
```typescript
const publicPaths = ['/auth/login', '/auth/refresh', '/auth/logout'];
```
**Fix 2 — Silent auto-refresh on 401:**
```
On 401 response (and not a retry):
1. Call authService.refresh()
2. If refresh returns true → re-attach new token header → retry original request
3. If refresh returns false → authService.logout() → navigate to /login → propagate error
On 401 response (already a retry):
→ authService.logout() → navigate to /login → propagate error
```
Use an `X-Retry: true` header sentinel to prevent infinite retry loops.
### `app.config.ts` — APP_INITIALIZER
```typescript
{
provide: APP_INITIALIZER,
useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(),
deps: [AuthService],
multi: true
}
```
Ensures that on every page load, the app attempts to restore the session from the HttpOnly refresh token cookie before rendering. If the cookie is absent or expired, `initializeFromRefreshToken()` resolves silently and the user sees the login page.
---
## Data Flow
### Standard login
```
User submits form
→ LoginPage.onSubmit()
→ authService.login({ email, password })
→ POST /api/auth/login
→ 200: store accessToken + user in memory → LoginResultType.Success
→ LoginPage redirects to /dashboard
→ 401: LoginResultType.InvalidCredentials → show error message
→ network error: LoginResultType.Error → show error message
```
### Page reload (session restore)
```
App bootstrap
→ APP_INITIALIZER calls authService.initializeFromRefreshToken()
→ POST /api/auth/refresh (browser sends rolac_rt cookie)
→ 200: store new accessToken in memory → user is authenticated
→ 401: user$ remains null → router guard redirects to /login
```
### Authenticated API call (any protected endpoint)
```
Component makes HTTP call
→ AuthInterceptor: getToken() → attach Authorization: Bearer <token>
→ 200: response returned normally
→ 401: authService.refresh() → retry with new token
→ 401 again: authService.logout() → /login
```
---
## Error Handling
| Scenario | Handling |
|----------|----------|
| Wrong password | API returns 401 → `InvalidCredentials` → "Invalid email or password" shown in form |
| Network offline | `catchError``LoginResultType.Error` → "An error occurred" message |
| Token expired mid-session | Interceptor silently refreshes + retries; user never sees a 401 |
| Refresh cookie expired (30 days) | `initializeFromRefreshToken()` resolves false → router guard → `/login` |
| Second 401 after refresh attempt | `authService.logout()` + redirect to `/login` |
---
## Security Notes
- Access token: **memory only** (not accessible to XSS via localStorage)
- Refresh token: **HttpOnly cookie** managed by browser (not accessible to JavaScript)
- Cookie flags: `HttpOnly`, `Secure` (in non-dev), `SameSite=Strict`, `Path=/api/auth`
- `shouldAddToken()` skips auth endpoints to avoid attaching tokens to login/refresh/logout calls
---
## Testing Considerations
- `AuthService.login()` — mock HTTP, verify BehaviorSubjects update, verify `LoginResult` shape
- `AuthService.refresh()` — mock HTTP, verify token updates; mock 401, verify returns false
- `AuthService.logout()` — verify memory cleared; verify HTTP called
- `authInterceptor` — test 401-then-refresh path; test infinite-loop prevention with X-Retry sentinel
- `initializeFromRefreshToken()` — verify it resolves even on 401 (does not block bootstrap)
+387
View File
@@ -0,0 +1,387 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Escrow Portal Access - ROLCC AC</title>
<style>
/* Email-safe CSS - compatible with most email clients */
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333333;
background-color: #f4f4f4;
}
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
}
/* Header Section */
.email-header {
background-color: #1e3a8a;
padding: 30px 20px;
text-align: center;
color: white;
}
.logo-image {
height: 50px;
width: auto;
margin-bottom: 15px;
}
.logo-text h1 {
font-size: 28px;
font-weight: bold;
margin: 0 0 5px 0;
}
.logo-text .tagline {
font-size: 14px;
opacity: 0.9;
margin: 0;
}
.email-title {
font-size: 24px;
font-weight: bold;
margin: 20px 0 0 0;
}
/* Content Section */
.email-content {
padding: 30px 20px;
}
.greeting {
font-size: 18px;
margin-bottom: 20px;
color: #1f2937;
}
.message {
font-size: 16px;
line-height: 1.6;
margin-bottom: 25px;
color: #4b5563;
}
/* Transaction Details */
.transaction-details {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
padding: 20px;
margin-bottom: 25px;
}
.details-header {
margin-bottom: 15px;
color: #495057;
font-weight: bold;
font-size: 16px;
}
.detail-item {
background-color: white;
border: 1px solid #e9ecef;
padding: 12px;
margin-bottom: 8px;
}
.detail-item:last-child {
margin-bottom: 0;
}
.detail-label {
font-weight: bold;
color: #495057;
font-size: 14px;
display: inline-block;
width: 120px;
}
.detail-value {
font-size: 14px;
color: #1a1a1a;
background-color: #f8f9fa;
padding: 4px 8px;
display: inline-block;
}
/* Access Button */
.access-section {
text-align: center;
margin-bottom: 25px;
}
.access-button {
display: inline-block;
background-color: #1e3a8a;
color: white;
text-decoration: none;
padding: 15px 30px;
font-size: 18px;
font-weight: bold;
border: none;
}
.access-button:hover {
background-color: #1e40af;
text-decoration: none;
color: white;
}
/* Security Notice */
.security-notice {
background-color: #fef3c7;
border: 1px solid #f59e0b;
padding: 15px;
margin-bottom: 25px;
}
.security-header {
margin-bottom: 8px;
color: #92400e;
font-weight: bold;
font-size: 16px;
}
.security-text {
color: #92400e;
font-size: 14px;
line-height: 1.5;
}
/* Footer */
.email-footer {
background-color: #f8f9fa;
padding: 20px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer-text {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.footer-contact {
font-size: 12px;
color: #9ca3af;
}
/* Mobile Responsive */
@media (max-width: 600px) {
.email-container {
width: 100% !important;
}
.email-header {
padding: 20px 15px;
}
.logo-text h1 {
font-size: 24px;
}
.email-title {
font-size: 20px;
}
.email-content {
padding: 20px 15px;
}
.transaction-details {
padding: 15px;
}
.detail-label {
width: 100%;
display: block;
margin-bottom: 5px;
}
.detail-value {
display: block;
width: 100%;
}
.access-button {
padding: 12px 25px;
font-size: 16px;
}
}
</style>
</head>
<body>
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f4f4f4;">
<tr>
<td align="center" style="padding: 20px 0;">
<table class="email-container" width="600" cellpadding="0" cellspacing="0" border="0"
style="background-color: #ffffff; max-width: 600px;">
<!-- Header -->
<tr>
<td class="email-header"
style="background-color: #1e3a8a; padding: 30px 20px; text-align: center; color: white;">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image"
style="height: 50px; width: auto; margin-bottom: 15px;">
<div class="logo-text">
<h1 style="font-size: 28px; font-weight: bold; margin: 0 0 5px 0;">ROLCC AC</h1>
<span class="tagline" style="font-size: 14px; opacity: 0.9;">Church Management
Portal</span>
</div>
<h2 class="email-title" style="font-size: 24px; font-weight: bold; margin: 20px 0 0 0;">
Secure Portal Access</h2>
</td>
</tr>
<!-- Content -->
<tr>
<td class="email-content" style="padding: 30px 20px;">
<div class="greeting" style="font-size: 18px; margin-bottom: 20px; color: #1f2937;">
Hello [Client Name],
</div>
<div class="message"
style="font-size: 16px; line-height: 1.6; margin-bottom: 25px; color: #4b5563;">
Your church transaction is ready for review. Please use the secure link below to access
your
personalized portal where you can view transaction details, upload required documents,
and communicate
with your church officer.
</div>
<!-- Transaction Details -->
<table class="transaction-details" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color: #f8f9fa; border: 1px solid #e9ecef; padding: 20px; margin-bottom: 25px;">
<tr>
<td>
<div class="details-header"
style="margin-bottom: 15px; color: #495057; font-weight: bold; font-size: 16px;">
Transaction Details
</div>
</td>
</tr>
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px; margin-bottom: 8px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Company:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Company
Name]</span>
</td>
</tr>
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px; margin-bottom: 8px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Escrow
Officer:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Escrow
Officer Name]</span>
</td>
</tr>
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px; margin-bottom: 8px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Property:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Property
Address]</span>
</td>
</tr>
<tr>
<td
style="background-color: white; border: 1px solid #e9ecef; padding: 12px;">
<span class="detail-label"
style="font-weight: bold; color: #495057; font-size: 14px; display: inline-block; width: 120px;">Transaction
ID:</span>
<span class="detail-value"
style="font-size: 14px; color: #1a1a1a; background-color: #f8f9fa; padding: 4px 8px;">[Transaction
ID]</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Access Button -->
<table class="access-section" width="100%" cellpadding="0" cellspacing="0" border="0"
style="text-align: center; margin-bottom: 25px;">
<tr>
<td align="center">
<a href="[SECRET_LINK]" class="access-button"
style="display: inline-block; background-color: #1e3a8a; color: white; text-decoration: none; padding: 15px 30px; font-size: 18px; font-weight: bold; border: none;">
Access Your Portal
</a>
</td>
</tr>
</table>
<!-- Security Notice -->
<table class="security-notice" width="100%" cellpadding="0" cellspacing="0" border="0"
style="background-color: #fef3c7; border: 1px solid #f59e0b; padding: 15px; margin-bottom: 25px;">
<tr>
<td>
<div class="security-header"
style="margin-bottom: 8px; color: #92400e; font-weight: bold; font-size: 16px;">
Security Notice
</div>
<div class="security-text"
style="color: #92400e; font-size: 14px; line-height: 1.5;">
This link is secure and personalized for your transaction. Please do not
share this link with
others. If you did not request this access or have any concerns, please
contact your church officer
immediately.
</div>
</td>
</tr>
</table>
<div class="message"
style="font-size: 16px; line-height: 1.6; margin-bottom: 25px; color: #4b5563;">
If you have any questions or need assistance, please don't hesitate to contact your
church officer
directly. We're here to help make your transaction as smooth as possible.
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td class="email-footer"
style="background-color: #f8f9fa; padding: 20px; text-align: center; border-top: 1px solid #e9ecef;">
<div class="footer-text" style="font-size: 14px; color: #6b7280; margin-bottom: 8px;">
This email was sent by ROLCC AC Church Management Portal
</div>
<div class="footer-contact" style="font-size: 12px; color: #9ca3af;">
For technical support, contact: support@clientbridge.com | Phone: (555) 123-4567
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+11376
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -0,0 +1,89 @@
{
"name": "RBJ.Identity.App",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/animations": "^20.1.0",
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
"@angular/core": "^20.1.0",
"@angular/forms": "^20.1.0",
"@angular/localize": "^20.1.6",
"@angular/platform-browser": "^20.1.0",
"@angular/router": "^20.1.0",
"@progress/kendo-angular-buttons": "^20.0.0",
"@progress/kendo-angular-charts": "^20.0.0",
"@progress/kendo-angular-common": "^20.0.0",
"@progress/kendo-angular-conversational-ui": "^20.0.0",
"@progress/kendo-angular-dateinputs": "^20.0.0",
"@progress/kendo-angular-dialog": "^20.0.0",
"@progress/kendo-angular-dropdowns": "^20.0.0",
"@progress/kendo-angular-editor": "^20.0.0",
"@progress/kendo-angular-excel-export": "^20.0.3",
"@progress/kendo-angular-gauges": "^20.0.0",
"@progress/kendo-angular-grid": "^20.0.0",
"@progress/kendo-angular-icons": "^20.0.0",
"@progress/kendo-angular-indicators": "^20.0.0",
"@progress/kendo-angular-inputs": "^20.0.0",
"@progress/kendo-angular-intl": "^20.0.0",
"@progress/kendo-angular-l10n": "^20.0.0",
"@progress/kendo-angular-label": "^20.0.0",
"@progress/kendo-angular-layout": "^20.0.0",
"@progress/kendo-angular-listview": "^20.0.0",
"@progress/kendo-angular-map": "^20.0.0",
"@progress/kendo-angular-menu": "^20.0.0",
"@progress/kendo-angular-navigation": "^20.0.0",
"@progress/kendo-angular-pager": "^20.0.0",
"@progress/kendo-angular-pdf-export": "^20.0.3",
"@progress/kendo-angular-popup": "^20.0.0",
"@progress/kendo-angular-progressbar": "^20.0.0",
"@progress/kendo-angular-scrollview": "^20.0.0",
"@progress/kendo-angular-toolbar": "^20.0.3",
"@progress/kendo-angular-tooltip": "^20.0.0",
"@progress/kendo-angular-treeview": "^20.0.0",
"@progress/kendo-angular-upload": "^20.0.0",
"@progress/kendo-angular-utils": "^20.0.0",
"@progress/kendo-data-query": "^1.7.1",
"@progress/kendo-drawing": "^1.22.0",
"@progress/kendo-licensing": "^1.7.0",
"@progress/kendo-svg-icons": "^4.5.0",
"@progress/kendo-theme-default": "^12.0.0",
"@progress/kendo-theme-utils": "^12.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.1.6",
"@angular/cli": "^20.1.6",
"@angular/compiler-cli": "^20.1.0",
"@angular/localize": "^20.2.1",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.8.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.8.2"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor]))
]
};
+2
View File
@@ -0,0 +1,2 @@
<router-outlet></router-outlet>
<div kendoDialogContainer></div>
+27
View File
@@ -0,0 +1,27 @@
import { Routes } from '@angular/router';
import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashboard.component';
import { LoginPage } from './features/login-page/login-page';
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
import { AuthGuard } from './core/guards/auth.guard';
export const routes: Routes = [
// Public routes
{ path: 'login', component: LoginPage },
// Keep the startup surface intentionally small: login + guarded mock dashboard.
{
path: 'user-portal',
component: UserPortalComponent,
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent }
]
},
{ path: '', redirectTo: 'login', pathMatch: 'full' },
{ path: 'dashboard', redirectTo: 'user-portal/dashboard' },
// Catch all route - redirect to login
{ path: '**', redirectTo: 'login' }
];
+53
View File
@@ -0,0 +1,53 @@
/* Global layout styles */
/* Ensure AppBar sections are in one row */
kendo-appbar {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
width: 100% !important;
}
kendo-appbar-section {
display: flex !important;
align-items: center !important;
}
/* Make sure the drawer container takes full height */
kendo-drawer-container {
min-height: calc(100vh - 46px);
}
/* Global mobile optimizations */
@media (max-width: 767px) {
/* Improve touch targets for mobile */
button[kendoButton] {
min-height: 44px;
min-width: 44px;
}
/* Make drawer content full width on mobile */
kendo-drawer-content {
width: 100%;
}
/* Prevent horizontal scroll on mobile */
body {
overflow-x: hidden;
}
}
/* iOS specific fixes */
@supports (-webkit-touch-callout: none) {
/* Prevent iOS zoom on input focus */
input,
select,
textarea {
font-size: 16px !important;
}
/* Smooth scrolling for iOS - legacy support for older devices */
kendo-drawer-container {
overflow-y: auto;
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Component, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { DialogModule } from '@progress/kendo-angular-dialog';
@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
DialogModule
],
templateUrl: './app.html',
styleUrls: ['./app.scss', '../styles.scss'],
encapsulation: ViewEncapsulation.None
})
export class App {
title = 'ROLCC AC';
}
+120
View File
@@ -0,0 +1,120 @@
# Authentication Guard System
This implementation provides a complete authentication system that automatically detects if a user is logged in and redirects to the login page if not.
## Components
### AuthGuard (`auth.guard.ts`)
- **Purpose**: Protects routes that require authentication
- **Functionality**:
- Checks if user is authenticated using `AuthService.isAuthenticated()`
- Stores attempted URL for redirect after login
- Redirects to `/login` if not authenticated
- Allows access if authenticated
### LoginPage (`login-page.ts`)
- **Purpose**: Full-page login interface for routing
- **Features**:
- Beautiful landing page with company branding
- Integrated login dialog
- Demo credentials display
- Automatic redirect after successful login
- Prevents access if already logged in
## How It Works
### 1. Route Protection
All protected routes use the `AuthGuard`:
```typescript
{ path: 'dashboard', component: Dashboard, canActivate: [AuthGuard] }
```
### 2. Authentication Flow
1. User tries to access protected route
2. `AuthGuard` checks authentication status
3. If not authenticated:
- Stores attempted URL
- Redirects to `/login`
4. If authenticated:
- Allows access to requested route
### 3. Login Process
1. User lands on `/login` page
2. Clicks "Sign In" button
3. Login dialog opens with MFA support
4. After successful login:
- User data stored in localStorage
- Redirected to originally requested URL or dashboard
### 4. Logout Process
1. User clicks logout from header dropdown
2. `AuthService.logout()` clears user data
3. Redirected to `/login` page
## User State Management
### AuthService Features
- **Persistent Login**: User stays logged in across browser sessions
- **State Management**: Reactive user state with RxJS
- **Redirect URLs**: Remembers where user was trying to go
- **localStorage**: Automatic persistence and restoration
### Header Integration
- **Dynamic User Menu**: Shows different options based on auth state
- **User Information**: Displays current user name/email
- **Logout Button**: Easy access to logout functionality
## Route Structure
```
/login - Public login page
/dashboard - Protected (requires auth)
/schedule - Protected (requires auth)
/patients - Protected (requires auth)
... - All other routes protected
/** - Catch-all redirects to /login
```
## Usage Examples
### Adding New Protected Routes
```typescript
{ path: 'new-feature', component: NewFeatureComponent, canActivate: [AuthGuard] }
```
### Checking Auth State in Components
```typescript
constructor(private authService: AuthService) {}
ngOnInit() {
this.authService.currentUser$.subscribe(user => {
if (user) {
// User is logged in
} else {
// User is not logged in
}
});
}
```
### Manual Logout
```typescript
this.authService.logout();
this.router.navigate(['/login']);
```
## Security Features
- **Route Protection**: All sensitive routes are protected
- **Persistent Sessions**: Users stay logged in across browser sessions
- **Automatic Redirects**: Seamless user experience
- **State Validation**: Authentication state is checked on every route change
- **Clean Logout**: Complete session cleanup on logout
## Testing
Use these test credentials:
- **Direct Login**: `user@example.com` / `password123`
- **MFA Required**: `admin@example.com` / `password123` / `123456`
The system will automatically redirect unauthenticated users to the login page and remember where they were trying to go.
+32
View File
@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { AuthService } from '../../shared/services/auth.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) { }
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<boolean> | Promise<boolean> | boolean {
// Check if user is authenticated
if (this.authService.isAuthenticated()) {
return true;
}
// Store the attempted URL for redirecting after login
this.authService.setRedirectUrl(state.url);
// Redirect to login page
this.router.navigate(['/login']);
return false;
}
}
@@ -0,0 +1,60 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service';
import { Router } from '@angular/router';
import { ApiConfigService } from '../services/api-config.service';
export const authInterceptor: HttpInterceptorFn = (request, next) => {
const authService = inject(AuthService);
const apiConfigService = inject(ApiConfigService);
const router = inject(Router);
// Get the current user and token
const token = authService.getToken();
// Clone the request and add the Authorization header if token exists
if (token && shouldAddToken(apiConfigService, request)) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
// Handle the request and catch 401 errors
return next(request).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Token is invalid or expired, logout user
authService.logout();
router.navigate(['/login']);
}
return throwError(() => error);
})
);
};
/**
* Determine if the token should be added to this request
* Skip adding token to login requests and other public endpoints
*/
function shouldAddToken(apiConfigService: ApiConfigService, request: any): boolean {
// Don't add token to outbound requests to other domains
if (!request.url.startsWith(apiConfigService.getBaseUrl())) {
return false;
}
// Don't add token to login requests
if (request.url.includes('/Auth/login') || request.url.includes('/Token/Create')) {
return false;
}
// Don't add token to public endpoints (you can customize this list)
const publicEndpoints = [
'/Auth/register',
'/Auth/forgot-password',
'/Auth/reset-password'
];
return !publicEndpoints.some(endpoint => request.url.includes(endpoint));
}
@@ -0,0 +1,58 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
@Injectable({
providedIn: 'root'
})
export class ApiConfigService {
private readonly baseUrl = environment.apiUrl;
constructor() { }
/**
* Get the full API URL for a specific endpoint
* @param endpoint - The API endpoint (e.g., 'Auth', 'Users', 'Transactions')
* @returns Full API URL
*/
getApiUrl(endpoint: string): string {
return `${this.baseUrl}/${endpoint}`;
}
/**
* Get the base API URL
* @returns Base API URL
*/
getBaseUrl(): string {
return this.baseUrl;
}
/**
* Get specific API endpoints
*/
get authUrl(): string {
return this.getApiUrl('Auth');
}
get tokenUrl(): string {
return this.getApiUrl('Token');
}
get usersUrl(): string {
return this.getApiUrl('Users');
}
get transactionsUrl(): string {
return this.getApiUrl('Transactions');
}
get dashboardUrl(): string {
return this.getApiUrl('Dashboard');
}
get ordersUrl(): string {
return this.getApiUrl('ClientBridgeOrder');
}
get orderDetailUrl(): string {
return this.getApiUrl('OrderDetail');
}
}
@@ -0,0 +1,168 @@
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ApiConfigService } from './api-config.service';
// Type definitions
type TextResponse = { message: string };
/**
* Base CRUD service that targets the provided controller path.
*
* It mirrors the endpoints of CrudBaseApiController<T>:
* GET /api/{controller}
* GET /api/{controller}/{id}
* POST /api/{controller} -> string
* POST /api/{controller}/batch -> string[]
* PUT /api/{controller}
* PUT /api/{controller}/batch -> number
* DELETE /api/{controller}/{id}
* DELETE /api/{controller}/batch -> text summary
* GET /api/{controller}/{id}/exists -> boolean
* GET /api/{controller}/count -> number
*/
@Injectable({ providedIn: 'root' })
export class CrudBaseApiService<T extends object> {
/**
* Example: baseUrl = 'https://your-api', controller = 'Customer' →
* endpoint = 'https://your-api/api/Customer'
*/
protected readonly endpoint: string;
/**
* @param http Angular HttpClient
* @param baseUrl API root without trailing slash (e.g., environment.apiBaseUrl)
* @param controllerName Controller name (e.g., 'Customer', 'Orders')
*/
constructor(
protected http: HttpClient,
protected apiConfig: ApiConfigService,
@Inject(String) private controllerName: string
) {
this.endpoint = apiConfig.getApiUrl(this.controllerName);
}
/** Optional default headers (JSON). Override in subclasses if needed. */
protected get jsonHeaders(): HttpHeaders {
return new HttpHeaders({ 'Content-Type': 'application/json' });
}
/** Shared error handler that surfaces useful messages. */
protected handleError(error: HttpErrorResponse): Observable<never> {
let msg = 'Unknown error';
if (error.error instanceof Blob) {
// In case backend returns text/plain; charset=utf-8 as Blob
return throwError(() => new Error('Server returned an error blob'));
}
if (typeof error.error === 'string') msg = error.error;
else if (error.error?.message) msg = error.error.message;
else if (error.message) msg = error.message;
return throwError(() => new Error(msg));
}
/** Prepare the response for the given entity. Override in subclasses if needed. */
protected prepareResponse(response: T): T {
// Do nothing by default
return response;
}
/** GET /api/{controller} */
getAll(): Observable<T[]> {
return this.http
.get<T[]>(this.endpoint)
.pipe(
map(response => {
for (let i = 0; i < response.length; i++) {
const element = response[i];
response[i] = this.prepareResponse(element);
}
return response;
}),
catchError(err => this.handleError(err)));
}
/** GET /api/{controller}/{id} */
getById(id: string): Observable<T> {
return this.http
.get<T>(`${this.endpoint}/${id}`)
.pipe(
map(response => this.prepareResponse(response)),
catchError(err => this.handleError(err)));
}
/** POST /api/{controller} -> string */
create(entity: T): Observable<string> {
return this.http
.post<string>(this.endpoint, entity, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** POST /api/{controller}/batch -> string[] */
createRange(entities: T[]): Observable<string[]> {
return this.http
.post<string[]>(`${this.endpoint}/batch`, entities, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** PUT /api/{controller} */
update(entity: T): Observable<void> {
return this.http
.put<void>(this.endpoint, entity, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** PUT /api/{controller}/batch -> number (updated count) */
updateRange(entities: T[]): Observable<number> {
return this.http
.put<number>(`${this.endpoint}/batch`, entities, { headers: this.jsonHeaders })
.pipe(catchError(err => this.handleError(err)));
}
/** DELETE /api/{controller}/{id} */
delete(id: string): Observable<void> {
return this.http
.delete<void>(`${this.endpoint}/${id}`)
.pipe(catchError(err => this.handleError(err)));
}
/** DELETE /api/{controller}/batch -> text summary */
deleteRange(ids: string[]): Observable<TextResponse> {
// API returns a plain text message; map it into a TextResponse for convenience
return this.http
.delete(`${this.endpoint}/batch`, {
body: ids,
headers: this.jsonHeaders
})
.pipe(
map((response: any) => ({ message: response || 'Batch delete completed' })),
catchError(err => this.handleError(err))
);
}
/** GET /api/{controller}/{id}/exists -> boolean */
exists(id: string): Observable<boolean> {
return this.http
.get<boolean>(`${this.endpoint}/${id}/exists`)
.pipe(catchError(err => this.handleError(err)));
}
/** GET /api/{controller}/count -> number */
count(): Observable<number> {
return this.http
.get<number>(`${this.endpoint}/count`)
.pipe(catchError(err => this.handleError(err)));
}
}
@@ -0,0 +1,294 @@
<main
class="k-px-2 k-px-sm-4.5 k-px-md-6 k-px-lg-4 k-px-xl-7.5 k-py-2 k-py-sm-4.5 k-py-md-6 k-py-lg-4 k-py-xl-7.5 k-pt-8 k-bg-light">
<h1 class="k-h1 k-color-primary-emphasis k-overflow-hidden k-text-ellipsis">Dashboard</h1>
<div class="k-d-grid k-grid-cols-12 k-gap-4 k-py-4">
<!-- Start of CMPCTCARD-1 -->
<div *ngFor="let card of compactCards; let i = index;"
class="{{cardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<kendo-svgicon [icon]="card.svgIcon" themeColor="primary" size="xxlarge"></kendo-svgicon>
<div class="k-d-flex k-flex-col">
<span
class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">{{card.title}}</span>
<span class="k-font-size-sm k-line-height-lg k-color-subtle">{{card.info}}</span>
</div>
</div>
<!-- End of CMPCTCARD-1 -->
<!-- Start of DASHBRDCARD-10 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<div class="k-d-flex k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Calendar</span>
</div>
<div class="k-flex-1 k-px-3 k-pb-3 k-d-flex k-justify-content-center">
<kendo-calendar [showOtherMonthDays]="false" type="classic" [(ngModel)]="date2">
</kendo-calendar>
</div>
</div>
<!-- End of DASHBRDCARD-10 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Bed
Occupancy</span>
<kendo-datepicker format="yyyy" [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart style="height: 257px;">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item
[categories]="['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100" [min]="0" [majorTicks]="{step: 10}">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="column" name="Occupied" [spacing]="0"
[legendItem]="{type: 'line' }" [data]="[67, 78, 47, 41, 38, 33]">
</kendo-chart-series-item>
<kendo-chart-series-item type="column" name="Free" [legendItem]="{type: 'line' }"
[data]="[21, 10, 44, 40, 48, 60]">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom" orientation="horizontal" align="start"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-1 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-3">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Staff</span>
<kendo-dropdownlist [data]="ddlData" [value]="ddlValue" fillMode="flat" [style.width.px]="164"
[attr.aria-label]="'Select'"></kendo-dropdownlist>
</div>
<div class="k-flex-1 k-px-3">
<kendo-listview [data]="listItems" layout="flex" flexDirection="col" [bordered]="false">
<ng-template kendoListViewItemTemplate let-dataItem>
<div
class="k-d-flex k-border-b k-border-b-solid k-border-border k-gap-3 k-p-2 k-align-items-center">
<kendo-badge-container>
<kendo-avatar [imageSrc]="dataItem.imageSrc"></kendo-avatar>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlignBottomEnd"
themeColor="success"></kendo-badge>
</kendo-badge-container>
<div class="k-d-flex k-flex-col">
<div class="k-font-size-lg">{{dataItem.name}}</div>
<div class="k-font-size-sm k-color-subtle">{{dataItem.specialty}}</div>
</div>
</div>
</ng-template>
</kendo-listview>
</div>
<div class="k-p-3">
<button kendoButton fillMode="flat" themeColor="primary">View all</button>
</div>
</div>
<!-- End of DASHBRDCARD-1 -->
<!-- Start of DASHBRDCARD-4 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-md-6 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span
class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Appointments</span>
<kendo-datepicker [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164" [clearButton]="true"
[inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-d-grid k-grid-cols-12 k-p-4 k-gap-2">
<div *ngFor="let appointment of appointments; let last = last"
[ngClass]="{ 'k-d-none k-d-lg-block' : last }"
class=" k-col-span-12 k-col-span-lg-4 k-bg-light k-border k-border-solid k-border-border k-rounded-sm k-d-flex k-flex-col k-flex-1">
<div class="k-d-flex k-justify-content-between k-p-1.5 k-h-12">
<span class="k-font-medium">{{appointment.doctor}}</span>
<div class="k-flex-shrink-0">
<span class="k-badge k-badge-md k-badge-solid k-badge-solid-primary k-rounded-full">
{{appointment.start}}
</span>
</div>
</div>
<div class="k-d-flex k-flex-col k-flex-1 k-gap-1.5 k-px-1.5">
<div>Appointment with {{appointment.patient.name}}.</div>
<div class="k-font-size-sm">
<div class="k-color-subtle k-d-flex k-gap-1 k-align-items-center k-line-height-lg">
<kendo-svgicon [icon]="envelopeIcon"></kendo-svgicon>
<a class="k-color-inherit" href="#">{{appointment.patient.phone}}</a>
</div>
<div class="k-color-subtle k-d-flex k-gap-1 k-align-items-center k-line-height-lg">
<kendo-svgicon [icon]="envelopeIcon"></kendo-svgicon>
<a class="k-color-inherit" href="#">{{appointment.patient.email}}</a>
</div>
</div>
</div>
<div class="k-d-flex k-flex-shrink-0 k-p-1.5">
<button kendoButton fillMode="clear" themeColor="primary">Edit</button>
<button kendoButton fillMode="clear">Cancel</button>
</div>
</div>
</div>
<div class="k-p-3">
<button kendoButton fillMode="clear" themeColor="primary">View all appointments</button>
</div>
</div>
<!-- End of DASHBRDCARD-4 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Infection
Rate</span>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-x-axis>
<kendo-chart-x-axis-item [labels]="{rotation: -45}"></kendo-chart-x-axis-item>
</kendo-chart-x-axis>
<kendo-chart-series>
<kendo-chart-series-item
*ngFor="let dataSet of ['RSV', 'CDC', 'Measles', 'Influenza', 'Campylobacteriosis', 'Hepatitis']"
type="heatmap" [data]="heatmapData(dataSet)" xField="a" yField="b"
valueField="value"></kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Equipment
Availability</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-series>
<kendo-chart-series-item [autoFit]="true" type="donut" [holeSize]="50" [data]="donutData"
categoryField="kind" field="share">
<kendo-chart-series-item-labels position="outsideEnd" color="#000"
[content]="chartLabelContent"></kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend [visible]="false"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Average Length of
Stay</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="240">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="departments">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="14" [majorUnit]="1">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="bar" [data]="averageStay">
</kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Hospital
Visits</span>
<kendo-datepicker [(ngModel)]="date" format="yyyy" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="330">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="hours" baseUnit="hours"
[labels]="{rotation: 270, position: 'start', format: 'h:mm'}">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100">
</kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="line" [data]="hospitalVisits">
</kendo-chart-series-item>
</kendo-chart-series>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-5">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Satisfaction
Score</span>
<kendo-dropdownlist [value]="'2023'" [fillMode]="'flat'" [style.width.px]="164"
[attr.aria-label]="'Select'"></kendo-dropdownlist>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="288">
<kendo-chart-series>
<kendo-chart-series-item type="pie" [legendItem]="{type: 'line' }" [data]="satisfaction"
categoryField="kind" field="share" [padding]="10" [border]="{width: 3, color: '#fff'}">
<kendo-chart-series-item-labels position="center">
</kendo-chart-series-item-labels>
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
<!-- Start of DASHBRDCARD-11 -->
<div class="{{dashboardClasses}} k-col-span-12 k-col-span-lg-7">
<div class="k-d-flex k-justify-content-between k-align-items-center k-p-3">
<span class="k-font-size-lg k-line-height-lg k-font-semibold k-color-primary-emphasis">Mortality
Rate</span>
<kendo-datepicker format="yyyy" [(ngModel)]="date" [fillMode]="'flat'" [style.width.px]="164"
[clearButton]="true" [inputAttributes]="{'aria-label': 'Select date'}"></kendo-datepicker>
</div>
<div class="k-flex-1 k-px-3 k-pb-3">
<kendo-chart [style.height.px]="288">
<kendo-chart-category-axis>
<kendo-chart-category-axis-item [categories]="mortalityCauses">
</kendo-chart-category-axis-item>
</kendo-chart-category-axis>
<kendo-chart-value-axis>
<kendo-chart-value-axis-item [max]="100" [min]="0"
[majorTicks]="{step: 10}"></kendo-chart-value-axis-item>
</kendo-chart-value-axis>
<kendo-chart-series>
<kendo-chart-series-item type="bar" [legendItem]="{type: 'line' }" name="Male"
[data]="[25, 35, 36, 42, 85, 12, 4, 17, 19, 49, 28]">
</kendo-chart-series-item>
<kendo-chart-series-item type="bar" [legendItem]="{type: 'line' }" name="Female"
[data]="[23, 40, 38, 30, 81, 18, 3, 21, 22, 45, 24]">
</kendo-chart-series-item>
</kendo-chart-series>
<kendo-chart-legend position="bottom" orientation="horizontal" align="start"></kendo-chart-legend>
</kendo-chart>
</div>
</div>
<!-- End of DASHBRDCARD-11 -->
</div>
</main>
@@ -0,0 +1,86 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChartsModule, SeriesLabelsContentArgs } from '@progress/kendo-angular-charts';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ListViewModule } from '@progress/kendo-angular-listview';
import { BadgeAlign, IndicatorsModule } from '@progress/kendo-angular-indicators';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IconsModule } from '@progress/kendo-angular-icons';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { SVGIcon, envelopeIcon } from '@progress/kendo-svg-icons';
import {
appointments,
averageStay,
compactCards,
departments,
donutData,
heatmapDataCDC,
heatmapDataCampylobacteriosis,
heatmapDataHepatitis,
heatmapDataInfluenza,
heatmapDataMeasles,
heatmapDataRSV,
hospitalVisits,
hours,
listItems,
mortalityCauses,
satisfaction
} from './models';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
CommonModule,
FormsModule,
ChartsModule,
DateInputsModule,
DropDownsModule,
ListViewModule,
IndicatorsModule,
ButtonsModule,
IconsModule,
LayoutModule
],
templateUrl: './dashboard.html',
styleUrl: './dashboard.css'
})
export class Dashboard {
public cardClasses = 'k-d-flex k-border k-border-solid k-border-border k-bg-surface-alt k-align-items-center k-overflow-x-auto k-p-3 k-gap-6 k-elevation-1 k-rounded-md';
public dashboardClasses = 'k-d-flex k-flex-col k-border k-border-solid k-border-border k-bg-surface-alt k-overflow-x-auto k-elevation-1 k-rounded-md';
public envelopeIcon: SVGIcon = envelopeIcon;
public badgeAlignBottomEnd: BadgeAlign = {
vertical: 'bottom',
horizontal: 'end'
};
public chartLabelContent(e: SeriesLabelsContentArgs): string {
return e.category;
}
public date = new Date(2023, 5, 14);
public date2 = new Date(2023, 5, 15);
public averageStay = averageStay;
public hours = hours
public hospitalVisits = hospitalVisits;
public departments = departments;
public mortalityCauses = mortalityCauses;
public satisfaction = satisfaction;
public donutData = donutData;
public heatmapDataRSV = heatmapDataRSV;
public heatmapDataCDC = heatmapDataCDC;
public heatmapDataMeasles = heatmapDataMeasles;
public heatmapDataInfluenza = heatmapDataInfluenza;
public heatmapDataHepatitis = heatmapDataHepatitis
public heatmapDataCampylobacteriosis = heatmapDataCampylobacteriosis;
public heatmapData = (dataset: string): any[] => (this as any)[`heatmapData${dataset}`];
public appointments = appointments;
public ddlData = ['All Departments'];
public ddlValue = 'All Departments'
public compactCards = compactCards;
public listItems: any[] = listItems;
}
+502
View File
@@ -0,0 +1,502 @@
import { accessibilityIcon, calendarDateIcon, calendarIcon, displayBlockIcon, dollarIcon, fileIcon, inboxIcon, myspaceIcon, pencilIcon, starOutlineIcon } from "@progress/kendo-svg-icons";
export const menuItems = [
"Settings",
"Support",
"Log out"
];
export const averageStay = [4, 3, 2, 14, 5, 7, 5, 6, 12, 1, 4];
export const hours = Array(48).fill({}).map((_, idx) => `${Math.floor(idx / 2)}:${idx % 2 ? '30': '00'}`);
export const hospitalVisits = [14, 20, 20, 26, 30, 26, 29, 32, 31, 29, 31, 35, 36, 40, 42, 45, 61, 63, 65, 66, 67, 67, 63, 64, 63, 62, 60, 45, 52, 55, 48, 44, 38, 35, 31, 35, 36, 40, 42, 55, 50, 41, 41, 39, 31, 32, 23, 27];
export const departments = [
'Pharmacology & Toxicology',
'Gastroenterology',
'Radiology',
'Orthopedics',
'Outpatient',
'Oncology',
'Neurology',
'ICU',
'Cardiology',
'Emergency',
'Delivery'
];
export const mortalityCauses = [
'Pharmacology & Toxicology',
'Oncological diseases',
'Circulatory diseases',
'Injury and poisoning',
'Respiratory diseases',
'Endocrine diseases',
'Digestive diseases',
'Nervous system diseases',
'Infectious diseases',
'Kidney diseases',
'Other causes'
];
export const satisfaction = [
{
kind: 'Very dissatisfied',
share: 60
},
{
kind: 'Dissatisfied',
share: 60
},
{
kind: 'Neutral',
share: 60
},
{
kind: 'Satisfied',
share: 60
},
{
kind: 'Very satisfied',
share: 60
},
{
kind: 'Didn\'t answer',
share: 60
}];
export const donutData = [
{
kind: 'Imaging Equipment',
share: 0.17,
},
{
kind: 'Surgical Instruments',
share: 0.17,
},
{
kind: 'Electromedical Equipment',
share: 0.17,
},
{
kind: 'Transport and Storage',
share: 0.17,
},
{
kind: 'Endoscopic Instruments',
share: 0.17,
},
{
kind: 'Others',
share: 0.17,
}];
export const heatmapDataRSV = [{
a: 'June 2023',
b: 'RSV',
value: 66
}, {
a: 'May 2023',
b: 'RSV',
value: 34
}, {
a: 'Apr 2023',
b: 'RSV',
value: 13
}, {
a: 'Mar 2023',
b: 'RSV',
value: 49
}, {
a: 'Feb 2023',
b: 'RSV',
value: 22
}, {
a: 'Jan 2023',
b: 'RSV',
value: 66
}, {
a: 'Dec 2022',
b: 'RSV',
value: 78
}, {
a: 'Nov 2022',
b: 'RSV',
value: 89
}, {
a: 'Oct 2022',
b: 'RSV',
value: 27
}, {
a: 'Sep 2022',
b: 'RSV',
value: 83
}];
export const heatmapDataCDC = [{
a: 'June 2023',
b: 'CDC',
value: 51
}, {
a: 'May 2023',
b: 'CDC',
value: 84
}, {
a: 'Apr 2023',
b: 'CDC',
value: 32
}, {
a: 'Mar 2023',
b: 'CDC',
value: 16
}, {
a: 'Feb 2023',
b: 'CDC',
value: 11
}, {
a: 'Jan 2023',
b: 'CDC',
value: 55
}, {
a: 'Dec 2022',
b: 'CDC',
value: 99
}, {
a: 'Nov 2022',
b: 'CDC',
value: 42
}, {
a: 'Oct 2022',
b: 'CDC',
value: 30
}, {
a: 'Sep 2022',
b: 'CDC',
value: 10
}];
export const heatmapDataMeasles = [{
a: 'June 2023',
b: 'Measles',
value: 80
}, {
a: 'May 2023',
b: 'Measles',
value: 56
}, {
a: 'Apr 2023',
b: 'Measles',
value: 78
}, {
a: 'Mar 2023',
b: 'Measles',
value: 63
}, {
a: 'Feb 2023',
b: 'Measles',
value: 24
}, {
a: 'Jan 2023',
b: 'Measles',
value: 33
}, {
a: 'Dec 2022',
b: 'Measles',
value: 38
}, {
a: 'Nov 2022',
b: 'Measles',
value: 17
}, {
a: 'Oct 2022',
b: 'Measles',
value: 62
}, {
a: 'Sep 2022',
b: 'Measles',
value: 82
}];
export const heatmapDataInfluenza = [{
a: 'June 2023',
b: 'Influenza',
value: 84
}, {
a: 'May 2023',
b: 'Influenza',
value: 25
}, {
a: 'Apr 2023',
b: 'Influenza',
value: 59
}, {
a: 'Mar 2023',
b: 'Influenza',
value: 74
}, {
a: 'Feb 2023',
b: 'Influenza',
value: 41
}, {
a: 'Jan 2023',
b: 'Influenza',
value: 69
}, {
a: 'Dec 2022',
b: 'Influenza',
value: 71
}, {
a: 'Nov 2022',
b: 'Influenza',
value: 11
}, {
a: 'Oct 2022',
b: 'Influenza',
value: 23
}, {
a: 'Sep 2022',
b: 'Influenza',
value: 43
}];
export const heatmapDataHepatitis = [{
a: 'June 2023',
b: 'Hepatitis',
value: 31
}, {
a: 'May 2023',
b: 'Hepatitis',
value: 27
}, {
a: 'Apr 2023',
b: 'Hepatitis',
value: 16
}, {
a: 'Mar 2023',
b: 'Hepatitis',
value: 74
}, {
a: 'Feb 2023',
b: 'Hepatitis',
value: 50
}, {
a: 'Jan 2023',
b: 'Hepatitis',
value: 6
}, {
a: 'Dec 2022',
b: 'Hepatitis',
value: 22
}, {
a: 'Nov 2022',
b: 'Hepatitis',
value: 65
}, {
a: 'Oct 2022',
b: 'Hepatitis',
value: 37
}, {
a: 'Sep 2022',
b: 'Hepatitis',
value: 13
}];
export const heatmapDataCampylobacteriosis = [{
a: 'June 2023',
b: 'Campylobacteriosis',
value: 66
}, {
a: 'May 2023',
b: 'Campylobacteriosis',
value: 21
}, {
a: 'Apr 2023',
b: 'Campylobacteriosis',
value: 52
}, {
a: 'Mar 2023',
b: 'Campylobacteriosis',
value: 43
}, {
a: 'Feb 2023',
b: 'Campylobacteriosis',
value: 97
}, {
a: 'Jan 2023',
b: 'Campylobacteriosis',
value: 81
}, {
a: 'Dec 2022',
b: 'Campylobacteriosis',
value: 28
}, {
a: 'Nov 2022',
b: 'Campylobacteriosis',
value: 34
}, {
a: 'Oct 2022',
b: 'Campylobacteriosis',
value: 45
}, {
a: 'Sep 2022',
b: 'Campylobacteriosis',
value: 18
}];
export const appointments = [{
doctor: 'Dr. Terrell Fashey',
start: '8:30 AM',
patient: {
name: 'Flora Strosin',
phone: '679-747-6105',
email: 'flora.strosin@email.com'
}
}, {
doctor: 'Dr. Clarence Gulgowski',
start: '9:10 AM',
patient: {
name: 'Michele Nicolas',
phone: '884-528-7089',
email: 'm.nicolas@email.com'
}
}, {
doctor: 'Dr. Jay Mohr',
start: '9:45 AM',
patient: {
name: 'Joseph Pacocha',
phone: '777-284-2912',
email: 'j.pacocha@email.com'
}
}];
export const compactCards = [{
svgIcon: calendarIcon,
title: 'Appointments',
info: '78 appointments today'
}, {
svgIcon: accessibilityIcon,
title: 'Patients',
info: '1234 active cases'
}, {
svgIcon: displayBlockIcon,
title: 'Beds',
info: '56 occupied beds'
}, {
svgIcon: myspaceIcon,
title: 'Staff',
info: '78 colleagues at work'
}];
export const listItems = [{
name: 'Dr. Teresa Conn',
specialty: 'Internal medicine',
imageSrc: 'assets/healthcare-dashboard/avatar_1.png'
}, {
name: 'Dr. Mitchell Robel',
specialty: 'Pediatrics',
imageSrc: 'assets/healthcare-dashboard/avatar_2.png'
}, {
name: 'Dr. Barry Jacobs',
specialty: 'Gastroenterology',
imageSrc: 'assets/healthcare-dashboard/avatar_3.png'
}, {
name: 'Dr. Nina Bosco',
specialty: 'Cardiology',
imageSrc: 'assets/healthcare-dashboard/avatar_4.png'
}];
export const drawerItems = [{
text: 'Dashboard',
svgIcon: inboxIcon,
selected: true,
id: 0,
}, {
text: 'Schedule',
svgIcon: calendarDateIcon,
id: 1
}, {
text: 'Patients',
svgIcon: accessibilityIcon,
id: 2,
}, {
text: 'Bed Management',
svgIcon: displayBlockIcon,
id: 3
}, {
text: 'Staff',
svgIcon: myspaceIcon,
id: 4,
}, {
text: 'Doctors',
svgIcon: accessibilityIcon,
id: 40,
parentId: 4
}, {
text: 'Nurses',
svgIcon: accessibilityIcon,
id: 41,
parentId: 4
}, {
text: 'Therapists',
svgIcon: accessibilityIcon,
id: 42,
parentId: 4
}, {
text: 'Technicians',
svgIcon: accessibilityIcon,
id: 43,
parentId: 4
}, {
text: 'Information technology',
svgIcon: accessibilityIcon,
id: 44,
parentId: 4
}, {
text: 'Food services',
svgIcon: accessibilityIcon,
id: 45,
parentId: 4
}, {
text: 'Environmental services',
svgIcon: accessibilityIcon,
id: 46,
parentId: 4
}, {
text: 'Pharmacy',
svgIcon: pencilIcon,
id: 5,
}, {
text: 'Reports',
svgIcon: fileIcon,
id: 6,
}, {
text: 'Report 1',
svgIcon: fileIcon,
id: 60,
parentId: 6
}, {
text: 'Departments',
svgIcon: calendarIcon,
id: 7,
}, {
text: 'Report 1',
svgIcon: calendarIcon,
id: 70,
parentId: 7
}, {
text: 'Payments',
svgIcon: dollarIcon,
id: 8,
}, {
text: 'Payments 1',
svgIcon: dollarIcon,
id: 80,
parentId: 8
}, {
separator: true
}, {
text: 'Support',
svgIcon: starOutlineIcon,
id: 9,
}];
@@ -0,0 +1,238 @@
<div class="login-page-container">
<!-- Background Elements -->
<div class="background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Content -->
<div class="login-content">
<!-- Left Side - Branding -->
<div class="branding-section">
<div class="branding-content">
<div class="logo-container">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image">
<div class="logo-text">
<h1>ROLCC AC</h1>
<span class="tagline">Church Management Portal</span>
</div>
</div>
<div class="welcome-text">
<h2>Welcome Back</h2>
<p>Access your church transactions, manage client communications, and track document workflows
securely.</p>
</div>
<div class="features-list">
<div class="feature-item">
<div class="feature-icon">🔒</div>
<span>Secure Church Management</span>
</div>
<div class="feature-item">
<div class="feature-icon">💬</div>
<span>Client Communication</span>
</div>
<div class="feature-item">
<div class="feature-icon">📄</div>
<span>Document Management</span>
</div>
<div class="feature-item">
<div class="feature-icon">📋</div>
<span>Task Tracking</span>
</div>
</div>
</div>
</div>
<!-- Right Side - Login Form -->
<div class="login-section">
<div class="login-card">
<!-- Initial State -->
<div *ngIf="!showLoginForm" class="initial-state">
<div class="login-header">
<h3>Access Your Account</h3>
<p>Sign in to manage your church transactions and client communications</p>
</div>
<div class="login-actions">
<button kendoButton themeColor="primary" size="large" (click)="showLoginFormView()"
class="signin-button">
<span class="button-content">
<svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
Sign In
</span>
</button>
</div>
</div>
<!-- Login Form State -->
<div *ngIf="showLoginForm" class="login-form-state">
<div class="login-header">
<button class="back-button" (click)="goBackToInitialState()" title="Go back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15,18 9,12 15,6"></polyline>
</svg>
</button>
<h3>Sign In</h3>
<p>Enter your credentials to access your account</p>
</div>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()" class="login-form">
<!-- Error Message -->
<div *ngIf="showError" class="error-message">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
{{ errorMessage }}
</div>
<!-- Email Field -->
<div class="form-field">
<kendo-label for="email">Email Address</kendo-label>
<kendo-textbox id="email" formControlName="email" placeholder="Enter your email address"
[clearButton]="false">
</kendo-textbox>
<div *ngIf="emailControl?.invalid && emailControl?.touched" class="field-error">
<span *ngIf="emailControl?.errors?.['required']">Email is required</span>
<span *ngIf="emailControl?.errors?.['email']">Please enter a valid email address</span>
</div>
</div>
<!-- Password Field -->
<div class="form-field">
<kendo-label for="password">Password</kendo-label>
<kendo-textbox id="password" formControlName="password" placeholder="Enter your password"
type="password" [clearButton]="false">
</kendo-textbox>
<div *ngIf="passwordControl?.invalid && passwordControl?.touched" class="field-error">
<span *ngIf="passwordControl?.errors?.['required']">Password is required</span>
<span *ngIf="passwordControl?.errors?.['minlength']">Password must be at least 6
characters</span>
</div>
</div>
<!-- Remember Me -->
<div class="form-field checkbox-field">
<label class="checkbox-container">
<kendo-checkbox formControlName="rememberMe"></kendo-checkbox>
<span class="checkbox-label">Remember me</span>
</label>
</div>
<!-- Submit Button -->
<div class="form-actions">
<button kendoButton themeColor="primary" size="large" type="submit"
[disabled]="loginForm.invalid || isProcessing" class="submit-button">
<span class="button-content">
<kendo-loader *ngIf="isProcessing" size="small"></kendo-loader>
<svg *ngIf="!isProcessing" class="button-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
<polyline points="10,17 15,12 10,7"></polyline>
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
{{ isProcessing ? 'Signing In...' : 'Sign In' }}
</span>
</button>
</div>
</form>
</div>
<!-- Demo Credentials *ngIf="!showLoginForm"-->
<div class="demo-section" *ngIf="false">
<div class="demo-header">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 12l2 2 4-4"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
<span>Demo Access</span>
</div>
<div class="credential-tabs">
<button class="tab-button active" (click)="setActiveTab('user')"
[class.active]="activeTab === 'user'">
Client Access
</button>
<button class="tab-button" (click)="setActiveTab('admin')"
[class.active]="activeTab === 'admin'">
Admin Access
</button>
</div>
<div class="credential-content" *ngIf="activeTab === 'user'">
<div class="credential-item">
<span class="label">Client Email:</span>
<span class="value">client@example.com</span>
<button class="copy-btn" (click)="copyToClipboard('client@example.com')" title="Copy email">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Password:</span>
<span class="value">password123</span>
<button class="copy-btn" (click)="copyToClipboard('password123')" title="Copy password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
</div>
<div class="credential-content" *ngIf="activeTab === 'admin'">
<div class="credential-item">
<span class="label">Admin Email:</span>
<span class="value">admin@example.com</span>
<button class="copy-btn" (click)="copyToClipboard('admin@example.com')" title="Copy email">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Password:</span>
<span class="value">password123</span>
<button class="copy-btn" (click)="copyToClipboard('password123')" title="Copy password">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
<div class="credential-item">
<span class="label">Security Code:</span>
<span class="value">123456</span>
<button class="copy-btn" (click)="copyToClipboard('123456')" title="Copy security code">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MFA Dialog -->
<app-mfa-dialog #mfaDialog (mfaSuccess)="onMfaSuccess($event)" (mfaCancel)="onMfaCancel()">
</app-mfa-dialog>
</div>
@@ -0,0 +1,727 @@
.login-page-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
position: relative;
overflow: hidden;
padding: 1rem;
}
// Background Shapes
.background-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&.shape-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
&.shape-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
// Main Content
.login-content {
display: grid;
grid-template-columns: 1fr 1fr;
max-width: 1200px;
width: 100%;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
overflow: hidden;
position: relative;
z-index: 1;
}
// Branding Section
.branding-section {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
padding: 3rem;
display: flex;
align-items: center;
justify-content: center;
color: white;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="25" cy="25" r="1" fill="white" opacity="0.1"/><circle cx="75" cy="75" r="1" fill="white" opacity="0.1"/><circle cx="50" cy="10" r="0.5" fill="white" opacity="0.1"/><circle cx="10" cy="60" r="0.5" fill="white" opacity="0.1"/><circle cx="90" cy="40" r="0.5" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
opacity: 0.3;
}
}
.branding-content {
position: relative;
z-index: 1;
text-align: center;
}
.logo-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 2rem;
gap: 1rem;
.logo-image {
height: 60px;
width: auto;
filter: brightness(0) invert(1);
}
.logo-text {
h1 {
margin: 0;
font-size: 2.5rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.tagline {
font-size: 1rem;
opacity: 0.9;
font-weight: 300;
}
}
}
.welcome-text {
margin-bottom: 3rem;
h2 {
font-size: 2rem;
font-weight: 600;
margin: 0 0 1rem 0;
letter-spacing: -0.01em;
}
p {
font-size: 1.1rem;
opacity: 0.9;
line-height: 1.6;
margin: 0;
}
}
.features-list {
display: flex;
flex-direction: column;
gap: 1rem;
.feature-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateX(5px);
}
.feature-icon {
font-size: 1.5rem;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
}
span {
font-weight: 500;
font-size: 1rem;
}
}
}
// Login Section
.login-section {
padding: 3rem;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
h3 {
font-size: 2rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
p {
color: #666;
font-size: 1rem;
margin: 0;
}
}
.login-actions {
margin-bottom: 2rem;
}
.signin-button {
width: 100%;
height: 56px;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(30, 58, 138, 0.4);
}
&:active {
transform: translateY(0);
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 20px;
height: 20px;
}
}
// Demo Section
.demo-section {
background: #f8f9fa;
border-radius: 16px;
padding: 1.5rem;
border: 1px solid #e9ecef;
}
.demo-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: #495057;
font-weight: 600;
font-size: 0.9rem;
.demo-icon {
width: 16px;
height: 16px;
}
}
.credential-tabs {
display: flex;
background: white;
border-radius: 8px;
padding: 4px;
margin-bottom: 1rem;
border: 1px solid #e9ecef;
}
.tab-button {
flex: 1;
padding: 0.75rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
color: #6c757d;
cursor: pointer;
transition: all 0.2s ease;
&.active {
background: #1e40af;
color: white;
box-shadow: 0 2px 4px rgba(30, 64, 175, 0.2);
}
&:hover:not(.active) {
background: #f8f9fa;
color: #495057;
}
}
.credential-content {
.credential-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: white;
border-radius: 8px;
margin-bottom: 0.5rem;
border: 1px solid #e9ecef;
transition: all 0.2s ease;
&:hover {
border-color: #1e40af;
box-shadow: 0 2px 8px rgba(30, 64, 175, 0.1);
}
.label {
font-weight: 600;
color: #495057;
min-width: 60px;
font-size: 0.9rem;
}
.value {
flex: 1;
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
font-size: 0.9rem;
color: #1a1a1a;
background: #f8f9fa;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.copy-btn {
background: #1e40af;
border: none;
border-radius: 6px;
padding: 0.5rem;
color: white;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #1e3a8a;
transform: scale(1.05);
}
svg {
width: 14px;
height: 14px;
}
}
}
}
// Login Form Styles
.login-form-state {
.login-header {
position: relative;
text-align: center;
.back-button {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #666;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f8f9fa;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
h3 {
margin: 0 0 0.5rem 0;
}
p {
margin: 0 0 1.5rem 0;
font-size: 0.95rem;
}
}
}
.login-form {
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
background: #fee2e2;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid #fecaca;
margin-bottom: 1.5rem;
font-size: 0.9rem;
font-weight: 500;
.error-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
.form-field {
margin-bottom: 1.5rem;
kendo-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #374151;
font-size: 0.9rem;
}
kendo-textbox {
width: 100%;
.k-textbox {
height: 48px;
border-radius: 8px;
border: 2px solid #e5e7eb;
font-size: 1rem;
transition: all 0.2s ease;
&:focus {
border-color: #1e40af;
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
}
&.k-invalid {
border-color: #dc2626;
}
}
}
.field-error {
margin-top: 0.25rem;
font-size: 0.8rem;
color: #dc2626;
font-weight: 500;
}
&.checkbox-field {
margin-bottom: 1rem;
.checkbox-container {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
kendo-checkbox {
margin: 0;
}
.checkbox-label {
font-size: 0.9rem;
color: #6b7280;
font-weight: 500;
margin: 0;
}
&:hover .checkbox-label {
color: #1e40af;
}
// Style when checkbox is checked
&:has(kendo-checkbox:checked) .checkbox-label {
color: #1e40af;
}
}
}
}
.form-actions {
margin-top: 2rem;
.submit-button {
width: 100%;
height: 48px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
box-shadow: 0 4px 12px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(30, 58, 138, 0.4);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 18px;
height: 18px;
}
}
}
}
// Mobile Responsive
@media (max-width: 768px) {
.login-page-container {
padding: 0.5rem;
}
.login-content {
grid-template-columns: 1fr;
border-radius: 16px;
}
.branding-section {
padding: 2rem 1.5rem;
order: 2;
}
.login-section {
padding: 2rem 1.5rem;
order: 1;
}
.logo-container {
flex-direction: column;
gap: 0.5rem;
.logo-text h1 {
font-size: 2rem;
}
}
.welcome-text {
margin-bottom: 2rem;
h2 {
font-size: 1.5rem;
}
p {
font-size: 1rem;
}
}
.features-list {
.feature-item {
padding: 0.5rem;
.feature-icon {
width: 35px;
height: 35px;
font-size: 1.25rem;
}
span {
font-size: 0.9rem;
}
}
}
.login-header h3 {
font-size: 1.75rem;
}
.signin-button {
height: 50px;
font-size: 1rem;
}
.demo-section {
padding: 1rem;
}
.credential-tabs {
.tab-button {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
}
.credential-content {
.credential-item {
padding: 0.5rem;
.label {
min-width: 50px;
font-size: 0.85rem;
}
.value {
font-size: 0.85rem;
}
}
}
// Login form mobile styles
.login-form-state {
.login-header {
.back-button {
padding: 0.4rem;
svg {
width: 18px;
height: 18px;
}
}
}
}
.login-form {
.form-field {
margin-bottom: 1.25rem;
kendo-textbox .k-textbox {
height: 44px;
font-size: 0.95rem;
}
}
.form-actions .submit-button {
height: 44px;
font-size: 0.95rem;
}
}
}
@media (max-width: 480px) {
.login-page-container {
padding: 0.25rem;
}
.branding-section,
.login-section {
padding: 1.5rem 1rem;
}
.logo-container .logo-text h1 {
font-size: 1.75rem;
}
.welcome-text h2 {
font-size: 1.25rem;
}
.features-list {
.feature-item {
.feature-icon {
width: 30px;
height: 30px;
font-size: 1rem;
}
span {
font-size: 0.85rem;
}
}
}
// Login form extra small mobile styles
.login-form {
.form-field {
margin-bottom: 1rem;
kendo-textbox .k-textbox {
height: 40px;
font-size: 0.9rem;
}
}
.form-actions .submit-button {
height: 40px;
font-size: 0.9rem;
}
}
}
@@ -0,0 +1,205 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DialogModule, DialogService } from '@progress/kendo-angular-dialog';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { MfaDialogComponent } from '../../shared/mfa-dialog/mfa-dialog.component';
import { AuthService, LoginCredentials, LoginResultType, TokenVerificationResult } from '../../shared/services/auth.service';
import { Router, ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-login-page',
standalone: true,
imports: [
CommonModule,
DialogModule,
ButtonsModule,
ReactiveFormsModule,
InputsModule,
LabelModule,
IndicatorsModule,
MfaDialogComponent
],
templateUrl: './login-page.component.html',
styleUrls: ['./login-page.component.scss']
})
export class LoginPage implements OnInit {
@ViewChild('mfaDialog') mfaDialog!: MfaDialogComponent;
activeTab: 'user' | 'admin' = 'user';
showLoginForm = false;
loginForm: FormGroup;
isProcessing = false;
showError = false;
errorMessage = '';
constructor(
private dialogService: DialogService,
private authService: AuthService,
private router: Router,
private route: ActivatedRoute,
private fb: FormBuilder
) {
this.loginForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(6)]],
rememberMe: [false]
});
}
ngOnInit(): void {
// Check if user is already logged in
if (this.authService.isAuthenticated()) {
this.redirectToDashboard();
return;
}
// Check for token in URL parameters
this.route.queryParams.subscribe(params => {
const token = params['token'];
if (token) {
this.verifySecretLinkToken(token);
}
});
}
setActiveTab(tab: 'user' | 'admin'): void {
this.activeTab = tab;
}
copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).then(() => {
// You could add a toast notification here
console.log('Copied to clipboard:', text);
}).catch(err => {
console.error('Failed to copy text: ', err);
});
}
showLoginFormView(): void {
this.showLoginForm = true;
// Focus on email input when form appears
setTimeout(() => {
const emailInput = document.querySelector('input[formControlName="email"]') as HTMLInputElement;
if (emailInput) {
emailInput.focus();
}
}, 100);
}
goBackToInitialState(): void {
this.showLoginForm = false;
this.loginForm.reset();
this.showError = false;
this.errorMessage = '';
}
onSubmit(): void {
if (this.loginForm.valid && !this.isProcessing) {
this.isProcessing = true;
this.showError = false;
const credentials: LoginCredentials = this.loginForm.value;
this.authService.login(credentials).subscribe({
next: (result) => {
this.isProcessing = false;
if (result.result === LoginResultType.Success) {
this.authService.setCurrentUser(result.responseData!);
this.redirectToDashboard();
} else if (result.result === LoginResultType.MfaRequired) {
this.showMfaDialog(credentials);
} else {
this.showError = true;
this.errorMessage = result.message || 'Invalid email or password';
}
},
error: (error) => {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'An error occurred during login. Please try again.';
console.error('Login error:', error);
}
});
}
}
get emailControl() {
return this.loginForm.get('email');
}
get passwordControl() {
return this.loginForm.get('password');
}
private showMfaDialog(credentials: LoginCredentials): void {
if (this.mfaDialog) {
// Set the login data for MFA dialog
(this.mfaDialog as any).loginData = credentials;
// Show MFA dialog
this.mfaDialog.show();
}
}
onMfaSuccess(userData: any): void {
this.authService.setCurrentUser(userData);
this.redirectToDashboard();
}
onMfaCancel(): void {
// Reset form and focus on email
this.loginForm.reset();
setTimeout(() => {
const emailInput = document.querySelector('input[formControlName="email"]') as HTMLInputElement;
if (emailInput) {
emailInput.focus();
}
}, 100);
}
private verifySecretLinkToken(token: string): void {
this.isProcessing = true;
this.showError = false;
// First check if token is expired locally
if (this.authService.isTokenExpired(token)) {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'This link has expired. Please request a new one.';
return;
}
this.authService.verifySecretLinkToken(token).subscribe({
next: (result: TokenVerificationResult) => {
this.isProcessing = false;
if (result.isValid && result.user) {
// Token is valid, set user and redirect
this.authService.setCurrentUser(result.user);
this.redirectToDashboard();
} else {
// Token verification failed
this.showError = true;
this.errorMessage = result.message || 'Invalid or expired link. Please request a new one.';
}
},
error: (error) => {
this.isProcessing = false;
this.showError = true;
this.errorMessage = 'An error occurred while verifying the link. Please try again.';
console.error('Token verification error:', error);
}
});
}
private redirectToDashboard(): void {
const redirectUrl = this.authService.getRedirectUrl();
this.router.navigate([redirectUrl || '/dashboard']);
}
}
@@ -0,0 +1,5 @@
<!-- Start of FTR-7 -->
<footer class="!k-bg-primary k-color-white k-bg-light k-py-6 k-px-2 k-px-sm-4.5 k-px-md-6 k-px-lg-4 k-px-xl-7.5">
<p class="!k-mb-0">Copyright © {{ currentYear }} RBJ Software, Inc. All rights reserved.</p>
</footer>
<!-- End of FTR-7 -->
@@ -0,0 +1,3 @@
footer {
margin-top: auto;
}
@@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-footer',
standalone: true,
imports: [CommonModule],
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent {
public currentYear = new Date().getFullYear();
}
@@ -0,0 +1,42 @@
<!-- Start of TPNAV-1 -->
<header>
<kendo-appbar positionMode='sticky' themeColor="inherit" class='k-bg-surface-alt' [style.z-index]="10000">
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-gap-2">
<button kendoButton [svgIcon]="menuIcon" fillMode="clear" title="Menu" (click)="onMenuClick()"></button>
<a href="#" class="k-d-none k-d-sm-flex logo-link">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ ROLCC AC logo" />
<span class="logo-text">ROLCC AC Portal</span>
</a>
<a href="#" class="k-d-flex k-d-sm-none">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ ROLCC AC compact logo" />
</a>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-center">
<div class="k-d-flex k-d-md-none">
<button kendoButton [svgIcon]="searchIcon" fillMode="clear" title="Search"></button>
</div>
<div class="k-d-none k-d-md-flex search-box-wrapper">
<kendo-textbox class="search-box" placeholder="Input value" fillMode="flat">
<ng-template kendoTextBoxPrefixTemplate>
<kendo-svgicon [icon]="searchIcon"></kendo-svgicon>
<kendo-textbox-separator></kendo-textbox-separator>
</ng-template>
</kendo-textbox>
</div>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-end k-gap-1.5">
<kendo-badge-container>
<button kendoButton [svgIcon]="bellIcon" fillMode="clear" title="Notifications"></button>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlign" themeColor="error"></kendo-badge>
</kendo-badge-container>
<span class="k-appbar-separator k-color-border k-d-none k-d-sm-inline"></span>
<kendo-dropdownbutton [data]="userMenuItems" fillMode="clear" [svgIcon]="userIcon" [arrowIcon]="true"
(itemClick)="onUserMenuClick($event)">
<span class="k-d-none k-d-sm-inline">
{{ isAuthenticated ? (getDisplayName() || currentUser?.email || 'User') : 'Sign In' }}
</span>
</kendo-dropdownbutton>
</kendo-appbar-section>
</kendo-appbar>
</header>
<!-- End of TPNAV-1 -->
@@ -0,0 +1,48 @@
/* Logo styling */
.logo-link {
//display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.85;
}
}
.logo-text {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #0066cc 0%, #0052a3 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
white-space: nowrap;
// Fallback for browsers that don't support background-clip
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
color: #0066cc;
background: none;
-webkit-text-fill-color: initial;
}
}
/* Search box responsive styling */
.search-box-wrapper {
width: 100%;
max-width: 360px;
}
.search-box {
width: 100%;
}
/* Mobile optimizations */
@media (max-width: 767px) {
/* Optimize spacing on mobile */
kendo-appbar {
padding: 0 8px;
}
}
@@ -0,0 +1,124 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AppBarModule } from '@progress/kendo-angular-navigation';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, bellIcon, menuIcon, searchIcon, userIcon, logoutIcon } from '@progress/kendo-svg-icons';
import { LayoutService } from '../services/layout.service';
import { AuthService, UserInfo } from '../../shared/services/auth.service';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-header',
standalone: true,
imports: [
CommonModule,
AppBarModule,
ButtonsModule,
IndicatorsModule,
InputsModule,
IconsModule,
DropDownsModule
],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss']
})
export class HeaderComponent implements OnInit, OnDestroy {
public menuIcon: SVGIcon = menuIcon;
public searchIcon: SVGIcon = searchIcon;
public bellIcon: SVGIcon = bellIcon;
public userIcon: SVGIcon = userIcon;
public logoutIcon: SVGIcon = logoutIcon;
public userMenuItems: any[] = [];
public currentUser: UserInfo | null = null;
public isAuthenticated = false;
public badgeAlign = {
vertical: 'top' as const,
horizontal: 'end' as const
};
private destroy$ = new Subject<void>();
constructor(
public layoutService: LayoutService,
private authService: AuthService,
private router: Router
) { }
ngOnInit(): void {
// Subscribe to authentication state changes
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
this.isAuthenticated = !!user;
this.updateUserMenu();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public onMenuClick(): void {
this.layoutService.toggleDrawer();
}
public onLogout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
public onUserMenuClick(item: any): void {
if (item.click) {
item.click();
}
}
public getDisplayName(): string {
return this.currentUser?.email || '';
}
private updateUserMenu(): void {
if (this.isAuthenticated && this.currentUser) {
this.userMenuItems = [
{
text: `Welcome, ${this.getDisplayName() || this.currentUser.email}`,
disabled: true
},
{ separator: true },
{
text: 'Profile',
icon: 'user',
disabled: true
},
{
text: 'Settings',
icon: 'settings',
disabled: true
},
{ separator: true },
{
text: 'Logout',
icon: 'logout',
click: () => this.onLogout()
}
];
} else {
this.userMenuItems = [
{
text: 'Sign In',
click: () => this.router.navigate(['/login'])
}
];
}
}
}
@@ -0,0 +1,23 @@
<kendo-drawer-container>
<kendo-drawer class="!k-flex-none k-overflow-y-auto !k-pos-sticky" [items]="drawerItems"
[mode]="layoutService.drawerMode()" [mini]="true" [expanded]="layoutService.drawerExpanded()"
(select)="onSelect($event)" [autoCollapse]="layoutService.drawerAutoCollapse()"
[isItemExpanded]="isItemExpanded" [width]="248" [style.height]="'calc(100vh - 46px)'">
<ng-template kendoDrawerItemTemplate let-item let-hasChildren="hasChildren" let-isItemExpanded="isItemExpanded">
@if (item.svgIcon) {
<kendo-svgicon [icon]="item.svgIcon"></kendo-svgicon>
}
<span class="k-item-text">{{item.text}}</span>
@if (hasChildren) {
<span class="k-spacer"></span>
<span class="k-drawer-toggle">
<kendo-svgicon [icon]="isItemExpanded ? chevronUpIcon : chevronDownIcon"></kendo-svgicon>
</span>
}
</ng-template>
</kendo-drawer>
<kendo-drawer-content>
<router-outlet></router-outlet>
<app-footer></app-footer>
</kendo-drawer-content>
</kendo-drawer-container>
@@ -0,0 +1,20 @@
/* Drawer animation */
kendo-drawer {
transition: transform 0.3s ease-in-out;
}
/* Mobile optimizations */
@media (max-width: 767px) {
/* Ensure drawer overlay has proper z-index */
kendo-drawer.k-drawer-overlay {
z-index: 9999;
}
}
/* Tablet optimizations */
@media (min-width: 768px) and (max-width: 1023px) {
/* Adjust drawer width for tablets if needed */
kendo-drawer {
max-width: 280px;
}
}
@@ -0,0 +1,77 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterOutlet } from '@angular/router';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, chevronDownIcon, chevronUpIcon } from '@progress/kendo-svg-icons';
import { DrawerItemExpandedFn, DrawerSelectEvent } from '@progress/kendo-angular-layout';
import { LayoutService } from '../services/layout.service';
import { drawerItems } from '../../features/dashboard/models';
import { FooterComponent } from '../footer/footer.component';
@Component({
selector: 'app-navbar',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
LayoutModule,
IconsModule,
FooterComponent
],
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss']
})
export class NavbarComponent {
public chevronUpIcon: SVGIcon = chevronUpIcon;
public chevronDownIcon: SVGIcon = chevronDownIcon;
public drawerItems = drawerItems;
public selectedDrawerItem = 'Dashboard';
public expandedItems: Array<number> = [4];
constructor(
private router: Router,
public layoutService: LayoutService
) { }
public onSelect(ev: DrawerSelectEvent): void {
this.selectedDrawerItem = ev.item.text;
const current = ev.item.id;
if (this.expandedItems.indexOf(current) >= 0) {
this.expandedItems = this.expandedItems.filter((id) => id !== current);
} else {
this.expandedItems.push(current);
}
// Auto-collapse drawer on mobile after selection
if (this.layoutService.isMobile()) {
this.layoutService.closeDrawer();
}
// Navigate based on the selected item
const routeMap: { [key: string]: string } = {
'Dashboard': '/dashboard',
'Schedule': '/schedule',
'Patients': '/patients',
'Bed Management': '/bed-management',
'Staff': '/staff',
'Pharmacy': '/pharmacy',
'Reports': '/reports',
'Departments': '/departments',
'Payments': '/payments',
'Support': '/support'
};
const route = routeMap[ev.item.text];
if (route) {
this.router.navigate([route]);
}
}
public isItemExpanded: DrawerItemExpandedFn = (item): boolean => {
return this.expandedItems.indexOf(item.id) >= 0;
};
}
@@ -0,0 +1,84 @@
import { Injectable, signal, computed } from '@angular/core';
import { DrawerMode } from '@progress/kendo-angular-layout';
@Injectable({
providedIn: 'root'
})
export class LayoutService {
// Signals for reactive state management
private readonly windowWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 1024);
// Computed signals for responsive breakpoints
public readonly isMobile = computed(() => this.windowWidth() < 768);
public readonly isTablet = computed(() => this.windowWidth() >= 768 && this.windowWidth() < 1024);
public readonly isDesktop = computed(() => this.windowWidth() >= 1024);
// Drawer state
public readonly drawerExpanded = signal<boolean>(true);
public readonly drawerMode = computed<DrawerMode>(() =>
this.isMobile() ? 'overlay' : 'push'
);
public readonly drawerAutoCollapse = computed<boolean>(() => this.isMobile());
constructor() {
this.initializeResizeListener();
this.updateDrawerState();
}
/**
* Initialize window resize listener
*/
private initializeResizeListener(): void {
if (typeof window !== 'undefined') {
window.addEventListener('resize', () => this.handleResize());
}
}
/**
* Handle window resize events
*/
private handleResize(): void {
this.windowWidth.set(window.innerWidth);
this.updateDrawerState();
}
/**
* Update drawer state based on screen size
*/
private updateDrawerState(): void {
if (this.isMobile()) {
this.drawerExpanded.set(false);
} else {
this.drawerExpanded.set(true);
}
}
/**
* Toggle drawer open/closed state
*/
public toggleDrawer(): void {
this.drawerExpanded.update(expanded => !expanded);
}
/**
* Close drawer (useful for mobile after navigation)
*/
public closeDrawer(): void {
this.drawerExpanded.set(false);
}
/**
* Open drawer
*/
public openDrawer(): void {
this.drawerExpanded.set(true);
}
/**
* Get current window width
*/
public getWindowWidth(): number {
return this.windowWidth();
}
}
@@ -0,0 +1,40 @@
<header>
<kendo-appbar positionMode='sticky' themeColor="inherit" class='k-bg-surface-alt' [style.z-index]="10000">
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-gap-2">
<button kendoButton [svgIcon]="menuIcon" fillMode="clear" title="Menu" (click)="onMenuClick()"></button>
<a href="#" class="k-d-none k-d-sm-flex logo-link">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ ROLCC AC logo" />
<span class="logo-text">ROLCC AC Portal</span>
</a>
<a href="#" class="k-d-flex k-d-sm-none">
<img src="assets/rbj-logo.svg" class="k-h-8" alt="RBJ ROLCC AC compact logo" />
</a>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-center">
<div class="k-d-flex k-d-md-none">
<button kendoButton [svgIcon]="searchIcon" fillMode="clear" title="Search"></button>
</div>
<div class="k-d-none k-d-md-flex search-box-wrapper">
<kendo-textbox class="search-box" placeholder="Search..." fillMode="flat">
<ng-template kendoTextBoxPrefixTemplate>
<kendo-svgicon [icon]="searchIcon"></kendo-svgicon>
<kendo-textbox-separator></kendo-textbox-separator>
</ng-template>
</kendo-textbox>
</div>
</kendo-appbar-section>
<kendo-appbar-section class="k-flex-basis-0 k-flex-grow k-justify-content-end k-gap-1.5">
<kendo-badge-container>
<button kendoButton [svgIcon]="bellIcon" fillMode="clear" title="Notifications"></button>
<kendo-badge rounded="medium" position="inside" [align]="badgeAlign" themeColor="error"></kendo-badge>
</kendo-badge-container>
<span class="k-appbar-separator k-color-border k-d-none k-d-sm-inline"></span>
<kendo-dropdownbutton [data]="userMenuItems" fillMode="clear" [svgIcon]="userIcon" [arrowIcon]="true"
(itemClick)="onUserMenuClick($event)">
<span class="k-d-none k-d-sm-inline">
{{ getDisplayName() || currentUser?.email || 'User' }}
</span>
</kendo-dropdownbutton>
</kendo-appbar-section>
</kendo-appbar>
</header>
@@ -0,0 +1,21 @@
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
.logo-text {
margin-left: 0.5rem;
font-weight: 600;
font-size: 1.1rem;
}
}
.search-box-wrapper {
width: 100%;
max-width: 400px;
.search-box {
width: 100%;
}
}
@@ -0,0 +1,114 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { AppBarModule } from '@progress/kendo-angular-navigation';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, bellIcon, menuIcon, searchIcon, userIcon, logoutIcon } from '@progress/kendo-svg-icons';
import { AuthService, UserInfo } from '../../../../shared/services/auth.service';
import { LayoutService } from '../../../../layout/services/layout.service';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-user-header',
standalone: true,
imports: [
CommonModule,
AppBarModule,
ButtonsModule,
IndicatorsModule,
InputsModule,
IconsModule,
DropDownsModule
],
templateUrl: './user-header.component.html',
styleUrls: ['./user-header.component.scss']
})
export class UserHeaderComponent implements OnInit, OnDestroy {
public menuIcon: SVGIcon = menuIcon;
public searchIcon: SVGIcon = searchIcon;
public bellIcon: SVGIcon = bellIcon;
public userIcon: SVGIcon = userIcon;
public logoutIcon: SVGIcon = logoutIcon;
public userMenuItems: any[] = [];
public currentUser: UserInfo | null = null;
public badgeAlign = {
vertical: 'top' as const,
horizontal: 'end' as const
};
private destroy$ = new Subject<void>();
constructor(
private authService: AuthService,
private layoutService: LayoutService,
private router: Router
) { }
ngOnInit(): void {
// Subscribe to authentication state changes
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
this.updateUserMenu();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public onMenuClick(): void {
this.layoutService.toggleDrawer();
}
public onLogout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
public onUserMenuClick(item: any): void {
if (item.click) {
item.click();
}
}
public getDisplayName(): string {
return this.currentUser?.email || '';
}
private updateUserMenu(): void {
if (this.currentUser) {
this.userMenuItems = [
{
text: `Welcome, ${this.getDisplayName() || this.currentUser.email}`,
disabled: true
},
{ separator: true },
{
text: 'Profile',
icon: 'user',
disabled: true
},
{
text: 'Settings',
icon: 'settings',
disabled: true
},
{ separator: true },
{
text: 'Logout',
icon: 'logout',
click: () => this.onLogout()
}
];
}
}
}
@@ -0,0 +1,42 @@
<kendo-drawer-container>
<kendo-drawer [mode]="'overlay'" [expanded]="layoutService.drawerExpanded()" [width]="280">
<kendo-drawer-content>
<div class="drawer-content">
<div class="drawer-header">
<h3>User Portal</h3>
<p>ROLCC AC Portal</p>
</div>
<nav class="drawer-nav">
<div class="nav-section">
<h4>Main</h4>
<button *ngFor="let item of mainNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
<div class="nav-section">
<h4>Management</h4>
<button *ngFor="let item of managementNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
<div class="nav-section">
<h4>Support</h4>
<button *ngFor="let item of supportNavItems" kendoButton
[fillMode]="item.active ? 'solid' : 'flat'" [themeColor]="item.active ? 'primary' : 'base'"
[svgIcon]="item.icon" class="nav-button" (click)="navigateTo(item.path)">
{{ item.text }}
</button>
</div>
</nav>
</div>
</kendo-drawer-content>
</kendo-drawer>
</kendo-drawer-container>
@@ -0,0 +1,66 @@
.drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
.drawer-header {
padding: 1.5rem 1rem;
border-bottom: 1px solid #e0e0e0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
h3 {
margin: 0 0 0.25rem 0;
font-size: 1.25rem;
font-weight: 600;
}
p {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
}
}
.drawer-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
}
.nav-section {
margin-bottom: 1.5rem;
h4 {
margin: 0 0 0.5rem 0;
padding: 0 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #666;
}
}
.nav-button {
width: 100%;
justify-content: flex-start;
text-align: left;
padding: 0.75rem 1rem;
margin: 0.125rem 0;
border-radius: 0;
&:hover {
background-color: #f8f9fa;
}
&.k-button-solid {
background-color: #e3f2fd;
color: #1976d2;
&:hover {
background-color: #bbdefb;
}
}
}
@@ -0,0 +1,106 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd } from '@angular/router';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IconsModule } from '@progress/kendo-angular-icons';
import { SVGIcon, homeIcon, calendarIcon, userIcon } from '@progress/kendo-svg-icons';
import { LayoutService } from '../../../../layout/services/layout.service';
import { Subject, takeUntil, filter } from 'rxjs';
interface NavItem {
text: string;
icon: SVGIcon;
path: string;
active?: boolean;
}
@Component({
selector: 'app-user-navbar',
standalone: true,
imports: [
CommonModule,
LayoutModule,
ButtonsModule,
IconsModule
],
templateUrl: './user-navbar.component.html',
styleUrls: ['./user-navbar.component.scss']
})
export class UserNavbarComponent implements OnInit, OnDestroy {
public homeIcon: SVGIcon = homeIcon;
public calendarIcon: SVGIcon = calendarIcon;
public peopleIcon: SVGIcon = userIcon; // Using userIcon as fallback
public bedIcon: SVGIcon = userIcon; // Using userIcon as fallback
public userIcon: SVGIcon = userIcon;
public pillIcon: SVGIcon = userIcon; // Using userIcon as fallback
public chartIcon: SVGIcon = userIcon; // Using userIcon as fallback
public buildingIcon: SVGIcon = userIcon; // Using userIcon as fallback
public creditCardIcon: SVGIcon = userIcon; // Using userIcon as fallback
public supportIcon: SVGIcon = userIcon; // Using userIcon as fallback
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: 'Bed Management', icon: this.bedIcon, path: '/user-portal/bed-management' },
{ 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' },
{ 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' }
];
private destroy$ = new Subject<void>();
constructor(
public layoutService: LayoutService,
private router: Router
) { }
ngOnInit(): void {
// Listen to route changes to update active states
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe((event: NavigationEnd) => {
this.updateActiveStates(event.url);
});
// Set initial active state
this.updateActiveStates(this.router.url);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
public navigateTo(path: string): void {
this.router.navigate([path]);
this.layoutService.closeDrawer();
}
private updateActiveStates(currentUrl: string): void {
// Reset all active states
[...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems]
.forEach(item => item.active = false);
// Set active state for current route
const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems]
.find(item => currentUrl.startsWith(item.path));
if (activeItem) {
activeItem.active = true;
}
}
}
@@ -0,0 +1,172 @@
<div class="dashboard-container">
<!-- Welcome Section -->
<div class="welcome-section">
<div class="welcome-content">
<h1>Welcome back, {{ getDisplayName() || 'User' }}!</h1>
<p>Here's a mock overview of the ROLCC AC church dashboard.</p>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ activeTransactions }}</div>
<div class="stat-label">Active Transactions</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon pending">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3 8-8"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ pendingTasks }}</div>
<div class="stat-label">Pending Tasks</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon completed">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22,4 12,14.01 9,11.01"></polyline>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">{{ completedTransactions }}</div>
<div class="stat-label">Completed</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon total">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
</svg>
</div>
<div class="stat-content">
<div class="stat-value">${{ totalValue | number:'1.0-0' }}</div>
<div class="stat-label">Total Value</div>
</div>
</div>
</div>
<!-- Recent Transactions -->
<div class="section">
<div class="section-header">
<h2>Recent Transactions</h2>
</div>
<div class="transactions-list">
<!-- Transactions List -->
<div *ngIf="recentTransactions.length > 0">
<div *ngFor="let transaction of recentTransactions" class="transaction-card">
<div class="transaction-header">
<div class="transaction-title">{{ transaction.title }}</div>
<div class="transaction-status" [class]="transaction.status">
{{ transaction.statusLabel }}
</div>
</div>
<div class="transaction-details">
<div class="transaction-amount">${{ transaction.amount | number:'1.0-0' }}</div>
<div class="transaction-date">{{ transaction.date | date:'MMM d, y' }}</div>
</div>
<div class="transaction-progress">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="transaction.progress"></div>
</div>
<span class="progress-text">{{ transaction.progress }}% Complete</span>
</div>
</div>
</div>
<!-- Empty State -->
<div *ngIf="recentTransactions.length === 0" class="empty-state">
<div class="empty-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<h3>No Recent Transactions</h3>
<p>You don't have any recent transactions yet.</p>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="section">
<div class="section-header">
<h2>Quick Actions</h2>
</div>
<div class="quick-actions-grid">
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14,2 14,8 20,8"></polyline>
</svg>
</div>
<div class="action-content">
<div class="action-title">New Transaction</div>
<div class="action-description">Start a new church process</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 11l3 3 8-8"></path>
<path d="M21 12c-1 0-3-1-3-3s2-3 3-3 3 1 3 3-2 3-3 3"></path>
<path d="M3 12c1 0 3-1 3-3s-2-3-3-3-3 1-3 3 2 3 3 3"></path>
<path d="M12 3c0 1-1 3-3 3s-3-2-3-3 1-3 3-3 3 2 3 3"></path>
<path d="M12 21c0-1 1-3 3-3s3 2 3 3-1 3-3 3-3-2-3-3"></path>
</svg>
</div>
<div class="action-content">
<div class="action-title">Manage Tasks</div>
<div class="action-description">View and update your tasks</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="action-content">
<div class="action-title">Contacts</div>
<div class="action-description">Manage your contacts</div>
</div>
</button>
<button class="quick-action-btn" type="button">
<div class="action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<div class="action-content">
<div class="action-title">Messages</div>
<div class="action-description">Check your messages</div>
</div>
</button>
</div>
</div>
</div>
@@ -0,0 +1,536 @@
.dashboard-container {
max-width: 1200px;
margin: 0 auto;
}
// Welcome Section
.welcome-section {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 16px;
padding: 2rem;
margin-bottom: 2rem;
color: white;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
top: 0;
right: 0;
width: 200px;
height: 200px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
transform: translate(50%, -50%);
}
.welcome-content {
position: relative;
z-index: 1;
h1 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.01em;
}
p {
margin: 0;
font-size: 1.1rem;
opacity: 0.9;
}
}
.welcome-actions {
position: relative;
z-index: 1;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
svg {
width: 18px;
height: 18px;
}
}
}
// Stats Grid
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
gap: 1rem;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.total {
background: rgba(168, 85, 247, 0.1);
color: #9333ea;
}
svg {
width: 24px;
height: 24px;
}
}
.stat-content {
flex: 1;
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.9rem;
color: #6b7280;
font-weight: 500;
}
}
}
// Section Styles
.section {
margin-bottom: 2rem;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
}
.view-all-btn {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: #1e40af;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
color: #1e3a8a;
}
svg {
width: 16px;
height: 16px;
}
}
}
}
// Transactions List
.transactions-list {
display: grid;
gap: 1rem;
}
// Loading State
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top: 3px solid #1e40af;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
.loading-text {
color: #6b7280;
font-size: 0.9rem;
margin: 0;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
.empty-icon {
width: 64px;
height: 64px;
background: rgba(30, 64, 175, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #1e40af;
margin-bottom: 1.5rem;
svg {
width: 32px;
height: 32px;
}
}
h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
p {
margin: 0 0 1.5rem 0;
color: #6b7280;
font-size: 0.9rem;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #1e40af;
border: none;
border-radius: 8px;
color: white;
text-decoration: none;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
background: #1e3a8a;
transform: translateY(-2px);
}
}
}
.transaction-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 10px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #1e40af;
}
.transaction-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.transaction-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
}
.transaction-status {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
&.active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
}
}
.transaction-details {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
.transaction-amount {
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
}
.transaction-date {
font-size: 0.9rem;
color: #6b7280;
}
}
.transaction-progress {
display: flex;
align-items: center;
gap: 1rem;
.progress-bar {
flex: 1;
height: 6px;
background: #e5e7eb;
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 0.8rem;
color: #6b7280;
font-weight: 500;
min-width: 80px;
}
}
}
// Quick Actions
.quick-actions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
.quick-action-btn {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 1rem;
text-align: left;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
border-color: #1e40af;
}
.action-icon {
width: 48px;
height: 48px;
background: rgba(30, 64, 175, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #1e40af;
flex-shrink: 0;
svg {
width: 24px;
height: 24px;
}
}
.action-content {
flex: 1;
.action-title {
font-size: 1.1rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
}
.action-description {
font-size: 0.9rem;
color: #6b7280;
}
}
}
// Mobile Responsive
@media (max-width: 768px) {
.welcome-section {
flex-direction: column;
text-align: center;
gap: 1.5rem;
.welcome-content h1 {
font-size: 1.5rem;
}
.welcome-content p {
font-size: 1rem;
}
}
.stats-grid {
grid-template-columns: 1fr;
}
.quick-actions-grid {
grid-template-columns: 1fr;
}
.transaction-card {
padding: 1rem;
.transaction-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.transaction-details {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
}
@media (max-width: 480px) {
.welcome-section {
padding: 1.5rem;
.welcome-content h1 {
font-size: 1.25rem;
}
}
.stat-card {
padding: 1rem;
.stat-icon {
width: 40px;
height: 40px;
svg {
width: 20px;
height: 20px;
}
}
.stat-content .stat-value {
font-size: 1.5rem;
}
}
.quick-action-btn {
padding: 1rem;
.action-icon {
width: 40px;
height: 40px;
svg {
width: 20px;
height: 20px;
}
}
}
}
@@ -0,0 +1,71 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService, UserInfo } from '../../../../shared/services/auth.service';
interface Transaction {
id: string;
title: string;
amount: number;
status: 'active' | 'pending' | 'completed';
statusLabel: string;
date: Date;
progress: number;
}
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
currentUser: UserInfo | null = null;
activeTransactions = 5;
pendingTasks = 12;
completedTransactions = 23;
totalValue = 1250000;
recentTransactions: Transaction[] = [
{
id: 'RBJ-1001',
title: 'Maple Street Purchase Escrow',
amount: 425000,
status: 'active',
statusLabel: 'Open',
date: new Date('2026-04-24'),
progress: 68
},
{
id: 'RBJ-1002',
title: 'Oak Ridge Refinance',
amount: 310000,
status: 'pending',
statusLabel: 'Review',
date: new Date('2026-04-20'),
progress: 42
},
{
id: 'RBJ-1003',
title: 'Cedar Lane Closing',
amount: 515000,
status: 'completed',
statusLabel: 'Closed',
date: new Date('2026-04-12'),
progress: 100
}
];
constructor(private authService: AuthService) { }
ngOnInit(): void {
this.authService.currentUser$.subscribe(user => {
this.currentUser = user;
});
}
getDisplayName(): string {
return this.currentUser?.email || '';
}
}
@@ -0,0 +1,122 @@
<div class="user-portal-container">
<!-- Background Elements -->
<div class="background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Portal Layout -->
<div class="portal-layout">
<!-- Sidebar Overlay for Mobile -->
<div *ngIf="isMobile && !sidebarCollapsed" class="sidebar-overlay" (click)="onSidebarOverlayClick()">
</div>
<!-- Sidebar Navigation -->
<aside class="sidebar" [class.collapsed]="sidebarCollapsed">
<div class="sidebar-header">
<div class="logo-section">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo-image">
<div class="logo-text" *ngIf="!sidebarCollapsed">
<h2>ROLCC AC</h2>
<span class="tagline">Escrow Portal</span>
</div>
</div>
<button class="sidebar-toggle" (click)="toggleSidebar()" title="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<h4 *ngIf="!sidebarCollapsed">Overview</h4>
<a routerLink="/user-portal/dashboard" routerLinkActive="active" class="nav-item"
(click)="onNavigationClick()">
<div class="nav-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<span *ngIf="!sidebarCollapsed">Dashboard</span>
</a>
</div>
</nav>
<div class="sidebar-footer" *ngIf="!sidebarCollapsed">
<div class="user-info">
<div class="user-avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<div class="user-details">
<div class="user-name">{{ getDisplayName() || 'User' }}
</div>
<div class="user-email">{{ currentUser?.email }}</div>
</div>
</div>
<button class="logout-btn" (click)="logout()" title="Logout" aria-label="Logout">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16,17 21,12 16,7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
Logout
</button>
</div>
</aside>
<!-- Main Content Area -->
<main [class]="mainContentClass">
<!-- Top Header -->
<header class="top-header">
<div class="header-left">
<button class="mobile-menu-btn" (click)="toggleSidebar()" *ngIf="isMobile" title="Toggle menu"
aria-label="Toggle menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="breadcrumb">
<span class="breadcrumb-item">{{ currentPageTitle }}</span>
</div>
</div>
<div class="header-right">
<div class="header-actions">
<button class="action-btn" title="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<div class="notification-badge" *ngIf="unreadNotifications > 0">{{ unreadNotifications }}
</div>
</button>
<button class="action-btn" title="Search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</button>
</div>
</div>
</header>
<!-- Page Content -->
<div class="page-content">
<router-outlet></router-outlet>
</div>
</main>
</div>
</div>
@@ -0,0 +1,554 @@
.user-portal-container {
min-height: 100vh;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
position: relative;
overflow: hidden;
}
// Background Shapes
.background-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
animation: float 6s ease-in-out infinite;
&.shape-1 {
width: 200px;
height: 200px;
top: 10%;
left: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 150px;
height: 150px;
top: 60%;
right: 15%;
animation-delay: 2s;
}
&.shape-3 {
width: 100px;
height: 100px;
bottom: 20%;
left: 20%;
animation-delay: 4s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
// Main Portal Layout
.portal-layout {
display: flex;
min-height: 100vh;
position: relative;
z-index: 1;
}
// Desktop layout - fixed sidebar with scrolling content
@media (min-width: 769px) {
.portal-layout {
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
}
.main-content {
margin-left: 290px; // Account for fixed sidebar width
flex: 1;
}
}
// When sidebar is collapsed on desktop
.sidebar.collapsed + .main-content {
margin-left: 70px;
}
}
// Sidebar Overlay for Mobile
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
// Sidebar Styles
.sidebar {
width: 280px;
height: 100vh;
background: rgba(255, 255, 255, 0.95);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
border-right: 1px solid rgba(255, 255, 255, 0.2);
display: flex;
flex-direction: column;
transition: all 0.3s ease;
box-shadow: 2px 0 20px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
&.collapsed {
width: 70px;
.logo-text,
.nav-section h4,
.nav-item span,
.user-details,
.logout-btn span {
opacity: 0;
visibility: hidden;
}
.sidebar-footer {
padding: 1rem 0.5rem;
}
.user-info {
justify-content: center;
}
}
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
}
.logo-section {
display: flex;
align-items: center;
gap: 0.75rem;
.logo-image {
height: 40px;
width: auto;
//filter: brightness(0) saturate(100%) invert(27%) sepia(51%) saturate(2878%) hue-rotate(346deg) brightness(104%)contrast(97%);
}
.logo-text {
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #1e3a8a;
letter-spacing: -0.01em;
}
.tagline {
font-size: 0.75rem;
color: #6b7280;
font-weight: 500;
}
}
}
.sidebar-toggle {
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
.sidebar-nav {
flex: 1;
padding: 1rem 0;
overflow-y: auto;
max-height: calc(100vh - 200px); // Account for header and footer
}
.nav-section {
margin-bottom: 2rem;
h4 {
margin: 0 0 1rem 1.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
}
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: #6b7280;
text-decoration: none;
transition: all 0.2s ease;
position: relative;
margin: 0.125rem 0;
&:hover {
background: rgba(30, 64, 175, 0.1);
color: #1e40af;
}
&.active {
background: rgba(30, 64, 175, 0.15);
color: #1e40af;
font-weight: 600;
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: #1e40af;
}
}
.nav-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
span {
font-size: 0.9rem;
font-weight: 500;
}
.nav-badge {
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.5rem;
border-radius: 10px;
margin-left: auto;
min-width: 20px;
text-align: center;
}
}
.sidebar-footer {
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
.user-avatar {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
svg {
width: 20px;
height: 20px;
}
}
.user-details {
flex: 1;
min-width: 0;
.user-name {
font-weight: 600;
color: #1f2937;
font-size: 0.9rem;
margin-bottom: 0.125rem;
}
.user-email {
font-size: 0.8rem;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.logout-btn {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: none;
border: 1px solid #e5e7eb;
border-radius: 8px;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
font-weight: 500;
&:hover {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
svg {
width: 16px;
height: 16px;
}
}
// Main Content Area
.main-content {
flex: 1;
display: flex;
flex-direction: column;
background: rgba(255, 255, 255, 0.95);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
margin: 1rem;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
height: calc(100vh - 2rem);
max-height: calc(100vh - 2rem);
}
.top-header {
padding: 1.5rem 2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.8);
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
}
.breadcrumb {
.breadcrumb-item {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn {
position: relative;
background: none;
border: none;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
color: #6b7280;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f3f4f6;
color: #1e40af;
}
svg {
width: 20px;
height: 20px;
}
.notification-badge {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background: #ef4444;
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
}
.page-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
height: 100%;
max-height: 100%;
}
// Mobile Responsive
@media (max-width: 768px) {
.portal-layout {
flex-direction: column;
}
.sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
transform: translateX(-100%);
transition: transform 0.3s ease;
&:not(.collapsed) {
transform: translateX(0);
}
&.collapsed {
transform: translateX(-100%);
}
}
.main-content {
margin: 0;
border-radius: 0;
height: 100vh;
max-height: 100vh;
}
.mobile-menu-btn {
display: block;
}
.page-content {
padding: 1rem;
}
.top-header {
padding: 1rem;
}
}
@media (max-width: 480px) {
.page-content {
padding: 0.75rem;
}
.top-header {
padding: 0.75rem;
}
.breadcrumb .breadcrumb-item {
font-size: 1.1rem;
}
}
// Overlay for mobile sidebar
@media (max-width: 768px) {
.sidebar:not(.collapsed)::before {
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: -1;
}
}
// Desktop sidebar collapsed state
@media (min-width: 769px) {
.sidebar.collapsed {
width: 70px;
}
.main-content.sidebar-collapsed {
margin-left: 70px;
}
}
@@ -0,0 +1,135 @@
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, NavigationEnd, RouterModule, RouterLink, RouterLinkActive } from '@angular/router';
import { RouterOutlet } from '@angular/router';
import { AuthService, UserInfo } from '../../shared/services/auth.service';
import { Subject, takeUntil, filter } from 'rxjs';
@Component({
selector: 'app-user-portal',
standalone: true,
imports: [
CommonModule,
RouterModule,
RouterLink,
RouterLinkActive,
RouterOutlet
],
templateUrl: './user-portal.component.html',
styleUrls: ['./user-portal.component.scss']
})
export class UserPortalComponent implements OnInit, OnDestroy {
sidebarCollapsed = false;
isMobile = false;
currentUser: UserInfo | null = null;
currentPageTitle = 'Dashboard';
unreadMessages = 3;
unreadNotifications = 2;
private destroy$ = new Subject<void>();
constructor(
private authService: AuthService,
private router: Router
) { }
ngOnInit(): void {
this.checkScreenSize();
this.setupUserSubscription();
this.setupRouteSubscription();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
@HostListener('window:resize', ['$event'])
onResize(event: any): void {
this.checkScreenSize();
}
private checkScreenSize(): void {
this.isMobile = window.innerWidth <= 768;
if (this.isMobile) {
this.sidebarCollapsed = true;
} else {
// On desktop, start with sidebar expanded
this.sidebarCollapsed = false;
}
}
private setupUserSubscription(): void {
this.authService.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(user => {
this.currentUser = user;
});
}
private setupRouteSubscription(): void {
this.router.events
.pipe(
filter(event => event instanceof NavigationEnd),
takeUntil(this.destroy$)
)
.subscribe(() => {
this.updatePageTitle();
});
}
private updatePageTitle(): void {
const url = this.router.url;
const segments = url.split('/').filter(segment => segment);
if (segments.length >= 2) {
const page = segments[1];
this.currentPageTitle = this.getPageTitle(page);
} else {
this.currentPageTitle = 'Dashboard';
}
}
private getPageTitle(page: string): string {
const titles: { [key: string]: string } = {
'dashboard': 'Dashboard',
'transactions': 'Escrow Transactions',
'tasks': 'Tasks & Todos',
'contacts': 'Contacts',
'documents': 'Documents',
'messages': 'Messages',
'settings': 'Settings'
};
return titles[page] || 'Dashboard';
}
toggleSidebar(): void {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
get mainContentClass(): string {
return this.sidebarCollapsed ? 'main-content sidebar-collapsed' : 'main-content';
}
onSidebarOverlayClick(): void {
if (this.isMobile && !this.sidebarCollapsed) {
this.sidebarCollapsed = true;
}
}
onNavigationClick(): void {
if (this.isMobile) {
this.sidebarCollapsed = true;
}
}
logout(): void {
this.authService.logout();
this.router.navigate(['/login']);
}
getDisplayName(): string {
return this.currentUser?.email || '';
}
}
@@ -0,0 +1,88 @@
<div class="mfa-dialog-overlay" *ngIf="visible" (click)="close()">
<div class="mfa-dialog-container" (click)="$event.stopPropagation()">
<!-- Background Elements -->
<div class="dialog-background-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
</div>
<!-- Dialog Content -->
<div class="mfa-dialog-content">
<!-- Header -->
<div class="mfa-header">
<button class="close-button" (click)="close()" title="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<div class="header-content">
<div class="mfa-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<circle cx="12" cy="16" r="1"></circle>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h3>Two-Factor Authentication</h3>
<p>Enter the 6-digit code sent to your device</p>
</div>
</div>
<!-- MFA Code Input -->
<div class="mfa-code-section">
<div class="code-inputs-container">
<ng-container *ngFor="let code of userInputCodes2; let i = index">
<input #codeInput type="number" min="0" max="9" maxlength="1" class="mfa-input"
[(ngModel)]="userInputCodes2[i]" name="n{{i+1}}" (keydown)="onKeyDown(i,$event)"
(paste)="pasteCode(i,$event)" [autofocus]="i==0"
[attr.aria-label]="'MFA code digit ' + (i+1)" title="Enter MFA code digit {{i+1}}">
</ng-container>
</div>
<!-- Error Message -->
<div *ngIf="isInvalidCode" class="error-message">
<svg class="error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
Invalid code. Please try again.
</div>
</div>
<!-- Actions -->
<div class="mfa-actions">
<button kendoButton type="button" themeColor="secondary" size="medium" (click)="resendMFCode()"
[disabled]="processing || resendCountDown > 0" class="resend-button">
<span class="button-content">
<svg class="button-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23,4 23,10 17,10"></polyline>
<polyline points="1,20 1,14 7,14"></polyline>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
{{ resendCodeText }}
</span>
</button>
<button kendoButton type="button" themeColor="primary" size="large" (click)="submitCode()"
[disabled]="processing || !allowSubmit" class="verify-button">
<span class="button-content">
<kendo-loader *ngIf="processing" size="small"></kendo-loader>
<svg *ngIf="!processing" class="button-icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
{{ processing ? 'Verifying...' : 'Verify Code' }}
</span>
</button>
</div>
<!-- Help Text -->
<div class="help-text">
<p>Didn't receive the code? Check your spam folder or try resending.</p>
</div>
</div>
</div>
</div>
@@ -0,0 +1,418 @@
.mfa-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.mfa-dialog-container {
position: relative;
width: 100%;
max-width: 450px;
background: rgba(255, 255, 255, 0.98);
border-radius: 20px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
overflow: hidden;
animation: dialogSlideIn 0.3s ease-out;
}
@keyframes dialogSlideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Background Shapes
.dialog-background-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
border-radius: 50%;
background: rgba(30, 64, 175, 0.05);
animation: float 8s ease-in-out infinite;
&.shape-1 {
width: 80px;
height: 80px;
top: 15%;
right: 10%;
animation-delay: 0s;
}
&.shape-2 {
width: 60px;
height: 60px;
bottom: 20%;
left: 15%;
animation-delay: 3s;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-15px) rotate(180deg);
}
}
// Dialog Content
.mfa-dialog-content {
position: relative;
z-index: 1;
padding: 2rem;
}
// Header
.mfa-header {
position: relative;
text-align: center;
margin-bottom: 2rem;
.close-button {
position: absolute;
top: -0.5rem;
right: -0.5rem;
background: #f8f9fa;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: #6b7280;
&:hover {
background: #e5e7eb;
color: #374151;
transform: scale(1.1);
}
svg {
width: 16px;
height: 16px;
}
}
.header-content {
.mfa-icon {
width: 60px;
height: 60px;
margin: 0 auto 1rem;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
svg {
width: 28px;
height: 28px;
}
}
h3 {
font-size: 1.75rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
letter-spacing: -0.01em;
}
p {
color: #6b7280;
font-size: 1rem;
margin: 0;
line-height: 1.5;
}
}
}
// MFA Code Section
.mfa-code-section {
margin-bottom: 2rem;
.code-inputs-container {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-bottom: 1rem;
.mfa-input {
width: 50px;
height: 50px;
border: 2px solid #e5e7eb;
border-radius: 12px;
text-align: center;
font-size: 1.5rem;
font-weight: 600;
color: #1a1a1a;
background: #ffffff;
transition: all 0.2s ease;
outline: none;
&:focus {
border-color: #1e40af;
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
transform: scale(1.05);
}
&:hover:not(:focus) {
border-color: #d1d5db;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
&[type="number"] {
-moz-appearance: textfield;
}
}
}
.error-message {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: #fee2e2;
color: #dc2626;
padding: 0.75rem 1rem;
border-radius: 12px;
border: 1px solid #fecaca;
font-size: 0.9rem;
font-weight: 500;
animation: errorShake 0.5s ease-in-out;
.error-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
}
}
@keyframes errorShake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
// Actions
.mfa-actions {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
.resend-button {
flex: 1;
height: 48px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
background: #f8f9fa;
border: 2px solid #e5e7eb;
color: #6b7280;
transition: all 0.3s ease;
&:hover:not(:disabled) {
background: #e9ecef;
border-color: #d1d5db;
color: #495057;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 16px;
height: 16px;
}
}
.verify-button {
flex: 2;
height: 48px;
border-radius: 12px;
font-size: 1rem;
font-weight: 600;
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 50%, #3b82f6 100%);
border: none;
color: white;
box-shadow: 0 8px 25px rgba(30, 58, 138, 0.3);
transition: all 0.3s ease;
&:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 35px rgba(30, 58, 138, 0.4);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.button-content {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.button-icon {
width: 18px;
height: 18px;
}
}
}
// Help Text
.help-text {
text-align: center;
p {
color: #9ca3af;
font-size: 0.85rem;
margin: 0;
line-height: 1.4;
}
}
// Mobile Responsive
@media (max-width: 480px) {
.mfa-dialog-overlay {
padding: 0.5rem;
}
.mfa-dialog-container {
max-width: 100%;
border-radius: 16px;
}
.mfa-dialog-content {
padding: 1.5rem;
}
.mfa-header {
margin-bottom: 1.5rem;
.header-content {
.mfa-icon {
width: 50px;
height: 50px;
margin-bottom: 0.75rem;
svg {
width: 24px;
height: 24px;
}
}
h3 {
font-size: 1.5rem;
}
p {
font-size: 0.9rem;
}
}
}
.mfa-code-section {
margin-bottom: 1.5rem;
.code-inputs-container {
gap: 0.5rem;
.mfa-input {
width: 45px;
height: 45px;
font-size: 1.25rem;
}
}
}
.mfa-actions {
flex-direction: column;
gap: 0.75rem;
.resend-button,
.verify-button {
flex: 1;
height: 44px;
}
.verify-button {
order: -1;
}
}
}
@media (max-width: 360px) {
.mfa-dialog-content {
padding: 1rem;
}
.code-inputs-container {
gap: 0.4rem;
.mfa-input {
width: 40px;
height: 40px;
font-size: 1.1rem;
}
}
}
@@ -0,0 +1,219 @@
import { Component, ElementRef, QueryList, ViewChildren, Output, EventEmitter, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { AuthService, LoginCredentials, LoginResultType } from '../services/auth.service';
const CODE_LENGTH = 6;
@Component({
selector: 'app-mfa-dialog',
standalone: true,
imports: [
CommonModule,
FormsModule,
ButtonsModule,
IndicatorsModule
],
templateUrl: './mfa-dialog.component.html',
styleUrls: ['./mfa-dialog.component.scss']
})
export class MfaDialogComponent {
@ViewChildren('codeInput') codeInputs!: QueryList<ElementRef>;
@Output() mfaSuccess = new EventEmitter<any>();
@Output() mfaCancel = new EventEmitter<void>();
@Input() visible = false;
token: string = '';
userInputCodes: (string | null)[] = [];
userInputCodes2: (string | null)[] = [];
loginData!: LoginCredentials;
processing = false;
allowSubmit = false;
isInvalidCode = false;
resendCountDown = 30;
constructor(private authService: AuthService) { }
ngOnInit() {
for (let i = 0; i < CODE_LENGTH; i++) {
this.userInputCodes.push(null);
this.userInputCodes2.push(null);
}
this.setReSendCountDown();
}
pasteCode(index: number, event: ClipboardEvent) {
event.preventDefault();
const data = event.clipboardData?.getData('text/plain') || '';
let pasteCode = data.replace(new RegExp("[^0-9]", 'g'), "");
for (let i = index; i < CODE_LENGTH; i++) {
if (pasteCode.length > i) {
const code = pasteCode[i];
let input = this.codeInputs.find((element, j) => j === i);
if (input) {
input.nativeElement.value = code;
this.userInputCodes[i] = code;
}
}
}
this.validate(5);
}
public onKeyDown(index: number, e: KeyboardEvent): void {
const el: HTMLInputElement = e.target as HTMLInputElement;
if (e.ctrlKey && e.key.toUpperCase() == 'V') {
return;
} else {
let nextFocusInput: ElementRef | undefined = undefined;
switch (e.key) {
case 'ArrowLeft':
nextFocusInput = this.getInputElements(index, -1);
if (nextFocusInput) nextFocusInput.nativeElement.focus();
break;
case 'ArrowRight':
nextFocusInput = this.getInputElements(index, 1);
if (nextFocusInput) nextFocusInput.nativeElement.focus();
break;
case 'Backspace':
if (el.value) {
el.value = '';
} else {
nextFocusInput = this.getInputElements(index, -1);
}
break;
case 'Delete':
el.value = '';
break;
default:
break;
}
if (nextFocusInput) {
nextFocusInput.nativeElement.focus();
return;
}
let isReplacing = el.selectionStart != el.selectionEnd;
if (this.isEditingKeyPress(e)) {
e.preventDefault();
if (new RegExp("[0-9]", 'g').test(e.key)) {
this.userInputCodes[index] = e.key;
el.value = e.key;
let nextInput = this.getInputElements(index, 1);
if (nextInput) nextInput.nativeElement.focus();
} else {
el.value = '';
this.userInputCodes[index] = null;
}
}
}
this.isInvalidCode = false;
this.validate(index);
}
private getInputElements(index: number, indexOffset: number): ElementRef | undefined {
let nextInput = this.codeInputs.find((element, i) => i === (index + indexOffset));
return nextInput;
}
private isEditingKeyPress(e: KeyboardEvent): boolean {
return e.key.length === 1 || e.key === 'Backspace' || e.key === 'Delete' || e.key === 'ArrowLeft' || e.key === 'ArrowRight';
}
validate(focusIndex: number) {
this.allowSubmit = !this.userInputCodes.some(n => n == null);
this.token = this.userInputCodes.map(n => n != null ? n : '').join('');
if (this.token && this.token.length == 6 && focusIndex == 5) {
this.submitCode();
}
}
close() {
this.visible = false;
this.mfaCancel.emit();
}
show() {
this.visible = true;
this.resetForm();
}
hide() {
this.visible = false;
}
resetForm() {
this.userInputCodes = [];
this.userInputCodes2 = [];
this.token = '';
this.isInvalidCode = false;
this.processing = false;
this.allowSubmit = false;
for (let i = 0; i < CODE_LENGTH; i++) {
this.userInputCodes.push(null);
this.userInputCodes2.push(null);
}
// Focus on first input
setTimeout(() => {
const firstInput = document.querySelector('.mfa-input:first-child') as HTMLInputElement;
if (firstInput) {
firstInput.focus();
}
}, 100);
}
submitCode() {
this.processing = true;
this.loginData.mfaCode = this.token;
// Handle login MFA verification
{
this.authService.login(this.loginData).subscribe({
next: (result) => {
this.processing = false;
if (result.result === LoginResultType.Success) {
this.mfaSuccess.emit(result.responseData);
this.visible = false;
} else {
this.isInvalidCode = true;
}
},
error: (error) => {
this.processing = false;
this.isInvalidCode = true;
console.error('MFA verification error:', error);
}
});
}
}
setReSendCountDown() {
if (this.resendCountDown > 0) {
setTimeout(() => {
this.resendCountDown--;
this.setReSendCountDown();
}, 1000);
}
}
resendMFCode() {
this.resendCountDown = 30;
this.loginData.mfaCode = '';
// Simulate resend MFA code - replace with actual service call
console.log('Resending MFA code to:', this.loginData.email);
//TODO: Implement resend MFA code for regular login
this.setReSendCountDown();
}
public get resendCodeText(): string {
return 'Resend Code' + (this.resendCountDown > 0 ? ` (${this.resendCountDown})` : '');
}
}
+20
View File
@@ -0,0 +1,20 @@
// Placeholder enums — expand as the church module is built out
export enum EscrowStatus {
Open = 'Open',
Closed = 'Closed',
Cancelled = 'Cancelled'
}
export enum CbAssigneeRole {
None = 'None',
Buyer = 'Buyer',
Seller = 'Seller',
BuyerRealEstateAgent = 'BuyerRealEstateAgent',
SellerRealEstateAgent = 'SellerRealEstateAgent',
EscrowOfficer = 'EscrowOfficer',
EscrowAssignee = 'EscrowAssignee',
LoanBroker = 'LoanBroker',
Lender = 'Lender',
SellerTransactionCoordinator = 'SellerTransactionCoordinator',
BuyerTransactionCoordinator = 'BuyerTransactionCoordinator'
}
+11
View File
@@ -0,0 +1,11 @@
// Export user models
export * from './user.model';
export * from './enums.model';
/** Address info used by string utilities */
export interface AddressInfo {
address: string;
city: string;
state: string;
zip: string;
}
+2
View File
@@ -0,0 +1,2 @@
// User model placeholder — types live in auth.service.ts
export {};
@@ -0,0 +1,224 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController
} from '@angular/common/http/testing';
// NOTE: UserInfo is the interface Task 2 will add to auth.service.ts.
// Importing it here intentionally causes a compile error until Task 2 is implemented.
// Task 2 MUST export `UserInfo` (not the old `User`) for these tests to pass.
import {
AuthService,
LoginResultType,
UserInfo
} from './auth.service';
import { ApiConfigService } from '../../core/services/api-config.service';
const MOCK_USER: UserInfo = {
id: 'user-123',
email: 'test@example.com',
roles: ['Admin'],
languagePreference: 'en'
};
const MOCK_API_RESPONSE = {
accessToken: 'mock-access-token',
expiresIn: 900,
user: MOCK_USER
};
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
let apiConfig: ApiConfigService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AuthService, ApiConfigService]
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
apiConfig = TestBed.inject(ApiConfigService);
});
afterEach(() => {
httpMock.verify();
});
// ── login() ────────────────────────────────────────────────────────────────
describe('login()', () => {
it('should POST to /api/auth/login with email and password', () => {
service.login({ email: 'test@example.com', password: 'secret' }).subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/login`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({ email: 'test@example.com', password: 'secret' });
expect(req.request.withCredentials).toBeTrue();
req.flush(MOCK_API_RESPONSE);
});
it('should return LoginResultType.Success and store token + user on 200', () => {
let result: any;
service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(MOCK_API_RESPONSE);
expect(result.result).toBe(LoginResultType.Success);
expect(result.responseData).toEqual(MOCK_USER);
expect(service.getToken()).toBe('mock-access-token');
expect(service.getCurrentUser()).toEqual(MOCK_USER);
expect(service.isAuthenticated()).toBeTrue();
});
it('should return LoginResultType.InvalidCredentials on 401', () => {
let result: any;
service.login({ email: 'bad@example.com', password: 'wrong' }).subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(
{ message: 'Invalid credentials' },
{ status: 401, statusText: 'Unauthorized' }
);
expect(result.result).toBe(LoginResultType.InvalidCredentials);
expect(service.getToken()).toBeNull();
expect(service.isAuthenticated()).toBeFalse();
});
it('should return LoginResultType.Error on non-401 HTTP error', () => {
let result: any;
service.login({ email: 'test@example.com', password: 'secret' }).subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(
{ message: 'Server error' },
{ status: 500, statusText: 'Internal Server Error' }
);
expect(result.result).toBe(LoginResultType.Error);
});
});
// ── refresh() ──────────────────────────────────────────────────────────────
describe('refresh()', () => {
it('should POST to /api/auth/refresh with withCredentials', () => {
service.refresh().subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/refresh`);
expect(req.request.method).toBe('POST');
expect(req.request.withCredentials).toBeTrue();
req.flush(MOCK_API_RESPONSE);
});
it('should return true and update token + user on 200', () => {
let result: boolean | undefined;
service.refresh().subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE);
expect(result).toBeTrue();
expect(service.getToken()).toBe('mock-access-token');
expect(service.getCurrentUser()).toEqual(MOCK_USER);
});
it('should return false and leave state unchanged on 401', () => {
let result: boolean | undefined;
service.refresh().subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
{ message: 'Refresh token expired' },
{ status: 401, statusText: 'Unauthorized' }
);
expect(result).toBeFalse();
expect(service.getToken()).toBeNull();
expect(service.isAuthenticated()).toBeFalse();
});
it('should return false and not throw on 5xx error', () => {
let result: boolean | undefined;
service.refresh().subscribe(r => result = r);
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
{ message: 'Server error' },
{ status: 500, statusText: 'Internal Server Error' }
);
expect(result).toBeFalse();
expect(service.isAuthenticated()).toBeFalse();
});
});
// ── logout() ───────────────────────────────────────────────────────────────
describe('logout()', () => {
it('should clear token and user from memory immediately', (done) => {
// Seed state via the login flow (avoids accessing private BehaviorSubjects)
service.login({ email: 'test@example.com', password: 'secret' }).subscribe(() => {
// Now token + user are set — call logout and assert BEFORE flushing the HTTP call
service.logout();
expect(service.getToken()).toBeNull();
expect(service.getCurrentUser()).toBeNull();
expect(service.isAuthenticated()).toBeFalse();
// Now flush the logout HTTP call so httpMock.verify() is satisfied
httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(null, { status: 204, statusText: 'No Content' });
done();
});
// Flush the login call
httpMock.expectOne(`${apiConfig.authUrl}/login`).flush(MOCK_API_RESPONSE);
});
it('should POST to /api/auth/logout with withCredentials', () => {
service.logout();
const req = httpMock.expectOne(`${apiConfig.authUrl}/logout`);
expect(req.request.method).toBe('POST');
expect(req.request.withCredentials).toBeTrue();
req.flush(null, { status: 204, statusText: 'No Content' });
});
it('should not throw if the logout API call fails', () => {
service.logout();
// Flush an error response — service must swallow it
httpMock.expectOne(`${apiConfig.authUrl}/logout`).flush(
{ message: 'Server error' },
{ status: 500, statusText: 'Internal Server Error' }
);
// If we reach here without an unhandled error, the test passes
expect(service.isAuthenticated()).toBeFalse();
});
});
// ── initializeFromRefreshToken() ───────────────────────────────────────────
describe('initializeFromRefreshToken()', () => {
it('should resolve even when refresh returns 401 (does not block bootstrap)', async () => {
const promise = service.initializeFromRefreshToken();
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(
{ message: 'No cookie' },
{ status: 401, statusText: 'Unauthorized' }
);
await expectAsync(promise).toBeResolved();
});
it('should resolve and authenticate user when refresh succeeds', async () => {
const promise = service.initializeFromRefreshToken();
httpMock.expectOne(`${apiConfig.authUrl}/refresh`).flush(MOCK_API_RESPONSE);
await expectAsync(promise).toBeResolved();
expect(service.isAuthenticated()).toBeTrue();
});
});
// ── setCurrentUser() / getCurrentUser() ────────────────────────────────────
describe('setCurrentUser()', () => {
it('should update currentUser$ and mark authenticated', () => {
service.setCurrentUser(MOCK_USER);
expect(service.getCurrentUser()).toEqual(MOCK_USER);
expect(service.isAuthenticated()).toBeTrue();
});
});
// ── redirect URL helpers ────────────────────────────────────────────────────
describe('redirect URL helpers', () => {
it('should default redirect to /dashboard', () => {
expect(service.getRedirectUrl()).toBe('/dashboard');
});
it('should store and return a custom redirect URL', () => {
service.setRedirectUrl('/members');
expect(service.getRedirectUrl()).toBe('/members');
});
});
});
+257
View File
@@ -0,0 +1,257 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { ApiConfigService } from '../../core/services/api-config.service';
// ── Public interfaces ─────────────────────────────────────────────────────────
/** Matches the C# UserInfo DTO exactly. */
export interface UserInfo {
id: string;
email: string;
roles: string[];
languagePreference: string;
}
/** Matches the C# LoginResponse DTO exactly. */
export interface ApiLoginResponse {
accessToken: string;
expiresIn: number;
user: UserInfo;
}
export interface LoginCredentials {
email: string;
password: string;
/** Reserved for future MFA support — ignored by the current API. */
mfaCode?: string;
}
export enum LoginResultType {
Success = 'Success',
/** Kept dormant — the current API has no MFA endpoint. */
MfaRequired = 'MfaRequired',
InvalidCredentials = 'InvalidCredentials',
Error = 'Error'
}
export interface LoginResult {
result: LoginResultType;
responseData?: UserInfo;
message?: string;
}
export interface TokenVerificationResult {
isValid: boolean;
/** Constructed from JWT claims when using secret-link login. */
user?: UserInfo;
/** The raw JWT from the URL — use as the access token for this session. */
accessToken?: string;
message?: string;
expiresAt?: Date;
requiresMfa?: boolean;
}
// ── Service ───────────────────────────────────────────────────────────────────
@Injectable({ providedIn: 'root' })
export class AuthService {
/**
* In-memory only — never written to localStorage.
* Non-private intentionally: unit tests seed state via these subjects directly.
* Production code must use getToken(), getCurrentUser(), and setCurrentUser().
*/
accessToken$ = new BehaviorSubject<string | null>(null);
currentUser$ = new BehaviorSubject<UserInfo | null>(null);
/** Observable stream of the current user (null = not authenticated). */
public currentUser = this.currentUser$.asObservable();
private redirectUrl = '/dashboard';
constructor(
private http: HttpClient,
private apiConfig: ApiConfigService
) {}
// ── Auth API calls ──────────────────────────────────────────────────────
/**
* Authenticate with email + password.
* On success, stores the access token and user in memory and returns
* LoginResultType.Success. Never throws — errors are mapped to LoginResult.
*/
login(credentials: LoginCredentials): Observable<LoginResult> {
return this.http.post<ApiLoginResponse>(
`${this.apiConfig.authUrl}/login`,
{ email: credentials.email, password: credentials.password },
{ withCredentials: true }
).pipe(
tap(response => {
this.accessToken$.next(response.accessToken);
this.currentUser$.next(response.user);
}),
map(response => ({
result: LoginResultType.Success,
responseData: response.user
} as LoginResult)),
catchError(error => {
if (error.status === 401) {
return of({
result: LoginResultType.InvalidCredentials,
message: error.error?.message || 'Invalid email or password'
} as LoginResult);
}
return of({
result: LoginResultType.Error,
message: error.error?.message || 'An error occurred during login'
} as LoginResult);
})
);
}
/**
* Silently exchange the HttpOnly `rolac_rt` cookie for a new access token.
* Returns true on success, false if the cookie is absent or expired.
* Never throws.
*/
refresh(): Observable<boolean> {
return this.http.post<ApiLoginResponse>(
`${this.apiConfig.authUrl}/refresh`,
{},
{ withCredentials: true }
).pipe(
tap(response => {
this.accessToken$.next(response.accessToken);
this.currentUser$.next(response.user);
}),
map(() => true),
catchError(() => of(false))
);
}
/**
* Clears in-memory auth state immediately, then fires a fire-and-forget
* POST to revoke the server-side refresh token cookie.
*/
logout(): void {
this.accessToken$.next(null);
this.currentUser$.next(null);
this.http.post(
`${this.apiConfig.authUrl}/logout`,
{},
{ withCredentials: true }
).pipe(
catchError(() => of(null))
).subscribe();
}
/**
* Called by APP_INITIALIZER on every page load.
* Attempts to restore the session via the refresh token cookie.
* Always resolves — never rejects — so it cannot block app bootstrap.
*/
initializeFromRefreshToken(): Promise<void> {
return new Promise(resolve => {
this.refresh().subscribe(() => resolve());
});
}
// ── State accessors ─────────────────────────────────────────────────────
getToken(): string | null {
return this.accessToken$.value;
}
isAuthenticated(): boolean {
return this.currentUser$.value !== null;
}
getCurrentUser(): UserInfo | null {
return this.currentUser$.value;
}
/**
* Manually set the current user — used by the MFA success callback
* and the secret-link token flow.
*/
setCurrentUser(user: UserInfo): void {
this.currentUser$.next(user);
}
setRedirectUrl(url: string): void {
this.redirectUrl = url;
}
getRedirectUrl(): string {
return this.redirectUrl;
}
// ── Secret-link token helpers (unchanged logic, updated types) ───────────
/**
* Verifies a JWT token received as a URL parameter (secret-link login).
* Performs local verification only — no API call.
* Constructs a UserInfo from the JWT claims (id, email, roles,
* languagePreference).
*/
verifySecretLinkToken(token: string): Observable<TokenVerificationResult> {
try {
const tokenData = this.parseJwtToken(token);
if (!tokenData) {
return of({ isValid: false, message: 'Invalid token format' });
}
if (this.isTokenExpired(token)) {
return of({
isValid: false,
message: 'This link has expired. Please request a new one.'
});
}
const user: UserInfo = {
id: tokenData.userId || tokenData.sub || tokenData.id || '',
email: tokenData.email || tokenData.email_address || '',
roles: Array.isArray(tokenData.roles)
? tokenData.roles
: tokenData.role ? [tokenData.role] : [],
languagePreference: tokenData.languagePreference || 'en'
};
return of({
isValid: true,
user,
message: 'Token verified successfully. MFA required.',
expiresAt: tokenData.exp ? new Date(tokenData.exp * 1000) : undefined,
requiresMfa: true
});
} catch (error) {
return of({ isValid: false, message: 'Invalid or corrupted token' });
}
}
/** Returns true if the JWT's `exp` claim is in the past. */
isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp < Math.floor(Date.now() / 1000);
} catch {
return true;
}
}
private parseJwtToken(token: string): any | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const decoded = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded);
} catch {
return null;
}
}
}
@@ -0,0 +1,182 @@
import { Injectable } from '@angular/core';
import { SVGIcon } from '@progress/kendo-angular-icons';
import { EscrowStatus, CbAssigneeRole } from '../models/enums.model';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class UiUtilsService {
public setPageTitleSubject = new Subject<string>();
public setPageTitle$ = this.setPageTitleSubject.asObservable();
// Icon properties - these should be injected or provided by a parent component
// For now, we'll make them optional and let the calling component provide them
checkCircleIcon?: SVGIcon;
clockIcon?: SVGIcon;
alertCircleIcon?: SVGIcon;
userIcon?: SVGIcon;
constructor() { }
/**
* Get CSS class for church status
*/
getStatusClass(status: EscrowStatus): string {
switch (status) {
case EscrowStatus.Open:
return 'status-active';
case EscrowStatus.Closed:
return 'status-completed';
case EscrowStatus.Cancelled:
return 'status-cancelled';
default:
return '';
}
}
/**
* Get display label for church status
*/
getEscrowStatusLabel(status: EscrowStatus): string {
switch (status) {
case EscrowStatus.Open:
return 'Open';
case EscrowStatus.Closed:
return 'Closed';
case EscrowStatus.Cancelled:
return 'Cancelled';
default:
return '';
}
}
/**
* Get CSS class for priority level
*/
getPriorityClass(priority: string): string {
switch (priority) {
case 'high':
return 'priority-high';
case 'medium':
return 'priority-medium';
case 'low':
return 'priority-low';
default:
return '';
}
}
/**
* Get icon for church status
*/
getStatusIcon(status: EscrowStatus): SVGIcon | undefined {
switch (status) {
case EscrowStatus.Open:
return this.checkCircleIcon;
case EscrowStatus.Closed:
return this.checkCircleIcon;
case EscrowStatus.Cancelled:
return this.alertCircleIcon;
default:
return this.clockIcon;
}
}
/**
* Get CSS class for assignee role
*/
getRoleClass(role: CbAssigneeRole): string {
switch (role) {
case CbAssigneeRole.Buyer:
return 'role-buyer';
case CbAssigneeRole.Seller:
return 'role-seller';
case CbAssigneeRole.BuyerRealEstateAgent:
case CbAssigneeRole.SellerRealEstateAgent:
return 'role-agent';
case CbAssigneeRole.EscrowOfficer:
case CbAssigneeRole.EscrowAssignee:
return 'role-church';
case CbAssigneeRole.LoanBroker:
case CbAssigneeRole.Lender:
return 'role-lender';
case CbAssigneeRole.SellerTransactionCoordinator:
case CbAssigneeRole.BuyerTransactionCoordinator:
return 'role-coordinator';
case CbAssigneeRole.None:
return 'role-default';
default:
return 'role-default';
}
}
/**
* Get icon for assignee role
*/
getRoleIcon(role: CbAssigneeRole): SVGIcon | undefined {
switch (role) {
case CbAssigneeRole.Buyer:
case CbAssigneeRole.Seller:
case CbAssigneeRole.BuyerRealEstateAgent:
case CbAssigneeRole.SellerRealEstateAgent:
case CbAssigneeRole.EscrowOfficer:
case CbAssigneeRole.EscrowAssignee:
case CbAssigneeRole.LoanBroker:
case CbAssigneeRole.Lender:
case CbAssigneeRole.SellerTransactionCoordinator:
case CbAssigneeRole.BuyerTransactionCoordinator:
return this.userIcon;
case CbAssigneeRole.None:
return this.userIcon;
default:
return this.userIcon;
}
}
/**
* Get display label for assignee role
*/
getRoleLabel(role: CbAssigneeRole): string {
switch (role) {
case CbAssigneeRole.Buyer:
return 'Buyer';
case CbAssigneeRole.Seller:
return 'Seller';
case CbAssigneeRole.BuyerRealEstateAgent:
return 'Buyer Agent';
case CbAssigneeRole.SellerRealEstateAgent:
return 'Seller Agent';
case CbAssigneeRole.EscrowOfficer:
return 'Escrow Officer';
case CbAssigneeRole.EscrowAssignee:
return 'Escrow Assignee';
case CbAssigneeRole.LoanBroker:
return 'Loan Broker';
case CbAssigneeRole.Lender:
return 'Lender';
case CbAssigneeRole.SellerTransactionCoordinator:
return 'Seller Coordinator';
case CbAssigneeRole.BuyerTransactionCoordinator:
return 'Buyer Coordinator';
case CbAssigneeRole.None:
return 'None';
default:
return 'Unknown';
}
}
/**
* Set icons for the service (should be called by components that use this service)
*/
setIcons(icons: {
checkCircleIcon?: SVGIcon;
clockIcon?: SVGIcon;
alertCircleIcon?: SVGIcon;
userIcon?: SVGIcon;
}): void {
this.checkCircleIcon = icons.checkCircleIcon;
this.clockIcon = icons.clockIcon;
this.alertCircleIcon = icons.alertCircleIcon;
this.userIcon = icons.userIcon;
}
}
+271
View File
@@ -0,0 +1,271 @@
// =============================================================================
// UI Utility Classes
// =============================================================================
// Centralized SCSS for utility classes used by UiUtilsService
// These classes provide consistent styling across all components
// =============================================================================
// Status Badge Classes
// =============================================================================
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
kendo-svgicon {
width: 12px;
height: 12px;
}
// Status variants
&.status-active {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.status-pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.status-completed {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.status-cancelled {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
}
// =============================================================================
// Priority Badge Classes
// =============================================================================
.priority-badge {
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
// Priority variants
&.priority-high {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
&.priority-medium {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.priority-low {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
}
// =============================================================================
// Role Badge Classes
// =============================================================================
.role-badge {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
kendo-svgicon {
width: 12px;
height: 12px;
}
// Role variants
&.role-buyer {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.role-seller {
background: rgba(168, 85, 247, 0.1);
color: #9333ea;
}
&.role-agent {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.role-church {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
}
&.role-lender {
background: rgba(139, 69, 19, 0.1);
color: #8b4513;
}
&.role-coordinator {
background: rgba(75, 85, 99, 0.1);
color: #4b5563;
}
&.role-default {
background: rgba(107, 114, 128, 0.1);
color: #6b7280;
}
}
// =============================================================================
// Task Status Classes (for transaction-detail component)
// =============================================================================
.task-status {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
&.task-completed {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
&.task-in-progress {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
&.task-pending {
background: rgba(251, 191, 36, 0.1);
color: #d97706;
}
&.task-cancelled {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
}
// =============================================================================
// Utility Mixins (for consistent styling)
// =============================================================================
@mixin badge-base {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-weight: 600;
text-transform: uppercase;
}
@mixin status-colors($bg-color, $text-color) {
background: rgba($bg-color, 0.1);
color: $text-color;
}
// =============================================================================
// Responsive Design
// =============================================================================
@media (max-width: 768px) {
.status-badge,
.role-badge {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
}
.priority-badge {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
}
}
// =============================================================================
// Dark Mode Support (if needed in the future)
// =============================================================================
@media (prefers-color-scheme: dark) {
.status-badge {
&.status-active {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
&.status-pending {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
&.status-completed {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
&.status-cancelled {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
}
.priority-badge {
&.priority-high {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
&.priority-medium {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
&.priority-low {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
}
.role-badge {
&.role-buyer {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
&.role-seller {
background: rgba(168, 85, 247, 0.2);
color: #a78bfa;
}
&.role-agent {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
&.role-church {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
}
&.role-lender {
background: rgba(139, 69, 19, 0.2);
color: #d97706;
}
&.role-coordinator {
background: rgba(75, 85, 99, 0.2);
color: #9ca3af;
}
&.role-default {
background: rgba(107, 114, 128, 0.2);
color: #9ca3af;
}
}
}
@@ -0,0 +1,77 @@
<div class="token-verification-container">
<div class="verification-card">
<div class="logo-section">
<img src="assets/rbj-logo.svg" alt="RBJ Logo" class="logo">
</div>
<div class="verification-content">
<!-- Verifying State -->
<div *ngIf="isVerifying" class="verifying-state">
<kendo-loader size="large"></kendo-loader>
<h2>Verifying your access...</h2>
<p>Please wait while we verify your email link.</p>
</div>
<!-- Token Verified State -->
<div *ngIf="verificationResult?.isValid && !isVerifying && !verificationResult?.requiresMfa"
class="success-state">
<div class="success-icon">
<i class="k-icon k-i-check-circle"></i>
</div>
<h2>Access Granted!</h2>
<p>Welcome back, {{ verificationResult?.user?.firstName }}!</p>
<p class="redirect-message">Redirecting you to the dashboard...</p>
</div>
<!-- MFA Required State -->
<div *ngIf="verificationResult?.isValid && verificationResult?.requiresMfa && !isVerifying"
class="mfa-required-state">
<div class="success-icon">
<i class="k-icon k-i-check-circle"></i>
</div>
<h2>Token Verified!</h2>
<p>Welcome back, {{ verificationResult?.user?.firstName }}!</p>
<p class="mfa-message">Please complete MFA verification to continue...</p>
<!-- Debug button to manually trigger MFA dialog -->
<div class="debug-actions" style="margin-top: 20px;">
<button kendoButton themeColor="primary" (click)="showMfaDialog()" class="action-btn">
Show MFA Dialog (Debug)
</button>
</div>
</div>
<!-- Error State -->
<div *ngIf="errorMessage && !isVerifying" class="error-state">
<div class="error-icon">
<i class="k-icon k-i-warning"></i>
</div>
<h2>Link Verification Failed</h2>
<p class="error-message">{{ errorMessage }}</p>
<div class="help-text">
<p>If you're having trouble accessing your account:</p>
<ul>
<li>Make sure you clicked the complete link from your email</li>
<li>Check if the link has expired</li>
<li>Try requesting a new access link</li>
</ul>
</div>
<div class="action-buttons">
<button kendoButton themeColor="primary" (click)="requestNewLink()" class="action-btn">
Request New Link
</button>
<button kendoButton (click)="goToLogin()" class="action-btn secondary">
Go to Login
</button>
</div>
</div>
</div>
</div>
<!-- MFA Dialog -->
<app-mfa-dialog #mfaDialog (mfaSuccess)="onMfaSuccess($event)" (mfaCancel)="onMfaCancel()">
</app-mfa-dialog>
</div>
@@ -0,0 +1,164 @@
.token-verification-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.verification-card {
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
padding: 40px;
max-width: 500px;
width: 100%;
text-align: center;
}
.logo-section {
margin-bottom: 30px;
.logo {
height: 60px;
width: auto;
}
}
.verification-content {
h2 {
color: #333;
margin-bottom: 16px;
font-size: 24px;
font-weight: 600;
}
p {
color: #666;
margin-bottom: 12px;
line-height: 1.5;
}
}
.verifying-state {
.k-loader {
margin-bottom: 20px;
}
}
.success-state {
.success-icon {
margin-bottom: 20px;
.k-icon {
font-size: 48px;
color: #28a745;
}
}
.redirect-message {
color: #28a745;
font-weight: 500;
margin-top: 20px;
}
}
.mfa-required-state {
.success-icon {
margin-bottom: 20px;
.k-icon {
font-size: 48px;
color: #28a745;
}
}
.mfa-message {
color: #007bff;
font-weight: 500;
margin-top: 20px;
}
}
.error-state {
.error-icon {
margin-bottom: 20px;
.k-icon {
font-size: 48px;
color: #dc3545;
}
}
.error-message {
color: #dc3545;
font-weight: 500;
margin-bottom: 20px;
}
.help-text {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 16px;
margin-bottom: 30px;
text-align: left;
p {
margin-bottom: 8px;
font-weight: 500;
color: #495057;
}
ul {
margin: 0;
padding-left: 20px;
li {
margin-bottom: 4px;
color: #6c757d;
font-size: 14px;
}
}
}
.action-buttons {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
.action-btn {
min-width: 140px;
&.secondary {
background-color: #6c757d;
border-color: #6c757d;
&:hover {
background-color: #5a6268;
border-color: #545b62;
}
}
}
}
}
// Responsive design
@media (max-width: 768px) {
.verification-card {
padding: 30px 20px;
margin: 10px;
}
.action-buttons {
flex-direction: column;
align-items: center;
.action-btn {
width: 100%;
max-width: 200px;
}
}
}
@@ -0,0 +1,171 @@
import { Component, OnInit, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogModule } from '@progress/kendo-angular-dialog';
import { AuthService, TokenVerificationResult, UserInfo } from '../services/auth.service';
import { MfaDialogComponent } from '../mfa-dialog/mfa-dialog.component';
@Component({
selector: 'app-token-verification',
standalone: true,
imports: [
CommonModule,
IndicatorsModule,
ButtonsModule,
DialogModule,
MfaDialogComponent
],
templateUrl: './token-verification.component.html',
styleUrls: ['./token-verification.component.scss']
})
export class TokenVerificationComponent implements OnInit, AfterViewInit {
@ViewChild('mfaDialog') mfaDialog!: MfaDialogComponent;
isVerifying = true;
verificationResult: TokenVerificationResult | null = null;
errorMessage = '';
tokenUser: UserInfo | null = null;
constructor(
private route: ActivatedRoute,
private router: Router,
private authService: AuthService
) { }
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
const token = params['token'];
if (token) {
this.verifyToken(token);
} else {
this.handleError('No token provided in the link');
}
});
}
ngAfterViewInit(): void {
console.log('AfterViewInit - MFA dialog reference:', this.mfaDialog);
}
private verifyToken(token: string): void {
this.isVerifying = true;
this.errorMessage = '';
// Validate token format first
if (!this.isValidJwtFormat(token)) {
this.handleError('Invalid token format. Please check your email link.');
return;
}
this.authService.verifySecretLinkToken(token).subscribe({
next: (result: TokenVerificationResult) => {
this.isVerifying = false;
this.verificationResult = result;
if (result.isValid && result.user) {
// Token is valid, store user data and show MFA dialog
this.tokenUser = result.user;
this.verificationResult = result;
console.log('Token verification result:', result);
console.log('Requires MFA:', result.requiresMfa);
if (result.requiresMfa) {
// Show MFA dialog for token-based authentication
console.log('Showing MFA dialog...');
this.showMfaDialog();
} else {
// If MFA is not required, proceed directly
console.log('MFA not required, proceeding directly...');
this.authService.setCurrentUser(result.user);
setTimeout(() => {
this.redirectToDashboard();
}, 2000);
}
} else {
this.handleError(result.message || 'Invalid or expired link. Please request a new one.');
}
},
error: (error) => {
this.isVerifying = false;
this.handleError('An error occurred while verifying the link. Please try again.');
console.error('Token verification error:', error);
}
});
}
private isValidJwtFormat(token: string): boolean {
// Basic JWT format validation (3 parts separated by dots)
const parts = token.split('.');
return parts.length === 3 && parts.every(part => part.length > 0);
}
private handleError(message: string): void {
this.isVerifying = false;
this.errorMessage = message;
}
private redirectToDashboard(): void {
const redirectUrl = this.authService.getRedirectUrl();
this.router.navigate([redirectUrl || '/dashboard']);
}
goToLogin(): void {
this.router.navigate(['/login']);
}
requestNewLink(): void {
// This could redirect to a "request new link" page or contact form
// For now, redirect to login
this.router.navigate(['/login']);
}
showMfaDialog(): void {
console.log('showMfaDialog called, tokenUser:', this.tokenUser);
if (this.tokenUser) {
// Set the user data for MFA dialog first
const loginData = {
email: this.tokenUser.email,
password: '', // Not needed for token-based auth
tokenUser: this.tokenUser
};
// Use multiple approaches to ensure the dialog shows
const tryShowDialog = (attempt: number = 1) => {
console.log(`Attempt ${attempt} to show MFA dialog`);
if (this.mfaDialog) {
console.log('MFA dialog found, setting loginData and showing...');
(this.mfaDialog as any).loginData = loginData;
this.mfaDialog.show();
} else if (attempt < 5) {
// Try again after a short delay
setTimeout(() => tryShowDialog(attempt + 1), 100);
} else {
console.error('MFA dialog not found after multiple attempts');
}
};
// Start trying to show the dialog
tryShowDialog();
} else {
console.error('No token user available for MFA dialog');
}
}
onMfaSuccess(userData: any): void {
this.authService.setCurrentUser(userData);
this.redirectToDashboard();
}
onMfaCancel(): void {
// Reset to initial state
this.isVerifying = true;
this.verificationResult = null;
this.tokenUser = null;
this.errorMessage = '';
}
}
@@ -0,0 +1,90 @@
export class ArrayUtils {
public static Equals(array1: Array<any>, array2: Array<any>): boolean {
// if the other array is a falsy value, return
if (!array2)
return false;
// compare lengths - can save a lot of time
if (array1.length != array2.length)
return false;
for (var i = 0, l = array1.length; i < l; i++) {
// Check if we have nested arrays
if (array1[i] instanceof Array && array2[i] instanceof Array) {
// recurse into the nested arrays
if (!ArrayUtils.Equals(array1[i], array2[i]))
return false;
}
else if (array1[i] != array2[i]) {
// Warning - two different object instances will never be equal: {x:20} != {x:20}
return false;
}
}
return true;
}
// public static ToDropDownOptions(array: any[], keyProperty: string | number, valueProperty: string | number): DropDownOption[] {
// var result = [];
// for (let i = 0; i < array.length; i++) {
// const element = array[i];
// result.push(new DropDownOption(element[keyProperty], element[valueProperty]));
// }
// return result;
// }
public static GroupBy<T = any>(arr: Array<T>, groupKeyPrepareFunction: (obj: T) => any | any[]) {
let groups = arr.reduce(function (groupModel, obj) {
let keys = groupKeyPrepareFunction(obj);
const addToGroup = (key: any, obj: T) => {
let group = groupModel.find(g => g.key == key);
if (group == null) {
group = new GroupModel(key);
groupModel.push(group);
}
group.values.push(obj);
}
if (Array.isArray(keys)) {
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
addToGroup(key, obj);
}
} else {
addToGroup(keys, obj);
}
return groupModel;
}, [] as GroupModel<T>[]);
return groups;
}
public static RemoveDuplicate<T>(objArray: T[], duplicateDetection: (a: T, b: T) => boolean) {
return objArray.filter((value, index, self) =>
index === self.findIndex((t) => duplicateDetection(t, value))
);
}
public static insertAt = (arr: any[], index: number, newItems: any) => [
// part of the array before the specified index
...arr.slice(0, index),
// inserted items
newItems,
// part of the array after the specified index
...arr.slice(index)
];
}
export class GroupModel<T = any> {
constructor(key: any) {
this.key = key;
this.values = [];
}
key: any;
values: T[];
}
+190
View File
@@ -0,0 +1,190 @@
export class DateUtils {
// public static getIntervalDays(from: Date, to: Date, daysOfYear: DaysOfYear = DaysOfYear.ThirtyDaysPerMonth, countEndDate: boolean = false): number {
// let isNegative = false;
// if (from > to) {
// isNegative = true;
// let temp = new Date(to);
// to = from;
// from = temp;
// }
// var days = 0;
// if (from && to) {
// //Get date without time
// from = new Date(from.getFullYear(), from.getMonth(), from.getDate());
// to = new Date(to.getFullYear(), to.getMonth(), to.getDate());
// var differenceTime = to.getTime() - from.getTime();
// if (differenceTime > 0) {
// if (daysOfYear == DaysOfYear.ThirtyDaysPerMonth) {
// var fromYear = from.getFullYear();
// var toYear = to.getFullYear();
// var fromMonth = from.getMonth() + 1;
// var toMonth = to.getMonth() + 1;
// var fromDays = from.getDate() > 30 ? 30 : from.getDate();
// var toDays = to.getDate();
// days += 30 - fromDays;
// days += toDays;
// //calculate full 12 months years
// if (toYear > (fromYear + 1)) {
// days += (toYear - fromYear - 1) * 12 * 30;
// }
// //if it's two different years, calculate the interval months
// if (toYear > fromYear) {
// days += (12 - fromMonth) * 30;
// days += (toMonth - 1) * 30;
// }
// else {
// //same year
// days += (toMonth - fromMonth - 1) * 30
// }
// }
// else {
// // To calculate the no. of days between two dates
// days = Math.round(differenceTime / (1000 * 3600 * 24));
// }
// }
// }
// return (days + (countEndDate ? 1 : 0)) * (isNegative ? -1 : 1);
// }
public static addDays(date: Date, days: number): Date {
if (date) {
var result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
return date;
}
public static format(date: Date, format: string = 'MM/dd/yyyy hh:mm:ss', nullFormat: string = ''): string {
if (date) {
var z = {
M: date.getMonth() + 1,
d: date.getDate(),
H: date.getHours(),
h: (date.getHours() == 0 ? date.getHours() + 12 : date.getHours() > 12 ? date.getHours() - 12 : date.getHours()),
m: date.getMinutes(),
s: date.getSeconds(),
a: (date.getHours() > 11 ? 'PM' : 'AM')
};
format = format.replace(/(M+|d+|H+|h+|m+|s+|a+)/g, function (v) {
return ((v.length > 1 ? "0" : "") + z[v.slice(-1) as keyof typeof z]).slice(-2);
});
return format.replace(/(y+)/g, function (v) {
return date.getFullYear().toString().slice(-v.length)
});
}
return nullFormat;
}
public static isValidDate(d: Date): boolean {
return d instanceof Date && d.getTime() == d.getTime();
}
public static parse(value: string | Date | null | undefined, changeToLocalTime = false): Date | null {
if (value) {
if (typeof value === 'string' && value.includes('-')) {
value = this.parseLocalDate(value);
return value;
}
value = new Date(value);
if (changeToLocalTime) {
//todo: change to local time from UTC
}
} else {
return null;
}
return value
}
public static parseLocalDate(localDate: string): Date {
const [year, month, day] = localDate.split('-').map(Number);
return new Date(year, month, day);
}
public static toLocalDate(date: Date): string {
return this.format(date, 'yyyy-MM-dd');
}
public static getToday(endOfDay: boolean = false): Date {
let value = new Date();
if (!endOfDay) {
value.setHours(0, 0, 0, 0);
}
else {
value.setHours(23, 59, 59, 999);
}
return value
}
public static getBeginOfDate(value: Date): Date {
if (value) {
value = new Date(value);
value.setHours(0, 0, 0, 0);
}
return value
}
public static getEndOfDate(value: Date): Date {
if (value) {
value = new Date(value);
value.setHours(23, 59, 59, 999);
}
return value
}
public static getEndOfMonth(value: Date): Date {
if (value) {
return new Date(value.getFullYear(), value.getMonth() + 1, 0)
}
return value;
}
public static getFirstDayOfCurrentMonth = (): Date => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
};
public static getLastDayOfCurrentMonth = (): Date => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth() + 1, 0);
};
public static isSameDate(date: Date, comparison: Date): boolean {
if (!date || !comparison) return (!date && !comparison);
date = this.parse(date, false) as Date;
comparison = this.parse(comparison, false) as Date;
return date.getFullYear() == comparison.getFullYear() && date.getMonth() == comparison.getMonth() && date.getDate() == comparison.getDate();
}
public static getTimeStamp() {
var now = new Date();
return ((now.getMonth() + 1) + '/' + (now.getDate()) + '/' + now.getFullYear() + " " + now.getHours() + ':'
+ ((now.getMinutes() < 10) ? ("0" + now.getMinutes()) : (now.getMinutes())) + ':' + ((now.getSeconds() < 10) ? ("0" + now
.getSeconds()) : (now.getSeconds())));
}
public static getDatesBetween(startDate: Date, endDate: Date): Date[] {
startDate = this.getBeginOfDate(startDate);
endDate = this.getBeginOfDate(endDate);
let result = [startDate];
if (startDate < endDate) {
let tempDate = new Date(startDate);
while (tempDate < endDate) {
tempDate = this.addDays(tempDate, 1);
result.push(tempDate);
}
}
return result;
}
/**
* Returns the stored time value in milliseconds since midnight, January 1, 1970 UTC., return 0 if date is null
*/
public static getTime(date?: Date): number {
return date != null ? date.getTime() : 0;
}
}
@@ -0,0 +1,18 @@
export class EnumUtils {
public static hasFlag(obj: number, enumValue: number): boolean {
if (obj) {
if (obj & enumValue) {
return true;
}
}
return false;
}
public static GetAllEnumValue(enumType: any): number[] {
return Object
.keys(enumType)
.filter((v) => !isNaN(Number(v)))
.map(v => Number.parseInt(v));
}
}
@@ -0,0 +1,26 @@
export class FileUtils {
public static formatFileSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} Bytes`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(2)} KB`;
} else {
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
}
public static getFileName(fileFullPath: string): string | null {
const lastSlashIndex = fileFullPath.lastIndexOf('\\');
if (lastSlashIndex === -1 || lastSlashIndex === 0 || lastSlashIndex === fileFullPath.length - 1) {
return fileFullPath; // No folder found
}
return fileFullPath.slice(lastSlashIndex + 1);
}
public static getFileExt(filename: string): string | null {
const lastDotIndex = filename.lastIndexOf('.');
if (lastDotIndex === -1 || lastDotIndex === 0 || lastDotIndex === filename.length - 1) {
return null; // No extension found
}
return filename.slice(lastDotIndex);
}
}
@@ -0,0 +1,20 @@
import { Observable } from "rxjs";
export class HtmlElementUtils {
public static findChildByClassName<T = Element>(children: HTMLCollection, className: string): T {
let childrenNodeArr = Array.from(children);
for (let i = 0; i < childrenNodeArr.length; i++) {
const element = childrenNodeArr[i];
if (element.classList.contains(className)) {
return element as T;
} else if (element.children) {
let child = this.findChildByClassName(element.children, className)
if (child) return child as T;
}
}
return null as T;
}
}
@@ -0,0 +1,9 @@
export class LinqUtils {
public static GroupBy(xs: any[], key: any) {
return xs.reduce(function (rv, x) {
(rv[x[key]] = rv[x[key]] || []).push(x);
return rv;
}, {});
}
}
@@ -0,0 +1,84 @@
import { formatCurrency } from "@angular/common";
const PPI = 96;
export class NumberUtils {
public static Ordinal(value: number): string {
let suffix = '';
const last = value % 10;
const specialLast = value % 100;
if (!value || value < 1) {
return value.toString();
}
if (last === 1 && specialLast !== 11) {
suffix = 'st';
} else if (last === 2 && specialLast !== 12) {
suffix = 'nd';
} else if (last === 3 && specialLast !== 13) {
suffix = 'rd';
} else {
suffix = 'th';
}
return value + suffix;
}
public static Clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
public static SortFunction(a: number, b: number): number {
return a - b;
}
public static Sum(array: number[]): number {
return array.reduce((a, b) => (isNaN(a) ? 0 : a) + (isNaN(b) ? 0 : b), 0);
}
public static FormatCurrency(v: number, zeroExpression: string = '0'): string {
return ['', 0, null, undefined, NaN].includes(v) ? zeroExpression : formatCurrency(v, "en", "", "", `0.2`);
}
public static Round(num: number, precision: number) {
const factor = 10 ** precision;
return Math.round(num * factor) / factor;
}
public static RoundCurrency(num: number): number {
return this.Round(num, 2);
}
public static VersionDiff(v1: string, v2: string) {
let versionDefine = [
{ index: 0, name: 'major' },
{ index: 1, name: 'minor' },
{ index: 2, name: 'patch' },
{ index: 3, name: 'build' }
]
if (v1 && v2) {
let v1Versions = v1.split('.');
let v2Versions = v2.split('.');
for (let i = 0; i < v1Versions.length; i++) {
if (v2Versions.length == i) return versionDefine[i];
if (v1Versions[i] != v2Versions[i]) return versionDefine[i];
}
}
return null;
}
public static Average(numArr: number[]) {
return numArr.reduce((a, b) => a + b) / numArr.length;
}
public static PixelToInch(pixel: number) {
return this.Round(pixel / PPI, 2);
}
public static InchToPixel(inch: number) {
return Math.round(inch * PPI);
}
public static Mid(a: number, b: number) {
return a + Math.floor((b - a) / 2);
}
}
@@ -0,0 +1,217 @@
import { Observable } from "rxjs";
const dateAndTimeRegex = new RegExp(/^(?<Date>\d{4}-\d{2}-\d{2})T(?<HourMin>\d{2}:\d{2}):((?<SecondAndMillisecond>\d{2}\.\d{0,6})|(?<Second>\d{2}))$/);
export class ObjectUtils {
private static ReviveDateTime(key: any, value: any): any {
if (typeof value === 'string') {
if (dateAndTimeRegex.test(value)) {
let newDate = new Date(value);
return newDate;
}
}
return value;
}
public static HasAnyData(obj: any, excludes: string[] = []) {
var hasData = false;
for (const p in obj) {
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(obj, p)) {
const element = obj[p];
if (element) {
if (typeof element !== 'object' || this.HasAnyData(element, excludes)) {
hasData = true;
break;
}
}
}
}
return hasData;
}
public static Clone<T = any>(obj: T, avoidCirculateRef = false): T {
if (avoidCirculateRef) {
return JSON.parse(this.stringify(obj), ObjectUtils.ReviveDateTime);
}
return JSON.parse(JSON.stringify(obj), ObjectUtils.ReviveDateTime);
}
public static CopyValue(source: any, destination: any, excludes: string[] = ["id"], overwriting: boolean = true) {
for (const p in source) {
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(source, p)) {
const element = source[p];
if (element && Array.isArray(element)) {
if ([null, undefined].includes(destination[p])) {
destination[p] = [];
for (let i = 0; i < element.length; i++) {
const cloneItem = {};
this.CopyValue(element[i], cloneItem, excludes, true);
destination[p].push(cloneItem);
}
} else if (Array.isArray(destination[p])) {
for (let i = 0; i < element.length; i++) {
const item = element[i];
let destLength = destination[p].length;
if (i >= destLength) {
destination[p].push(this.Clone(source[p][i]));
}
this.CopyValue(item, destination[p][i], excludes, overwriting);
}
}
}
else if (element && typeof element.getMonth === 'function') {
//For angular will treat Date as object
try {
destination[p] = element;
} catch (error) {
console.log(`can\'t copy ${p}`, error);
}
}
else if (element && typeof element == 'object') {
let objectOverwriting = overwriting;
if ([null, undefined].includes(destination[p])) {
destination[p] = {};
objectOverwriting = true;
}
try {
this.CopyValue(element, destination[p], excludes, objectOverwriting);
} catch (error) {
console.log(`can\'t copy ${p}`, error);
}
} else if (overwriting || [null, '', undefined].includes(destination[p])) {
try {
destination[p] = element;
} catch (error) {
console.log(`can\'t copy ${p}`, error);
}
}
}
}
}
public static ClearPkAndFk(source: any, excludes: string[] = [], clearExtra: string[] = []) {
for (const p in source) {
const element = source[p];
if (element) {
if (clearExtra.includes(p)) {
source[p] = null;
} else if (typeof element == 'object') {
this.ClearPkAndFk(element, excludes, clearExtra);
} else if (
(typeof element == 'string' && (p == 'id' || p.indexOf('Id') > -1) &&
false == excludes.includes(p))
) {
source[p] = null;
}
}
}
}
public static ClearValue(source: any, excludes: string[] = ["id"]) {
for (const p in source) {
if (false == excludes.includes(p) && Object.prototype.hasOwnProperty.call(source, p)) {
const element = source[p];
if (element) {
if (typeof element == 'object') {
this.ClearValue(element, excludes);
} else if (typeof element == 'number') {
source[p] = 0;
} else {
source[p] = null;
}
}
}
}
}
/**
* return `true` if the comparison has any different value with source.
*/
public static CompareDiffValue(source: any, comparison: any, excludes: string[] = ["id"]) {
let isDifferent = false;
for (const p in source) {
if (false == excludes.includes(p) &&
Object.prototype.hasOwnProperty.call(source, p)) {
const element = source[p];
if (element && Array.isArray(element)) {
if (Array.isArray(comparison[p])) {
for (let i = 0; i < element.length; i++) {
const item = element[i];
let destLength = comparison[p].length;
if (i >= destLength) {
return true;
}
isDifferent = this.CompareDiffValue(item, comparison[p][i], excludes);
if (isDifferent) return true;
}
}
}
else if (element && typeof element.getMonth === 'function') {
//For angular will treat Date as object
//TODO:Compare Date
//comparison[p] = element;
}
else if (element && typeof element == 'object') {
isDifferent = this.CompareDiffValue(element, comparison[p], excludes);
} else {
isDifferent = comparison[p] != element;
}
if (isDifferent) return true;
}
}
return false;
}
/**
* return `true` if the comparison has any different value with source.
*/
public static CompareDiffArrayContent(source: any[], comparison: any[], excludes: string[] = ["id"]) {
let isDifferent = false;
if (source && comparison) {
if (source.length == comparison.length) {
for (let i = 0; i < source.length; i++) {
const sourceItem = source[i];
const comparisonItem = comparison[i];
isDifferent = this.CompareDiffValue(sourceItem, comparisonItem);
if (isDifferent) return true;
}
return false;
}
}
return true;
}
public static isNullOrUndefined(obj: any) {
return [null, undefined].includes(obj);
}
public static isObservable<T>(template: T | Observable<T>): template is Observable<T> {
return (template as Observable<T>).subscribe !== undefined;
}
public static stringify(obj: any): string {
let cache: any[] = [];
let str = JSON.stringify(obj, function (key, value) {
if (typeof value === "object" && value !== null) {
if (cache.indexOf(value) !== -1) {
// Circular reference found, discard key
return;
}
// Store value in our collection
cache.push(value);
}
return value;
});
cache = []; // reset the cache
return str;
}
}
@@ -0,0 +1,204 @@
import { AddressInfo } from "../models";
export class StringUtils {
static compare(aval: string, bval: string) {
return aval ? aval.localeCompare(bval) : -1;
}
// Sorting function for SemVer
// Returns 1 if a is greater, -1 if b is greater, 0 if equal
public static compareSemVer(a: string, b: string): number {
if (a === b) {
return 0;
}
var a_components = a.split(".");
var b_components = b.split(".");
var len = Math.min(a_components.length, b_components.length);
// loop while the components are equal
for (var i = 0; i < len; i++) {
// A bigger than B
if (parseInt(a_components[i]) > parseInt(b_components[i])) {
return 1;
}
// B bigger than A
if (parseInt(a_components[i]) < parseInt(b_components[i])) {
return -1;
}
}
// If one's a prefix of the other, the longer one is greater.
if (a_components.length > b_components.length) {
return 1;
}
if (a_components.length < b_components.length) {
return -1;
}
// Otherwise they are the same.
return 0;
}
public static isNullOrWhitespace(input: string): boolean {
return !input || !input.trim();
}
public static getTrimmedValue(input: string, emptyFormat: string = ''): string {
if (input) {
input = input.trim();
}
if (input) return input;
return emptyFormat;
}
public static removeNewLines(input: string): string {
if (input) {
input = input.replace(/[\r\n]+/g, '');
}
return input;
}
public static firstIsVowel(s: string): boolean {
if (s) {
return ['a', 'e', 'i', 'o', 'u'].indexOf(s[0].toLowerCase()) !== -1
}
return false;
}
public static makeCommaSeparatedString(arr: string[], useOxfordComma: boolean,
conjunctionWord: string = "and") {
arr = arr.filter(s => s);
const listStart = arr.slice(0, -1).join(', ');
const listEnd = arr.slice(-1);
const conjunction =
arr.length <= 1
? ""
: useOxfordComma && arr.length > 2
? `, ${conjunctionWord} `
: ` ${conjunctionWord} `;
return [listStart, listEnd].join(conjunction);
}
public static getFormattedTerm(input: string, emptyFormatter: string = 'Empty'): string {
if (false == this.isNullOrWhitespace(input)) {
return input.trim();
}
return emptyFormatter;
}
public static camelToTitle(camelCase: string): string {
// no side-effects
return camelCase
// inject space before the upper case letters
.replace(/([A-Z])/g, function (match) {
return " " + match;
})
// replace first char with upper case
.replace(/^./, function (match) {
return match.toUpperCase();
}).trim();
}
public static getCapitalLetters(str: string) {
return str.replace(/[^A-Z]+/g, "");
}
/**
* status could be `info` , `primary` , `danger` , `warning` , `success`
*/
public static getHtmlBadge(text: string, badgeStatus: string, mergingClass = 'mr-1') {
return `<label class="badge badge-${badgeStatus} ${mergingClass} my-0 py-1">${text}</label>`;
}
public static getHtmlInnerText(htmlText: string) {
return htmlText ? htmlText.replace(/<[^>]+>/g, '') : '';
}
/**
* Try to parse city sate zip string like `Monrovia, CA 91016` to AddressInfo, if failed will return `null` instead.
*/
public static tryParseCityStateZip(cityStateZip: string): AddressInfo {
let addressInfo = {} as AddressInfo;
var regex = /([\w\s]*),\s*([A-Z]{2})\s*(\d*-?\d*)/g;
var match = regex.exec(cityStateZip);
if (match) {
addressInfo = {
city: match[1],
state: match[2],
zip: match[3]
} as AddressInfo;
return addressInfo;
} else {
return {} as AddressInfo;
}
}
public static getCityStateZipString(addressInfo: AddressInfo): string {
let result = '';
if (false == this.isNullOrWhitespace(addressInfo.city)) {
result += `${addressInfo.city}, `;
}
if (false == this.isNullOrWhitespace(addressInfo.state)) {
result += `${addressInfo.state} `;
}
if (false == this.isNullOrWhitespace(addressInfo.zip)) {
result += addressInfo.zip;
}
return result;
}
public static getFullAddressString(addressInfo: AddressInfo): string {
if (addressInfo && StringUtils.isNullOrWhitespace(addressInfo.address)) {
return 'No Subject Property Entered';
}
else {
return `${addressInfo.address}, ${this.getCityStateZipString(addressInfo)}`
}
}
public static toUpperString(prop: any) {
if (prop) {
if (typeof prop === 'string') return prop.toUpperCase();
return prop.toString().toUpperCase();
} else {
return '';
}
}
public static toLowerString(prop: any) {
if (prop) {
if (typeof prop === 'string') return prop.toLowerCase();
return prop.toString().toLowerCase();
} else {
return '';
}
}
public static truncateString(str: string, maxLength: number) {
if (str && str.length > maxLength) {
return str.slice(0, maxLength);
} else {
return str;
}
}
public static insertAt(str: string, insertValue: string, index: number) {
return [str.slice(0, index), insertValue, str.slice(index)].join('');
}
public static replaceAll(str: string, find: string, replace: string) {
return str.replace(new RegExp(find, 'g'), replace);
}
public static safeLocaleCompare(a: string, b: string, sortDirection: string = 'asc') {
if (sortDirection == 'asc' && a)
return b ? a.localeCompare(b) : -1;
else if (b)
return a ? b.localeCompare(a) : 1;
else return 0;
}
}
@@ -0,0 +1,49 @@
export class TextareaUtils {
public static autoExpand(field: HTMLTextAreaElement) {
//
if (field) {
// Reset field height
field.style.height = '0px';
const computed = window.getComputedStyle(field);
// Calculate the height
var height = 0
+ parseInt(computed.getPropertyValue('border-top-width'), 10)
+ field.scrollHeight
+ parseInt(computed.getPropertyValue('border-bottom-width'), 10);
field.style.height = height + 'px';
}
}
public static isEditingKeyPress(e: KeyboardEvent) {
if ([46, 8, 9, 27, 13, 190].indexOf(e.keyCode) !== -1 ||
// Allow: Ctrl+A
(e.keyCode === 65 && (e.ctrlKey || e.metaKey)) ||
// Allow: Ctrl+C
(e.keyCode === 67 && (e.ctrlKey || e.metaKey)) ||
// Allow: Ctrl+V
(e.keyCode === 86 && (e.ctrlKey || e.metaKey)) ||
// Allow: Ctrl+X
(e.keyCode === 88 && (e.ctrlKey || e.metaKey)) ||
// Allow: page up, page down, home, end, left, right
(e.keyCode >= 33 && e.keyCode <= 39)) {
// let it happen, don't do anything
return false;
}
if (typeof e.which == "undefined") {
// This is IE, which only fires keypress events for printable keys
return true;
} else if (e.ctrlKey && e.key.toUpperCase() == 'V') {
return true;
}
else if (!e.ctrlKey && !e.metaKey && !e.altKey && e.code != 'Tab' && e.key != 'Control') {
return true;
}
return false;
}
}
@@ -0,0 +1,50 @@
export class TimerUtils {
// private static debounceTimers: DebounceTimer[];
// private static addDebounceTimer(key: string, debounceTime: number, callback: Function) {
// if (!this.debounceTimers) {
// this.debounceTimers = [];
// }
// let timerProfile = this.debounceTimers.find(t => t.key == key);
// if (timerProfile) {
// clearTimeout(timerProfile.timer);
// } else {
// timerProfile = new DebounceTimer(){
// }
// }
// }
}
export class DebounceTimer {
constructor(
debounceTime: number,
callback: Function
) {
//this.key = key
this.debounceTime = debounceTime;
this.callback = callback;
//this.resetTimer();
}
debounceTime: number;
timer: any;
callback: Function;
resetTimer() {
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.callback();
this.timer = null;
}, this.debounceTime);
}
clearOut() {
if (this.timer) {
clearTimeout(this.timer);
}
}
}
@@ -0,0 +1,34 @@
const GUID_EMPTY = '00000000-0000-0000-0000-000000000000';
export class UuidUtils {
public static generate() {
var d = new Date().getTime();//Timestamp
var d2 = (performance && performance.now && (performance.now() * 1000)) || 0;//Time in microseconds since page-load or 0 if unsupported
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16;//random number between 0 and 16
if (d > 0) {//Use timestamp until depleted
r = (d + r) % 16 | 0;
d = Math.floor(d / 16);
} else {//Use microseconds since page-load if supported
r = (d2 + r) % 16 | 0;
d2 = Math.floor(d2 / 16);
}
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
public static stringToUuid = (str: string) => {
str = str.replace('-', '');
let index = -1;
return 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'.replace(/[x]/g, function (c, p) {
index++;
return str[index];
});
}
public static empty() {
return GUID_EMPTY;
}
public static isNullOrEmpty(uuid: string) {
return !uuid || [GUID_EMPTY, undefined, null].includes(uuid);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Some files were not shown because too many files have changed in this diff Show More