Compare commits
19 Commits
| 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
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
/.claude
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ROLAC.API\ROLAC.API.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class AuthServiceTests
|
||||||
|
{
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Factory helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private static AppDbContext BuildDb() =>
|
||||||
|
new(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
private static IConfiguration BuildConfig() =>
|
||||||
|
new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "Jwt:RefreshTokenExpiryDays", "30" },
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
/// <summary>Creates a <see cref="UserManager{TUser}"/> mock with sensible defaults.</summary>
|
||||||
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||||
|
AppUser? findResult = null,
|
||||||
|
bool passwordOk = true,
|
||||||
|
IList<string>? roles = null)
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<AppUser>>();
|
||||||
|
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||||
|
#pragma warning disable CS8625
|
||||||
|
var mgr = new Mock<UserManager<AppUser>>(
|
||||||
|
store.Object, null, null, null, null, null, null, null, null);
|
||||||
|
#pragma warning restore CS8625
|
||||||
|
|
||||||
|
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(findResult);
|
||||||
|
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(findResult);
|
||||||
|
mgr.Setup(m => m.CheckPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(passwordOk);
|
||||||
|
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
|
||||||
|
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||||
|
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
|
return mgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ITokenService mock: GenerateAccessToken → "access-token",
|
||||||
|
/// GenerateRefreshToken → "raw-refresh", HashToken(x) → "hash:{x}".
|
||||||
|
/// </summary>
|
||||||
|
private static Mock<ITokenService> BuildTokenService()
|
||||||
|
{
|
||||||
|
var svc = new Mock<ITokenService>();
|
||||||
|
svc.Setup(t => t.GenerateAccessToken(It.IsAny<AppUser>(), It.IsAny<IList<string>>()))
|
||||||
|
.Returns("access-token");
|
||||||
|
svc.Setup(t => t.GenerateRefreshToken())
|
||||||
|
.Returns("raw-refresh");
|
||||||
|
svc.Setup(t => t.HashToken(It.IsAny<string>()))
|
||||||
|
.Returns<string>(s => $"hash:{s}");
|
||||||
|
return svc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthService BuildSut(
|
||||||
|
Mock<UserManager<AppUser>> umMock,
|
||||||
|
Mock<ITokenService> tsMock,
|
||||||
|
AppDbContext db)
|
||||||
|
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Login tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_ValidCredentials_ReturnsAccessTokenAndRefreshToken()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||||
|
var um = BuildUserManager(findResult: user, roles: new[] { "member" });
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var db = BuildDb();
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
|
||||||
|
var (response, raw) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
Assert.Equal("access-token", response.AccessToken);
|
||||||
|
Assert.Equal(900, response.ExpiresIn);
|
||||||
|
Assert.Equal("u1", response.User.Id);
|
||||||
|
Assert.Equal("a@b.com", response.User.Email);
|
||||||
|
Assert.Contains("member", response.User.Roles);
|
||||||
|
Assert.Equal("raw-refresh", raw);
|
||||||
|
|
||||||
|
// Persisted in DB
|
||||||
|
var stored = db.RefreshTokens.Single();
|
||||||
|
Assert.Equal("hash:raw-refresh", stored.TokenHash);
|
||||||
|
Assert.Equal("u1", stored.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_UnknownEmail_ThrowsUnauthorized()
|
||||||
|
{
|
||||||
|
var um = BuildUserManager(findResult: null);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => sut.LoginAsync(new LoginRequest { Email = "nope@b.com", Password = "P@ssw0rd!" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WrongPassword_ThrowsUnauthorized()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||||
|
var um = BuildUserManager(findResult: user, passwordOk: false);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "wrong" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_InactiveAccount_ThrowsUnauthorized()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = false };
|
||||||
|
var um = BuildUserManager(findResult: user, passwordOk: true);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_Success_UpdatesLastLoginAt()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||||
|
|
||||||
|
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Refresh tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_ValidToken_RotatesRefreshToken()
|
||||||
|
{
|
||||||
|
const string rawOld = "old-raw";
|
||||||
|
const string rawNew = "raw-refresh"; // what BuildTokenService returns
|
||||||
|
|
||||||
|
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||||
|
var um = BuildUserManager(findResult: user);
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var db = BuildDb();
|
||||||
|
|
||||||
|
// Seed an active token
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
UserId = "u1",
|
||||||
|
TokenHash = "hash:old-raw",
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
var (response, newRaw) = await sut.RefreshAsync(rawOld);
|
||||||
|
|
||||||
|
Assert.Equal("access-token", response.AccessToken);
|
||||||
|
Assert.Equal(rawNew, newRaw);
|
||||||
|
|
||||||
|
// Old token revoked
|
||||||
|
var old = db.RefreshTokens.First(rt => rt.TokenHash == "hash:old-raw");
|
||||||
|
Assert.NotNull(old.RevokedAt);
|
||||||
|
Assert.Equal("hash:raw-refresh", old.ReplacedByHash);
|
||||||
|
|
||||||
|
// New token stored
|
||||||
|
var newTok = db.RefreshTokens.First(rt => rt.TokenHash == "hash:raw-refresh");
|
||||||
|
Assert.Null(newTok.RevokedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_UnknownToken_ThrowsUnauthorized()
|
||||||
|
{
|
||||||
|
var um = BuildUserManager();
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, BuildDb());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => sut.RefreshAsync("no-such-token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Refresh_ExpiredToken_ThrowsUnauthorized()
|
||||||
|
{
|
||||||
|
var db = BuildDb();
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
UserId = "u1",
|
||||||
|
TokenHash = "hash:expired-raw",
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(-1), // already expired
|
||||||
|
CreatedAt = DateTime.UtcNow.AddDays(-31),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var um = BuildUserManager();
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(
|
||||||
|
() => sut.RefreshAsync("expired-raw"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Logout tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Logout_ValidToken_RevokesRefreshToken()
|
||||||
|
{
|
||||||
|
const string raw = "my-raw-token";
|
||||||
|
|
||||||
|
var db = BuildDb();
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
UserId = "u1",
|
||||||
|
TokenHash = "hash:my-raw-token",
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var um = BuildUserManager();
|
||||||
|
var ts = BuildTokenService();
|
||||||
|
var sut = BuildSut(um, ts, db);
|
||||||
|
|
||||||
|
await sut.LogoutAsync(raw);
|
||||||
|
|
||||||
|
var token = db.RefreshTokens.Single();
|
||||||
|
Assert.NotNull(token.RevokedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class TokenServiceTests
|
||||||
|
{
|
||||||
|
private readonly TokenService _sut;
|
||||||
|
|
||||||
|
public TokenServiceTests()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "Jwt:SecretKey", "test-secret-key-that-is-at-least-32-characters-long!!" },
|
||||||
|
{ "Jwt:Issuer", "rolac-api-test" },
|
||||||
|
{ "Jwt:Audience", "rolac-client-test" },
|
||||||
|
{ "Jwt:AccessTokenExpiryMinutes", "15" },
|
||||||
|
{ "Jwt:RefreshTokenExpiryDays", "30" },
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_sut = new TokenService(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base64Url-decodes the payload segment of a JWT and returns it as a
|
||||||
|
/// <see cref="JsonDocument"/>. Caller is responsible for disposing.
|
||||||
|
/// </summary>
|
||||||
|
private static JsonDocument ReadJwtPayload(string token)
|
||||||
|
{
|
||||||
|
var b64 = token.Split('.')[1];
|
||||||
|
var mod4 = b64.Length % 4;
|
||||||
|
if (mod4 > 0) b64 += new string('=', 4 - mod4);
|
||||||
|
var bytes = Convert.FromBase64String(b64.Replace('-', '+').Replace('_', '/'));
|
||||||
|
return JsonDocument.Parse(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GenerateAccessToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
|
||||||
|
var roles = new List<string> { "member", "finance" };
|
||||||
|
|
||||||
|
var token = _sut.GenerateAccessToken(user, roles);
|
||||||
|
|
||||||
|
Assert.NotEmpty(token);
|
||||||
|
|
||||||
|
// Decode payload without touching any Microsoft.IdentityModel API so that
|
||||||
|
// library version mismatches (7.1.2 vs 7.5.2) cannot affect this test.
|
||||||
|
using var doc = ReadJwtPayload(token);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("user-123", root.GetProperty("sub").GetString());
|
||||||
|
Assert.Equal("test@rolac.org", root.GetProperty("email").GetString());
|
||||||
|
|
||||||
|
// JwtSecurityTokenHandler's DefaultOutboundClaimTypeMap maps
|
||||||
|
// ClaimTypes.Role → "role", so roles land under the "role" key.
|
||||||
|
// When multiple claims share a type they are serialised as a JSON array.
|
||||||
|
var roleEl = root.GetProperty("role");
|
||||||
|
var roleClaims = roleEl.ValueKind == JsonValueKind.Array
|
||||||
|
? roleEl.EnumerateArray().Select(e => e.GetString()!).ToList()
|
||||||
|
: new List<string> { roleEl.GetString()! };
|
||||||
|
|
||||||
|
Assert.Contains("member", roleClaims);
|
||||||
|
Assert.Contains("finance", roleClaims);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAccessToken_ExpiresIn15Minutes()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
|
||||||
|
|
||||||
|
var token = _sut.GenerateAccessToken(user, new List<string>());
|
||||||
|
|
||||||
|
using var doc = ReadJwtPayload(token);
|
||||||
|
var expUnix = doc.RootElement.GetProperty("exp").GetInt64();
|
||||||
|
var expectedUnix = DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeSeconds();
|
||||||
|
|
||||||
|
// Allow ±2 s for test execution time.
|
||||||
|
Assert.InRange(expUnix, expectedUnix - 2, expectedUnix + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GenerateRefreshToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateRefreshToken_Returns86CharUrlSafeBase64()
|
||||||
|
{
|
||||||
|
var token = _sut.GenerateRefreshToken();
|
||||||
|
|
||||||
|
Assert.NotEmpty(token);
|
||||||
|
// 64 bytes → 88 standard Base64 chars → 86 URL-safe chars (no '==' padding,
|
||||||
|
// '+' → '-', '/' → '_'). All chars must be unreserved in RFC 6265 cookie-values.
|
||||||
|
Assert.Equal(86, token.Length);
|
||||||
|
Assert.True(
|
||||||
|
token.All(c => char.IsLetterOrDigit(c) || c == '-' || c == '_'),
|
||||||
|
"Token must use URL-safe Base64 alphabet (A-Z a-z 0-9 - _)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
|
||||||
|
{
|
||||||
|
var token1 = _sut.GenerateRefreshToken();
|
||||||
|
var token2 = _sut.GenerateRefreshToken();
|
||||||
|
|
||||||
|
Assert.NotEqual(token1, token2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HashToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashToken_SameInputProducesSameHash()
|
||||||
|
{
|
||||||
|
const string raw = "some-raw-token-value";
|
||||||
|
|
||||||
|
var hash1 = _sut.HashToken(raw);
|
||||||
|
var hash2 = _sut.HashToken(raw);
|
||||||
|
|
||||||
|
Assert.Equal(hash1, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashToken_DifferentInputsProduceDifferentHashes()
|
||||||
|
{
|
||||||
|
var hash1 = _sut.HashToken("token-a");
|
||||||
|
var hash2 = _sut.HashToken("token-b");
|
||||||
|
|
||||||
|
Assert.NotEqual(hash1, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashToken_Returns64CharLowercaseHexString()
|
||||||
|
{
|
||||||
|
var hash = _sut.HashToken("any-token");
|
||||||
|
|
||||||
|
Assert.Equal(64, hash.Length);
|
||||||
|
Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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