From 0318d1b3bbefbd5f99b415de5846fcd7b969978b Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Sun, 15 Feb 2026 08:11:39 -0700 Subject: [PATCH] feat: enhance audit logs with geo flags, relative time, and UX improvements (#2760) --- .../model/dto/response/AuditLogDto.java | 1 + .../booklore/model/entity/AuditLogEntity.java | 3 + .../booklore/service/audit/AuditService.java | 5 ++ .../booklore/service/audit/GeoIpService.java | 74 +++++++++++++++++++ .../V121__Add_country_code_to_audit_log.sql | 1 + .../settings/audit-logs/audit-log.service.ts | 1 + .../audit-logs/audit-logs.component.html | 33 +++++++-- .../audit-logs/audit-logs.component.scss | 38 ++++++++++ .../audit-logs/audit-logs.component.ts | 71 +++++++++++++++++- .../src/i18n/en/settings-audit-logs.json | 4 +- .../src/i18n/es/settings-audit-logs.json | 4 +- 11 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 booklore-api/src/main/java/org/booklore/service/audit/GeoIpService.java create mode 100644 booklore-api/src/main/resources/db/migration/V121__Add_country_code_to_audit_log.sql diff --git a/booklore-api/src/main/java/org/booklore/model/dto/response/AuditLogDto.java b/booklore-api/src/main/java/org/booklore/model/dto/response/AuditLogDto.java index 19a365264..336066d59 100644 --- a/booklore-api/src/main/java/org/booklore/model/dto/response/AuditLogDto.java +++ b/booklore-api/src/main/java/org/booklore/model/dto/response/AuditLogDto.java @@ -21,5 +21,6 @@ public class AuditLogDto { private Long entityId; private String description; private String ipAddress; + private String countryCode; private LocalDateTime createdAt; } diff --git a/booklore-api/src/main/java/org/booklore/model/entity/AuditLogEntity.java b/booklore-api/src/main/java/org/booklore/model/entity/AuditLogEntity.java index b6a7b0bad..a0716a921 100644 --- a/booklore-api/src/main/java/org/booklore/model/entity/AuditLogEntity.java +++ b/booklore-api/src/main/java/org/booklore/model/entity/AuditLogEntity.java @@ -41,6 +41,9 @@ public class AuditLogEntity { @Column(name = "ip_address", length = 45) private String ipAddress; + @Column(name = "country_code", length = 2) + private String countryCode; + @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; diff --git a/booklore-api/src/main/java/org/booklore/service/audit/AuditService.java b/booklore-api/src/main/java/org/booklore/service/audit/AuditService.java index b1869b24b..1b0d4517f 100644 --- a/booklore-api/src/main/java/org/booklore/service/audit/AuditService.java +++ b/booklore-api/src/main/java/org/booklore/service/audit/AuditService.java @@ -23,6 +23,7 @@ import java.util.List; public class AuditService { private final AuditLogRepository auditLogRepository; + private final GeoIpService geoIpService; public void log(AuditAction action, String description) { log(action, null, null, description); @@ -46,6 +47,8 @@ public class AuditService { // Non-HTTP context (scheduled tasks, etc.) } + String countryCode = geoIpService.resolveCountryCode(ipAddress); + AuditLogEntity entity = AuditLogEntity.builder() .userId(userId) .username(username) @@ -54,6 +57,7 @@ public class AuditService { .entityId(entityId) .description(description) .ipAddress(ipAddress) + .countryCode(countryCode) .build(); auditLogRepository.save(entity); @@ -86,6 +90,7 @@ public class AuditService { .entityId(entity.getEntityId()) .description(entity.getDescription()) .ipAddress(entity.getIpAddress()) + .countryCode(entity.getCountryCode()) .createdAt(entity.getCreatedAt()) .build(); } diff --git a/booklore-api/src/main/java/org/booklore/service/audit/GeoIpService.java b/booklore-api/src/main/java/org/booklore/service/audit/GeoIpService.java new file mode 100644 index 000000000..1433f79cb --- /dev/null +++ b/booklore-api/src/main/java/org/booklore/service/audit/GeoIpService.java @@ -0,0 +1,74 @@ +package org.booklore.service.audit; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.net.InetAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GeoIpService { + + private static final String GEO_API_URL = "http://ip-api.com/json/%s?fields=countryCode"; + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(2); + + private final HttpClient httpClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map cache = new ConcurrentHashMap<>(); + + public String resolveCountryCode(String ip) { + if (ip == null || ip.isBlank() || isPrivateOrLocal(ip)) { + return null; + } + String result = cache.get(ip); + if (result == null) { + result = fetchCountryCode(ip); + cache.put(ip, result); + } + return result.isEmpty() ? null : result; + } + + private String fetchCountryCode(String ip) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(String.format(GEO_API_URL, ip))) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 200) { + JsonNode node = objectMapper.readTree(response.body()); + if (node.has("countryCode") && !node.get("countryCode").asText().isBlank()) { + return node.get("countryCode").asText(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.debug("Interrupted while resolving country code for IP: {}", ip); + } catch (Exception e) { + log.debug("Failed to resolve country code for IP: {}", ip); + } + return ""; + } + + private boolean isPrivateOrLocal(String ip) { + try { + InetAddress addr = InetAddress.getByName(ip); + return addr.isLoopbackAddress() || addr.isSiteLocalAddress() || addr.isLinkLocalAddress(); + } catch (Exception e) { + return true; + } + } +} diff --git a/booklore-api/src/main/resources/db/migration/V121__Add_country_code_to_audit_log.sql b/booklore-api/src/main/resources/db/migration/V121__Add_country_code_to_audit_log.sql new file mode 100644 index 000000000..5a54782cf --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V121__Add_country_code_to_audit_log.sql @@ -0,0 +1 @@ +ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS country_code CHAR(2) NULL; diff --git a/booklore-ui/src/app/features/settings/audit-logs/audit-log.service.ts b/booklore-ui/src/app/features/settings/audit-logs/audit-log.service.ts index 8be9e8f15..604bf32c5 100644 --- a/booklore-ui/src/app/features/settings/audit-logs/audit-log.service.ts +++ b/booklore-ui/src/app/features/settings/audit-logs/audit-log.service.ts @@ -12,6 +12,7 @@ export interface AuditLog { entityId: number | null; description: string; ipAddress: string | null; + countryCode: string | null; createdAt: string; } diff --git a/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.html b/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.html index dd594a9fb..e7d419bfe 100644 --- a/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.html +++ b/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.html @@ -43,6 +43,20 @@ [showButtonBar]="true" class="date-range-filter"> + +
@@ -61,25 +75,34 @@ - {{ t('columns.timestamp') }} + {{ t('columns.timestamp') }} {{ t('columns.user') }} {{ t('columns.action') }} {{ t('columns.description') }} - {{ t('columns.ip') }} + {{ t('columns.ip') }} - {{ log.createdAt | date:'medium' }} + + {{ getRelativeTime(log.createdAt) }} + {{ log.username }} {{ formatAction(log.action) }} - {{ log.description }} - {{ log.ipAddress || '—' }} + + {{ log.description }} + + + {{ countryCodeToFlag(log.countryCode) }} + {{ log.ipAddress || '—' }} + diff --git a/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.scss b/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.scss index bff12a59a..483d82924 100644 --- a/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.scss +++ b/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.scss @@ -57,6 +57,31 @@ min-width: 220px; } +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border: 1px solid var(--p-content-border-color); + border-radius: 6px; + background: transparent; + color: var(--p-text-muted-color); + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: var(--p-content-hover-background); + color: var(--p-text-color); + } + + &.active { + background: color-mix(in srgb, var(--p-primary-color) 15%, transparent); + color: var(--p-primary-color); + border-color: var(--p-primary-color); + } +} + ::ng-deep .p-datatable-table { table-layout: fixed; } @@ -95,12 +120,19 @@ white-space: nowrap; font-size: 0.875rem; color: var(--p-text-muted-color); + cursor: default; } .description-cell { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + cursor: pointer; + + &.expanded { + white-space: normal; + word-break: break-word; + } } .ip-cell { @@ -109,6 +141,12 @@ color: var(--p-text-muted-color); } +.flag-emoji { + font-size: 1.15rem; + margin-right: 0.25rem; + vertical-align: middle; +} + ::ng-deep .p-datatable.p-datatable-sm .p-datatable-tbody > tr > td.empty-message { text-align: center; padding: 3rem 2rem; diff --git a/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.ts b/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.ts index d299536bd..0abd22310 100644 --- a/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.ts +++ b/booklore-ui/src/app/features/settings/audit-logs/audit-logs.component.ts @@ -1,10 +1,11 @@ -import {Component, inject, OnInit} from '@angular/core'; +import {Component, inject, OnInit, OnDestroy} from '@angular/core'; import {CommonModule} from '@angular/common'; import {TableLazyLoadEvent, TableModule} from 'primeng/table'; import {Select} from 'primeng/select'; import {DatePicker} from 'primeng/datepicker'; import {FormsModule} from '@angular/forms'; import {TranslocoDirective} from '@jsverse/transloco'; +import {Subscription, interval} from 'rxjs'; import {AuditLog, AuditLogService} from './audit-log.service'; interface ActionOption { @@ -24,7 +25,7 @@ interface UsernameOption { templateUrl: './audit-logs.component.html', styleUrl: './audit-logs.component.scss' }) -export class AuditLogsComponent implements OnInit { +export class AuditLogsComponent implements OnInit, OnDestroy { private readonly auditLogService = inject(AuditLogService); logs: AuditLog[] = []; @@ -34,6 +35,9 @@ export class AuditLogsComponent implements OnInit { selectedAction: string | null = null; selectedUsername: string | null = null; dateRange: Date[] | null = null; + expandedRows = new Set(); + autoRefresh = false; + private autoRefreshSub?: Subscription; usernameOptions: UsernameOption[] = [{label: 'All Users', value: ''}]; @@ -79,6 +83,10 @@ export class AuditLogsComponent implements OnInit { this.loadLogs(); } + ngOnDestroy(): void { + this.autoRefreshSub?.unsubscribe(); + } + loadUsernames(): void { this.auditLogService.getDistinctUsernames().subscribe({ next: (usernames) => { @@ -90,8 +98,10 @@ export class AuditLogsComponent implements OnInit { }); } - loadLogs(): void { - this.loading = true; + loadLogs(showLoading = true): void { + if (showLoading) { + this.loading = true; + } const action = this.selectedAction || undefined; const username = this.selectedUsername || undefined; const from = this.dateRange?.[0] ? this.formatDateTime(this.dateRange[0]) : undefined; @@ -124,6 +134,59 @@ export class AuditLogsComponent implements OnInit { } } + toggleAutoRefresh(): void { + this.autoRefresh = !this.autoRefresh; + if (this.autoRefresh) { + this.autoRefreshSub = interval(10000).subscribe(() => this.loadLogs(false)); + } else { + this.autoRefreshSub?.unsubscribe(); + } + } + + clearAllFilters(): void { + this.selectedAction = null; + this.selectedUsername = null; + this.dateRange = null; + this.currentPage = 0; + this.loadLogs(); + } + + get hasActiveFilters(): boolean { + return !!this.selectedAction || !!this.selectedUsername || + (this.dateRange != null && this.dateRange.length > 0 && this.dateRange[0] != null); + } + + toggleDescription(logId: number): void { + if (this.expandedRows.has(logId)) { + this.expandedRows.delete(logId); + } else { + this.expandedRows.add(logId); + } + } + + countryCodeToFlag(code: string): string { + if (!code || code.length !== 2) return ''; + return [...code.toUpperCase()] + .map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65)) + .join(''); + } + + getRelativeTime(dateStr: string): string { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHour < 24) return `${diffHour}h ago`; + if (diffDay < 30) return `${diffDay}d ago`; + return new Intl.DateTimeFormat(undefined, {dateStyle: 'medium'}).format(date); + } + formatAction(action: string): string { return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(/\B\w+/g, c => c.toLowerCase()); } diff --git a/booklore-ui/src/i18n/en/settings-audit-logs.json b/booklore-ui/src/i18n/en/settings-audit-logs.json index 00aaaecb8..08126bfc8 100644 --- a/booklore-ui/src/i18n/en/settings-audit-logs.json +++ b/booklore-ui/src/i18n/en/settings-audit-logs.json @@ -5,10 +5,12 @@ "filterPlaceholder": "Filter by action", "userFilterPlaceholder": "Filter by user", "dateRangePlaceholder": "Filter by date range", + "clearFilters": "Clear all filters", + "autoRefresh": "Auto-refresh (10s)", "pageReport": "Showing {first} to {last} of {totalRecords} entries", "empty": "No audit logs found", "columns": { - "timestamp": "Date & Time", + "timestamp": "When", "user": "User", "action": "Action", "description": "Description", diff --git a/booklore-ui/src/i18n/es/settings-audit-logs.json b/booklore-ui/src/i18n/es/settings-audit-logs.json index 1665cffe4..a2bfcac9e 100644 --- a/booklore-ui/src/i18n/es/settings-audit-logs.json +++ b/booklore-ui/src/i18n/es/settings-audit-logs.json @@ -5,10 +5,12 @@ "filterPlaceholder": "Filtrar por acción", "userFilterPlaceholder": "Filtrar por usuario", "dateRangePlaceholder": "Filtrar por rango de fechas", + "clearFilters": "Borrar todos los filtros", + "autoRefresh": "Actualización automática (10s)", "pageReport": "Mostrando {first} a {last} de {totalRecords} entradas", "empty": "No se encontraron registros de auditoría", "columns": { - "timestamp": "Fecha y hora", + "timestamp": "Cuándo", "user": "Usuario", "action": "Acción", "description": "Descripción",