Record auth audits and surface them in Activity, poll sidebar counts without resetting idle, and add a profile logout path plus theme red/green/blue tokens so failure badges render.
307 lines
7.6 KiB
TypeScript
307 lines
7.6 KiB
TypeScript
import type {
|
||
AuthAuditEvent,
|
||
NodeAuditAction,
|
||
NodeAuditEvent,
|
||
NodeKind,
|
||
} from './api/client'
|
||
|
||
export type DateRangePreset = 'all' | 'today' | '7d' | '30d' | 'custom'
|
||
|
||
export type SortDirection = 'asc' | 'desc'
|
||
|
||
export type NodeLogsSortKey =
|
||
| 'at'
|
||
| 'action'
|
||
| 'actor'
|
||
| 'node'
|
||
| 'kind'
|
||
| 'detail'
|
||
|
||
export type AuthLogsSortKey =
|
||
| 'at'
|
||
| 'action'
|
||
| 'actor'
|
||
| 'target'
|
||
| 'outcome'
|
||
| 'category'
|
||
| 'detail'
|
||
|
||
export type AuthOutcomeFilter = 'all' | 'failure' | 'success'
|
||
|
||
export type AuthCategoryFilter = 'all' | AuthAuditEvent['category']
|
||
|
||
export type AuthActionQuickFilter = 'all' | 'login_failures' | string
|
||
|
||
function normalizeSearchText(value: string): string {
|
||
return value.trim().toLowerCase()
|
||
}
|
||
|
||
export function eventMatchesSearch(
|
||
fields: ReadonlyArray<string | undefined | null>,
|
||
query: string,
|
||
): boolean {
|
||
const normalizedQuery = normalizeSearchText(query)
|
||
if (normalizedQuery === '') {
|
||
return true
|
||
}
|
||
return fields.some((field) =>
|
||
normalizeSearchText(field ?? '').includes(normalizedQuery),
|
||
)
|
||
}
|
||
|
||
export function startOfLocalDay(reference: Date = new Date()): Date {
|
||
return new Date(
|
||
reference.getFullYear(),
|
||
reference.getMonth(),
|
||
reference.getDate(),
|
||
)
|
||
}
|
||
|
||
export function resolveDateRangeBounds(
|
||
preset: DateRangePreset,
|
||
customFrom: string,
|
||
customTo: string,
|
||
now: Date = new Date(),
|
||
): { from: Date | null; to: Date | null } {
|
||
if (preset === 'all') {
|
||
return { from: null, to: null }
|
||
}
|
||
if (preset === 'today') {
|
||
return { from: startOfLocalDay(now), to: null }
|
||
}
|
||
if (preset === '7d') {
|
||
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||
return { from, to: null }
|
||
}
|
||
if (preset === '30d') {
|
||
const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||
return { from, to: null }
|
||
}
|
||
|
||
const fromParsed = customFrom ? Date.parse(customFrom) : Number.NaN
|
||
const toParsed = customTo ? Date.parse(customTo) : Number.NaN
|
||
return {
|
||
from: Number.isNaN(fromParsed) ? null : new Date(fromParsed),
|
||
to: Number.isNaN(toParsed) ? null : new Date(toParsed),
|
||
}
|
||
}
|
||
|
||
export function isWithinDateRange(
|
||
at: string,
|
||
from: Date | null,
|
||
to: Date | null,
|
||
): boolean {
|
||
const timestamp = Date.parse(at)
|
||
if (Number.isNaN(timestamp)) {
|
||
return false
|
||
}
|
||
if (from !== null && timestamp < from.getTime()) {
|
||
return false
|
||
}
|
||
if (to !== null && timestamp > to.getTime()) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function compareStrings(left: string, right: string): number {
|
||
return left.localeCompare(right, undefined, { sensitivity: 'base' })
|
||
}
|
||
|
||
function compareTimestamps(left: string, right: string): number {
|
||
const leftTime = Date.parse(left)
|
||
const rightTime = Date.parse(right)
|
||
if (Number.isNaN(leftTime) && Number.isNaN(rightTime)) {
|
||
return 0
|
||
}
|
||
if (Number.isNaN(leftTime)) {
|
||
return 1
|
||
}
|
||
if (Number.isNaN(rightTime)) {
|
||
return -1
|
||
}
|
||
return leftTime - rightTime
|
||
}
|
||
|
||
function applySortDirection(comparison: number, direction: SortDirection): number {
|
||
return direction === 'asc' ? comparison : -comparison
|
||
}
|
||
|
||
export function filterNodeEvents(
|
||
events: ReadonlyArray<NodeAuditEvent>,
|
||
options: {
|
||
search: string
|
||
kind: 'all' | NodeKind
|
||
action: 'all' | NodeAuditAction
|
||
dateFrom: Date | null
|
||
dateTo: Date | null
|
||
},
|
||
): NodeAuditEvent[] {
|
||
return events.filter((event) => {
|
||
if (options.kind !== 'all' && event.node_kind !== options.kind) {
|
||
return false
|
||
}
|
||
if (options.action !== 'all' && event.action !== options.action) {
|
||
return false
|
||
}
|
||
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
|
||
return false
|
||
}
|
||
return eventMatchesSearch(
|
||
[
|
||
event.at,
|
||
event.action,
|
||
event.actor,
|
||
event.node_name,
|
||
event.node_id,
|
||
event.node_kind,
|
||
event.detail,
|
||
],
|
||
options.search,
|
||
)
|
||
})
|
||
}
|
||
|
||
export function sortNodeEvents(
|
||
events: ReadonlyArray<NodeAuditEvent>,
|
||
sortKey: NodeLogsSortKey,
|
||
direction: SortDirection,
|
||
): NodeAuditEvent[] {
|
||
const sorted = [...events]
|
||
sorted.sort((left, right) => {
|
||
let comparison = 0
|
||
switch (sortKey) {
|
||
case 'at':
|
||
comparison = compareTimestamps(left.at, right.at)
|
||
break
|
||
case 'action':
|
||
comparison = compareStrings(left.action, right.action)
|
||
break
|
||
case 'actor':
|
||
comparison = compareStrings(left.actor, right.actor)
|
||
break
|
||
case 'node':
|
||
comparison = compareStrings(left.node_name, right.node_name)
|
||
break
|
||
case 'kind':
|
||
comparison = compareStrings(left.node_kind, right.node_kind)
|
||
break
|
||
case 'detail':
|
||
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
|
||
break
|
||
}
|
||
return applySortDirection(comparison, direction)
|
||
})
|
||
return sorted
|
||
}
|
||
|
||
export function filterAuthEvents(
|
||
events: ReadonlyArray<AuthAuditEvent>,
|
||
options: {
|
||
search: string
|
||
outcome: AuthOutcomeFilter
|
||
category: AuthCategoryFilter
|
||
actionFilter: AuthActionQuickFilter
|
||
dateFrom: Date | null
|
||
dateTo: Date | null
|
||
},
|
||
): AuthAuditEvent[] {
|
||
return events.filter((event) => {
|
||
if (options.outcome !== 'all' && event.outcome !== options.outcome) {
|
||
return false
|
||
}
|
||
if (options.category !== 'all' && event.category !== options.category) {
|
||
return false
|
||
}
|
||
if (options.actionFilter === 'login_failures') {
|
||
if (event.action !== 'login' || event.outcome !== 'failure') {
|
||
return false
|
||
}
|
||
} else if (
|
||
options.actionFilter !== 'all' &&
|
||
event.action !== options.actionFilter
|
||
) {
|
||
return false
|
||
}
|
||
if (!isWithinDateRange(event.at, options.dateFrom, options.dateTo)) {
|
||
return false
|
||
}
|
||
return eventMatchesSearch(
|
||
[
|
||
event.at,
|
||
event.action,
|
||
event.outcome,
|
||
event.category,
|
||
event.actor,
|
||
event.target,
|
||
event.detail,
|
||
],
|
||
options.search,
|
||
)
|
||
})
|
||
}
|
||
|
||
export function sortAuthEvents(
|
||
events: ReadonlyArray<AuthAuditEvent>,
|
||
sortKey: AuthLogsSortKey,
|
||
direction: SortDirection,
|
||
): AuthAuditEvent[] {
|
||
const sorted = [...events]
|
||
sorted.sort((left, right) => {
|
||
let comparison = 0
|
||
switch (sortKey) {
|
||
case 'at':
|
||
comparison = compareTimestamps(left.at, right.at)
|
||
break
|
||
case 'action':
|
||
comparison = compareStrings(left.action, right.action)
|
||
break
|
||
case 'actor':
|
||
comparison = compareStrings(left.actor, right.actor)
|
||
break
|
||
case 'target':
|
||
comparison = compareStrings(left.target ?? '', right.target ?? '')
|
||
break
|
||
case 'outcome':
|
||
comparison = compareStrings(left.outcome, right.outcome)
|
||
break
|
||
case 'category':
|
||
comparison = compareStrings(left.category, right.category)
|
||
break
|
||
case 'detail':
|
||
comparison = compareStrings(left.detail ?? '', right.detail ?? '')
|
||
break
|
||
}
|
||
return applySortDirection(comparison, direction)
|
||
})
|
||
return sorted
|
||
}
|
||
|
||
export function paginateItems<T>(
|
||
items: ReadonlyArray<T>,
|
||
page: number,
|
||
pageSize: number,
|
||
): { pageItems: T[]; totalPages: number; safePage: number } {
|
||
const totalPages = Math.max(1, Math.ceil(items.length / pageSize))
|
||
const safePage = Math.min(Math.max(1, page), totalPages)
|
||
const startIndex = (safePage - 1) * pageSize
|
||
return {
|
||
pageItems: items.slice(startIndex, startIndex + pageSize),
|
||
totalPages,
|
||
safePage,
|
||
}
|
||
}
|
||
|
||
export function pageRangeLabel(
|
||
totalCount: number,
|
||
page: number,
|
||
pageSize: number,
|
||
): string {
|
||
if (totalCount === 0) {
|
||
return 'Showing 0 of 0'
|
||
}
|
||
const start = (page - 1) * pageSize + 1
|
||
const end = Math.min(page * pageSize, totalCount)
|
||
return `Showing ${start}–${end} of ${totalCount}`
|
||
}
|