Compare commits
19 Commits
9b28fbcfb6
...
60405ef0aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 60405ef0aa | |||
| 62428cd2d4 | |||
| 4874f2a0a3 | |||
| dc7909e247 | |||
| aa0c5403a1 | |||
| 98965274b8 | |||
| d1f342e3d0 | |||
| 2aa095c158 | |||
| ef0098d5cc | |||
| 8b86bd573e | |||
| 9db8b34181 | |||
| f74563bb36 | |||
| b335867b30 | |||
| a66a3f7cb0 | |||
| 40d740d6e0 | |||
| 5a789fb0c2 | |||
| cab4c6778f | |||
| 4da8806bfc | |||
| d5648315a0 |
@@ -91,3 +91,4 @@ logs/
|
||||
*.log
|
||||
*.tmp
|
||||
*.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')));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
public class AppRole : IdentityRole
|
||||
{
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
@ROLAC.API_HostAddress = http://localhost:5208
|
||||
|
||||
GET {{ROLAC.API_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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" ]
|
||||
}
|
||||
}
|
||||
@@ -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.*
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
Generated
+11376
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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]))
|
||||
]
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
<router-outlet></router-outlet>
|
||||
<div kendoDialogContainer></div>
|
||||
@@ -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' }
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})` : '');
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user