feat: add audit log with email notification (#26)

This commit is contained in:
Elias Schneider
2024-09-09 10:29:41 +02:00
committed by GitHub
parent 4010ee27d6
commit 9121239dd7
51 changed files with 944 additions and 163 deletions

View File

@@ -9,12 +9,16 @@
input = $bindable(),
label,
description,
disabled = false,
type = 'text',
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number>;
label: string;
description?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
children?: Snippet;
} = $props();
@@ -30,7 +34,7 @@
{#if children}
{@render children()}
{:else if input}
<Input {id} bind:value={input.value} />
<Input {id} {type} bind:value={input.value} {disabled} />
{/if}
{#if input?.error}
<p class="mt-1 text-sm text-red-500">{input.error}</p>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte';
@@ -17,7 +17,7 @@
{#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" />
<h1 class="text-lg font-medium" data-testid="application-name">
{$applicationConfigurationStore.appName}
{$appConfigStore.appName}
</h1>
{/if}
</div>

View File

@@ -12,7 +12,7 @@
<tr
class={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"border-b transition-colors data-[state=selected]:bg-muted",
className
)}
{...$$restProps}

View File

@@ -1,29 +1,29 @@
import type {
AllApplicationConfiguration,
ApplicationConfigurationRawResponse
AllAppConfig,
AppConfigRawResponse
} from '$lib/types/application-configuration';
import APIService from './api-service';
export default class ApplicationConfigurationService extends APIService {
export default class AppConfigService extends APIService {
async list(showAll = false) {
let url = '/application-configuration';
if (showAll) {
url += '/all';
}
const { data } = await this.api.get<ApplicationConfigurationRawResponse>(url);
const { data } = await this.api.get<AppConfigRawResponse>(url);
const applicationConfiguration: Partial<AllApplicationConfiguration> = {};
const appConfig: Partial<AllAppConfig> = {};
data.forEach(({ key, value }) => {
(applicationConfiguration as any)[key] = value;
(appConfig as any)[key] = value;
});
return applicationConfiguration as AllApplicationConfiguration;
return appConfig as AllAppConfig;
}
async update(applicationConfiguration: AllApplicationConfiguration) {
const res = await this.api.put('/application-configuration', applicationConfiguration);
return res.data as AllApplicationConfiguration;
async update(appConfig: AllAppConfig) {
const res = await this.api.put('/application-configuration', appConfig);
return res.data as AllAppConfig;
}
async updateFavicon(favicon: File) {

View File

@@ -0,0 +1,20 @@
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class AuditLogService extends APIService {
async list(pagination?: PaginationRequest) {
const page = pagination?.page || 1;
const limit = pagination?.limit || 10;
const res = await this.api.get('/audit-logs', {
params: {
page,
limit
}
});
return res.data as Paginated<AuditLog>;
}
}
export default AuditLogService;

View File

@@ -1,22 +1,22 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import type { ApplicationConfiguration } from '$lib/types/application-configuration';
import AppConfigService from '$lib/services/app-config-service';
import type { AppConfig } from '$lib/types/application-configuration';
import { writable } from 'svelte/store';
const applicationConfigurationStore = writable<ApplicationConfiguration>();
const appConfigStore = writable<AppConfig>();
const applicationConfigurationService = new ApplicationConfigurationService();
const appConfigService = new AppConfigService();
const reload = async () => {
const applicationConfiguration = await applicationConfigurationService.list();
applicationConfigurationStore.set(applicationConfiguration);
const appConfig = await appConfigService.list();
appConfigStore.set(appConfig);
};
const set = (applicationConfiguration: ApplicationConfiguration) => {
applicationConfigurationStore.set(applicationConfiguration);
}
const set = (appConfig: AppConfig) => {
appConfigStore.set(appConfig);
};
export default {
subscribe: applicationConfigurationStore.subscribe,
subscribe: appConfigStore.subscribe,
reload,
set
};

View File

@@ -1,13 +1,18 @@
export type AllApplicationConfiguration = {
export type AllAppConfig = {
appName: string;
sessionDuration: string;
sessionDuration: string;
emailEnabled: string;
smtpHost: string;
smtpPort: string;
smtpFrom: string;
smtpUser: string;
smtpPassword: string;
};
export type ApplicationConfiguration = AllApplicationConfiguration;
export type AppConfig = AllAppConfig;
export type ApplicationConfigurationRawResponse = {
export type AppConfigRawResponse = {
key: string;
type: string;
value: string;
}[];
type: string;
value: string;
}[];

View File

@@ -0,0 +1,8 @@
export type AuditLog = {
id: string;
event: string;
ipAddress: string;
device: string;
createdAt: string;
data: any;
};

View File

@@ -1,19 +1,17 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ cookies }) => {
const userService = new UserService(cookies.get('access_token'));
const applicationConfigurationService = new ApplicationConfigurationService(
cookies.get('access_token')
);
const appConfigService = new AppConfigService(cookies.get('access_token'));
const user = await userService
.getCurrent()
.then((user) => user)
.catch(() => null);
const applicationConfiguration = await applicationConfigurationService
const appConfig = await appConfigService
.list()
.then((config) => config)
.catch((e) => {
@@ -24,6 +22,6 @@ export const load: LayoutServerLoad = async ({ cookies }) => {
});
return {
user,
applicationConfiguration
appConfig
};
};

View File

@@ -4,7 +4,7 @@
import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte';
@@ -19,17 +19,17 @@
children: Snippet;
} = $props();
const { user, applicationConfiguration } = data;
const { user, appConfig } = data;
if (browser && user) {
userStore.setUser(user);
}
if (applicationConfiguration) {
applicationConfigurationStore.set(applicationConfiguration);
if (appConfig) {
appConfigStore.set(appConfig);
}
</script>
{#if !applicationConfiguration}
{#if !appConfig}
<Error
message="A critical error occured. Please contact your administrator."
showButton={false}

View File

@@ -4,7 +4,7 @@
import * as Card from '$lib/components/ui/card';
import OidcService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
@@ -91,7 +91,7 @@
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your
<b>{$applicationConfigurationStore.appName}</b> account?
<b>{$appConfigStore.appName}</b> account?
</p>
{:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}>

View File

@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
@@ -40,7 +40,7 @@
</div>
</div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$applicationConfigurationStore.appName}
Sign in to {$appConfigStore.appName}
</h1>
<p class="text-muted-foreground mt-2">
Authenticate yourself with your passkey to access the admin panel

View File

@@ -4,7 +4,7 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store.js';
import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js';
import type { User } from '$lib/types/user.type.js';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -18,9 +18,9 @@
isLoading = true;
userService
.exchangeOneTimeAccessToken(data.token)
.then((user :User) => {
.then((user: User) => {
userStore.setUser(user);
goto('/settings')
goto('/settings');
})
.catch(axiosErrorToast);
isLoading = false;
@@ -29,15 +29,15 @@
<SignInWrapper>
<div class="flex justify-center">
<div class="rounded-2xl bg-muted p-3">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
</div>
<h1 class="mt-5 font-playfair text-4xl font-bold">One Time Access</h1>
<p class="mt-2 text-muted-foreground">
You've been granted one-time access to your {$applicationConfigurationStore.appName} account. Please note that if you continue,
this link will become invalid. To avoid this, make sure to add a passkey. Otherwise, you'll need
to request a new link.
<h1 class="font-playfair mt-5 text-4xl font-bold">One Time Access</h1>
<p class="text-muted-foreground mt-2">
You've been granted one-time access to your {$appConfigStore.appName} account. Please note that if
you continue, this link will become invalid. To avoid this, make sure to add a passkey. Otherwise,
you'll need to request a new link.
</p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button>
</SignInWrapper>

View File

@@ -9,7 +9,10 @@
children: Snippet;
} = $props();
let links = $state([{ href: '/settings/account', label: 'My Account' }]);
let links = $state([
{ href: '/settings/account', label: 'My Account' },
{ href: '/settings/audit-log', label: 'Audit Log' }
]);
if ($userStore?.isAdmin) {
links = [
@@ -22,10 +25,8 @@
</script>
<section>
<div class="bg-muted/40 h-screen w-full">
<main
class="mx-auto flex min-h-screen max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"
>
<div class="bg-muted/40 min-h-screen w-full">
<main class="mx-auto flex max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row">
<div>
<div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>

View File

@@ -1,10 +1,8 @@
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import AppConfigService from '$lib/services/app-config-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const applicationConfigurationService = new ApplicationConfigurationService(
cookies.get('access_token')
);
const applicationConfiguration = await applicationConfigurationService.list(true);
return { applicationConfiguration };
const appConfigService = new AppConfigService(cookies.get('access_token'));
const appConfig = await appConfigService.list(true);
return { appConfig };
};

View File

@@ -1,24 +1,30 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import ApplicationConfigurationService from '$lib/services/application-configuration-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration';
import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util';
import { toast } from 'svelte-sonner';
import ApplicationConfigurationForm from './application-configuration-form.svelte';
import AppConfigEmailForm from './forms/app-config-email-form.svelte';
import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte';
let { data } = $props();
let applicationConfiguration = $state(data.applicationConfiguration);
let appConfig = $state(data.appConfig);
const applicationConfigurationService = new ApplicationConfigurationService();
const appConfigService = new AppConfigService();
async function updateConfiguration(configuration: AllApplicationConfiguration) {
await applicationConfigurationService
.update(configuration)
.then(() => toast.success('Application configuration updated successfully'))
.catch(axiosErrorToast);
await applicationConfigurationStore.reload();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
await appConfigService
.update({
...appConfig,
...updatedAppConfig
})
.catch((e) => {
axiosErrorToast(e);
throw e;
});
await appConfigStore.reload();
}
async function updateImages(
@@ -26,12 +32,10 @@
backgroundImage: File | null,
favicon: File | null
) {
const faviconPromise = favicon
? applicationConfigurationService.updateFavicon(favicon)
: Promise.resolve();
const logoPromise = logo ? applicationConfigurationService.updateLogo(logo) : Promise.resolve();
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
const logoPromise = logo ? appConfigService.updateLogo(logo) : Promise.resolve();
const backgroundImagePromise = backgroundImage
? applicationConfigurationService.updateBackgroundImage(backgroundImage)
? appConfigService.updateBackgroundImage(backgroundImage)
: Promise.resolve();
await Promise.all([logoPromise, backgroundImagePromise, faviconPromise])
@@ -49,7 +53,20 @@
<Card.Title>General</Card.Title>
</Card.Header>
<Card.Content>
<ApplicationConfigurationForm {applicationConfiguration} callback={updateConfiguration} />
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Email</Card.Title>
<Card.Description>
Enable email notifications to alert users when a login is detected from a new device or
location.
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
let {
callback,
appConfig
}: {
appConfig: AllAppConfig;
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
let emailEnabled = $state(appConfig.emailEnabled == 'true');
const updatedAppConfig = {
emailEnabled: emailEnabled.toString(),
smtpHost: appConfig.smtpHost,
smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom
};
const formSchema = z.object({
smtpHost: z.string().min(1),
smtpPort: z.string().min(1),
smtpUser: z.string().min(1),
smtpPassword: z.string().min(1),
smtpFrom: z.string().email()
});
const { inputs, ...form } = createForm< typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() {
const data = form.validate();
if (!data) return false;
isLoading = true;
await callback({
...data,
emailEnabled: 'true'
}).finally(() => (isLoading = false));
toast.success('Email configuration updated successfully');
return true;
}
async function onDisable() {
await callback({ emailEnabled: 'false' });
emailEnabled = false;
toast.success('Email disabled successfully');
}
async function onEnable() {
if (await onSubmit()) {
emailEnabled = true;
}
}
</script>
<form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-2 gap-5">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
</div>
<div class="mt-5 flex justify-end gap-3">
{#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button>
{:else}
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button>
{/if}
</div>
</form>

View File

@@ -1,23 +1,24 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import type { AllApplicationConfiguration } from '$lib/types/application-configuration';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
let {
callback,
applicationConfiguration
appConfig
}: {
applicationConfiguration: AllApplicationConfiguration;
callback: (user: AllApplicationConfiguration) => Promise<void>;
appConfig: AllAppConfig;
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props();
let isLoading = $state(false);
const updatedApplicationConfiguration: AllApplicationConfiguration = {
appName: applicationConfiguration.appName,
sessionDuration: applicationConfiguration.sessionDuration
const updatedAppConfig = {
appName: appConfig.appName,
sessionDuration: appConfig.sessionDuration
};
const formSchema = z.object({
@@ -32,15 +33,14 @@
}
)
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, updatedApplicationConfiguration);
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() {
const data = form.validate();
if (!data) return;
isLoading = true;
await callback(data);
isLoading = false;
await callback(data).finally(() => (isLoading = false));
toast.success('Application configuration updated successfully');
}
</script>

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import OIDCService from '$lib/services/oidc-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideMinus } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.svelte';
import OIDCClientList from './oidc-client-list.svelte';
import { axiosErrorToast } from '$lib/utils/error-util';
import clientSecretStore from '$lib/stores/client-secret-store';
import { goto } from '$app/navigation';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
let { data } = $props();
let clients = $state(data);
@@ -22,7 +22,7 @@
async function createOIDCClient(client: OidcClientCreateWithLogo) {
try {
const createdClient = await oidcService.createClient(client);
if(client.logo){
if (client.logo) {
await oidcService.updateClientLogo(createdClient, client.logo);
}
const clientSecret = await oidcService.createClientSecret(createdClient.id);
@@ -31,7 +31,7 @@
toast.success('OIDC client created successfully');
return true;
} catch (e) {
axiosErrorToast(e)
axiosErrorToast(e);
return false;
}
}
@@ -46,7 +46,7 @@
<div class="flex items-center justify-between">
<div>
<Card.Title>Create OIDC Client</Card.Title>
<Card.Description>Add a new OIDC client to {$applicationConfigurationStore.appName}.</Card.Description>
<Card.Description>Add a new OIDC client to {$appConfigStore.appName}.</Card.Description>
</div>
{#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button>

View File

@@ -2,7 +2,7 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import UserService from '$lib/services/user-service';
import applicationConfigurationStore from '$lib/stores/application-configuration-store';
import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated } from '$lib/types/pagination.type';
import type { User, UserCreate } from '$lib/types/user.type';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -42,9 +42,7 @@
<div class="flex items-center justify-between">
<div>
<Card.Title>Create User</Card.Title>
<Card.Description
>Add a new user to {$applicationConfigurationStore.appName}.</Card.Description
>
<Card.Description>Add a new user to {$appConfigStore.appName}.</Card.Description>
</div>
{#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>Add User</Button>

View File

@@ -0,0 +1,13 @@
import AuditLogService from '$lib/services/audit-log-service';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
const auditLogService = new AuditLogService(cookies.get('access_token'));
const auditLogs = await auditLogService.list({
limit: 15,
page: 1,
});
return {
auditLogs
};
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import AuditLogList from './audit-log-list.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Audit Log</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>Audit Log</Card.Title>
<Card.Description class="mt-1">See your account activities from the last 3 months.</Card.Description>
</Card.Header>
<Card.Content>
<AuditLogList auditLogs={data.auditLogs} />
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import * as Pagination from '$lib/components/ui/pagination';
import * as Table from '$lib/components/ui/table';
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
let { auditLogs: initialAuditLog }: { auditLogs: Paginated<AuditLog> } = $props();
let auditLogs = $state<Paginated<AuditLog>>(initialAuditLog);
const auditLogService = new AuditLogService();
let pagination = $state<PaginationRequest>({
page: 1,
limit: 15
});
function toFriendlyEventString(event: string) {
const words = event.split('_');
const capitalizedWords = words.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
});
return capitalizedWords.join(' ');
}
</script>
<Table.Root>
<Table.Header class="whitespace-nowrap">
<Table.Row>
<Table.Head>Time</Table.Head>
<Table.Head>Event</Table.Head>
<Table.Head>IP Address</Table.Head>
<Table.Head>Device</Table.Head>
<Table.Head>Client</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body class="whitespace-nowrap">
{#if auditLogs.data.length === 0}
<Table.Row>
<Table.Cell colspan={6} class="text-center">No logs found</Table.Cell>
</Table.Row>
{:else}
{#each auditLogs.data as auditLog}
<Table.Row>
<Table.Cell>{new Date(auditLog.createdAt).toLocaleString()}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
</Table.Cell>
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
<Table.Cell>{auditLog.device}</Table.Cell>
<Table.Cell>{auditLog.data.clientName}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
{#if auditLogs?.data?.length ?? 0 > 0}
<Pagination.Root
class="mt-5"
count={auditLogs.pagination.totalItems}
perPage={pagination.limit}
onPageChange={async (p) =>
(auditLogs = await auditLogService.list({
page: p,
limit: pagination.limit
}))}
bind:page={auditLogs.pagination.currentPage}
let:pages
let:currentPage
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={auditLogs.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
{/if}