Skip to main content

FingerprintJS + Admin Security Screens — 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: Integrate FingerprintJS Pro into the xtrakt login flow, capture login audit data in xtrakt-sso-be, and build four Angular admin screens (Insights, Device Intelligence, Audit Log, Security Config).

Architecture: FingerprintJS Pro Angular SDK captures visitorId in the browser at login, sends it as X-Visitor-Id header on POST /connect/token. The AuthController.Exchange() method reads this header plus IP/User-Agent, then calls ILoginAuditService to persist a LoginAuditLog row and resolve geo data from ip-api.com (fire-and-forget). Four new Angular components call GET/PUT /admin/* endpoints on xtrakt-sso-be, protected by an adminGuard. These screens are visible only to platform admins (cogneris-platform-admin role).

Tech Stack: .NET 10.0 / EF Core / PostgreSQL (sso-be), Angular 21 standalone components, FingerprintJS Pro Angular, Chart.js (canvas, no wrapper), XLSX (export), system-font neumorphic styles.

All backend commands: run from xtrakt-sso-be/ All frontend commands: run from xtrakt-sso-fe/src/


File Map

Backend — xtrakt-sso-be

ActionPath
Createsrc/xTrackt.Sso.Domain/Entities/LoginAuditLog.cs
Createsrc/xTrackt.Sso.Domain/Entities/SecurityParameter.cs
Createsrc/xTrackt.Sso.Domain/Repositories/ILoginAuditRepository.cs
Createsrc/xTrackt.Sso.Domain/Repositories/ISecurityParameterRepository.cs
Createsrc/xTrackt.Sso.Infra/Mappings/LoginAuditLogMapping.cs
Createsrc/xTrackt.Sso.Infra/Mappings/SecurityParameterMapping.cs
Createsrc/xTrackt.Sso.Infra/Repositories/LoginAuditRepository.cs
Createsrc/xTrackt.Sso.Infra/Repositories/SecurityParameterRepository.cs
Modifysrc/xTrackt.Sso.Infra/Startup.cs
Createsrc/xTrackt.Sso.Infra/Migrations/<timestamp>_AddAuditLog.cs (via dotnet ef)
Createsrc/xTrackt.Sso.Application/Services/Interfaces/ILoginAuditService.cs
Createsrc/xTrackt.Sso.Application/Services/LoginAuditService.cs
Createsrc/xTrackt.Sso.Application/Services/Interfaces/ISecurityParameterService.cs
Createsrc/xTrackt.Sso.Application/Services/SecurityParameterService.cs
Modifysrc/xTrackt.Sso.Application/Startup.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/GetOverview/GetOverviewHandler.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/GetOverview/GetOverviewRequest.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/GetOverview/GetOverviewResponse.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/ListDevices/ListDevicesHandler.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/ListDevices/ListDevicesRequest.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/ListDevices/ListDevicesResponse.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/ListAudit/ListAuditHandler.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/ListAudit/ListAuditRequest.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/ListAudit/ListAuditResponse.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/GetSecurityConfig/GetSecurityConfigHandler.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/GetSecurityConfig/GetSecurityConfigRequest.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/GetSecurityConfig/GetSecurityConfigResponse.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/UpdateSecurityConfig/UpdateSecurityConfigHandler.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/UpdateSecurityConfig/UpdateSecurityConfigRequest.cs
Createsrc/xTrackt.Sso.Application/Handlers/Admin/UpdateSecurityConfig/UpdateSecurityConfigResponse.cs
Createsrc/Web/Controllers/AdminController.cs
Modifysrc/Web/Controllers/AuthController.cs

Frontend — xtrakt-sso-fe/src

ActionPath
Modifypackage.json (install deps)
Modifysrc/app/app.config.ts
Modifysrc/app/services/auth.service.ts
Modifysrc/app/login/login.component.ts
Createsrc/app/guards/admin.guard.ts
Modifysrc/app/app.routes.ts
Modifysrc/app/menu/menu.component.ts
Createsrc/app/services/admin.service.ts
Createsrc/app/admin/world-map.component.ts
Createsrc/app/admin/world-map.component.html
Createsrc/app/admin/world-map.component.scss
Createsrc/app/admin/insights.component.ts
Createsrc/app/admin/insights.component.html
Createsrc/app/admin/insights.component.scss
Createsrc/app/admin/device-intelligence.component.ts
Createsrc/app/admin/device-intelligence.component.html
Createsrc/app/admin/device-intelligence.component.scss
Createsrc/app/admin/audit-log.component.ts
Createsrc/app/admin/audit-log.component.html
Createsrc/app/admin/audit-log.component.scss
Createsrc/app/admin/security-config.component.ts
Createsrc/app/admin/security-config.component.html
Createsrc/app/admin/security-config.component.scss
Modifysrc/assets/i18n/en.json
Modifysrc/assets/i18n/pt-BR.json

Task 1: Domain Entities

Files:

  • Create: src/xTrackt.Sso.Domain/Entities/LoginAuditLog.cs

  • Create: src/xTrackt.Sso.Domain/Entities/SecurityParameter.cs

  • Create: src/xTrackt.Sso.Domain/Repositories/ILoginAuditRepository.cs

  • Create: src/xTrackt.Sso.Domain/Repositories/ISecurityParameterRepository.cs

  • Step 1: Create LoginAuditLog entity

// src/xTrackt.Sso.Domain/Entities/LoginAuditLog.cs
namespace xTrackt.Sso.Domain.Entities;

public class LoginAuditLog
{
public Guid Id { get; set; }
public string VisitorId { get; set; } = string.Empty;
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string IpAddress { get; set; } = string.Empty;
public string UserAgent { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public string CountryCode { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public bool Success { get; set; }
public DateTime LoggedAt { get; set; }
}
  • Step 2: Create SecurityParameter entity
// src/xTrackt.Sso.Domain/Entities/SecurityParameter.cs
namespace xTrackt.Sso.Domain.Entities;

public class SecurityParameter
{
public Guid Id { get; set; }
public string ParamKey { get; set; } = string.Empty;
public string ParamValue { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; }
}
  • Step 3: Create ILoginAuditRepository
// src/xTrackt.Sso.Domain/Repositories/ILoginAuditRepository.cs
namespace xTrackt.Sso.Domain.Repositories;

public interface ILoginAuditRepository
{
Task AddAsync(LoginAuditLog log);
Task<(List<LoginAuditLog> Items, int Total)> GetPageAsync(
DateTime from, DateTime to, bool? success, string? userName, int page, int pageSize);
Task<List<LoginAuditLog>> GetRangeAsync(DateTime from, DateTime to);
}
  • Step 4: Create ISecurityParameterRepository
// src/xTrackt.Sso.Domain/Repositories/ISecurityParameterRepository.cs
namespace xTrackt.Sso.Domain.Repositories;

public interface ISecurityParameterRepository
{
Task<List<SecurityParameter>> GetAllAsync();
Task<SecurityParameter?> GetByKeyAsync(string key);
Task UpsertAsync(string key, string value, string description);
}
  • Step 5: Commit
git add src/xTrackt.Sso.Domain/
git commit -m "feat(domain): add LoginAuditLog and SecurityParameter entities"

Task 2: Infra — Mappings and Repositories

Files:

  • Create: src/xTrackt.Sso.Infra/Mappings/LoginAuditLogMapping.cs

  • Create: src/xTrackt.Sso.Infra/Mappings/SecurityParameterMapping.cs

  • Create: src/xTrackt.Sso.Infra/Repositories/LoginAuditRepository.cs

  • Create: src/xTrackt.Sso.Infra/Repositories/SecurityParameterRepository.cs

  • Step 1: Create LoginAuditLogMapping

Note: The existing xtrakt-sso-be uses lowercase column names without underscores (e.g. passwordhash, cellphoneconfirmed). Follow the same convention.

// src/xTrackt.Sso.Infra/Mappings/LoginAuditLogMapping.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using xTrackt.Sso.Domain.Entities;

namespace xTrackt.Sso.Infra.Mappings;

public class LoginAuditLogMapping : IEntityTypeConfiguration<LoginAuditLog>
{
public void Configure(EntityTypeBuilder<LoginAuditLog> builder)
{
builder.ToTable("loginauditlog");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.VisitorId).HasColumnName("visitorid").HasMaxLength(200);
builder.Property(x => x.UserId).HasColumnName("userid").HasMaxLength(200);
builder.Property(x => x.UserName).HasColumnName("username").HasMaxLength(200);
builder.Property(x => x.IpAddress).HasColumnName("ipaddress").HasMaxLength(100);
builder.Property(x => x.UserAgent).HasColumnName("useragent").HasMaxLength(500);
builder.Property(x => x.Country).HasColumnName("country").HasMaxLength(100);
builder.Property(x => x.CountryCode).HasColumnName("countrycode").HasMaxLength(10);
builder.Property(x => x.City).HasColumnName("city").HasMaxLength(100);
builder.Property(x => x.Success).HasColumnName("success");
builder.Property(x => x.LoggedAt).HasColumnName("loggedat");
builder.HasIndex(x => x.LoggedAt);
builder.HasIndex(x => x.VisitorId);
}
}
  • Step 2: Create SecurityParameterMapping
// src/xTrackt.Sso.Infra/Mappings/SecurityParameterMapping.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using xTrackt.Sso.Domain.Entities;

namespace xTrackt.Sso.Infra.Mappings;

public class SecurityParameterMapping : IEntityTypeConfiguration<SecurityParameter>
{
public void Configure(EntityTypeBuilder<SecurityParameter> builder)
{
builder.ToTable("securityparameter");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ParamKey).HasColumnName("paramkey").HasMaxLength(100).IsRequired();
builder.Property(x => x.ParamValue).HasColumnName("paramvalue").HasMaxLength(500).IsRequired();
builder.Property(x => x.Description).HasColumnName("description").HasMaxLength(500);
builder.Property(x => x.UpdatedAt).HasColumnName("updatedat");
builder.HasIndex(x => x.ParamKey).IsUnique();
}
}
  • Step 3: Create LoginAuditRepository
// src/xTrackt.Sso.Infra/Repositories/LoginAuditRepository.cs
using Microsoft.EntityFrameworkCore;
using xTrackt.Sso.Domain.Entities;
using xTrackt.Sso.Domain.Repositories;
using xTrakt.DataAccess.Entity;

namespace xTrackt.Sso.Infra.Repositories;

public class LoginAuditRepository : xTrakt.DataAccess.Repository, ILoginAuditRepository
{
public LoginAuditRepository(PostgreSqlContext context) : base(context) { }

public async Task AddAsync(LoginAuditLog log)
{
await DbContext.Set<LoginAuditLog>().AddAsync(log);
await DbContext.SaveChangesAsync();
}

public async Task<(List<LoginAuditLog> Items, int Total)> GetPageAsync(
DateTime from, DateTime to, bool? success, string? userName, int page, int pageSize)
{
var query = DbContext.Set<LoginAuditLog>()
.AsNoTracking()
.Where(x => x.LoggedAt >= from && x.LoggedAt <= to);

if (success.HasValue)
query = query.Where(x => x.Success == success.Value);

if (!string.IsNullOrWhiteSpace(userName))
query = query.Where(x => x.UserName.ToLower().Contains(userName.ToLower()));

var total = await query.CountAsync();
var items = await query
.OrderByDescending(x => x.LoggedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();

return (items, total);
}

public async Task<List<LoginAuditLog>> GetRangeAsync(DateTime from, DateTime to)
{
return await DbContext.Set<LoginAuditLog>()
.AsNoTracking()
.Where(x => x.LoggedAt >= from && x.LoggedAt <= to)
.OrderByDescending(x => x.LoggedAt)
.ToListAsync();
}
}
  • Step 4: Create SecurityParameterRepository
// src/xTrackt.Sso.Infra/Repositories/SecurityParameterRepository.cs
using Microsoft.EntityFrameworkCore;
using xTrackt.Sso.Domain.Entities;
using xTrackt.Sso.Domain.Repositories;
using xTrakt.DataAccess.Entity;

namespace xTrackt.Sso.Infra.Repositories;

public class SecurityParameterRepository : xTrakt.DataAccess.Repository, ISecurityParameterRepository
{
public SecurityParameterRepository(PostgreSqlContext context) : base(context) { }

public async Task<List<SecurityParameter>> GetAllAsync()
{
return await DbContext.Set<SecurityParameter>().AsNoTracking().ToListAsync();
}

public async Task<SecurityParameter?> GetByKeyAsync(string key)
{
return await DbContext.Set<SecurityParameter>().AsNoTracking()
.FirstOrDefaultAsync(x => x.ParamKey == key);
}

public async Task UpsertAsync(string key, string value, string description)
{
var existing = await DbContext.Set<SecurityParameter>()
.FirstOrDefaultAsync(x => x.ParamKey == key);

if (existing is null)
{
await DbContext.Set<SecurityParameter>().AddAsync(new SecurityParameter
{
Id = Guid.NewGuid(),
ParamKey = key,
ParamValue = value,
Description = description,
UpdatedAt = DateTime.UtcNow
});
}
else
{
existing.ParamValue = value;
existing.Description = description;
existing.UpdatedAt = DateTime.UtcNow;
DbContext.Set<SecurityParameter>().Update(existing);
}

await DbContext.SaveChangesAsync();
}
}
  • Step 5: Register repos in Infra/Startup.cs

Add these two lines in AddInfraestructureServices, after the existing AddScoped<IProfileRepository, ProfileRepository>() line:

services.AddScoped<ILoginAuditRepository, LoginAuditRepository>();
services.AddScoped<ISecurityParameterRepository, SecurityParameterRepository>();

Add the missing using at the top of the file:

using xTrackt.Sso.Infra.Repositories;
  • Step 6: Commit
git add src/xTrackt.Sso.Infra/
git commit -m "feat(infra): add LoginAuditLog and SecurityParameter mappings and repos"

Task 3: EF Core Migration

Files:

  • Create: migration via dotnet ef

  • Step 1: Add migration

Run from xtrakt-sso-be/:

dotnet ef migrations add AddAuditLog \
--project src/xTrackt.Sso.Infra \
--startup-project src/Web

Expected: new migration file created in src/xTrackt.Sso.Infra/Migrations/.

  • Step 2: Apply migration to dev DB
dotnet ef database update \
--project src/xTrackt.Sso.Infra \
--startup-project src/Web

Expected: tables loginauditlog and securityparameter created in the database.

  • Step 3: Commit
git add src/xTrackt.Sso.Infra/Migrations/
git commit -m "feat(infra): migration AddAuditLog"

Task 4: Application Services

Files:

  • Create: src/xTrackt.Sso.Application/Services/Interfaces/ILoginAuditService.cs

  • Create: src/xTrackt.Sso.Application/Services/LoginAuditService.cs

  • Create: src/xTrackt.Sso.Application/Services/Interfaces/ISecurityParameterService.cs

  • Create: src/xTrackt.Sso.Application/Services/SecurityParameterService.cs

  • Modify: src/xTrackt.Sso.Application/Startup.cs

  • Step 1: Create ILoginAuditService

// src/xTrackt.Sso.Application/Services/Interfaces/ILoginAuditService.cs
namespace xTrackt.Sso.Application.Services.Interfaces;

public record LoginAttemptData(
string VisitorId,
string UserId,
string UserName,
string IpAddress,
string UserAgent,
bool Success);

public interface ILoginAuditService
{
Task RecordAsync(LoginAttemptData data);
}
  • Step 2: Create LoginAuditService

The service calls ip-api.com to resolve geo info, then persists the log. The HTTP call is best-effort — if it fails, geo fields are empty.

// src/xTrackt.Sso.Application/Services/LoginAuditService.cs
using System.Text.Json;
using Microsoft.Extensions.Logging;
using xTrackt.Sso.Application.Services.Interfaces;
using xTrackt.Sso.Domain.Entities;
using xTrackt.Sso.Domain.Repositories;

namespace xTrackt.Sso.Application.Services;

public class LoginAuditService : ILoginAuditService
{
private readonly ILoginAuditRepository _repo;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<LoginAuditService> _logger;

public LoginAuditService(
ILoginAuditRepository repo,
IHttpClientFactory httpClientFactory,
ILogger<LoginAuditService> logger)
{
_repo = repo;
_httpClientFactory = httpClientFactory;
_logger = logger;
}

public async Task RecordAsync(LoginAttemptData data)
{
string country = string.Empty, countryCode = string.Empty, city = string.Empty;

try
{
if (!string.IsNullOrWhiteSpace(data.IpAddress) &&
data.IpAddress != "::1" && data.IpAddress != "127.0.0.1")
{
var http = _httpClientFactory.CreateClient();
http.Timeout = TimeSpan.FromSeconds(3);
var geoJson = await http.GetStringAsync(
$"http://ip-api.com/json/{data.IpAddress}?fields=country,countryCode,city");
var geo = JsonDocument.Parse(geoJson).RootElement;
country = geo.TryGetProperty("country", out var c) ? c.GetString() ?? string.Empty : string.Empty;
countryCode = geo.TryGetProperty("countryCode", out var cc) ? cc.GetString() ?? string.Empty : string.Empty;
city = geo.TryGetProperty("city", out var ct) ? ct.GetString() ?? string.Empty : string.Empty;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "GeoIP lookup failed for IP {Ip}", data.IpAddress);
}

await _repo.AddAsync(new LoginAuditLog
{
Id = Guid.NewGuid(),
VisitorId = data.VisitorId,
UserId = data.UserId,
UserName = data.UserName,
IpAddress = data.IpAddress,
UserAgent = data.UserAgent,
Country = country,
CountryCode = countryCode,
City = city,
Success = data.Success,
LoggedAt = DateTime.UtcNow
});
}
}
  • Step 3: Create ISecurityParameterService
// src/xTrackt.Sso.Application/Services/Interfaces/ISecurityParameterService.cs
namespace xTrackt.Sso.Application.Services.Interfaces;

public record SecurityParamDto(string Key, string Value, string Description, DateTime UpdatedAt);
public record UpdateSecurityParamsRequest(List<SecurityParamDto> Params);

public interface ISecurityParameterService
{
Task<List<SecurityParamDto>> GetAllAsync();
Task UpdateAsync(UpdateSecurityParamsRequest request);
}
  • Step 4: Create SecurityParameterService
// src/xTrackt.Sso.Application/Services/SecurityParameterService.cs
using xTrackt.Sso.Application.Services.Interfaces;
using xTrackt.Sso.Domain.Repositories;

namespace xTrackt.Sso.Application.Services;

public class SecurityParameterService : ISecurityParameterService
{
private readonly ISecurityParameterRepository _repo;

public SecurityParameterService(ISecurityParameterRepository repo)
{
_repo = repo;
}

public async Task<List<SecurityParamDto>> GetAllAsync()
{
var all = await _repo.GetAllAsync();
return all.Select(x => new SecurityParamDto(x.ParamKey, x.ParamValue, x.Description, x.UpdatedAt)).ToList();
}

public async Task UpdateAsync(UpdateSecurityParamsRequest request)
{
foreach (var p in request.Params)
await _repo.UpsertAsync(p.Key, p.Value, p.Description);
}
}
  • Step 5: Register services in Application/Startup.cs

Add after the existing AddScoped<IListUsersService, ListUsersService>() line:

services.AddHttpClient();
services.AddScoped<ILoginAuditService, LoginAuditService>();
services.AddScoped<ISecurityParameterService, SecurityParameterService>();

Add missing using:

using xTrackt.Sso.Application.Services.Interfaces;
  • Step 6: Commit
git add src/xTrackt.Sso.Application/
git commit -m "feat(application): add LoginAuditService and SecurityParameterService"

Task 5: Admin Handlers

Files: src/xTrackt.Sso.Application/Handlers/Admin/ (15 new files)

  • Step 1: Create GetOverview handler files
// src/xTrackt.Sso.Application/Handlers/Admin/GetOverview/GetOverviewRequest.cs
namespace xTrackt.Sso.Application.Handlers.Admin.GetOverview;

public class GetOverviewRequest
{
public string Period { get; set; } = "7d";
}
// src/xTrackt.Sso.Application/Handlers/Admin/GetOverview/GetOverviewResponse.cs
namespace xTrackt.Sso.Application.Handlers.Admin.GetOverview;

public class GetOverviewResponse
{
public int TotalLogins { get; set; }
public int UniqueVisitors { get; set; }
public double EventsPerVisitor { get; set; }
public int PrevTotalLogins { get; set; }
public List<DailyCount> DailyCounts { get; set; } = new();
public List<DailyCount> PrevDailyCounts { get; set; } = new();
public List<CountryStat> Countries { get; set; } = new();
public int[,] Heatmap { get; set; } = new int[7, 24]; // [dayOfWeek, hour]
public Dictionary<string, int> TopBrowsers { get; set; } = new();
}

public record DailyCount(DateTime Date, int Count);
public record CountryStat(string Code, string Name, int Count);
// src/xTrackt.Sso.Application/Handlers/Admin/GetOverview/GetOverviewHandler.cs
using Microsoft.Extensions.Logging;
using xTrackt.Sso.Application.Services.Interfaces;
using xTrakt.Application;

namespace xTrackt.Sso.Application.Handlers.Admin.GetOverview;

public class GetOverviewHandler : HandlerBase<GetOverviewRequest, GetOverviewResponse>
{
private readonly ILoginAuditService _auditService;
private readonly xTrackt.Sso.Domain.Repositories.ILoginAuditRepository _repo;

public GetOverviewHandler(
ILogger<HandlerBase<GetOverviewRequest, GetOverviewResponse>> logger,
ILoginAuditService auditService,
xTrackt.Sso.Domain.Repositories.ILoginAuditRepository repo) : base(logger)
{
_auditService = auditService;
_repo = repo;
}

protected override async Task HandleAsync()
{
var (from, to, prevFrom, prevTo) = ParsePeriod(Input.Period);
var logs = await _repo.GetRangeAsync(from, to);
var prevLogs = await _repo.GetRangeAsync(prevFrom, prevTo);

var heatmap = new int[7, 24];
foreach (var log in logs)
heatmap[(int)log.LoggedAt.DayOfWeek, log.LoggedAt.Hour]++;

var topBrowsers = logs
.GroupBy(x => ParseBrowser(x.UserAgent))
.OrderByDescending(g => g.Count())
.Take(5)
.ToDictionary(g => g.Key, g => g.Count());

Response.Data = new GetOverviewResponse
{
TotalLogins = logs.Count,
UniqueVisitors = logs.Select(x => x.VisitorId).Where(v => !string.IsNullOrEmpty(v)).Distinct().Count(),
EventsPerVisitor = logs.Count == 0 ? 0 : Math.Round((double)logs.Count / Math.Max(1, logs.Select(x => x.VisitorId).Distinct().Count()), 2),
PrevTotalLogins = prevLogs.Count,
DailyCounts = logs.GroupBy(x => x.LoggedAt.Date)
.Select(g => new DailyCount(g.Key, g.Count()))
.OrderBy(x => x.Date).ToList(),
PrevDailyCounts = prevLogs.GroupBy(x => x.LoggedAt.Date)
.Select(g => new DailyCount(g.Key, g.Count()))
.OrderBy(x => x.Date).ToList(),
Countries = logs.Where(x => !string.IsNullOrEmpty(x.CountryCode))
.GroupBy(x => x.CountryCode)
.Select(g => new CountryStat(g.Key, g.First().Country, g.Count()))
.OrderByDescending(x => x.Count).ToList(),
Heatmap = heatmap,
TopBrowsers = topBrowsers
};
}

protected override Task ValidateAsync() => Task.CompletedTask;

private static (DateTime from, DateTime to, DateTime prevFrom, DateTime prevTo) ParsePeriod(string period)
{
var to = DateTime.UtcNow;
var days = period switch { "1d" => 1, "30d" => 30, _ => 7 };
var from = to.AddDays(-days);
return (from, to, from.AddDays(-days), from);
}

private static string ParseBrowser(string userAgent)
{
if (string.IsNullOrEmpty(userAgent)) return "Unknown";
if (userAgent.Contains("Edg/")) return "Edge";
if (userAgent.Contains("Chrome/")) return "Chrome";
if (userAgent.Contains("Firefox/")) return "Firefox";
if (userAgent.Contains("Safari/") && !userAgent.Contains("Chrome")) return "Safari";
return "Other";
}
}
  • Step 2: Create ListDevices handler files
// src/xTrackt.Sso.Application/Handlers/Admin/ListDevices/ListDevicesRequest.cs
namespace xTrackt.Sso.Application.Handlers.Admin.ListDevices;

public class ListDevicesRequest
{
public string Period { get; set; } = "7d";
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public string? Risk { get; set; }
}
// src/xTrackt.Sso.Application/Handlers/Admin/ListDevices/ListDevicesResponse.cs
namespace xTrackt.Sso.Application.Handlers.Admin.ListDevices;

public class ListDevicesResponse
{
public List<DeviceRecord> Items { get; set; } = new();
public int Total { get; set; }
public int TotalDevices { get; set; }
public int HighRiskDevices { get; set; }
public int BlockedDevices { get; set; }
}

public record DeviceRecord(
string VisitorId,
DateTime FirstSeen,
DateTime LastSeen,
int LoginCount,
int FailureCount,
string LastIp,
string LastBrowser,
string Risk);
// src/xTrackt.Sso.Application/Handlers/Admin/ListDevices/ListDevicesHandler.cs
using Microsoft.Extensions.Logging;
using xTrackt.Sso.Domain.Repositories;
using xTrakt.Application;

namespace xTrackt.Sso.Application.Handlers.Admin.ListDevices;

public class ListDevicesHandler : HandlerBase<ListDevicesRequest, ListDevicesResponse>
{
private readonly ILoginAuditRepository _repo;

public ListDevicesHandler(
ILogger<HandlerBase<ListDevicesRequest, ListDevicesResponse>> logger,
ILoginAuditRepository repo) : base(logger)
{
_repo = repo;
}

protected override async Task HandleAsync()
{
var to = DateTime.UtcNow;
var days = Input.Period == "30d" ? 30 : 7;
var from = to.AddDays(-days);

var logs = await _repo.GetRangeAsync(from, to);

var devices = logs
.Where(x => !string.IsNullOrEmpty(x.VisitorId))
.GroupBy(x => x.VisitorId)
.Select(g =>
{
var failures = g.Count(x => !x.Success);
var total = g.Count();
var failureRate = total == 0 ? 0.0 : (double)failures / total;
var distinctUsers = g.Select(x => x.UserName).Distinct().Count();
var risk = distinctUsers > 1 || failureRate > 0.6 ? "HIGH"
: failureRate > 0.3 ? "MEDIUM"
: "LOW";
var last = g.OrderByDescending(x => x.LoggedAt).First();
return new DeviceRecord(
g.Key,
g.Min(x => x.LoggedAt),
g.Max(x => x.LoggedAt),
total,
failures,
last.IpAddress,
ParseBrowser(last.UserAgent),
risk);
})
.ToList();

var filtered = string.IsNullOrEmpty(Input.Risk)
? devices
: devices.Where(d => d.Risk == Input.Risk.ToUpper()).ToList();

var highRisk = devices.Count(d => d.Risk == "HIGH");

Response.Data = new ListDevicesResponse
{
TotalDevices = devices.Count,
HighRiskDevices = highRisk,
BlockedDevices = 0,
Total = filtered.Count,
Items = filtered
.Skip((Input.Page - 1) * Input.PageSize)
.Take(Input.PageSize)
.ToList()
};
}

protected override Task ValidateAsync() => Task.CompletedTask;

private static string ParseBrowser(string ua)
{
if (string.IsNullOrEmpty(ua)) return "Unknown";
if (ua.Contains("Edg/")) return "Edge";
if (ua.Contains("Chrome/")) return "Chrome";
if (ua.Contains("Firefox/")) return "Firefox";
if (ua.Contains("Safari/") && !ua.Contains("Chrome")) return "Safari";
return "Other";
}
}
  • Step 3: Create ListAudit handler files
// src/xTrackt.Sso.Application/Handlers/Admin/ListAudit/ListAuditRequest.cs
namespace xTrackt.Sso.Application.Handlers.Admin.ListAudit;

public class ListAuditRequest
{
public string Period { get; set; } = "7d";
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public bool? Success { get; set; }
public string? UserName { get; set; }
public DateTime? DateFrom { get; set; }
public DateTime? DateTo { get; set; }
}
// src/xTrackt.Sso.Application/Handlers/Admin/ListAudit/ListAuditResponse.cs
namespace xTrackt.Sso.Application.Handlers.Admin.ListAudit;

public class ListAuditResponse
{
public List<AuditRow> Items { get; set; } = new();
public int Total { get; set; }
public int TotalLogins { get; set; }
public int SuccessCount { get; set; }
public int FailureCount { get; set; }
public double SuccessRate { get; set; }
}

public record AuditRow(
Guid Id,
DateTime LoggedAt,
string UserName,
string VisitorId,
string IpAddress,
string Country,
string City,
string UserAgent,
bool Success);
// src/xTrackt.Sso.Application/Handlers/Admin/ListAudit/ListAuditHandler.cs
using Microsoft.Extensions.Logging;
using xTrackt.Sso.Domain.Repositories;
using xTrakt.Application;

namespace xTrackt.Sso.Application.Handlers.Admin.ListAudit;

public class ListAuditHandler : HandlerBase<ListAuditRequest, ListAuditResponse>
{
private readonly ILoginAuditRepository _repo;

public ListAuditHandler(
ILogger<HandlerBase<ListAuditRequest, ListAuditResponse>> logger,
ILoginAuditRepository repo) : base(logger)
{
_repo = repo;
}

protected override async Task HandleAsync()
{
var to = Input.DateTo ?? DateTime.UtcNow;
var days = Input.Period switch { "1d" => 1, "30d" => 30, _ => 7 };
var from = Input.DateFrom ?? to.AddDays(-days);

var (items, total) = await _repo.GetPageAsync(from, to, Input.Success, Input.UserName, Input.Page, Input.PageSize);
var all = await _repo.GetRangeAsync(from, to);

var successCount = all.Count(x => x.Success);
var failureCount = all.Count - successCount;

Response.Data = new ListAuditResponse
{
Total = total,
TotalLogins = all.Count,
SuccessCount = successCount,
FailureCount = failureCount,
SuccessRate = all.Count == 0 ? 0 : Math.Round((double)successCount / all.Count * 100, 1),
Items = items.Select(x => new AuditRow(
x.Id, x.LoggedAt, x.UserName, x.VisitorId,
x.IpAddress, x.Country, x.City, x.UserAgent, x.Success)).ToList()
};
}

protected override Task ValidateAsync() => Task.CompletedTask;
}
  • Step 4: Create GetSecurityConfig handler files
// src/xTrackt.Sso.Application/Handlers/Admin/GetSecurityConfig/GetSecurityConfigRequest.cs
namespace xTrackt.Sso.Application.Handlers.Admin.GetSecurityConfig;
public class GetSecurityConfigRequest { }
// src/xTrackt.Sso.Application/Handlers/Admin/GetSecurityConfig/GetSecurityConfigResponse.cs
namespace xTrackt.Sso.Application.Handlers.Admin.GetSecurityConfig;

public class GetSecurityConfigResponse
{
public List<SecurityConfigItem> Items { get; set; } = new();
}

public record SecurityConfigItem(string Key, string Value, string Description);
// src/xTrackt.Sso.Application/Handlers/Admin/GetSecurityConfig/GetSecurityConfigHandler.cs
using Microsoft.Extensions.Logging;
using xTrackt.Sso.Application.Services.Interfaces;
using xTrakt.Application;

namespace xTrackt.Sso.Application.Handlers.Admin.GetSecurityConfig;

public class GetSecurityConfigHandler : HandlerBase<GetSecurityConfigRequest, GetSecurityConfigResponse>
{
private readonly ISecurityParameterService _service;

public GetSecurityConfigHandler(
ILogger<HandlerBase<GetSecurityConfigRequest, GetSecurityConfigResponse>> logger,
ISecurityParameterService service) : base(logger)
{
_service = service;
}

protected override async Task HandleAsync()
{
var all = await _service.GetAllAsync();
Response.Data = new GetSecurityConfigResponse
{
Items = all.Select(x => new SecurityConfigItem(x.Key, x.Value, x.Description)).ToList()
};
}

protected override Task ValidateAsync() => Task.CompletedTask;
}
  • Step 5: Create UpdateSecurityConfig handler files
// src/xTrackt.Sso.Application/Handlers/Admin/UpdateSecurityConfig/UpdateSecurityConfigRequest.cs
namespace xTrackt.Sso.Application.Handlers.Admin.UpdateSecurityConfig;

public class UpdateSecurityConfigRequest
{
public List<SecurityConfigUpdate> Items { get; set; } = new();
}

public record SecurityConfigUpdate(string Key, string Value, string Description);
// src/xTrackt.Sso.Application/Handlers/Admin/UpdateSecurityConfig/UpdateSecurityConfigResponse.cs
namespace xTrackt.Sso.Application.Handlers.Admin.UpdateSecurityConfig;
public class UpdateSecurityConfigResponse { public bool Success { get; set; } }
// src/xTrackt.Sso.Application/Handlers/Admin/UpdateSecurityConfig/UpdateSecurityConfigHandler.cs
using Microsoft.Extensions.Logging;
using xTrackt.Sso.Application.Services.Interfaces;
using xTrakt.Application;

namespace xTrackt.Sso.Application.Handlers.Admin.UpdateSecurityConfig;

public class UpdateSecurityConfigHandler : HandlerBase<UpdateSecurityConfigRequest, UpdateSecurityConfigResponse>
{
private readonly ISecurityParameterService _service;

public UpdateSecurityConfigHandler(
ILogger<HandlerBase<UpdateSecurityConfigRequest, UpdateSecurityConfigResponse>> logger,
ISecurityParameterService service) : base(logger)
{
_service = service;
}

protected override async Task HandleAsync()
{
await _service.UpdateAsync(new UpdateSecurityParamsRequest(
Input.Items.Select(x => new SecurityParamDto(x.Key, x.Value, x.Description, DateTime.UtcNow)).ToList()));
Response.Data = new UpdateSecurityConfigResponse { Success = true };
}

protected override Task ValidateAsync() => Task.CompletedTask;
}
  • Step 6: Build to verify no compilation errors
dotnet build src/xTrackt.Sso.Application/xTrackt.Sso.Application.csproj

Expected: Build succeeded, 0 errors.

  • Step 7: Commit
git add src/xTrackt.Sso.Application/Handlers/Admin/
git commit -m "feat(application): add admin handlers for overview, devices, audit, security config"

Task 6: Web Layer — AdminController + AuthController Audit Hook

Files:

  • Create: src/Web/Controllers/AdminController.cs

  • Modify: src/Web/Controllers/AuthController.cs

  • Step 1: Create AdminController

// src/Web/Controllers/AdminController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation.AspNetCore;
using xTrackt.Sso.Application.Handlers.Admin.GetOverview;
using xTrackt.Sso.Application.Handlers.Admin.GetSecurityConfig;
using xTrackt.Sso.Application.Handlers.Admin.ListAudit;
using xTrackt.Sso.Application.Handlers.Admin.ListDevices;
using xTrackt.Sso.Application.Handlers.Admin.UpdateSecurityConfig;
using xTrakt.Base;
using xTrakt.WebApi;

namespace Web.Controllers;

[ApiController]
[Route("admin")]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public class AdminController : xTraktControllerBase<AdminController>
{
public AdminController(IServiceContext serviceContext, ILogger<AdminController> logger)
: base(serviceContext, logger) { }

[HttpGet("overview")]
public async Task<IServiceResponse<GetOverviewResponse>> GetOverviewAsync(
[FromServices] GetOverviewHandler handler,
[FromQuery] string period = "7d")
=> await handler.ProcessAsync(new GetOverviewRequest { Period = period });

[HttpGet("devices")]
public async Task<IServiceResponse<ListDevicesResponse>> ListDevicesAsync(
[FromServices] ListDevicesHandler handler,
[FromQuery] string period = "7d",
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? risk = null)
=> await handler.ProcessAsync(new ListDevicesRequest { Period = period, Page = page, PageSize = pageSize, Risk = risk });

[HttpGet("audit")]
public async Task<IServiceResponse<ListAuditResponse>> ListAuditAsync(
[FromServices] ListAuditHandler handler,
[FromQuery] string period = "7d",
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? success = null,
[FromQuery] string? userName = null,
[FromQuery] DateTime? dateFrom = null,
[FromQuery] DateTime? dateTo = null)
=> await handler.ProcessAsync(new ListAuditRequest
{
Period = period, Page = page, PageSize = pageSize,
Success = success, UserName = userName,
DateFrom = dateFrom, DateTo = dateTo
});

[HttpGet("security-config")]
public async Task<IServiceResponse<GetSecurityConfigResponse>> GetSecurityConfigAsync(
[FromServices] GetSecurityConfigHandler handler)
=> await handler.ProcessAsync(new GetSecurityConfigRequest());

[HttpPut("security-config")]
public async Task<IServiceResponse<UpdateSecurityConfigResponse>> UpdateSecurityConfigAsync(
[FromServices] UpdateSecurityConfigHandler handler,
[FromBody] UpdateSecurityConfigRequest request)
=> await handler.ProcessAsync(request);
}
  • Step 2: Modify AuthController.Exchange() to fire-and-forget audit log

Add ILoginAuditService to the constructor injection in AuthController. First add the field and update the constructor:

// Add field after existing _authService:
private readonly ILoginAuditService _loginAuditService;

// Update constructor signature (add parameter):
public AuthController(
IServiceContext serviceContext,
ILogger<AuthController> logger,
IAuthService authService,
ILoginAuditService loginAuditService) : base(serviceContext, logger)
{
_authService = authService;
_loginAuditService = loginAuditService;
}

Add the using at the top of the file:

using xTrackt.Sso.Application.Services.Interfaces;
  • Step 3: Add audit recording in Exchange()

Inside the Exchange method, add these lines at the top of the method (before the try/catch):

var visitorId = Request.Headers["X-Visitor-Id"].FirstOrDefault() ?? string.Empty;
var ipAddress = Request.Headers["X-Forwarded-For"].FirstOrDefault()
?? HttpContext.Connection.RemoteIpAddress?.ToString()
?? string.Empty;
var userAgent = Request.Headers["User-Agent"].FirstOrDefault() ?? string.Empty;
var username = HttpContext.GetOpenIddictServerRequest()?.Username ?? string.Empty;

Then, in the try block, after the line IServiceResponse<ConnectTokenResponse> result = await handler.ProcessAsync(oidcRequest);, add:

// Fire-and-forget audit log
var auditData = new LoginAttemptData(
visitorId,
result.Data?.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? string.Empty,
username,
ipAddress,
userAgent,
result.Data?.Success ?? false);
_ = Task.Run(() => _loginAuditService.RecordAsync(auditData));

Also, in each catch block (UserNotFoundException, PasswordVerificationException), before the return statement, add:

_ = Task.Run(() => _loginAuditService.RecordAsync(
new LoginAttemptData(visitorId, string.Empty, username, ipAddress, userAgent, false)));
  • Step 4: Build full solution
dotnet build

Expected: Build succeeded, 0 errors.

  • Step 5: Commit
git add src/Web/
git commit -m "feat(web): add AdminController and audit hook in AuthController"

Task 7: Frontend — Install FingerprintJS + Configure app.config.ts

Files:

  • Modify: package.json (via npm install)
  • Modify: src/app/app.config.ts

All commands from xtrakt-sso-fe/src/.

  • Step 1: Install FingerprintJS Pro Angular and Chart.js
npm install @fingerprintjs/fingerprintjs-pro-angular chart.js xlsx

Expected: packages added to package.json and node_modules/.

  • Step 2: Configure FingerprintJS in app.config.ts

Replace the entire app.config.ts:

// src/app/app.config.ts
import { ApplicationConfig, importProvidersFrom, provideBrowserGlobalErrorListeners, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { provideTransloco } from '@jsverse/transloco';
import { FingerprintjsProAngularModule } from '@fingerprintjs/fingerprintjs-pro-angular';

import { routes } from './app.routes';
import { AuthInterceptor } from './services/auth.interceptor';
import { TranslocoHttpLoader } from './transloco-loader';

export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptorsFromDi()),
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true,
},
provideTransloco({
config: {
availableLangs: ['en', 'pt-BR'],
defaultLang: 'en',
reRenderOnLangChange: true,
prodMode: !isDevMode(),
},
loader: TranslocoHttpLoader,
}),
importProvidersFrom(
FingerprintjsProAngularModule.forRoot({
loadOptions: { apiKey: 'CFoxHcakGXnWQ4vp6BXX' },
})
),
],
};
  • Step 3: Verify build compiles
npm run build -- --configuration development 2>&1 | tail -20

Expected: Build succeeds (or only pre-existing warnings).

  • Step 4: Commit
git add src/app/app.config.ts package.json package-lock.json
git commit -m "feat(fe): install FingerprintJS Pro Angular and chart.js"

Task 8: Frontend — LoginComponent FingerprintJS Integration

Files:

  • Modify: src/app/services/auth.service.ts

  • Modify: src/app/login/login.component.ts

  • Step 1: Add optional visitorId to auth.service.ts login()

Find the login(model: LoginModel) method and update its signature and body to accept an optional visitorId:

login(model: LoginModel, visitorId?: string): Observable<TokenResponse> {
let headers = new HttpHeaders({
'Content-Type': 'application/x-www-form-urlencoded',
});

if (this.clientAuthHeader) {
headers = headers.set('Authorization', this.clientAuthHeader);
}

if (visitorId) {
headers = headers.set('X-Visitor-Id', visitorId);
}

const body = new HttpParams()
.set('grant_type', 'password')
.set('username', model.email)
.set('password', model.password);

return this.http.post(`${this.apiUrl}/connect/token`, body.toString(), { headers, responseType: 'text' }).pipe(
map((responseStr) => JSON.parse(responseStr) as TokenResponse),
switchMap((response) =>
this.getPlatformToken(response.access_token).pipe(
tap((platformToken) => {
this.storeToken(platformToken, model.email);
}),
map(() => response)
)
)
);
}
  • Step 2: Modify LoginComponent to capture visitorId before login

At the top of login.component.ts, add the import:

import { FingerprintjsProAngularService } from '@fingerprintjs/fingerprintjs-pro-angular';

Add the service to the constructor:

constructor(
private fb: FormBuilder,
private authService: AuthService,
private router: Router,
private ngZone: NgZone,
private cdr: ChangeDetectorRef,
private translocoService: TranslocoService,
private fpjsService: FingerprintjsProAngularService // ADD THIS
) { ... }

Replace the onSubmit() method:

onSubmit(): void {
if (this.loginForm.invalid || this.isSubmitting) {
return;
}

this.errorMessage = '';
this.isSubmitting = true;

// Get fingerprint first (best-effort: proceed even if it fails)
this.fpjsService
.getVisitorData()
.then((result) => result.visitorId)
.catch(() => undefined)
.then((visitorId) => {
this.authService
.login(this.loginForm.value, visitorId)
.pipe(
finalize(() => {
this.isSubmitting = false;
this.cdr.detectChanges();
})
)
.subscribe({
next: () => {
this.router.navigate(['/menu']);
},
error: (error: HttpErrorResponse) => {
if (error.status === 401) {
this.errorMessage = 'login.errors.unauthorized';
} else if (error.status === 0) {
this.errorMessage = 'login.errors.connection';
} else {
this.errorMessage = 'login.errors.server';
}
this.isSubmitting = false;
this.cdr.detectChanges();
},
});
});
}
  • Step 3: Build to verify
npm run build -- --configuration development 2>&1 | tail -20

Expected: succeeds.

  • Step 4: Commit
git add src/app/services/auth.service.ts src/app/login/login.component.ts
git commit -m "feat(fe): capture FingerprintJS visitorId at login and send as X-Visitor-Id header"

Task 9: Frontend — Admin Guard, Routes, and Menu Items

Files:

  • Create: src/app/guards/admin.guard.ts

  • Modify: src/app/app.routes.ts

  • Modify: src/app/menu/menu.component.ts

  • Step 1: Create admin.guard.ts

// src/app/guards/admin.guard.ts
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { AuthService } from '../services/auth.service';

export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);

if (authService.isLoggedIn() && authService.isPlatformAdmin()) {
return true;
}

router.navigate(['/menu']);
return false;
};
  • Step 2: Add admin routes to app.routes.ts

Add these imports at the top of app.routes.ts:

import { adminGuard } from './guards/admin.guard';
import { InsightsComponent } from './admin/insights.component';
import { DeviceIntelligenceComponent } from './admin/device-intelligence.component';
import { AuditLogComponent } from './admin/audit-log.component';
import { SecurityConfigComponent } from './admin/security-config.component';

Inside the children array of the MenuComponent route, add:

{ path: 'admin/insights', component: InsightsComponent, canActivate: [adminGuard], data: { title: 'menu.admin.insights' } },
{ path: 'admin/devices', component: DeviceIntelligenceComponent, canActivate: [adminGuard], data: { title: 'menu.admin.devices' } },
{ path: 'admin/audit', component: AuditLogComponent, canActivate: [adminGuard], data: { title: 'menu.admin.audit' } },
{ path: 'admin/security-config', component: SecurityConfigComponent, canActivate: [adminGuard], data: { title: 'menu.admin.security_config' } },
  • Step 3: Add MONITORING menu section to menu.component.ts

In the allMenuItems array, add this entry before the portal item:

{
label: 'menu.admin.title',
id: 'admin',
expanded: false,
children: [
{ label: 'menu.admin.insights', id: 'admin.insights' },
{ label: 'menu.admin.devices', id: 'admin.devices' },
{ label: 'menu.admin.audit', id: 'admin.audit' },
{ label: 'menu.admin.security_config', id: 'admin.security_config' },
],
},

In menu.component.html, ensure the menu item with id starting with 'admin' navigates to the correct routes. Find the existing navigation template logic and verify it maps these ids to /admin/insights, /admin/devices, /admin/audit, /admin/security-config. If the template uses a url property for navigation, add url fields to the children:

{ label: 'menu.admin.insights', id: 'admin.insights', url: '/admin/insights' },
{ label: 'menu.admin.devices', id: 'admin.devices', url: '/admin/devices' },
{ label: 'menu.admin.audit', id: 'admin.audit', url: '/admin/audit' },
{ label: 'menu.admin.security_config', id: 'admin.security_config', url: '/admin/security-config' },
  • Step 4: Commit
git add src/app/guards/admin.guard.ts src/app/app.routes.ts src/app/menu/menu.component.ts
git commit -m "feat(fe): add admin guard, routes, and MONITORING menu section"

Task 10: Frontend — AdminService

Files:

  • Create: src/app/services/admin.service.ts

  • Step 1: Create AdminService

// src/app/services/admin.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { EnvironmentService } from './environment.service';

export interface OverviewResponse {
totalLogins: number;
uniqueVisitors: number;
eventsPerVisitor: number;
prevTotalLogins: number;
dailyCounts: { date: string; count: number }[];
prevDailyCounts: { date: string; count: number }[];
countries: { code: string; name: string; count: number }[];
heatmap: number[][];
topBrowsers: { [key: string]: number };
}

export interface DeviceRecord {
visitorId: string;
firstSeen: string;
lastSeen: string;
loginCount: number;
failureCount: number;
lastIp: string;
lastBrowser: string;
risk: string;
}

export interface DevicesResponse {
items: DeviceRecord[];
total: number;
totalDevices: number;
highRiskDevices: number;
blockedDevices: number;
}

export interface AuditRow {
id: string;
loggedAt: string;
userName: string;
visitorId: string;
ipAddress: string;
country: string;
city: string;
userAgent: string;
success: boolean;
}

export interface AuditResponse {
items: AuditRow[];
total: number;
totalLogins: number;
successCount: number;
failureCount: number;
successRate: number;
}

export interface SecurityConfigItem {
key: string;
value: string;
description: string;
}

export interface SecurityConfigResponse {
items: SecurityConfigItem[];
}

@Injectable({ providedIn: 'root' })
export class AdminService {
private readonly http = inject(HttpClient);
private readonly apiUrl = inject(EnvironmentService).ssoApiUrl;

private parse<T>(res: string): T {
return JSON.parse(res) as T;
}

getOverview(period: string): Observable<OverviewResponse> {
return this.http
.get(`${this.apiUrl}/admin/overview`, {
params: new HttpParams().set('period', period),
responseType: 'text' as 'json',
})
.pipe(map((r) => (this.parse<{ data: OverviewResponse }>(r as string)).data));
}

getDevices(period: string, page: number, pageSize: number, risk?: string): Observable<DevicesResponse> {
let params = new HttpParams().set('period', period).set('page', page).set('pageSize', pageSize);
if (risk) params = params.set('risk', risk);
return this.http
.get(`${this.apiUrl}/admin/devices`, { params, responseType: 'text' as 'json' })
.pipe(map((r) => (this.parse<{ data: DevicesResponse }>(r as string)).data));
}

getAudit(
period: string,
page: number,
pageSize: number,
success?: boolean | null,
userName?: string,
dateFrom?: string,
dateTo?: string
): Observable<AuditResponse> {
let params = new HttpParams().set('period', period).set('page', page).set('pageSize', pageSize);
if (success !== null && success !== undefined) params = params.set('success', success);
if (userName) params = params.set('userName', userName);
if (dateFrom) params = params.set('dateFrom', dateFrom);
if (dateTo) params = params.set('dateTo', dateTo);
return this.http
.get(`${this.apiUrl}/admin/audit`, { params, responseType: 'text' as 'json' })
.pipe(map((r) => (this.parse<{ data: AuditResponse }>(r as string)).data));
}

getSecurityConfig(): Observable<SecurityConfigResponse> {
return this.http
.get(`${this.apiUrl}/admin/security-config`, { responseType: 'text' as 'json' })
.pipe(map((r) => (this.parse<{ data: SecurityConfigResponse }>(r as string)).data));
}

updateSecurityConfig(items: SecurityConfigItem[]): Observable<void> {
return this.http
.put<void>(`${this.apiUrl}/admin/security-config`, { items }, { responseType: 'text' as 'json' })
.pipe(map(() => undefined));
}
}
  • Step 2: Commit
git add src/app/services/admin.service.ts
git commit -m "feat(fe): add AdminService for all four admin endpoints"

Task 11: Frontend — InsightsComponent

Files:

  • Create: src/app/admin/insights.component.ts

  • Create: src/app/admin/insights.component.html

  • Create: src/app/admin/insights.component.scss

  • Step 1: Create InsightsComponent TypeScript

// src/app/admin/insights.component.ts
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslocoModule } from '@jsverse/transloco';
import { AdminService, OverviewResponse } from '../services/admin.service';
import { WorldMapComponent } from './world-map.component';
import { Chart, registerables } from 'chart.js';

Chart.register(...registerables);

@Component({
selector: 'app-insights',
standalone: true,
imports: [CommonModule, TranslocoModule, WorldMapComponent],
templateUrl: './insights.component.html',
styleUrls: ['./insights.component.scss'],
})
export class InsightsComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('lineCanvas') lineCanvas!: ElementRef<HTMLCanvasElement>;
@ViewChild('heatCanvas') heatCanvas!: ElementRef<HTMLCanvasElement>;

period = '7d';
data: OverviewResponse | null = null;
loading = true;
private lineChart?: Chart;
private heatChart?: Chart;

constructor(private adminService: AdminService) {}

ngOnInit(): void {
this.load();
}

ngAfterViewInit(): void {}

setPeriod(p: string): void {
this.period = p;
this.load();
}

private load(): void {
this.loading = true;
this.adminService.getOverview(this.period).subscribe({
next: (d) => {
this.data = d;
this.loading = false;
setTimeout(() => this.buildCharts(), 0);
},
error: () => (this.loading = false),
});
}

private buildCharts(): void {
if (!this.data) return;

if (this.lineChart) this.lineChart.destroy();
if (this.heatChart) this.heatChart.destroy();

// Line chart
const labels = this.data.dailyCounts.map((d) => new Date(d.date).toLocaleDateString());
const current = this.data.dailyCounts.map((d) => d.count);
const prev = this.data.prevDailyCounts.map((d) => d.count);

this.lineChart = new Chart(this.lineCanvas.nativeElement, {
type: 'line',
data: {
labels,
datasets: [
{ label: 'Current period', data: current, borderColor: '#3b82f6', backgroundColor: 'rgba(59,130,246,0.1)', fill: true, tension: 0.4 },
{ label: 'Previous period', data: prev, borderColor: '#b8c0cc', backgroundColor: 'transparent', borderDash: [4, 4], tension: 0.4 },
],
},
options: { responsive: true, plugins: { legend: { position: 'top' } }, scales: { y: { beginAtZero: true } } },
});

// Heatmap (7 days × 24 hours) rendered as a bar chart approximation
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const heatLabels = Array.from({ length: 24 }, (_, i) => `${i}h`);
const heatmap = this.data.heatmap;
const datasets = days.map((day, di) => ({
label: day,
data: Array.from({ length: 24 }, (_, h) => (heatmap[di] ? heatmap[di][h] ?? 0 : 0)),
backgroundColor: `rgba(59,130,246,${0.3 + di * 0.1})`,
}));

this.heatChart = new Chart(this.heatCanvas.nativeElement, {
type: 'bar',
data: { labels: heatLabels, datasets },
options: { responsive: true, plugins: { legend: { position: 'top' } }, scales: { x: { stacked: false }, y: { beginAtZero: true } } },
});
}

ngOnDestroy(): void {
this.lineChart?.destroy();
this.heatChart?.destroy();
}

get browserEntries(): { key: string; value: number }[] {
if (!this.data?.topBrowsers) return [];
return Object.entries(this.data.topBrowsers).map(([key, value]) => ({ key, value }));
}

get mapData(): { code: string; count: number }[] {
return this.data?.countries.map((c) => ({ code: c.code, count: c.count })) ?? [];
}
}
  • Step 2: Create InsightsComponent HTML
<!-- src/app/admin/insights.component.html -->
<div class="page-container">
<div class="page-header">
<h1 class="page-title" transloco="menu.admin.insights"></h1>
<div class="period-pills">
<button class="pill" [class.active]="period === '1d'" (click)="setPeriod('1d')">1D</button>
<button class="pill" [class.active]="period === '7d'" (click)="setPeriod('7d')">7D</button>
<button class="pill" [class.active]="period === '30d'" (click)="setPeriod('30d')">30D</button>
</div>
</div>

<div *ngIf="loading" class="loading-state">Loading...</div>

<ng-container *ngIf="!loading && data">
<!-- KPI Cards -->
<div class="kpi-row">
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.insights.total_logins"></div>
<div class="kpi-value">{{ data.totalLogins }}</div>
<div class="kpi-delta" [class.positive]="data.totalLogins >= data.prevTotalLogins">
vs {{ data.prevTotalLogins }} prev
</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.insights.unique_visitors"></div>
<div class="kpi-value">{{ data.uniqueVisitors }}</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.insights.events_per_visitor"></div>
<div class="kpi-value">{{ data.eventsPerVisitor }}</div>
</div>
</div>

<!-- Line Chart -->
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.insights.logins_over_time"></h3>
<canvas #lineCanvas></canvas>
</div>

<!-- World Map + Top Browsers -->
<div class="two-col">
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.insights.world_map"></h3>
<app-world-map [data]="mapData"></app-world-map>
</div>
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.insights.top_browsers"></h3>
<div *ngFor="let b of browserEntries" class="browser-row">
<span class="browser-name">{{ b.key }}</span>
<div class="browser-bar-wrap">
<div class="browser-bar" [style.width.%]="(b.value / data.totalLogins) * 100"></div>
</div>
<span class="browser-count">{{ b.value }}</span>
</div>
</div>
</div>

<!-- Heatmap -->
<div class="neu-card chart-card">
<h3 class="card-title" transloco="admin.insights.activity_heatmap"></h3>
<canvas #heatCanvas></canvas>
</div>
</ng-container>
</div>
  • Step 3: Create InsightsComponent SCSS
// src/app/admin/insights.component.scss
.page-container { padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--neu-text); }
.period-pills { display: flex; gap: .5rem; }
.pill {
padding: .35rem .85rem; border-radius: var(--neu-r-pill); border: none; cursor: pointer;
background: var(--neu-bg); box-shadow: var(--neu-raised-sm); color: var(--neu-text); font-size: .82rem;
&.active { box-shadow: var(--neu-inset-sm); color: var(--neu-accent); }
}
.kpi-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.kpi-card { padding: 1.25rem 1.5rem; }
.kpi-label { font-size: .78rem; color: #8f9bb3; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .5rem; }
.kpi-value { font-size: 2rem; font-weight: 700; color: var(--neu-text); }
.kpi-delta { font-size: .78rem; color: #8f9bb3; margin-top: .25rem; &.positive { color: var(--neu-success); } }
.chart-card { padding: 1.25rem; margin-bottom: 1.5rem; }
.card-title { font-size: 1rem; font-weight: 600; color: var(--neu-text); margin: 0 0 1rem; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem; }
.browser-row { display: flex; align-items: center; gap: .75rem; margin-bottom: .5rem; }
.browser-name { width: 60px; font-size: .82rem; color: var(--neu-text); }
.browser-bar-wrap { flex: 1; background: var(--neu-bg); border-radius: 4px; box-shadow: var(--neu-inset-sm); height: 8px; }
.browser-bar { height: 100%; border-radius: 4px; background: var(--neu-accent); }
.browser-count { font-size: .82rem; color: #8f9bb3; width: 40px; text-align: right; }
.loading-state { padding: 2rem; color: #8f9bb3; text-align: center; }
  • Step 4: Create WorldMapComponent (minimal SVG)
// src/app/admin/world-map.component.ts
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';

@Component({
selector: 'app-world-map',
standalone: true,
imports: [CommonModule],
templateUrl: './world-map.component.html',
styleUrls: ['./world-map.component.scss'],
})
export class WorldMapComponent {
@Input() data: { code: string; count: number }[] = [];

getCount(code: string): number {
return this.data.find((d) => d.code === code)?.count ?? 0;
}

getOpacity(code: string): number {
const max = Math.max(...this.data.map((d) => d.count), 1);
const count = this.getCount(code);
return count === 0 ? 0 : 0.15 + (count / max) * 0.85;
}
}
<!-- src/app/admin/world-map.component.html -->
<!-- Simplified world map SVG - shows country rectangles for top regions -->
<div class="map-container">
<div class="country-grid">
<div
*ngFor="let c of data"
class="country-block"
[style.opacity]="getOpacity(c.code)"
[title]="c.code + ': ' + c.count"
>
<span class="country-code">{{ c.code }}</span>
<span class="country-count">{{ c.count }}</span>
</div>
<div *ngIf="data.length === 0" class="empty">No geo data yet</div>
</div>
</div>
// src/app/admin/world-map.component.scss
.map-container { padding: .5rem; }
.country-grid { display: flex; flex-wrap: wrap; gap: .5rem; }
.country-block {
background: var(--neu-accent); color: #fff; border-radius: 6px;
padding: .4rem .65rem; font-size: .75rem; transition: opacity .2s;
display: flex; flex-direction: column; align-items: center; min-width: 50px;
}
.country-code { font-weight: 700; }
.country-count { font-size: .68rem; }
.empty { color: #8f9bb3; font-size: .85rem; }
  • Step 5: Commit
git add src/app/admin/insights.component.* src/app/admin/world-map.component.*
git commit -m "feat(fe): add InsightsComponent and WorldMapComponent"

Task 12: Frontend — DeviceIntelligenceComponent

Files:

  • Create: src/app/admin/device-intelligence.component.ts

  • Create: src/app/admin/device-intelligence.component.html

  • Create: src/app/admin/device-intelligence.component.scss

  • Step 1: Create DeviceIntelligenceComponent TypeScript

// src/app/admin/device-intelligence.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslocoModule } from '@jsverse/transloco';
import { AdminService, DevicesResponse, DeviceRecord } from '../services/admin.service';

@Component({
selector: 'app-device-intelligence',
standalone: true,
imports: [CommonModule, FormsModule, TranslocoModule],
templateUrl: './device-intelligence.component.html',
styleUrls: ['./device-intelligence.component.scss'],
})
export class DeviceIntelligenceComponent implements OnInit {
period = '7d';
riskFilter = '';
page = 1;
pageSize = 20;
data: DevicesResponse | null = null;
loading = true;
selectedDevice: DeviceRecord | null = null;

constructor(private adminService: AdminService) {}

ngOnInit(): void {
this.load();
}

setPeriod(p: string): void {
this.period = p;
this.page = 1;
this.load();
}

setRisk(r: string): void {
this.riskFilter = r;
this.page = 1;
this.load();
}

goPage(p: number): void {
this.page = p;
this.load();
}

openDetail(device: DeviceRecord): void {
this.selectedDevice = device;
}

closeDetail(): void {
this.selectedDevice = null;
}

private load(): void {
this.loading = true;
this.adminService.getDevices(this.period, this.page, this.pageSize, this.riskFilter || undefined).subscribe({
next: (d) => { this.data = d; this.loading = false; },
error: () => (this.loading = false),
});
}

get totalPages(): number {
return Math.ceil((this.data?.total ?? 0) / this.pageSize);
}

get pageNumbers(): number[] {
return Array.from({ length: this.totalPages }, (_, i) => i + 1);
}
}
  • Step 2: Create DeviceIntelligenceComponent HTML
<!-- src/app/admin/device-intelligence.component.html -->
<div class="page-container">
<div class="page-header">
<h1 class="page-title" transloco="menu.admin.devices"></h1>
<div class="period-pills">
<button class="pill" [class.active]="period === '7d'" (click)="setPeriod('7d')">7D</button>
<button class="pill" [class.active]="period === '30d'" (click)="setPeriod('30d')">30D</button>
</div>
</div>

<div class="kpi-row" *ngIf="data">
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.devices.total"></div>
<div class="kpi-value">{{ data.totalDevices }}</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.devices.high_risk"></div>
<div class="kpi-value danger">{{ data.highRiskDevices }}</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.devices.blocked"></div>
<div class="kpi-value">{{ data.blockedDevices }}</div>
</div>
</div>

<div class="risk-filters">
<button class="pill" [class.active]="riskFilter === ''" (click)="setRisk('')">All</button>
<button class="pill high" [class.active]="riskFilter === 'HIGH'" (click)="setRisk('HIGH')">HIGH</button>
<button class="pill medium" [class.active]="riskFilter === 'MEDIUM'" (click)="setRisk('MEDIUM')">MEDIUM</button>
<button class="pill low" [class.active]="riskFilter === 'LOW'" (click)="setRisk('LOW')">LOW</button>
</div>

<div *ngIf="loading" class="loading-state">Loading...</div>

<div class="neu-card table-card" *ngIf="!loading && data">
<table class="data-table">
<thead>
<tr>
<th transloco="admin.devices.visitor_id"></th>
<th transloco="admin.devices.first_seen"></th>
<th transloco="admin.devices.last_seen"></th>
<th transloco="admin.devices.logins"></th>
<th transloco="admin.devices.failures"></th>
<th transloco="admin.devices.last_ip"></th>
<th transloco="admin.devices.browser"></th>
<th transloco="admin.devices.risk"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let d of data.items" (click)="openDetail(d)" class="clickable-row">
<td class="monospace">{{ d.visitorId | slice:0:12 }}…</td>
<td>{{ d.firstSeen | date:'short' }}</td>
<td>{{ d.lastSeen | date:'short' }}</td>
<td>{{ d.loginCount }}</td>
<td>{{ d.failureCount }}</td>
<td>{{ d.lastIp }}</td>
<td>{{ d.lastBrowser }}</td>
<td><span class="risk-badge" [class]="d.risk.toLowerCase()">{{ d.risk }}</span></td>
</tr>
</tbody>
</table>
<div class="pagination">
<button class="pill" *ngFor="let p of pageNumbers" [class.active]="p === page" (click)="goPage(p)">{{ p }}</button>
</div>
</div>

<!-- Detail Modal -->
<div class="modal-overlay" *ngIf="selectedDevice" (click)="closeDetail()">
<div class="neu-card modal-card" (click)="$event.stopPropagation()">
<h3 class="modal-title" transloco="admin.devices.detail_title"></h3>
<div class="modal-row"><span>Visitor ID</span><span class="monospace">{{ selectedDevice.visitorId }}</span></div>
<div class="modal-row"><span>First Seen</span><span>{{ selectedDevice.firstSeen | date:'medium' }}</span></div>
<div class="modal-row"><span>Last Seen</span><span>{{ selectedDevice.lastSeen | date:'medium' }}</span></div>
<div class="modal-row"><span>Total Logins</span><span>{{ selectedDevice.loginCount }}</span></div>
<div class="modal-row"><span>Failures</span><span>{{ selectedDevice.failureCount }}</span></div>
<div class="modal-row"><span>Last IP</span><span>{{ selectedDevice.lastIp }}</span></div>
<div class="modal-row"><span>Browser</span><span>{{ selectedDevice.lastBrowser }}</span></div>
<div class="modal-row"><span>Risk</span><span class="risk-badge" [class]="selectedDevice.risk.toLowerCase()">{{ selectedDevice.risk }}</span></div>
<button class="neu-btn" (click)="closeDetail()" transloco="common.close"></button>
</div>
</div>
</div>
  • Step 3: Create DeviceIntelligenceComponent SCSS
// src/app/admin/device-intelligence.component.scss
.page-container { padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--neu-text); }
.period-pills, .risk-filters { display: flex; gap: .5rem; margin-bottom: 1rem; }
.pill {
padding: .35rem .85rem; border-radius: var(--neu-r-pill); border: none; cursor: pointer;
background: var(--neu-bg); box-shadow: var(--neu-raised-sm); color: var(--neu-text); font-size: .82rem;
&.active { box-shadow: var(--neu-inset-sm); color: var(--neu-accent); }
&.high.active { color: var(--neu-danger); }
&.medium.active { color: #f59e0b; }
&.low.active { color: var(--neu-success); }
}
.kpi-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.kpi-card { padding: 1.25rem 1.5rem; }
.kpi-label { font-size: .78rem; color: #8f9bb3; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .5rem; }
.kpi-value { font-size: 2rem; font-weight: 700; color: var(--neu-text); &.danger { color: var(--neu-danger); } }
.table-card { padding: 1.25rem; }
.data-table { width: 100%; border-collapse: collapse;
th { font-size: .75rem; font-weight: 700; text-transform: uppercase; color: #8f9bb3; padding: .5rem .75rem; text-align: left; }
td { padding: .6rem .75rem; font-size: .82rem; color: var(--neu-text); border-top: 1px solid rgba(0,0,0,.05); }
}
.clickable-row { cursor: pointer; &:hover td { background: rgba(59,130,246,.04); } }
.monospace { font-family: monospace; }
.risk-badge {
padding: .2rem .6rem; border-radius: 4px; font-size: .72rem; font-weight: 700;
&.high { background: #fef2f2; color: var(--neu-danger); }
&.medium { background: #fffbeb; color: #f59e0b; }
&.low { background: #f0fdf4; color: var(--neu-success); }
}
.pagination { display: flex; gap: .4rem; margin-top: 1rem; justify-content: flex-end; }
.loading-state { padding: 2rem; color: #8f9bb3; text-align: center; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.modal-card { padding: 2rem; min-width: 380px; }
.modal-title { font-size: 1.1rem; font-weight: 700; color: var(--neu-text); margin: 0 0 1.25rem; }
.modal-row { display: flex; justify-content: space-between; padding: .5rem 0; border-bottom: 1px solid rgba(0,0,0,.05); font-size: .85rem; color: var(--neu-text); }
.neu-btn {
margin-top: 1.25rem; padding: .6rem 1.5rem; border-radius: var(--neu-r-pill); border: none;
background: var(--neu-bg); box-shadow: var(--neu-raised-md); cursor: pointer; color: var(--neu-text); font-size: .88rem;
}
  • Step 4: Commit
git add src/app/admin/device-intelligence.component.*
git commit -m "feat(fe): add DeviceIntelligenceComponent"

Task 13: Frontend — AuditLogComponent

Files:

  • Create: src/app/admin/audit-log.component.ts

  • Create: src/app/admin/audit-log.component.html

  • Create: src/app/admin/audit-log.component.scss

  • Step 1: Create AuditLogComponent TypeScript

// src/app/admin/audit-log.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslocoModule } from '@jsverse/transloco';
import { AdminService, AuditResponse, AuditRow } from '../services/admin.service';
import * as XLSX from 'xlsx';

@Component({
selector: 'app-audit-log',
standalone: true,
imports: [CommonModule, FormsModule, TranslocoModule],
templateUrl: './audit-log.component.html',
styleUrls: ['./audit-log.component.scss'],
})
export class AuditLogComponent implements OnInit {
period = '7d';
page = 1;
pageSize = 20;
successFilter: boolean | null = null;
userNameFilter = '';
dateFrom = '';
dateTo = '';
data: AuditResponse | null = null;
loading = true;

constructor(private adminService: AdminService) {}

ngOnInit(): void {
this.load();
}

setPeriod(p: string): void {
this.period = p;
this.page = 1;
this.load();
}

applyFilters(): void {
this.page = 1;
this.load();
}

goPage(p: number): void {
this.page = p;
this.load();
}

private load(): void {
this.loading = true;
this.adminService
.getAudit(
this.period, this.page, this.pageSize,
this.successFilter,
this.userNameFilter || undefined,
this.dateFrom || undefined,
this.dateTo || undefined
)
.subscribe({
next: (d) => { this.data = d; this.loading = false; },
error: () => (this.loading = false),
});
}

exportCsv(): void {
if (!this.data) return;
const rows = this.data.items.map((r) => this.rowToObj(r));
const csv = [
Object.keys(rows[0]).join(','),
...rows.map((r) => Object.values(r).join(',')),
].join('\n');
this.download('audit-log.csv', 'text/csv', csv);
}

exportXlsx(): void {
if (!this.data) return;
const ws = XLSX.utils.json_to_sheet(this.data.items.map((r) => this.rowToObj(r)));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Audit Log');
XLSX.writeFile(wb, 'audit-log.xlsx');
}

private rowToObj(r: AuditRow): Record<string, string> {
return {
DateTime: new Date(r.loggedAt).toISOString(),
User: r.userName,
VisitorId: r.visitorId,
IP: r.ipAddress,
Country: r.country,
City: r.city,
Browser: r.userAgent,
Status: r.success ? 'Success' : 'Failure',
};
}

private download(filename: string, type: string, content: string): void {
const blob = new Blob([content], { type });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
}

get totalPages(): number {
return Math.ceil((this.data?.total ?? 0) / this.pageSize);
}

get pageNumbers(): number[] {
return Array.from({ length: this.totalPages }, (_, i) => i + 1);
}
}
  • Step 2: Create AuditLogComponent HTML
<!-- src/app/admin/audit-log.component.html -->
<div class="page-container">
<div class="page-header">
<h1 class="page-title" transloco="menu.admin.audit"></h1>
<div class="header-actions">
<div class="period-pills">
<button class="pill" [class.active]="period === '1d'" (click)="setPeriod('1d')">1D</button>
<button class="pill" [class.active]="period === '7d'" (click)="setPeriod('7d')">7D</button>
<button class="pill" [class.active]="period === '30d'" (click)="setPeriod('30d')">30D</button>
</div>
<button class="neu-btn" (click)="exportCsv()" transloco="admin.audit.export_csv"></button>
<button class="neu-btn" (click)="exportXlsx()" transloco="admin.audit.export_xlsx"></button>
</div>
</div>

<div class="kpi-row" *ngIf="data">
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.audit.total"></div>
<div class="kpi-value">{{ data.totalLogins }}</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.audit.success"></div>
<div class="kpi-value success">{{ data.successCount }}</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.audit.failure"></div>
<div class="kpi-value danger">{{ data.failureCount }}</div>
</div>
<div class="neu-card kpi-card">
<div class="kpi-label" transloco="admin.audit.success_rate"></div>
<div class="kpi-value">{{ data.successRate }}%</div>
</div>
</div>

<!-- Filters -->
<div class="neu-card filters-card">
<div class="filters-row">
<select class="neu-select" [(ngModel)]="successFilter" (change)="applyFilters()">
<option [ngValue]="null">All status</option>
<option [ngValue]="true">Success</option>
<option [ngValue]="false">Failure</option>
</select>
<input class="neu-input" type="text" [(ngModel)]="userNameFilter" placeholder="Filter by user..." (keyup.enter)="applyFilters()" />
<input class="neu-input" type="date" [(ngModel)]="dateFrom" (change)="applyFilters()" />
<input class="neu-input" type="date" [(ngModel)]="dateTo" (change)="applyFilters()" />
<button class="neu-btn" (click)="applyFilters()" transloco="common.filter"></button>
</div>
</div>

<div *ngIf="loading" class="loading-state">Loading...</div>

<div class="neu-card table-card" *ngIf="!loading && data">
<table class="data-table">
<thead>
<tr>
<th transloco="admin.audit.datetime"></th>
<th transloco="admin.audit.user"></th>
<th transloco="admin.audit.visitor_id"></th>
<th transloco="admin.audit.ip"></th>
<th transloco="admin.audit.country"></th>
<th transloco="admin.audit.city"></th>
<th transloco="admin.audit.browser"></th>
<th transloco="admin.audit.status"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let r of data.items">
<td>{{ r.loggedAt | date:'short' }}</td>
<td>{{ r.userName }}</td>
<td class="monospace">{{ r.visitorId | slice:0:10 }}…</td>
<td>{{ r.ipAddress }}</td>
<td>{{ r.country }}</td>
<td>{{ r.city }}</td>
<td>{{ r.userAgent | slice:0:30 }}</td>
<td><span class="status-badge" [class.success]="r.success" [class.failure]="!r.success">{{ r.success ? 'OK' : 'FAIL' }}</span></td>
</tr>
</tbody>
</table>
<div class="pagination">
<button class="pill" *ngFor="let p of pageNumbers" [class.active]="p === page" (click)="goPage(p)">{{ p }}</button>
</div>
</div>
</div>
  • Step 3: Create AuditLogComponent SCSS
// src/app/admin/audit-log.component.scss
.page-container { padding: 2rem; }
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--neu-text); }
.header-actions { display: flex; gap: .75rem; align-items: center; }
.period-pills { display: flex; gap: .4rem; }
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 1.5rem; }
.kpi-card { padding: 1.25rem 1.5rem; }
.kpi-label { font-size: .78rem; color: #8f9bb3; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .5rem; }
.kpi-value { font-size: 2rem; font-weight: 700; color: var(--neu-text);
&.success { color: var(--neu-success); } &.danger { color: var(--neu-danger); }
}
.pill {
padding: .35rem .85rem; border-radius: var(--neu-r-pill); border: none; cursor: pointer;
background: var(--neu-bg); box-shadow: var(--neu-raised-sm); color: var(--neu-text); font-size: .82rem;
&.active { box-shadow: var(--neu-inset-sm); color: var(--neu-accent); }
}
.neu-btn {
padding: .45rem 1.1rem; border-radius: var(--neu-r-pill); border: none;
background: var(--neu-bg); box-shadow: var(--neu-raised-sm); cursor: pointer; color: var(--neu-text); font-size: .82rem;
}
.filters-card { padding: 1rem 1.25rem; margin-bottom: 1.25rem; }
.filters-row { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; }
.neu-select, .neu-input {
padding: .45rem .85rem; border-radius: 10px; border: none;
background: var(--neu-bg); box-shadow: var(--neu-inset-sm); color: var(--neu-text); font-size: .82rem;
outline: none;
}
.table-card { padding: 1.25rem; }
.data-table { width: 100%; border-collapse: collapse;
th { font-size: .72rem; font-weight: 700; text-transform: uppercase; color: #8f9bb3; padding: .5rem .75rem; text-align: left; }
td { padding: .55rem .75rem; font-size: .8rem; color: var(--neu-text); border-top: 1px solid rgba(0,0,0,.05); }
}
.monospace { font-family: monospace; }
.status-badge {
padding: .2rem .55rem; border-radius: 4px; font-size: .72rem; font-weight: 700;
&.success { background: #f0fdf4; color: var(--neu-success); }
&.failure { background: #fef2f2; color: var(--neu-danger); }
}
.pagination { display: flex; gap: .4rem; margin-top: 1rem; justify-content: flex-end; }
.loading-state { padding: 2rem; color: #8f9bb3; text-align: center; }
  • Step 4: Commit
git add src/app/admin/audit-log.component.*
git commit -m "feat(fe): add AuditLogComponent with CSV/XLSX export"

Task 14: Frontend — SecurityConfigComponent

Files:

  • Create: src/app/admin/security-config.component.ts

  • Create: src/app/admin/security-config.component.html

  • Create: src/app/admin/security-config.component.scss

  • Step 1: Create SecurityConfigComponent TypeScript

// src/app/admin/security-config.component.ts
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup } from '@angular/forms';
import { TranslocoModule } from '@jsverse/transloco';
import { AdminService, SecurityConfigItem } from '../services/admin.service';

const DEFAULT_PARAMS: SecurityConfigItem[] = [
{ key: 'maxFailedAttemptsPerVisitor', value: '5', description: 'Max failed login attempts per visitor before alert' },
{ key: 'maxFailedAttemptsPerIp', value: '10', description: 'Max failed login attempts per IP before alert' },
{ key: 'blockWindowMinutes', value: '60', description: 'Time window in minutes for counting failed attempts' },
{ key: 'multiVisitorAlertThreshold', value: '3', description: 'Number of distinct users per visitor before alert' },
{ key: 'multiVisitorWindowMinutes', value: '1440', description: 'Window in minutes for multi-visitor detection' },
];

@Component({
selector: 'app-security-config',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslocoModule],
templateUrl: './security-config.component.html',
styleUrls: ['./security-config.component.scss'],
})
export class SecurityConfigComponent implements OnInit {
form: FormGroup;
loading = true;
saving = false;
savedMsg = false;
params: SecurityConfigItem[] = [];

constructor(private fb: FormBuilder, private adminService: AdminService) {
this.form = this.fb.group({});
}

ngOnInit(): void {
this.adminService.getSecurityConfig().subscribe({
next: (res) => {
this.params = res.items.length > 0 ? res.items : DEFAULT_PARAMS;
this.buildForm();
this.loading = false;
},
error: () => {
this.params = DEFAULT_PARAMS;
this.buildForm();
this.loading = false;
},
});
}

private buildForm(): void {
const controls: { [key: string]: any } = {};
this.params.forEach((p) => (controls[p.key] = [p.value]));
this.form = this.fb.group(controls);
}

save(): void {
this.saving = true;
const items: SecurityConfigItem[] = this.params.map((p) => ({
key: p.key,
value: String(this.form.get(p.key)?.value ?? p.value),
description: p.description,
}));

this.adminService.updateSecurityConfig(items).subscribe({
next: () => {
this.saving = false;
this.savedMsg = true;
setTimeout(() => (this.savedMsg = false), 3000);
},
error: () => (this.saving = false),
});
}
}
  • Step 2: Create SecurityConfigComponent HTML
<!-- src/app/admin/security-config.component.html -->
<div class="page-container">
<div class="page-header">
<h1 class="page-title" transloco="menu.admin.security_config"></h1>
</div>

<div *ngIf="loading" class="loading-state">Loading...</div>

<form [formGroup]="form" (ngSubmit)="save()" *ngIf="!loading">
<div class="neu-card config-card">
<div *ngFor="let p of params" class="config-row">
<div class="config-info">
<div class="config-key">{{ p.key }}</div>
<div class="config-desc">{{ p.description }}</div>
</div>
<div class="config-input-wrap">
<button type="button" class="stepper-btn" (click)="form.get(p.key)!.setValue(+form.get(p.key)!.value - 1)"></button>
<input
class="neu-number-input"
type="number"
[formControlName]="p.key"
min="0"
/>
<button type="button" class="stepper-btn" (click)="form.get(p.key)!.setValue(+form.get(p.key)!.value + 1)">+</button>
</div>
</div>
</div>

<div class="form-footer">
<span class="saved-msg" *ngIf="savedMsg" transloco="admin.security_config.saved"></span>
<button type="submit" class="neu-btn primary" [disabled]="saving">
{{ saving ? '...' : ('admin.security_config.save' | transloco) }}
</button>
</div>
</form>
</div>
  • Step 3: Create SecurityConfigComponent SCSS
// src/app/admin/security-config.component.scss
.page-container { padding: 2rem; max-width: 700px; }
.page-header { margin-bottom: 1.5rem; }
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--neu-text); }
.config-card { padding: 1.5rem; }
.config-row {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 0; border-bottom: 1px solid rgba(0,0,0,.06);
&:last-child { border-bottom: none; }
}
.config-info { flex: 1; padding-right: 1.5rem; }
.config-key { font-size: .9rem; font-weight: 600; color: var(--neu-text); }
.config-desc { font-size: .78rem; color: #8f9bb3; margin-top: .2rem; }
.config-input-wrap { display: flex; align-items: center; gap: .5rem; }
.stepper-btn {
width: 32px; height: 32px; border-radius: 8px; border: none; cursor: pointer;
background: var(--neu-bg); box-shadow: var(--neu-raised-sm);
color: var(--neu-text); font-size: 1.1rem; display: flex; align-items: center; justify-content: center;
&:active { box-shadow: var(--neu-inset-sm); }
}
.neu-number-input {
width: 80px; text-align: center; border: none; outline: none;
background: var(--neu-bg); box-shadow: var(--neu-inset-sm);
border-radius: 10px; padding: .5rem; font-size: .95rem; color: var(--neu-text);
}
.form-footer { display: flex; align-items: center; gap: 1.5rem; margin-top: 1.5rem; }
.saved-msg { font-size: .85rem; color: var(--neu-success); }
.neu-btn {
padding: .6rem 2rem; border-radius: var(--neu-r-pill); border: none;
background: var(--neu-bg); box-shadow: var(--neu-raised-md); cursor: pointer; color: var(--neu-text);
&.primary { background: var(--neu-accent); color: #fff; box-shadow: none; }
&:disabled { opacity: .6; cursor: default; }
}
.loading-state { padding: 2rem; color: #8f9bb3; text-align: center; }
  • Step 4: Commit
git add src/app/admin/security-config.component.*
git commit -m "feat(fe): add SecurityConfigComponent"

Task 15: Frontend — i18n Strings

Files:

  • Modify: src/assets/i18n/en.json

  • Modify: src/assets/i18n/pt-BR.json

  • Step 1: Add English i18n strings

In en.json, add this block at the top level (alongside existing keys like "menu", "login", etc.):

"menu": {
"admin": {
"title": "Security",
"insights": "Insights",
"devices": "Device Intelligence",
"audit": "Audit Log",
"security_config": "Security Config"
}
}

(Merge into the existing "menu" key — do not duplicate it.)

Also add a new "admin" top-level key:

"admin": {
"insights": {
"total_logins": "Total Logins",
"unique_visitors": "Unique Visitors",
"events_per_visitor": "Events / Visitor",
"logins_over_time": "Logins Over Time",
"world_map": "Geographic Distribution",
"top_browsers": "Top Browsers",
"activity_heatmap": "Activity Heatmap"
},
"devices": {
"total": "Total Devices",
"high_risk": "High Risk",
"blocked": "Blocked",
"visitor_id": "Visitor ID",
"first_seen": "First Seen",
"last_seen": "Last Seen",
"logins": "Logins",
"failures": "Failures",
"last_ip": "Last IP",
"browser": "Browser",
"risk": "Risk",
"detail_title": "Device Detail"
},
"audit": {
"total": "Total",
"success": "Success",
"failure": "Failure",
"success_rate": "Success Rate",
"datetime": "Date/Time",
"user": "User",
"visitor_id": "Visitor ID",
"ip": "IP",
"country": "Country",
"city": "City",
"browser": "Browser",
"status": "Status",
"export_csv": "Export CSV",
"export_xlsx": "Export XLSX"
},
"security_config": {
"save": "Save Changes",
"saved": "Saved successfully"
}
},
"common": {
"close": "Close",
"filter": "Filter"
}
  • Step 2: Add Portuguese i18n strings

In pt-BR.json, add the same structure with Portuguese translations:

"menu": {
"admin": {
"title": "Segurança",
"insights": "Insights",
"devices": "Inteligência de Dispositivos",
"audit": "Log de Auditoria",
"security_config": "Configuração de Segurança"
}
}
"admin": {
"insights": {
"total_logins": "Total de Logins",
"unique_visitors": "Visitantes Únicos",
"events_per_visitor": "Eventos / Visitante",
"logins_over_time": "Logins ao Longo do Tempo",
"world_map": "Distribuição Geográfica",
"top_browsers": "Principais Navegadores",
"activity_heatmap": "Mapa de Calor de Atividade"
},
"devices": {
"total": "Total de Dispositivos",
"high_risk": "Alto Risco",
"blocked": "Bloqueados",
"visitor_id": "ID do Visitante",
"first_seen": "Primeira Vez",
"last_seen": "Última Vez",
"logins": "Logins",
"failures": "Falhas",
"last_ip": "Último IP",
"browser": "Navegador",
"risk": "Risco",
"detail_title": "Detalhes do Dispositivo"
},
"audit": {
"total": "Total",
"success": "Sucesso",
"failure": "Falha",
"success_rate": "Taxa de Sucesso",
"datetime": "Data/Hora",
"user": "Usuário",
"visitor_id": "ID do Visitante",
"ip": "IP",
"country": "País",
"city": "Cidade",
"browser": "Navegador",
"status": "Status",
"export_csv": "Exportar CSV",
"export_xlsx": "Exportar XLSX"
},
"security_config": {
"save": "Salvar Alterações",
"saved": "Salvo com sucesso"
}
},
"common": {
"close": "Fechar",
"filter": "Filtrar"
}
  • Step 3: Build frontend to verify no compilation errors
npm run build -- --configuration development 2>&1 | tail -30

Expected: Build succeeds.

  • Step 4: Commit
git add src/assets/i18n/
git commit -m "feat(fe): add i18n strings for admin security screens"

Self-Review Checklist

  • Spec coverage: ✅ FingerprintJS API key CFoxHcakGXnWQ4vp6BXX in app.config.ts · ✅ X-Visitor-Id header on /connect/token · ✅ LoginAuditLog captured server-side · ✅ GeoIP via ip-api.com · ✅ 4 Angular admin components · ✅ All 4 backend handlers · ✅ adminGuard protecting routes · ✅ MONITORING menu section · ✅ EF migration · ✅ Security config CRUD · ✅ CSV + XLSX export · ✅ i18n EN + PT-BR
  • No placeholders: All handler logic uses concrete LINQ queries; service constructors fully specified; all CSS uses tokens from Plan 1's styles.scss.
  • Type consistency: SecurityParamDto record used in both service interface and service impl · LoginAttemptData record used in both ILoginAuditService and call sites · DeviceRecord in ListDevicesResponse matches what AdminService deserializes.
  • Column naming: Lowercase no-underscores convention matches existing xtrakt-sso-be pattern (visitorid, ipaddress, countrycode, loggedat).
  • CORS: The X-Visitor-Id custom header must be added to the CORS allowed headers in xtrakt-sso-be's Web/Startup.cs. Find the existing AddCors or UseCors call and add "X-Visitor-Id" to the allowed headers list.