Skip to content

Commit 5e29d69

Browse files
committed
audit tracking
1 parent f523e65 commit 5e29d69

File tree

6 files changed

+424
-25
lines changed

6 files changed

+424
-25
lines changed

src/db/schema.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,23 @@ export const docFeedbackStatusEnum = pgEnum('doc_feedback_status', [
3636
'denied',
3737
])
3838
export const bannerScopeEnum = pgEnum('banner_scope', ['global', 'targeted'])
39+
export const auditActionEnum = pgEnum('audit_action', [
40+
'user.capabilities.update',
41+
'user.adsDisabled.update',
42+
'user.sessions.revoke',
43+
'role.create',
44+
'role.update',
45+
'role.delete',
46+
'role.assignment.create',
47+
'role.assignment.delete',
48+
'banner.create',
49+
'banner.update',
50+
'banner.delete',
51+
'feed.entry.create',
52+
'feed.entry.update',
53+
'feed.entry.delete',
54+
'feedback.moderate',
55+
])
3956
export const bannerStyleEnum = pgEnum('banner_style', [
4057
'info',
4158
'warning',
@@ -62,6 +79,22 @@ export type DocFeedbackStatus = 'pending' | 'approved' | 'denied'
6279
export type BannerScope = 'global' | 'targeted'
6380
export type BannerStyle = 'info' | 'warning' | 'success' | 'promo'
6481
export type EntryType = 'release' | 'blog' | 'announcement'
82+
export type AuditAction =
83+
| 'user.capabilities.update'
84+
| 'user.adsDisabled.update'
85+
| 'user.sessions.revoke'
86+
| 'role.create'
87+
| 'role.update'
88+
| 'role.delete'
89+
| 'role.assignment.create'
90+
| 'role.assignment.delete'
91+
| 'banner.create'
92+
| 'banner.update'
93+
| 'banner.delete'
94+
| 'feed.entry.create'
95+
| 'feed.entry.update'
96+
| 'feed.entry.delete'
97+
| 'feedback.moderate'
6598

6699
// Constants
67100
export const VALID_CAPABILITIES: readonly Capability[] = [
@@ -678,6 +711,67 @@ export type NewAnnouncementDismissal = InferInsertModel<
678711
typeof announcementDismissals
679712
>
680713

714+
// Login history table (tracks user logins for analytics and security)
715+
export const loginHistory = pgTable(
716+
'login_history',
717+
{
718+
id: uuid('id').primaryKey().defaultRandom(),
719+
userId: uuid('user_id')
720+
.notNull()
721+
.references(() => users.id, { onDelete: 'cascade' }),
722+
provider: oauthProviderEnum('provider').notNull(),
723+
ipAddress: varchar('ip_address', { length: 45 }), // IPv6 max length
724+
userAgent: text('user_agent'),
725+
isNewUser: boolean('is_new_user').notNull().default(false),
726+
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
727+
.notNull()
728+
.defaultNow(),
729+
},
730+
(table) => ({
731+
userIdIdx: index('login_history_user_id_idx').on(table.userId),
732+
createdAtIdx: index('login_history_created_at_idx').on(table.createdAt),
733+
providerIdx: index('login_history_provider_idx').on(table.provider),
734+
}),
735+
)
736+
737+
export type LoginHistory = InferSelectModel<typeof loginHistory>
738+
export type NewLoginHistory = InferInsertModel<typeof loginHistory>
739+
740+
// Audit logs table (tracks admin actions for security and compliance)
741+
export const auditLogs = pgTable(
742+
'audit_logs',
743+
{
744+
id: uuid('id').primaryKey().defaultRandom(),
745+
// Who performed the action
746+
actorId: uuid('actor_id')
747+
.notNull()
748+
.references(() => users.id, { onDelete: 'cascade' }),
749+
// What action was performed
750+
action: auditActionEnum('action').notNull(),
751+
// Target of the action (user, role, banner, etc.)
752+
targetType: varchar('target_type', { length: 50 }).notNull(), // 'user', 'role', 'banner', 'feed_entry', 'feedback'
753+
targetId: varchar('target_id', { length: 255 }).notNull(), // UUID or other identifier
754+
// Details of the change
755+
details: jsonb('details'), // { before: {...}, after: {...} } or other relevant data
756+
// Request metadata
757+
ipAddress: varchar('ip_address', { length: 45 }),
758+
userAgent: text('user_agent'),
759+
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' })
760+
.notNull()
761+
.defaultNow(),
762+
},
763+
(table) => ({
764+
actorIdIdx: index('audit_logs_actor_id_idx').on(table.actorId),
765+
actionIdx: index('audit_logs_action_idx').on(table.action),
766+
targetTypeIdx: index('audit_logs_target_type_idx').on(table.targetType),
767+
targetIdIdx: index('audit_logs_target_id_idx').on(table.targetId),
768+
createdAtIdx: index('audit_logs_created_at_idx').on(table.createdAt),
769+
}),
770+
)
771+
772+
export type AuditLog = InferSelectModel<typeof auditLogs>
773+
export type NewAuditLog = InferInsertModel<typeof auditLogs>
774+
681775
// Relations
682776
export const usersRelations = relations(users, ({ many }) => ({
683777
sessions: many(sessions),
@@ -686,6 +780,8 @@ export const usersRelations = relations(users, ({ many }) => ({
686780
docFeedback: many(docFeedback),
687781
announcementDismissals: many(announcementDismissals),
688782
bannerDismissals: many(bannerDismissals),
783+
loginHistory: many(loginHistory),
784+
auditLogs: many(auditLogs),
689785
}))
690786

691787
export const rolesRelations = relations(roles, ({ many }) => ({
@@ -758,3 +854,17 @@ export const bannerDismissalsRelations = relations(
758854
}),
759855
}),
760856
)
857+
858+
export const loginHistoryRelations = relations(loginHistory, ({ one }) => ({
859+
user: one(users, {
860+
fields: [loginHistory.userId],
861+
references: [users.id],
862+
}),
863+
}))
864+
865+
export const auditLogsRelations = relations(auditLogs, ({ one }) => ({
866+
actor: one(users, {
867+
fields: [auditLogs.actorId],
868+
references: [users.id],
869+
}),
870+
}))

src/hooks/useClickOutside.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,52 @@ export function useClickOutside<T extends HTMLElement = HTMLElement>({
3232
additionalRefs = [],
3333
}: UseClickOutsideOptions): React.RefObject<T | null> {
3434
const ref = React.useRef<T>(null)
35+
const touchStartRef = React.useRef<{ x: number; y: number } | null>(null)
3536

3637
React.useEffect(() => {
3738
if (!enabled) return
3839

39-
const handleClickOutside = (event: MouseEvent) => {
40-
const target = event.target as Node
40+
const isInsideRefs = (target: Node) => {
41+
if (ref.current?.contains(target)) return true
42+
for (const additionalRef of additionalRefs) {
43+
if (additionalRef.current?.contains(target)) return true
44+
}
45+
return false
46+
}
4147

42-
// Check if click is inside the main ref
43-
if (ref.current?.contains(target)) {
48+
const handlePointerDown = (event: PointerEvent) => {
49+
if (event.pointerType === 'touch') {
50+
// Track touch start position to detect scrolls vs taps
51+
touchStartRef.current = { x: event.clientX, y: event.clientY }
4452
return
4553
}
4654

47-
// Check if click is inside any additional refs
48-
for (const additionalRef of additionalRefs) {
49-
if (additionalRef.current?.contains(target)) {
50-
return
51-
}
55+
// Mouse/pen: handle immediately
56+
if (!isInsideRefs(event.target as Node)) {
57+
onClickOutside()
58+
}
59+
}
60+
61+
const handlePointerUp = (event: PointerEvent) => {
62+
if (event.pointerType !== 'touch') return
63+
64+
const start = touchStartRef.current
65+
touchStartRef.current = null
66+
67+
if (!start) return
68+
69+
// If finger moved more than 10px, it's a scroll not a tap
70+
const dx = Math.abs(event.clientX - start.x)
71+
const dy = Math.abs(event.clientY - start.y)
72+
if (dx > 10 || dy > 10) return
73+
74+
if (!isInsideRefs(event.target as Node)) {
75+
onClickOutside()
5276
}
77+
}
5378

54-
onClickOutside()
79+
const handlePointerCancel = () => {
80+
touchStartRef.current = null
5581
}
5682

5783
const handleEscape = (event: KeyboardEvent) => {
@@ -60,13 +86,17 @@ export function useClickOutside<T extends HTMLElement = HTMLElement>({
6086
}
6187
}
6288

63-
document.addEventListener('mousedown', handleClickOutside)
89+
document.addEventListener('pointerdown', handlePointerDown)
90+
document.addEventListener('pointerup', handlePointerUp)
91+
document.addEventListener('pointercancel', handlePointerCancel)
6492
if (closeOnEscape) {
6593
document.addEventListener('keydown', handleEscape)
6694
}
6795

6896
return () => {
69-
document.removeEventListener('mousedown', handleClickOutside)
97+
document.removeEventListener('pointerdown', handlePointerDown)
98+
document.removeEventListener('pointerup', handlePointerUp)
99+
document.removeEventListener('pointercancel', handlePointerCancel)
70100
if (closeOnEscape) {
71101
document.removeEventListener('keydown', handleEscape)
72102
}

src/routes/api/auth/callback/$provider.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
SESSION_DURATION_MS,
1414
SESSION_MAX_AGE_SECONDS,
1515
} from '~/auth/index.server'
16+
import { recordLogin } from '~/utils/audit.server'
1617

1718
export const Route = createFileRoute('/api/auth/callback/$provider')({
1819
server: {
@@ -133,6 +134,16 @@ export const Route = createFileRoute('/api/auth/callback/$provider')({
133134
throw new Error('User not found after OAuth account creation')
134135
}
135136

137+
// Record login event (fire and forget, don't block auth flow)
138+
recordLogin({
139+
userId: user.id,
140+
provider,
141+
isNewUser: result.isNewUser,
142+
request,
143+
}).catch((err) => {
144+
console.error('[AUTH:WARN] Failed to record login event:', err)
145+
})
146+
136147
// Create signed session cookie
137148
const sessionService = getSessionService()
138149
const expiresAt = Date.now() + SESSION_DURATION_MS

src/utils/audit.server.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { db } from '~/db/client'
2+
import {
3+
loginHistory,
4+
auditLogs,
5+
type AuditAction,
6+
type OAuthProvider,
7+
} from '~/db/schema'
8+
9+
// Extract IP address from request headers
10+
export function getClientIp(request: Request): string | undefined {
11+
// Check common proxy headers first
12+
const forwardedFor = request.headers.get('x-forwarded-for')
13+
if (forwardedFor) {
14+
// x-forwarded-for can contain multiple IPs, take the first one
15+
return forwardedFor.split(',')[0].trim()
16+
}
17+
18+
const realIp = request.headers.get('x-real-ip')
19+
if (realIp) {
20+
return realIp
21+
}
22+
23+
// Cloudflare
24+
const cfConnectingIp = request.headers.get('cf-connecting-ip')
25+
if (cfConnectingIp) {
26+
return cfConnectingIp
27+
}
28+
29+
return undefined
30+
}
31+
32+
// Record a login event
33+
export async function recordLogin(opts: {
34+
userId: string
35+
provider: OAuthProvider
36+
isNewUser: boolean
37+
request: Request
38+
}): Promise<void> {
39+
const { userId, provider, isNewUser, request } = opts
40+
41+
await db.insert(loginHistory).values({
42+
userId,
43+
provider,
44+
isNewUser,
45+
ipAddress: getClientIp(request),
46+
userAgent: request.headers.get('user-agent') || undefined,
47+
})
48+
}
49+
50+
// Record an audit log entry
51+
export async function recordAuditLog(opts: {
52+
actorId: string
53+
action: AuditAction
54+
targetType: 'user' | 'role' | 'banner' | 'feed_entry' | 'feedback'
55+
targetId: string
56+
details?: Record<string, unknown>
57+
request?: Request
58+
}): Promise<void> {
59+
const { actorId, action, targetType, targetId, details, request } = opts
60+
61+
await db.insert(auditLogs).values({
62+
actorId,
63+
action,
64+
targetType,
65+
targetId,
66+
details: details ?? null,
67+
ipAddress: request ? getClientIp(request) : undefined,
68+
userAgent: request?.headers.get('user-agent') || undefined,
69+
})
70+
}

0 commit comments

Comments
 (0)