feat: filter audit logs by date and flag sign-ins from new browsers

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 95cfae33e6
commit 80b12b910a
12 changed files with 393 additions and 58 deletions
+27 -26
View File
@@ -12,11 +12,12 @@
"select_a_date": "Select a date",
"profile_picture": "Profile picture",
"no_items_found": "No items found",
"select_items": "Select items...",
"search": "Search...",
"select_items": "Select items…",
"search": "Search…",
"copied": "Copied",
"something_went_wrong": "Something went wrong",
"go_back_to_home": "Go back to home",
"page_not_found": "This page doesn’t exist.",
"logo": "Logo",
"one_hour": "1 hour",
"twelve_hours": "12 hours",
@@ -62,8 +63,8 @@
"sqlite_network_storage_warning": "We detected that your SQLite database is stored on a network-attached drive like NFS, SMB, or FUSE. This is unsupported and can lead to your database getting corrupted.",
"time": "Time",
"event": "Event",
"approximate_location": "Approximate Location",
"ip_address": "IP Address",
"approximate_location": "Approximate location",
"ip_address": "IP address",
"device": "Device",
"client": "Client",
"unknown": "Unknown",
@@ -87,10 +88,7 @@
"username_must_end_with": "Username must end with an alphanumeric character",
"added_on": "Added on",
"rename": "Rename",
"delete": "Delete",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
"passkey_deleted_successfully": "Passkey deleted successfully",
"delete_passkey_name": "Delete {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully",
"name_passkey": "Name passkey",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
@@ -128,7 +126,6 @@
"role": "Role",
"admin": "Admin",
"user": "User",
"toggle_menu": "Toggle menu",
"back": "Back",
"custom_claims": "Custom claims",
"add_group": "Add group",
@@ -150,13 +147,11 @@
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"unrestrict": "Unrestrict",
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
"favicon": "Favicon",
"email_logo": "Email logo",
"language": "Language",
"reset_profile_picture_question": "Reset profile picture?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image and reset the profile picture to default. Do you want to continue?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "The uploaded image is removed and the default picture is shown instead.",
"reset": "Reset",
"reset_to_default": "Reset to default",
"reset_setting_to_default": "Reset {setting} to default",
@@ -171,7 +166,7 @@
"allow_all_users_question": "Allow all users to sign in to {name}?",
"allow_all_users_consequence": "Its allowed groups are removed, and anyone with a {appName} account can sign in.",
"audit_event_sign_in": "Sign in",
"audit_event_token_sign_in": "Token Sign In",
"audit_event_token_sign_in": "Token sign in",
"audit_event_remote_sign_in": "Remote sign in",
"audit_event_client_authorization": "Client authorization",
"audit_event_new_client_authorization": "New client authorization",
@@ -222,7 +217,7 @@
"select_user": "Select user",
"usage_limit": "Usage limit",
"expires": "Expires",
"signup": "Sign Up",
"signup": "Sign up",
"skip_for_now": "Skip for now",
"create_signup_token": "Create signup token",
"expired": "Expired",
@@ -248,17 +243,14 @@
"light": "Light",
"dark": "Dark",
"system": "System",
"unrestrict_oidc_client": "Unrestrict {clientName}",
"confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client {#b}{clientName}{/b}? This will remove all group assignments for this client and any user will be able to sign in.",
"yes": "Yes",
"no": "No",
"scim_provisioning": "SCIM provisioning",
"scim_endpoint": "SCIM endpoint",
"scim_token": "SCIM token",
"scim_sync_failed": "SCIM sync failed. Check the server logs for more information.",
"scim_sync_successful": "The SCIM sync has been completed successfully.",
"save_and_sync": "Save and Sync",
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
"save_and_sync": "Save and sync",
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync.",
"scopes": "Scopes",
"api_key_renewed": "API key renewed",
"app_config_home_page": "Home page",
@@ -278,27 +270,26 @@
"replay_protection": "Replay protection",
"apis": "APIs",
"add_api": "Add API",
"api_resource": "Resource",
"api_resource_description": "A unique URI that identifies this API resource. Clients request it with the resource parameter. It can't be changed later.",
"api_resource_description": "Clients request it with the resource parameter. It can’t be changed later.",
"api_permissions": "Permissions",
"api_permissions_description": "The permissions (scopes) that clients can request for this API.",
"api_permissions_description": "The scopes clients can request for this API.",
"add_permission": "Add permission",
"no_permissions": "No permissions",
"api_access": "API access",
"api_access_updated_successfully": "API access updated successfully",
"access_an_api_on_your_behalf": "Access an API on your behalf",
"allow_all_metadata_document_clients": "Allow all metadata document clients",
"allow_all_metadata_document_clients_description": "Every client registered through a metadata document can request tokens for this API on behalf of users.",
"allow_all_metadata_document_clients_description": "Every client registered through a metadata document can request tokens for this API on behalf of people.",
"granted_permissions": "Granted permissions",
"granted_through_cimd_access": "Granted because this API allows all metadata document clients",
"access_granted_through_cimd_access": "This API allows all metadata document clients, so the access granted that way is shown here but can only be changed on the API itself.",
"access": "Access",
"select_a_client_to_grant_access_to_this_api": "Select the client that should get access to this API.",
"revoke_access_to_name": "Revoke access to {name}",
"are_you_sure_you_want_to_revoke_the_access_of_this_client_to_the_api": "Are you sure you want to revoke this client's access to the API? Its access to other APIs is kept.",
"select_an_api_this_client_should_access": "Select the API this client should get access to.",
"granted_to_all_cimd_clients": "Granted to all metadata document clients",
"sign_in_with_another_device": "Sign in with another device",
"device_login_qr_code": "Device login QR code",
"show_qr_code": "Show QR code",
"hide_qr_code": "Hide QR code",
"deny": "Deny",
"approve": "Approve",
"visit_and_enter": "Visit {url} and enter:",
@@ -1124,5 +1115,15 @@
"palette_hint_navigate": "to move",
"palette_hint_open": "to open",
"palette_hint_close": "to close",
"hide_advanced_settings": "Hide advanced settings"
"date": "Date",
"last_24_hours": "Last 24 hours",
"last_n_days": "Last {count} days",
"custom_range": "Custom range",
"date_from": "From",
"date_to": "To",
"apply": "Apply",
"date_range_order": "The end date can’t be before the start date.",
"new_browser": "New browser",
"new_browser_hint": "First sign-in from this browser",
"page_not_found_title": "Page not found"
}
@@ -0,0 +1,108 @@
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.ts';
import { Input } from '#lib/components/ui/input/index.ts';
import * as Popover from '#lib/components/ui/popover/index.ts';
import { m } from '#lib/paraglide/messages.js';
import { getLocale } from '#lib/paraglide/runtime.js';
import type { AuditLogDateRange } from '#lib/types/audit-log.type.ts';
import { preventDefault } from '#lib/utils/event-util.ts';
import { Check, ChevronDown } from '@lucide/svelte';
let { range = $bindable({}) }: { range: AuditLogDateRange } = $props();
const DAY = 86_400_000;
const presets = [
{ id: '24h', label: m.last_24_hours(), ms: DAY },
{ id: '7d', label: m.last_n_days({ count: 7 }), ms: 7 * DAY },
{ id: '30d', label: m.last_n_days({ count: 30 }), ms: 30 * DAY },
{ id: '90d', label: m.last_n_days({ count: 90 }), ms: 90 * DAY }
];
let open = $state(false);
let selected = $state('any');
let label = $state<string>(m.any());
let from = $state('');
let to = $state('');
const invalid = $derived(!!from && !!to && to < from);
// Times are shown in the browser's zone, so the trigger names it
const zone =
new Intl.DateTimeFormat(getLocale(), { timeZoneName: 'shortOffset' })
.formatToParts(new Date())
.find((p) => p.type === 'timeZoneName')?.value ?? '';
function choose(preset: (typeof presets)[number]) {
selected = preset.id;
label = preset.label;
range = preset.ms ? { from: new Date(Date.now() - preset.ms).toISOString() } : {};
open = false;
}
function day(value: string) {
const date = new Date(`${value}T00:00`);
return date.toLocaleDateString(getLocale(), {
day: 'numeric',
month: 'short',
year: date.getFullYear() === new Date().getFullYear() ? undefined : 'numeric'
});
}
// A custom range covers whole local days, so the end is the start of the day after the To date
function applyCustom() {
if (invalid || (!from && !to)) return;
selected = 'custom';
label =
from && from === to ? day(from) : `${from ? day(from) : ''} – ${to ? day(to) : ''}`.trim();
const startOf = (value: string, addDays = 0) =>
new Date(new Date(`${value}T00:00`).getTime() + addDays * DAY).toISOString();
range = { from: from ? startOf(from) : undefined, to: to ? startOf(to, 1) : undefined };
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
aria-label={m.facet_filter_label({ name: m.date(), value: label })}
class="hover:bg-fill-hover aria-expanded:bg-fill-selected focus-visible:outline-ring text-control flex h-7 shrink-0 items-center gap-1 rounded-xs pr-1.5 pl-2 whitespace-nowrap transition-colors duration-(--dur-fast) focus-visible:outline-2 focus-visible:outline-offset-2"
>
<span class="text-fg-muted">{m.date()}:</span>
<span class={selected === 'any' ? 'text-fg-muted' : 'text-fg font-medium'}>{label}</span>
<span class="text-fg-muted">({zone})</span>
<ChevronDown class="text-fg-muted size-3" />
</Popover.Trigger>
<Popover.Content align="start" class="w-72 gap-0 p-1">
{#each [{ id: 'any', label: m.any(), ms: 0 }, ...presets] as preset (preset.id)}
<button
type="button"
aria-pressed={selected === preset.id}
class="hover:bg-fg/6 focus-visible:outline-ring text-control flex h-9 w-full items-center gap-2 rounded-sm px-2.5 text-left focus-visible:outline-2 focus-visible:-outline-offset-2"
onclick={() => choose(preset)}
>
<span class="flex size-4 items-center justify-center">
{#if selected === preset.id}<Check class="size-4" />{/if}
</span>
{preset.label}
</button>
{/each}
<form
class="mt-1 flex flex-col gap-3 border-t px-2.5 pt-3 pb-2"
onsubmit={preventDefault(applyCustom)}
>
<p class="text-caption text-fg-muted font-medium">{m.custom_range()}</p>
<div class="flex flex-col gap-2">
<label class="text-caption text-fg-muted flex flex-col gap-1">
{m.date_from()}
<Input type="date" bind:value={from} aria-invalid={invalid} />
</label>
<label class="text-caption text-fg-muted flex flex-col gap-1">
{m.date_to()}
<Input type="date" bind:value={to} aria-invalid={invalid} />
</label>
</div>
{#if invalid}<p class="text-caption text-danger-fg">{m.date_range_order()}</p>{/if}
<Button type="submit" size="sm" variant="secondary" disabled={invalid || (!from && !to)}>
{m.apply()}
</Button>
</form>
</Popover.Content>
</Popover.Root>
@@ -11,7 +11,7 @@
import { getLocale } from '#lib/paraglide/runtime.js';
import AuditLogService from '#lib/services/audit-log-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { AuditLog, AuditLogFilter } from '#lib/types/audit-log.type.ts';
import type { AuditLog, AuditLogDateRange, AuditLogFilter } from '#lib/types/audit-log.type.ts';
import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts';
import { translateAuditLogEvent } from '#lib/utils/audit-log-translator.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
@@ -22,10 +22,14 @@
let {
isAdmin = false,
filters
filters,
dateRange = {},
onClearFilters
}: {
isAdmin?: boolean;
filters?: AuditLogFilter;
dateRange?: AuditLogDateRange;
onClearFilters?: () => void;
} = $props();
const auditLogService = new AuditLogService();
@@ -45,6 +49,8 @@
void filters?.event;
void filters?.location;
void filters?.clientName;
void dateRange.from;
void dateRange.to;
untrack(() => {
requestOptions.pagination!.page = 1;
refresh();
@@ -55,8 +61,11 @@
isRefreshing = true;
try {
logs = isAdmin
? await auditLogService.listAllLogs({ ...requestOptions, filters: wrapFilters(filters) })
: await auditLogService.list(requestOptions);
? await auditLogService.listAllLogs(
{ ...requestOptions, filters: wrapFilters(filters) },
dateRange
)
: await auditLogService.list(requestOptions, dateRange);
} finally {
isRefreshing = false;
}
@@ -250,7 +259,10 @@
</span>
<span class="text-caption text-fg-muted flex min-w-0 items-center gap-x-1.5">
{#if isAdmin}
<Badge variant="mono">{log.event}</Badge>
<Badge variant="mono" class="max-sm:hidden">{log.event}</Badge>
{/if}
{#if log.data?.newBrowser === 'true'}
<Badge variant="warning" title={m.new_browser_hint()}>{m.new_browser()}</Badge>
{/if}
{#if via}<span class="shrink-0 max-sm:hidden">{via}</span>{/if}
<!-- Below xl the Device column folds into this line so the sentence keeps its width -->
@@ -307,7 +319,12 @@
{/if}
{@render detailItem(m.ip_address(), log.ipAddress, m.copy_ip_address())}
{@render detailItem(m.approximate_location(), formatLocation(log))}
{@render detailItem(m.device(), log.device)}
{@render detailItem(
m.device(),
log.data?.newBrowser === 'true'
? `${log.device} · ${m.new_browser_hint()}`
: log.device
)}
{@render detailItem(m.event_id(), log.id, m.copy_event_id())}
</dl>
<details class="group/raw mt-4">
@@ -1,15 +1,15 @@
import type { AuditLog } from '#lib/types/audit-log.type.ts';
import type { AuditLog, AuditLogDateRange } from '#lib/types/audit-log.type.ts';
import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type.ts';
import APIService from './api-service';
export default class AuditLogService extends APIService {
list = async (options?: ListRequestOptions) => {
const res = await this.api.get('/audit-logs', { params: options });
list = async (options?: ListRequestOptions, range?: AuditLogDateRange) => {
const res = await this.api.get('/audit-logs', { params: { ...options, ...range } });
return res.data as Paginated<AuditLog>;
};
listAllLogs = async (options?: ListRequestOptions) => {
const res = await this.api.get('/audit-logs/all', { params: options });
listAllLogs = async (options?: ListRequestOptions, range?: AuditLogDateRange) => {
const res = await this.api.get('/audit-logs/all', { params: { ...options, ...range } });
return res.data as Paginated<AuditLog>;
};
+6
View File
@@ -12,6 +12,12 @@ export type AuditLog = {
data: any;
};
// RFC 3339 bounds of the date filter: from is inclusive, to exclusive, and a missing bound is open
export type AuditLogDateRange = {
from?: string;
to?: string;
};
export type AuditLogFilter = {
userID: string;
event: string;
@@ -1,7 +1,11 @@
<script lang="ts">
import AuditLogDateFilter from '#lib/components/audit-log-date-filter.svelte';
import AuditLogList from '#lib/components/audit-log-list.svelte';
import PageHeader from '#lib/components/page-header.svelte';
import { m } from '#lib/paraglide/messages.js';
import type { AuditLogDateRange } from '#lib/types/audit-log.type.ts';
let dateRange: AuditLogDateRange = $state({});
</script>
<svelte:head>
@@ -10,6 +14,8 @@
<PageHeader title={m.activity()} description={m.activity_description()} />
<div class="mt-4">
<AuditLogList />
<div role="group" aria-label={m.filters()} class="mt-6 -mb-1 flex items-center gap-1 py-1">
<AuditLogDateFilter bind:range={dateRange} />
</div>
<AuditLogList {dateRange} />
@@ -1,22 +1,22 @@
<script lang="ts">
import AuditLogDateFilter from '#lib/components/audit-log-date-filter.svelte';
import AuditLogList from '#lib/components/audit-log-list.svelte';
import PageHeader from '#lib/components/page-header.svelte';
import AdvancedTableFilter from '#lib/components/table/advanced-table-filter.svelte';
import { m } from '#lib/paraglide/messages.js';
import AuditLogService from '#lib/services/audit-log-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { AuditLogFilter } from '#lib/types/audit-log.type.ts';
import type { AuditLogDateRange, AuditLogFilter } from '#lib/types/audit-log.type.ts';
import { eventTypes } from '#lib/utils/audit-log-translator.ts';
import { revealFocused } from '#lib/utils/event-util.ts';
const auditLogService = new AuditLogService();
let filters: AuditLogFilter = $state({
userID: '',
event: '',
location: '',
clientName: ''
});
const noFilters: AuditLogFilter = { userID: '', event: '', location: '', clientName: '' };
let filters = $state({ ...noFilters });
let dateRange: AuditLogDateRange = $state({});
let filterRow = $state<HTMLElement>();
let userOptions = $state<{ label: string; value: string }[]>([]);
let clientOptions = $state<{ label: string; value: string }[]>([]);
@@ -67,8 +67,10 @@
bind:this={filterRow}
role="group"
aria-label={m.filters()}
class="mt-6 -mb-1 flex items-center gap-1 py-1 max-sm:[scrollbar-width:none] max-sm:overflow-x-auto sm:flex-wrap"
onfocusin={revealFocused}
class="scroll-fade-x -mx-1 mt-6 -mb-1 flex scroll-px-1 items-center gap-1 p-1 max-sm:[scrollbar-width:none] max-sm:overflow-x-auto sm:flex-wrap"
>
<AuditLogDateFilter bind:range={dateRange} />
{#each facets as facet (facet.key)}
<AdvancedTableFilter
title={facet.title}
@@ -79,4 +81,16 @@
{/each}
</div>
<AuditLogList isAdmin {filters} />
<!-- The date range is never cleared, since the date filter keeps its own selection -->
<!-- Focus moves to the filters because the action removes itself -->
<AuditLogList
isAdmin
{filters}
{dateRange}
onClearFilters={Object.values(filters).some(Boolean)
? () => {
filters = { ...noFilters };
filterRow?.querySelector('button')?.focus();
}
: undefined}
/>