1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-09 06:44:18 +00:00

feat: global audit log (#320)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-04-03 10:11:49 -05:00
committed by GitHub
parent 734c6813ea
commit b65e693e12
33 changed files with 865 additions and 21 deletions

View File

@@ -9,8 +9,13 @@
let {
auditLogs,
isAdmin = false,
requestOptions
}: { auditLogs: Paginated<AuditLog>; requestOptions: SearchPaginationSortRequest } = $props();
}: {
auditLogs: Paginated<AuditLog>;
isAdmin?: boolean;
requestOptions: SearchPaginationSortRequest;
} = $props();
const auditLogService = new AuditLogService();
@@ -26,9 +31,13 @@
<AdvancedTable
items={auditLogs}
{requestOptions}
onRefresh={async (options) => (auditLogs = await auditLogService.list(options))}
onRefresh={async (options) =>
isAdmin
? (auditLogs = await auditLogService.listAllLogs(options))
: (auditLogs = await auditLogService.list(options))}
columns={[
{ label: m.time(), sortColumn: 'createdAt' },
...(isAdmin ? [{ label: 'Username' }] : []),
{ label: m.event(), sortColumn: 'event' },
{ label: m.approximate_location(), sortColumn: 'city' },
{ label: m.ip_address(), sortColumn: 'ipAddress' },
@@ -39,6 +48,15 @@
>
{#snippet rows({ item })}
<Table.Cell>{new Date(item.createdAt).toLocaleString()}</Table.Cell>
{#if isAdmin}
<Table.Cell>
{#if item.username}
{item.username}
{:else}
Unknown User
{/if}
</Table.Cell>
{/if}
<Table.Cell>
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
</Table.Cell>

View File

@@ -27,7 +27,6 @@
if (child.nodeType === 1) {
const itemDelay = delay + index * stagger;
(child as HTMLElement).style.setProperty('animation-delay', `${itemDelay}ms`);
console.log(itemDelay);
}
});
}
@@ -43,7 +42,7 @@
}
/* Apply these styles to all children */
.fade-wrapper > * {
.fade-wrapper > *:not(.no-fade) {
animation-fill-mode: both;
opacity: 0;
transform: translateY(10px);

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/utils/style';
import { LucideCheck, LucideChevronDown } from 'lucide-svelte';
import { tick } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let {
items,
value = $bindable(),
onSelect,
...restProps
}: HTMLAttributes<HTMLButtonElement> & {
items: {
value: string;
label: string;
}[];
value: string;
onSelect?: (value: string) => void;
} = $props();
let open = $state(false);
let filteredItems = $state(items);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger(triggerId: string) {
open = false;
tick().then(() => {
document.getElementById(triggerId)?.focus();
});
}
function filterItems(searchString: string) {
if (!searchString) {
filteredItems = items;
} else {
filteredItems = items.filter((item) =>
item.label.toLowerCase().includes(searchString.toLowerCase())
);
}
}
// Reset items when opening again
$effect(() => {
if (open) {
filteredItems = items;
}
});
</script>
<Popover.Root bind:open let:ids>
<Popover.Trigger asChild let:builder>
<Button
{...restProps}
builders={[builder]}
variant="outline"
role="combobox"
aria-expanded={open}
class={cn('justify-between', restProps.class)}
>
{items.find((item) => item.value === value)?.label || 'Select an option'}
<LucideChevronDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Content class="p-0" sameWidth>
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
<Command.Empty>No results found.</Command.Empty>
<Command.Group>
{#each filteredItems as item}
<Command.Item
value={item.value}
onSelect={() => {
value = item.value;
onSelect?.(item.value);
closeAndFocusTrigger(ids.trigger);
}}
>
<LucideCheck class={cn('mr-2 h-4 w-4', value !== item.value && 'text-transparent')} />
{item.label}
</Command.Item>
{/each}
</Command.Group>
</Command.Root>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { Dialog as DialogPrimitive } from "bits-ui";
import type { Command as CommandPrimitive } from "cmdk-sv";
import Command from "./command.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
type $$Props = DialogPrimitive.Props & CommandPrimitive.CommandProps;
export let open: $$Props["open"] = false;
export let value: $$Props["value"] = undefined;
</script>
<Dialog.Root bind:open {...$$restProps}>
<Dialog.Content class="overflow-hidden p-0 shadow-lg">
<Command
class="[&_[data-cmdk-group-heading]]:text-muted-foreground [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:font-medium [&_[data-cmdk-group]:not([hidden])_~[data-cmdk-group]]:pt-0 [&_[data-cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[data-cmdk-input]]:h-12 [&_[data-cmdk-item]]:px-2 [&_[data-cmdk-item]]:py-3 [&_[data-cmdk-item]_svg]:h-5 [&_[data-cmdk-item]_svg]:w-5"
{...$$restProps}
bind:value
>
<slot />
</Command>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js";
type $$Props = CommandPrimitive.EmptyProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Empty class={cn("py-6 text-center text-sm", className)} {...$$restProps}>
<slot />
</CommandPrimitive.Empty>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js";
type $$Props = CommandPrimitive.GroupProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Group
class={cn(
"text-foreground [&_[data-cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[data-cmdk-group-heading]]:px-2 [&_[data-cmdk-group-heading]]:py-1.5 [&_[data-cmdk-group-heading]]:text-xs [&_[data-cmdk-group-heading]]:font-medium",
className
)}
{...$$restProps}
>
<slot />
</CommandPrimitive.Group>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from '$lib/utils/style.js';
import { Command as CommandPrimitive } from 'cmdk-sv';
import Search from 'lucide-svelte/icons/search';
import type { ClassValue } from 'svelte/elements';
type $$Props = CommandPrimitive.InputProps;
let className: ClassValue | undefined | null = undefined;
export { className as class };
export let value: string = '';
</script>
<div class="flex items-center border-b px-2" data-cmdk-input-wrapper="">
<Search class="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
class={cn(
'placeholder:text-muted-foreground flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...$$restProps}
bind:value
/>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js";
type $$Props = CommandPrimitive.ItemProps;
export let asChild = false;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Item
{asChild}
class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...$$restProps}
let:action
let:attrs
>
<slot {action} {attrs} />
</CommandPrimitive.Item>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js";
type $$Props = CommandPrimitive.ListProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.List
class={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...$$restProps}
>
<slot />
</CommandPrimitive.List>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js";
type $$Props = CommandPrimitive.SeparatorProps;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Separator class={cn("bg-border -mx-1 h-px", className)} {...$$restProps} />

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/style.js";
type $$Props = HTMLAttributes<HTMLSpanElement>;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<span
class={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...$$restProps}
>
<slot />
</span>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Command as CommandPrimitive } from "cmdk-sv";
import { cn } from "$lib/utils/style.js";
type $$Props = CommandPrimitive.CommandProps;
export let value: $$Props["value"] = undefined;
let className: string | undefined | null = undefined;
export { className as class };
</script>
<CommandPrimitive.Root
class={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
bind:value
{...$$restProps}
>
<slot />
</CommandPrimitive.Root>

View File

@@ -0,0 +1,37 @@
import { Command as CommandPrimitive } from "cmdk-sv";
import Root from "./command.svelte";
import Dialog from "./command-dialog.svelte";
import Empty from "./command-empty.svelte";
import Group from "./command-group.svelte";
import Item from "./command-item.svelte";
import Input from "./command-input.svelte";
import List from "./command-list.svelte";
import Separator from "./command-separator.svelte";
import Shortcut from "./command-shortcut.svelte";
const Loading = CommandPrimitive.Loading;
export {
Root,
Dialog,
Empty,
Group,
Item,
Input,
List,
Separator,
Shortcut,
Loading,
//
Root as Command,
Dialog as CommandDialog,
Empty as CommandEmpty,
Group as CommandGroup,
Item as CommandItem,
Input as CommandInput,
List as CommandList,
Separator as CommandSeparator,
Shortcut as CommandShortcut,
Loading as CommandLoading,
};

View File

@@ -0,0 +1,18 @@
import { Tabs as TabsPrimitive } from "bits-ui";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
const Root = TabsPrimitive.Root;
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
type $$Props = TabsPrimitive.ContentProps;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
</script>
<TabsPrimitive.Content
class={cn(
"ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
)}
{value}
{...$$restProps}
>
<slot />
</TabsPrimitive.Content>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
type $$Props = TabsPrimitive.ListProps;
let className: $$Props["class"] = undefined;
export { className as class };
</script>
<TabsPrimitive.List
class={cn(
"bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1",
className
)}
{...$$restProps}
>
<slot />
</TabsPrimitive.List>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils/style.js";
type $$Props = TabsPrimitive.TriggerProps;
type $$Events = TabsPrimitive.TriggerEvents;
let className: $$Props["class"] = undefined;
export let value: $$Props["value"];
export { className as class };
</script>
<TabsPrimitive.Trigger
class={cn(
"ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm",
className
)}
{value}
{...$$restProps}
on:click
>
<slot />
</TabsPrimitive.Trigger>

View File

@@ -1,4 +1,4 @@
import type { AuditLog } from '$lib/types/audit-log.type';
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
@@ -9,6 +9,26 @@ class AuditLogService extends APIService {
});
return res.data as Paginated<AuditLog>;
}
async listAllLogs(options?: SearchPaginationSortRequest, filters?: AuditLogFilter) {
const res = await this.api.get('/audit-logs/all', {
params: {
...options,
filters
}
});
return res.data as Paginated<AuditLog>;
}
async listClientNames() {
const res = await this.api.get<string[]>('/audit-logs/filters/client-names');
return res.data;
}
async listUsers() {
const res = await this.api.get<Record<string, string>>('/audit-logs/filters/users');
return res.data;
}
}
export default AuditLogService;

View File

@@ -5,6 +5,14 @@ export type AuditLog = {
country?: string;
city?: string;
device: string;
userId: string;
username?: string;
createdAt: string;
data: any;
};
export type AuditLogFilter = {
userId: string,
event: string,
clientName: string,
}

View File

@@ -8,10 +8,13 @@ export type SortRequest = {
direction: 'asc' | 'desc';
};
export type FilterMap = Record<string, string>;
export type SearchPaginationSortRequest = {
search?: string;
pagination?: PaginationRequest;
sort?: SortRequest;
filters?: FilterMap;
};
export type PaginationResponse = {

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import AuditLogList from '$lib/components/audit-log-list.svelte';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import { LogsIcon } from 'lucide-svelte';
import AuditLogList from './audit-log-list.svelte';
import AuditLogSwitcher from './audit-log-switcher.svelte';
let { data } = $props();
let auditLogsRequestOptions = $state(data.auditLogsRequestOptions);
@@ -12,6 +13,8 @@
<title>{m.audit_log()}</title>
</svelte:head>
<AuditLogSwitcher currentPage="personal" />
<div>
<Card.Root>
<Card.Header>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
let { currentPage }: { currentPage: 'personal' | 'global' } = $props();
</script>
<div class="flex justify-end no-fade">
<Tabs.Root value={currentPage} >
<Tabs.List>
<Tabs.Trigger onclick={() => goto('/settings/audit-log')} value="personal"
>{m.personal()}</Tabs.Trigger
>
<Tabs.Trigger onclick={() => goto('/settings/audit-log/global')} value="global"
>{m.global()}</Tabs.Trigger
>
</Tabs.List>
</Tabs.Root>
</div>

View File

@@ -0,0 +1,22 @@
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import AuditLogService from '$lib/services/audit-log-service';
import type { SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { PageServerLoad } from '../../global-audit-log/$types';
export const load: PageServerLoad = async ({ cookies }) => {
const auditLogService = new AuditLogService(cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const requestOptions: SearchPaginationSortRequest = {
sort: {
column: 'createdAt',
direction: 'desc'
}
};
const auditLogs = await auditLogService.listAllLogs(requestOptions);
return {
auditLogs,
requestOptions
};
};

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import AuditLogList from '$lib/components/audit-log-list.svelte';
import SearchableSelect from '$lib/components/form/searchable-select.svelte';
import * as Card from '$lib/components/ui/card';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLogFilter } from '$lib/types/audit-log.type';
import AuditLogSwitcher from '../audit-log-switcher.svelte';
let { data } = $props();
const auditLogService = new AuditLogService();
let auditLogs = $state(data.auditLogs);
let requestOptions = $state(data.requestOptions);
let filters: AuditLogFilter = $state({
userId: '',
event: '',
clientName: ''
});
const eventTypes = $state({
SIGN_IN: m.sign_in(),
TOKEN_SIGN_IN: m.token_sign_in(),
CLIENT_AUTHORIZATION: m.client_authorization(),
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization()
});
$effect(() => {
auditLogService.listAllLogs(requestOptions, filters).then((response) => (auditLogs = response));
});
</script>
<svelte:head>
<title>{m.global_audit_log()}</title>
</svelte:head>
<AuditLogSwitcher currentPage="global" />
<Card.Root>
<Card.Header>
<Card.Title>{m.global_audit_log()}</Card.Title>
<Card.Description class="mt-1"
>{m.see_all_account_activities_from_the_last_3_months()}</Card.Description
>
</Card.Header>
<Card.Content>
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
{#await auditLogService.listUsers()}
<Select.Root>
<Select.Trigger class="w-full" disabled>
<Select.Value placeholder={m.all_users()} />
</Select.Trigger>
</Select.Root>
{:then users}
<SearchableSelect
class="w-full"
items={[
{ value: '', label: m.all_users() },
...Object.entries(users).map(([id, username]) => ({
value: id,
label: username
}))
]}
bind:value={filters.userId}
/>
{/await}
</div>
<div>
<Select.Root
selected={{
value: filters.event,
label: eventTypes[filters.event as keyof typeof eventTypes]
}}
onSelectedChange={(v) => (filters.event = v!.value)}
>
<Select.Trigger class="w-full">
<Select.Value placeholder={m.all_events()} />
</Select.Trigger>
<Select.Content>
<Select.Item value="">{m.all_events()}</Select.Item>
{#each Object.entries(eventTypes) as [value, label]}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div>
{#await auditLogService.listClientNames()}
<Select.Root>
<Select.Trigger class="w-full" disabled>
<Select.Value placeholder={m.all_clients()} />
</Select.Trigger>
</Select.Root>
{:then clientNames}
<SearchableSelect
class="w-full"
items={[
{ value: '', label: m.all_clients() },
...clientNames.map((name) => ({
value: name,
label: name
}))
]}
bind:value={filters.clientName}
/>
{/await}
</div>
</div>
<AuditLogList isAdmin={true} {auditLogs} {requestOptions} />
</Card.Content>
</Card.Root>