mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-08 05:56:38 +00:00
Merge pull request #1755 from fosrl/audit-logs
Request, action, and access logs
This commit is contained in:
@@ -1892,6 +1892,58 @@
|
||||
"toConfirm": "to confirm",
|
||||
"deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?",
|
||||
"clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.",
|
||||
"sidebarLogs": "Logs",
|
||||
"request": "Request",
|
||||
"logs": "Logs",
|
||||
"logsSettingsDescription": "Monitor logs collected from this orginization",
|
||||
"searchLogs": "Search logs...",
|
||||
"action": "Action",
|
||||
"actor": "Actor",
|
||||
"timestamp": "Timestamp",
|
||||
"accessLogs": "Access Logs",
|
||||
"exportCsv": "Export CSV",
|
||||
"actorId": "Actor ID",
|
||||
"allowedByRule": "Allowed by Rule",
|
||||
"allowedNoAuth": "Allowed No Auth",
|
||||
"validAccessToken": "Valid Access Token",
|
||||
"validHeaderAuth": "Valid header auth",
|
||||
"validPincode": "Valid Pincode",
|
||||
"validPassword": "Valid Password",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"resourceBlocked": "Resource Blocked",
|
||||
"droppedByRule": "Dropped by Rule",
|
||||
"noSessions": "No Sessions",
|
||||
"temporaryRequestToken": "Temporary Request Token",
|
||||
"noMoreAuthMethods": "No Valid Auth",
|
||||
"ip": "IP",
|
||||
"reason": "Reason",
|
||||
"requestLogs": "Request Logs",
|
||||
"host": "Host",
|
||||
"location": "Location",
|
||||
"actionLogs": "Action Logs",
|
||||
"sidebarLogsRequest": "Request Logs",
|
||||
"sidebarLogsAccess": "Access Logs",
|
||||
"sidebarLogsAction": "Action Logs",
|
||||
"logRetention": "Log Retention",
|
||||
"logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them",
|
||||
"requestLogsDescription": "View detailed request logs for resources in this organization",
|
||||
"logRetentionRequestLabel": "Request Log Retention",
|
||||
"logRetentionRequestDescription": "How long to retain request logs",
|
||||
"logRetentionAccessLabel": "Access Log Retention",
|
||||
"logRetentionAccessDescription": "How long to retain access logs",
|
||||
"logRetentionActionLabel": "Action Log Retention",
|
||||
"logRetentionActionDescription": "How long to retain action logs",
|
||||
"logRetentionDisabled": "Disabled",
|
||||
"logRetention3Days": "3 days",
|
||||
"logRetention7Days": "7 days",
|
||||
"logRetention14Days": "14 days",
|
||||
"logRetention30Days": "30 days",
|
||||
"logRetention90Days": "90 days",
|
||||
"logRetentionForever": "Forever",
|
||||
"actionLogsDescription": "View a history of actions performed in this organization",
|
||||
"accessLogsDescription": "View access auth requests for resources in this organization",
|
||||
"licenseRequiredToUse": "An Enterprise license is required to use this feature.",
|
||||
"certResolver": "Certificate Resolver",
|
||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||
"selectCertResolver": "Select Certificate Resolver",
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -52,6 +52,7 @@
|
||||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "0.44.6",
|
||||
"eslint": "9.37.0",
|
||||
"eslint-config-next": "15.5.6",
|
||||
@@ -81,6 +82,7 @@
|
||||
"posthog-node": "^5.9.5",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-day-picker": "9.11.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-easy-sort": "^1.8.0",
|
||||
"react-hook-form": "7.65.0",
|
||||
@@ -1628,6 +1630,7 @@
|
||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
@@ -1979,6 +1982,12 @@
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz",
|
||||
@@ -4015,6 +4024,7 @@
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -6844,6 +6854,7 @@
|
||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -7049,6 +7060,7 @@
|
||||
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7059,6 +7071,7 @@
|
||||
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
@@ -8490,6 +8503,7 @@
|
||||
"integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -8576,6 +8590,7 @@
|
||||
"integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -8669,6 +8684,7 @@
|
||||
"integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
}
|
||||
@@ -8697,6 +8713,7 @@
|
||||
"integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -8730,6 +8747,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -8740,6 +8758,7 @@
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -8883,6 +8902,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
|
||||
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
@@ -9556,6 +9576,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -10086,6 +10107,7 @@
|
||||
"integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -10199,6 +10221,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -10936,6 +10959,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-jalali": {
|
||||
"version": "4.1.0-0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz",
|
||||
@@ -11839,6 +11878,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -11935,6 +11975,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz",
|
||||
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -12102,6 +12143,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -12391,6 +12433,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
@@ -17514,6 +17557,7 @@
|
||||
"version": "4.0.3",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18492,6 +18536,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
@@ -18668,6 +18713,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -19119,15 +19165,38 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "9.11.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
|
||||
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-jalali": "^4.1.0-0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -19419,6 +19488,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
|
||||
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -19903,6 +19973,7 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -21068,7 +21139,8 @@
|
||||
"version": "4.1.14",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
|
||||
"integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -21625,6 +21697,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -22130,6 +22203,7 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz",
|
||||
"integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -22437,6 +22511,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"cookies": "^0.9.1",
|
||||
"cors": "2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "4.1.0",
|
||||
"drizzle-orm": "0.44.6",
|
||||
"eslint": "9.37.0",
|
||||
"eslint-config-next": "15.5.6",
|
||||
@@ -104,6 +105,7 @@
|
||||
"posthog-node": "^5.9.5",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.0",
|
||||
"react-day-picker": "9.11.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-easy-sort": "^1.8.0",
|
||||
"react-hook-form": "7.65.0",
|
||||
|
||||
@@ -119,7 +119,9 @@ export enum ActionsEnum {
|
||||
updateLoginPage = "updateLoginPage",
|
||||
getLoginPage = "getLoginPage",
|
||||
deleteLoginPage = "deleteLoginPage",
|
||||
applyBlueprint = "applyBlueprint"
|
||||
applyBlueprint = "applyBlueprint",
|
||||
viewLogs = "viewLogs",
|
||||
exportLogs = "exportLogs"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
integer,
|
||||
bigint,
|
||||
real,
|
||||
text
|
||||
text,
|
||||
index
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
|
||||
@@ -213,6 +214,43 @@ export const sessionTransferToken = pgTable("sessionTransferToken", {
|
||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||
});
|
||||
|
||||
export const actionAuditLog = pgTable("actionAuditLog", {
|
||||
id: serial("id").primaryKey(),
|
||||
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
actorType: varchar("actorType", { length: 50 }).notNull(),
|
||||
actor: varchar("actor", { length: 255 }).notNull(),
|
||||
actorId: varchar("actorId", { length: 255 }).notNull(),
|
||||
action: varchar("action", { length: 100 }).notNull(),
|
||||
metadata: text("metadata")
|
||||
}, (table) => ([
|
||||
index("idx_actionAuditLog_timestamp").on(table.timestamp),
|
||||
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
||||
]));
|
||||
|
||||
export const accessAuditLog = pgTable("accessAuditLog", {
|
||||
id: serial("id").primaryKey(),
|
||||
timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
actorType: varchar("actorType", { length: 50 }),
|
||||
actor: varchar("actor", { length: 255 }),
|
||||
actorId: varchar("actorId", { length: 255 }),
|
||||
resourceId: integer("resourceId"),
|
||||
ip: varchar("ip", { length: 45 }),
|
||||
type: varchar("type", { length: 100 }).notNull(),
|
||||
action: boolean("action").notNull(),
|
||||
location: text("location"),
|
||||
userAgent: text("userAgent"),
|
||||
metadata: text("metadata")
|
||||
}, (table) => ([
|
||||
index("idx_identityAuditLog_timestamp").on(table.timestamp),
|
||||
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
||||
]));
|
||||
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
@@ -230,3 +268,5 @@ export type RemoteExitNodeSession = InferSelectModel<
|
||||
>;
|
||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
integer,
|
||||
bigint,
|
||||
real,
|
||||
text
|
||||
text,
|
||||
index
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -40,7 +41,16 @@ export const orgs = pgTable("orgs", {
|
||||
orgId: varchar("orgId").primaryKey(),
|
||||
name: varchar("name").notNull(),
|
||||
subnet: varchar("subnet"),
|
||||
createdAt: text("createdAt")
|
||||
createdAt: text("createdAt"),
|
||||
settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever
|
||||
.notNull()
|
||||
.default(7),
|
||||
settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess")
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction")
|
||||
.notNull()
|
||||
.default(0)
|
||||
});
|
||||
|
||||
export const orgDomains = pgTable("orgDomains", {
|
||||
@@ -687,6 +697,42 @@ export const setupTokens = pgTable("setupTokens", {
|
||||
dateUsed: varchar("dateUsed")
|
||||
});
|
||||
|
||||
export const requestAuditLog = pgTable(
|
||||
"requestAuditLog",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
action: boolean("action").notNull(),
|
||||
reason: integer("reason").notNull(),
|
||||
actorType: text("actorType"),
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
userAgent: text("userAgent"),
|
||||
metadata: text("metadata"),
|
||||
headers: text("headers"), // JSON blob
|
||||
query: text("query"), // JSON blob
|
||||
originalRequestURL: text("originalRequestURL"),
|
||||
scheme: text("scheme"),
|
||||
host: text("host"),
|
||||
path: text("path"),
|
||||
method: text("method"),
|
||||
tls: boolean("tls")
|
||||
},
|
||||
(table) => [
|
||||
index("idx_requestAuditLog_timestamp").on(table.timestamp),
|
||||
index("idx_requestAuditLog_org_timestamp").on(
|
||||
table.orgId,
|
||||
table.timestamp
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -738,3 +784,7 @@ export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
|
||||
export type IdpOidcConfig = InferSelectModel<typeof idpOidcConfig>;
|
||||
export type LicenseKey = InferSelectModel<typeof licenseKey>;
|
||||
export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
sqliteTable,
|
||||
integer,
|
||||
text,
|
||||
real
|
||||
real,
|
||||
index
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
|
||||
import { metadata } from "@app/app/[orgId]/settings/layout";
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
@@ -207,6 +209,43 @@ export const sessionTransferToken = sqliteTable("sessionTransferToken", {
|
||||
expiresAt: integer("expiresAt").notNull()
|
||||
});
|
||||
|
||||
export const actionAuditLog = sqliteTable("actionAuditLog", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
actorType: text("actorType").notNull(),
|
||||
actor: text("actor").notNull(),
|
||||
actorId: text("actorId").notNull(),
|
||||
action: text("action").notNull(),
|
||||
metadata: text("metadata")
|
||||
}, (table) => ([
|
||||
index("idx_actionAuditLog_timestamp").on(table.timestamp),
|
||||
index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
||||
]));
|
||||
|
||||
export const accessAuditLog = sqliteTable("accessAuditLog", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
actorType: text("actorType"),
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
type: text("type").notNull(),
|
||||
action: integer("action", { mode: "boolean" }).notNull(),
|
||||
userAgent: text("userAgent"),
|
||||
metadata: text("metadata")
|
||||
}, (table) => ([
|
||||
index("idx_identityAuditLog_timestamp").on(table.timestamp),
|
||||
index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp)
|
||||
]));
|
||||
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
@@ -224,3 +263,5 @@ export type RemoteExitNodeSession = InferSelectModel<
|
||||
>;
|
||||
export type ExitNodeOrg = InferSelectModel<typeof exitNodeOrgs>;
|
||||
export type LoginPage = InferSelectModel<typeof loginPage>;
|
||||
export type ActionAuditLog = InferSelectModel<typeof actionAuditLog>;
|
||||
export type AccessAuditLog = InferSelectModel<typeof accessAuditLog>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
|
||||
import { boolean } from "yargs";
|
||||
|
||||
export const domains = sqliteTable("domains", {
|
||||
domainId: text("domainId").primaryKey(),
|
||||
@@ -33,7 +34,16 @@ export const orgs = sqliteTable("orgs", {
|
||||
orgId: text("orgId").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
subnet: text("subnet"),
|
||||
createdAt: text("createdAt")
|
||||
createdAt: text("createdAt"),
|
||||
settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever
|
||||
.notNull()
|
||||
.default(7),
|
||||
settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess")
|
||||
.notNull()
|
||||
.default(0),
|
||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction")
|
||||
.notNull()
|
||||
.default(0)
|
||||
});
|
||||
|
||||
export const userDomains = sqliteTable("userDomains", {
|
||||
@@ -159,11 +169,15 @@ export const targets = sqliteTable("targets", {
|
||||
});
|
||||
|
||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ autoIncrement: true }),
|
||||
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
targetId: integer("targetId")
|
||||
.notNull()
|
||||
.references(() => targets.targetId, { onDelete: "cascade" }),
|
||||
hcEnabled: integer("hcEnabled", { mode: "boolean" }).notNull().default(false),
|
||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
hcPath: text("hcPath"),
|
||||
hcScheme: text("hcScheme"),
|
||||
hcMode: text("hcMode").default("http"),
|
||||
@@ -173,7 +187,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
|
||||
hcTimeout: integer("hcTimeout").default(5), // in seconds
|
||||
hcHeaders: text("hcHeaders"),
|
||||
hcFollowRedirects: integer("hcFollowRedirects", { mode: "boolean" }).default(true),
|
||||
hcFollowRedirects: integer("hcFollowRedirects", {
|
||||
mode: "boolean"
|
||||
}).default(true),
|
||||
hcMethod: text("hcMethod").default("GET"),
|
||||
hcStatus: integer("hcStatus"), // http code
|
||||
hcHealth: text("hcHealth").default("unknown") // "unknown", "healthy", "unhealthy"
|
||||
@@ -727,6 +743,42 @@ export const idpOrg = sqliteTable("idpOrg", {
|
||||
orgMapping: text("orgMapping")
|
||||
});
|
||||
|
||||
export const requestAuditLog = sqliteTable(
|
||||
"requestAuditLog",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: text("orgId").references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
action: integer("action", { mode: "boolean" }).notNull(),
|
||||
reason: integer("reason").notNull(),
|
||||
actorType: text("actorType"),
|
||||
actor: text("actor"),
|
||||
actorId: text("actorId"),
|
||||
resourceId: integer("resourceId"),
|
||||
ip: text("ip"),
|
||||
location: text("location"),
|
||||
userAgent: text("userAgent"),
|
||||
metadata: text("metadata"),
|
||||
headers: text("headers"), // JSON blob
|
||||
query: text("query"), // JSON blob
|
||||
originalRequestURL: text("originalRequestURL"),
|
||||
scheme: text("scheme"),
|
||||
host: text("host"),
|
||||
path: text("path"),
|
||||
method: text("method"),
|
||||
tls: integer("tls", { mode: "boolean" })
|
||||
},
|
||||
(table) => [
|
||||
index("idx_requestAuditLog_timestamp").on(table.timestamp),
|
||||
index("idx_requestAuditLog_org_timestamp").on(
|
||||
table.orgId,
|
||||
table.timestamp
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -779,3 +831,7 @@ export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||
export type TargetHealthCheck = InferSelectModel<typeof targetHealthCheck>;
|
||||
export type IdpOidcConfig = InferSelectModel<typeof idpOidcConfig>;
|
||||
export type LicenseKey = InferSelectModel<typeof licenseKey>;
|
||||
export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { runSetupFunctions } from "./setup";
|
||||
import { createApiServer } from "./apiServer";
|
||||
import { createNextServer } from "./nextServer";
|
||||
import { createInternalServer } from "./internalServer";
|
||||
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyOrg,
|
||||
@@ -13,14 +14,15 @@ import {
|
||||
User,
|
||||
UserOrg
|
||||
} from "@server/db";
|
||||
import { createIntegrationApiServer } from "./integrationApiServer";
|
||||
import config from "@server/lib/config";
|
||||
import { setHostMeta } from "@server/lib/hostMeta";
|
||||
import { initTelemetryClient } from "./lib/telemetry.js";
|
||||
import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js";
|
||||
import { initTelemetryClient } from "@server/lib/telemetry";
|
||||
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||
import { initCleanup } from "#dynamic/cleanup";
|
||||
import license from "#dynamic/license/license";
|
||||
import { fetchServerIp } from "./lib/serverIpService.js";
|
||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||
|
||||
async function startServers() {
|
||||
await setHostMeta();
|
||||
|
||||
@@ -35,12 +37,13 @@ async function startServers() {
|
||||
|
||||
initTelemetryClient();
|
||||
|
||||
initLogCleanupInterval();
|
||||
|
||||
// Start all servers
|
||||
const apiServer = createApiServer();
|
||||
const internalServer = createInternalServer();
|
||||
|
||||
let nextServer;
|
||||
nextServer = await createNextServer();
|
||||
const nextServer = await createNextServer();
|
||||
if (config.getRawConfig().traefik.file_mode) {
|
||||
const monitor = new TraefikConfigManager();
|
||||
await monitor.start();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export async function getOrgTierData(
|
||||
orgId: string
|
||||
): Promise<{ tier: string | null; active: boolean }> {
|
||||
let tier = null;
|
||||
let active = false;
|
||||
const tier = null;
|
||||
const active = false;
|
||||
|
||||
return { tier, active };
|
||||
}
|
||||
|
||||
62
server/lib/cleanupLogs.ts
Normal file
62
server/lib/cleanupLogs.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { db, orgs } from "@server/db";
|
||||
import { cleanUpOldLogs as cleanUpOldAccessLogs } from "@server/private/lib/logAccessAudit";
|
||||
import { cleanUpOldLogs as cleanUpOldActionLogs } from "@server/private/middlewares/logActionAudit";
|
||||
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
|
||||
import { gt, or } from "drizzle-orm";
|
||||
|
||||
export function initLogCleanupInterval() {
|
||||
return setInterval(
|
||||
async () => {
|
||||
const orgsToClean = await db
|
||||
.select({
|
||||
orgId: orgs.orgId,
|
||||
settingsLogRetentionDaysAction:
|
||||
orgs.settingsLogRetentionDaysAction,
|
||||
settingsLogRetentionDaysAccess:
|
||||
orgs.settingsLogRetentionDaysAccess,
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(orgs)
|
||||
.where(
|
||||
or(
|
||||
gt(orgs.settingsLogRetentionDaysAction, 0),
|
||||
gt(orgs.settingsLogRetentionDaysAccess, 0),
|
||||
gt(orgs.settingsLogRetentionDaysRequest, 0)
|
||||
)
|
||||
);
|
||||
|
||||
for (const org of orgsToClean) {
|
||||
const {
|
||||
orgId,
|
||||
settingsLogRetentionDaysAction,
|
||||
settingsLogRetentionDaysAccess,
|
||||
settingsLogRetentionDaysRequest
|
||||
} = org;
|
||||
|
||||
if (settingsLogRetentionDaysAction > 0) {
|
||||
await cleanUpOldActionLogs(
|
||||
orgId,
|
||||
settingsLogRetentionDaysRequest
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsLogRetentionDaysAccess > 0) {
|
||||
await cleanUpOldAccessLogs(
|
||||
orgId,
|
||||
settingsLogRetentionDaysRequest
|
||||
);
|
||||
}
|
||||
|
||||
if (settingsLogRetentionDaysRequest > 0) {
|
||||
await cleanUpOldRequestLogs(
|
||||
orgId,
|
||||
settingsLogRetentionDaysRequest
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
// 3 * 60 * 60 * 1000
|
||||
60 * 1000 // for testing
|
||||
); // every 3 hours
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export async function getCountryCodeForIp(
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
if (!maxmindLookup) {
|
||||
logger.warn(
|
||||
logger.debug(
|
||||
"MaxMind DB path not configured, cannot perform GeoIP lookup"
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -27,3 +27,4 @@ export * from "./verifyDomainAccess";
|
||||
export * from "./verifyClientsEnabled";
|
||||
export * from "./verifyUserIsOrgOwner";
|
||||
export * from "./verifySiteResourceAccess";
|
||||
export * from "./logActionAudit";
|
||||
12
server/middlewares/logActionAudit.ts
Normal file
12
server/middlewares/logActionAudit.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
|
||||
export function logActionAudit(action: ActionsEnum) {
|
||||
return async function (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
next();
|
||||
};
|
||||
}
|
||||
157
server/private/lib/logAccessAudit.ts
Normal file
157
server/private/lib/logAccessAudit.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { accessAuditLog, db, orgs } from "@server/db";
|
||||
import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
async function getAccessDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = cache.get<number>(`org_${orgId}_accessDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
cache.set(
|
||||
`org_${orgId}_accessDays`,
|
||||
org.settingsLogRetentionDaysAction,
|
||||
300
|
||||
);
|
||||
|
||||
return org.settingsLogRetentionDaysAction;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const cutoffTimestamp = now - retentionDays * 24 * 60 * 60;
|
||||
|
||||
try {
|
||||
const deleteResult = await db
|
||||
.delete(accessAuditLog)
|
||||
.where(
|
||||
and(
|
||||
lt(accessAuditLog.timestamp, cutoffTimestamp),
|
||||
eq(accessAuditLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Cleaned up ${deleteResult.changes} access audit logs older than ${retentionDays} days`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error cleaning up old action audit logs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logAccessAudit(data: {
|
||||
action: boolean;
|
||||
type: string;
|
||||
orgId: string;
|
||||
resourceId?: number;
|
||||
user?: { username: string; userId: string };
|
||||
apiKey?: { name: string | null; apiKeyId: string };
|
||||
metadata?: any;
|
||||
userAgent?: string;
|
||||
requestIp?: string;
|
||||
}) {
|
||||
try {
|
||||
const retentionDays = await getAccessDays(data.orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return;
|
||||
}
|
||||
|
||||
let actorType: string | undefined;
|
||||
let actor: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
|
||||
const user = data.user;
|
||||
if (user) {
|
||||
actorType = "user";
|
||||
actor = user.username;
|
||||
actorId = user.userId;
|
||||
}
|
||||
const apiKey = data.apiKey;
|
||||
if (apiKey) {
|
||||
actorType = "apiKey";
|
||||
actor = apiKey.name || apiKey.apiKeyId;
|
||||
actorId = apiKey.apiKeyId;
|
||||
}
|
||||
|
||||
// if (!actorType || !actor || !actorId) {
|
||||
// logger.warn("logRequestAudit: Incomplete actor information");
|
||||
// return;
|
||||
// }
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
let metadata = null;
|
||||
if (metadata) {
|
||||
metadata = JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
const clientIp = data.requestIp
|
||||
? (() => {
|
||||
if (
|
||||
data.requestIp.startsWith("[") &&
|
||||
data.requestIp.includes("]")
|
||||
) {
|
||||
// if brackets are found, extract the IPv6 address from between the brackets
|
||||
const ipv6Match = data.requestIp.match(/\[(.*?)\]/);
|
||||
if (ipv6Match) {
|
||||
return ipv6Match[1];
|
||||
}
|
||||
}
|
||||
return data.requestIp;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
const countryCode = data.requestIp
|
||||
? await getCountryCodeFromIp(data.requestIp)
|
||||
: undefined;
|
||||
|
||||
await db.insert(accessAuditLog).values({
|
||||
timestamp: timestamp,
|
||||
orgId: data.orgId,
|
||||
actorType,
|
||||
actor,
|
||||
actorId,
|
||||
action: data.action,
|
||||
type: data.type,
|
||||
metadata,
|
||||
resourceId: data.resourceId,
|
||||
userAgent: data.userAgent,
|
||||
ip: clientIp,
|
||||
location: countryCode
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
||||
const geoIpCacheKey = `geoip_access:${ip}`;
|
||||
|
||||
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
|
||||
|
||||
if (!cachedCountryCode) {
|
||||
cachedCountryCode = await getCountryCodeForIp(ip); // do it locally
|
||||
// Cache for longer since IP geolocation doesn't change frequently
|
||||
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
||||
}
|
||||
|
||||
return cachedCountryCode;
|
||||
}
|
||||
@@ -789,7 +789,7 @@ export async function getTraefikConfig(
|
||||
continue;
|
||||
}
|
||||
|
||||
let tls = {};
|
||||
const tls = {};
|
||||
if (
|
||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||
) {
|
||||
|
||||
@@ -15,4 +15,5 @@ export * from "./verifyCertificateAccess";
|
||||
export * from "./verifyRemoteExitNodeAccess";
|
||||
export * from "./verifyIdpAccess";
|
||||
export * from "./verifyLoginPageAccess";
|
||||
export * from "../../lib/corsWithLoginPage";
|
||||
export * from "./logActionAudit";
|
||||
export * from "./verifySubscription";
|
||||
145
server/private/middlewares/logActionAudit.ts
Normal file
145
server/private/middlewares/logActionAudit.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { actionAuditLog, db, orgs } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
async function getActionDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = cache.get<number>(`org_${orgId}_actionDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
cache.set(`org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300);
|
||||
|
||||
return org.settingsLogRetentionDaysAction;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const cutoffTimestamp = now - retentionDays * 24 * 60 * 60;
|
||||
|
||||
try {
|
||||
const deleteResult = await db
|
||||
.delete(actionAuditLog)
|
||||
.where(
|
||||
and(
|
||||
lt(actionAuditLog.timestamp, cutoffTimestamp),
|
||||
eq(actionAuditLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Cleaned up ${deleteResult.changes} action audit logs older than ${retentionDays} days`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error cleaning up old action audit logs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function logActionAudit(action: ActionsEnum) {
|
||||
return async function (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
let orgId;
|
||||
let actorType;
|
||||
let actor;
|
||||
let actorId;
|
||||
|
||||
const user = req.user;
|
||||
if (user) {
|
||||
const userOrg = req.userOrg;
|
||||
orgId = userOrg?.orgId;
|
||||
actorType = "user";
|
||||
actor = user.username;
|
||||
actorId = user.userId;
|
||||
}
|
||||
const apiKey = req.apiKey;
|
||||
if (apiKey) {
|
||||
const apiKeyOrg = req.apiKeyOrg;
|
||||
orgId = apiKeyOrg?.orgId;
|
||||
actorType = "apiKey";
|
||||
actor = apiKey.name;
|
||||
actorId = apiKey.apiKeyId;
|
||||
}
|
||||
|
||||
if (!orgId) {
|
||||
logger.warn("logActionAudit: No organization context found");
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!actorType || !actor || !actorId) {
|
||||
logger.warn("logActionAudit: Incomplete actor information");
|
||||
return next();
|
||||
}
|
||||
|
||||
const retentionDays = await getActionDays(orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return next();
|
||||
}
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
let metadata = null;
|
||||
if (req.params) {
|
||||
metadata = JSON.stringify(req.params);
|
||||
}
|
||||
|
||||
await db.insert(actionAuditLog).values({
|
||||
timestamp,
|
||||
orgId,
|
||||
actorType,
|
||||
actor,
|
||||
actorId,
|
||||
action,
|
||||
metadata
|
||||
});
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying logging action"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
50
server/private/middlewares/verifySubscription.ts
Normal file
50
server/private/middlewares/verifySubscription.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { build } from "@server/build";
|
||||
import { getOrgTierData } from "#private/lib/billing";
|
||||
|
||||
export async function verifyValidSubscription(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
if (build != "saas") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const tier = await getOrgTierData(req.params.orgId);
|
||||
|
||||
if (!tier.active) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Organization does not have an active subscription"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying subscription"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
81
server/private/routers/auditLogs/exportAccessAuditLog.ts
Normal file
81
server/private/routers/auditLogs/exportAccessAuditLog.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, queryAccess } from "./queryAccessAuditLog";
|
||||
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/access/export",
|
||||
description: "Export the access audit log for an organization as CSV",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: queryAccessAuditLogsQuery,
|
||||
params: queryAccessAuditLogsParams
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function exportAccessAuditLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryAccess(data);
|
||||
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const csvData = generateCSV(log);
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${data.orgId}-${Date.now()}.csv"`);
|
||||
|
||||
return res.send(csvData);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
81
server/private/routers/auditLogs/exportActionAuditLog.ts
Normal file
81
server/private/routers/auditLogs/exportActionAuditLog.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction } from "./queryActionAuditLog";
|
||||
import { generateCSV } from "@server/routers/auditLogs/generateCSV";
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/action/export",
|
||||
description: "Export the action audit log for an organization as CSV",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: queryActionAuditLogsQuery,
|
||||
params: queryActionAuditLogsParams
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function exportActionAuditLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryAction(data);
|
||||
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const csvData = generateCSV(log);
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${data.orgId}-${Date.now()}.csv"`);
|
||||
|
||||
return res.send(csvData);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
17
server/private/routers/auditLogs/index.ts
Normal file
17
server/private/routers/auditLogs/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./queryActionAuditLog";
|
||||
export * from "./exportActionAuditLog";
|
||||
export * from "./queryAccessAuditLog";
|
||||
export * from "./exportAccessAuditLog";
|
||||
258
server/private/routers/auditLogs/queryAccessAuditLog.ts
Normal file
258
server/private/routers/auditLogs/queryAccessAuditLog.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { accessAuditLog, db, resources } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count } from "drizzle-orm";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { z } from "zod";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export const queryAccessAuditLogsQuery = z.object({
|
||||
// iso string just validate its a parseable date
|
||||
timeStart: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000)),
|
||||
timeEnd: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.default(new Date().toISOString()),
|
||||
action: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||
.optional(),
|
||||
actorType: z.string().optional(),
|
||||
actorId: z.string().optional(),
|
||||
resourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.optional(),
|
||||
actor: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export const queryAccessAuditLogsParams = z.object({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
||||
queryAccessAuditLogsParams
|
||||
);
|
||||
type Q = z.infer<typeof queryAccessAuditLogsCombined>;
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(accessAuditLog.timestamp, data.timeStart),
|
||||
lt(accessAuditLog.timestamp, data.timeEnd),
|
||||
eq(accessAuditLog.orgId, data.orgId),
|
||||
data.resourceId
|
||||
? eq(accessAuditLog.resourceId, data.resourceId)
|
||||
: undefined,
|
||||
data.actor ? eq(accessAuditLog.actor, data.actor) : undefined,
|
||||
data.actorType
|
||||
? eq(accessAuditLog.actorType, data.actorType)
|
||||
: undefined,
|
||||
data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined,
|
||||
data.location ? eq(accessAuditLog.location, data.location) : undefined,
|
||||
data.type ? eq(accessAuditLog.type, data.type) : undefined,
|
||||
data.action !== undefined
|
||||
? eq(accessAuditLog.action, data.action)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function queryAccess(data: Q) {
|
||||
return db
|
||||
.select({
|
||||
orgId: accessAuditLog.orgId,
|
||||
action: accessAuditLog.action,
|
||||
actorType: accessAuditLog.actorType,
|
||||
actorId: accessAuditLog.actorId,
|
||||
resourceId: accessAuditLog.resourceId,
|
||||
resourceName: resources.name,
|
||||
resourceNiceId: resources.niceId,
|
||||
ip: accessAuditLog.ip,
|
||||
location: accessAuditLog.location,
|
||||
userAgent: accessAuditLog.userAgent,
|
||||
metadata: accessAuditLog.metadata,
|
||||
type: accessAuditLog.type,
|
||||
timestamp: accessAuditLog.timestamp,
|
||||
actor: accessAuditLog.actor
|
||||
})
|
||||
.from(accessAuditLog)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(accessAuditLog.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(getWhere(data))
|
||||
.orderBy(accessAuditLog.timestamp);
|
||||
}
|
||||
|
||||
export function countAccessQuery(data: Q) {
|
||||
const countQuery = db
|
||||
.select({ count: count() })
|
||||
.from(accessAuditLog)
|
||||
.where(getWhere(data));
|
||||
return countQuery;
|
||||
}
|
||||
|
||||
async function queryUniqueFilterAttributes(
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
orgId: string
|
||||
) {
|
||||
const baseConditions = and(
|
||||
gt(accessAuditLog.timestamp, timeStart),
|
||||
lt(accessAuditLog.timestamp, timeEnd),
|
||||
eq(accessAuditLog.orgId, orgId)
|
||||
);
|
||||
|
||||
// Get unique actors
|
||||
const uniqueActors = await db
|
||||
.selectDistinct({
|
||||
actor: accessAuditLog.actor
|
||||
})
|
||||
.from(accessAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique locations
|
||||
const uniqueLocations = await db
|
||||
.selectDistinct({
|
||||
locations: accessAuditLog.location
|
||||
})
|
||||
.from(accessAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique resources with names
|
||||
const uniqueResources = await db
|
||||
.selectDistinct({
|
||||
id: accessAuditLog.resourceId,
|
||||
name: resources.name
|
||||
})
|
||||
.from(accessAuditLog)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(accessAuditLog.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(baseConditions);
|
||||
|
||||
return {
|
||||
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
|
||||
resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null),
|
||||
locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null)
|
||||
};
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/access",
|
||||
description: "Query the access audit log for an organization",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: queryAccessAuditLogsQuery,
|
||||
params: queryAccessAuditLogsParams
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function queryAccessAuditLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const parsedParams = queryAccessAuditLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryAccess(data);
|
||||
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const totalCountResult = await countAccessQuery(data);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
const filterAttributes = await queryUniqueFilterAttributes(
|
||||
data.timeStart,
|
||||
data.timeEnd,
|
||||
data.orgId
|
||||
);
|
||||
|
||||
return response<QueryAccessAuditLogResponse>(res, {
|
||||
data: {
|
||||
log: log,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit: data.limit,
|
||||
offset: data.offset
|
||||
},
|
||||
filterAttributes
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Access audit logs retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
211
server/private/routers/auditLogs/queryActionAuditLog.ts
Normal file
211
server/private/routers/auditLogs/queryActionAuditLog.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { actionAuditLog, db } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count } from "drizzle-orm";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { z } from "zod";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export const queryActionAuditLogsQuery = z.object({
|
||||
// iso string just validate its a parseable date
|
||||
timeStart: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000)),
|
||||
timeEnd: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.default(new Date().toISOString()),
|
||||
action: z.string().optional(),
|
||||
actorType: z.string().optional(),
|
||||
actorId: z.string().optional(),
|
||||
actor: z.string().optional(),
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export const queryActionAuditLogsParams = z.object({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export const queryActionAuditLogsCombined =
|
||||
queryActionAuditLogsQuery.merge(queryActionAuditLogsParams);
|
||||
type Q = z.infer<typeof queryActionAuditLogsCombined>;
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(actionAuditLog.timestamp, data.timeStart),
|
||||
lt(actionAuditLog.timestamp, data.timeEnd),
|
||||
eq(actionAuditLog.orgId, data.orgId),
|
||||
data.actor ? eq(actionAuditLog.actor, data.actor) : undefined,
|
||||
data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined,
|
||||
data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined,
|
||||
data.action ? eq(actionAuditLog.action, data.action) : undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function queryAction(data: Q) {
|
||||
return db
|
||||
.select({
|
||||
orgId: actionAuditLog.orgId,
|
||||
action: actionAuditLog.action,
|
||||
actorType: actionAuditLog.actorType,
|
||||
metadata: actionAuditLog.metadata,
|
||||
actorId: actionAuditLog.actorId,
|
||||
timestamp: actionAuditLog.timestamp,
|
||||
actor: actionAuditLog.actor
|
||||
})
|
||||
.from(actionAuditLog)
|
||||
.where(getWhere(data))
|
||||
.orderBy(actionAuditLog.timestamp);
|
||||
}
|
||||
|
||||
export function countActionQuery(data: Q) {
|
||||
const countQuery = db
|
||||
.select({ count: count() })
|
||||
.from(actionAuditLog)
|
||||
.where(getWhere(data));
|
||||
return countQuery;
|
||||
}
|
||||
|
||||
async function queryUniqueFilterAttributes(
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
orgId: string
|
||||
) {
|
||||
const baseConditions = and(
|
||||
gt(actionAuditLog.timestamp, timeStart),
|
||||
lt(actionAuditLog.timestamp, timeEnd),
|
||||
eq(actionAuditLog.orgId, orgId)
|
||||
);
|
||||
|
||||
// Get unique actors
|
||||
const uniqueActors = await db
|
||||
.selectDistinct({
|
||||
actor: actionAuditLog.actor
|
||||
})
|
||||
.from(actionAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
const uniqueActions = await db
|
||||
.selectDistinct({
|
||||
action: actionAuditLog.action
|
||||
})
|
||||
.from(actionAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
return {
|
||||
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
|
||||
actions: uniqueActions.map(row => row.action).filter((action): action is string => action !== null),
|
||||
};
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/action",
|
||||
description: "Query the action audit log for an organization",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: queryActionAuditLogsQuery,
|
||||
params: queryActionAuditLogsParams
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function queryActionAuditLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const parsedParams = queryActionAuditLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryAction(data);
|
||||
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const totalCountResult = await countActionQuery(data);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
const filterAttributes = await queryUniqueFilterAttributes(
|
||||
data.timeStart,
|
||||
data.timeEnd,
|
||||
data.orgId
|
||||
);
|
||||
|
||||
return response<QueryActionAuditLogResponse>(res, {
|
||||
data: {
|
||||
log: log,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit: data.limit,
|
||||
offset: data.offset
|
||||
},
|
||||
filterAttributes
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Action audit logs retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,20 +21,21 @@ import * as domain from "#private/routers/domain";
|
||||
import * as auth from "#private/routers/auth";
|
||||
import * as license from "#private/routers/license";
|
||||
import * as generateLicense from "./generatedLicense";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
|
||||
import { Router } from "express";
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserIsServerAdmin
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
logActionAudit,
|
||||
verifyCertificateAccess,
|
||||
verifyIdpAccess,
|
||||
verifyLoginPageAccess,
|
||||
verifyRemoteExitNodeAccess
|
||||
verifyRemoteExitNodeAccess,
|
||||
verifyValidSubscription
|
||||
} from "#private/middlewares";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -72,7 +73,8 @@ authenticated.put(
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createIdp),
|
||||
orgIdp.createOrgOidcIdp
|
||||
logActionAudit(ActionsEnum.createIdp),
|
||||
orgIdp.createOrgOidcIdp,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -81,7 +83,8 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
verifyIdpAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateIdp),
|
||||
orgIdp.updateOrgOidcIdp
|
||||
logActionAudit(ActionsEnum.updateIdp),
|
||||
orgIdp.updateOrgOidcIdp,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -90,7 +93,8 @@ authenticated.delete(
|
||||
verifyOrgAccess,
|
||||
verifyIdpAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteIdp),
|
||||
orgIdp.deleteOrgIdp
|
||||
logActionAudit(ActionsEnum.deleteIdp),
|
||||
orgIdp.deleteOrgIdp,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -127,7 +131,8 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
verifyCertificateAccess,
|
||||
verifyUserHasAction(ActionsEnum.restartCertificate),
|
||||
certificates.restartCertificate
|
||||
logActionAudit(ActionsEnum.restartCertificate),
|
||||
certificates.restartCertificate,
|
||||
);
|
||||
|
||||
if (build === "saas") {
|
||||
@@ -152,14 +157,16 @@ if (build === "saas") {
|
||||
"/org/:orgId/billing/create-checkout-session",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
billing.createCheckoutSession
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
billing.createCheckoutSession,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/billing/create-portal-session",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.billing),
|
||||
billing.createPortalSession
|
||||
logActionAudit(ActionsEnum.billing),
|
||||
billing.createPortalSession,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -206,7 +213,8 @@ authenticated.put(
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createRemoteExitNode),
|
||||
remoteExitNode.createRemoteExitNode
|
||||
logActionAudit(ActionsEnum.createRemoteExitNode),
|
||||
remoteExitNode.createRemoteExitNode,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -240,7 +248,8 @@ authenticated.delete(
|
||||
verifyOrgAccess,
|
||||
verifyRemoteExitNodeAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteRemoteExitNode),
|
||||
remoteExitNode.deleteRemoteExitNode
|
||||
logActionAudit(ActionsEnum.deleteRemoteExitNode),
|
||||
remoteExitNode.deleteRemoteExitNode,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
@@ -248,7 +257,8 @@ authenticated.put(
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createLoginPage),
|
||||
loginPage.createLoginPage
|
||||
logActionAudit(ActionsEnum.createLoginPage),
|
||||
loginPage.createLoginPage,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -257,7 +267,8 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
verifyLoginPageAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateLoginPage),
|
||||
loginPage.updateLoginPage
|
||||
logActionAudit(ActionsEnum.updateLoginPage),
|
||||
loginPage.updateLoginPage,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -266,7 +277,8 @@ authenticated.delete(
|
||||
verifyOrgAccess,
|
||||
verifyLoginPageAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteLoginPage),
|
||||
loginPage.deleteLoginPage
|
||||
logActionAudit(ActionsEnum.deleteLoginPage),
|
||||
loginPage.deleteLoginPage,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -334,3 +346,41 @@ authenticated.post(
|
||||
verifyUserIsServerAdmin,
|
||||
license.recheckStatus
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/action",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logs.queryActionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/action/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
logs.exportActionAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/access",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logs.queryAccessAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/access/export",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
logs.exportAccessAuditLogs
|
||||
);
|
||||
@@ -35,7 +35,9 @@ import {
|
||||
loginPageOrg,
|
||||
LoginPage,
|
||||
resourceHeaderAuth,
|
||||
ResourceHeaderAuth
|
||||
ResourceHeaderAuth,
|
||||
orgs,
|
||||
requestAuditLog
|
||||
} from "@server/db";
|
||||
import {
|
||||
resources,
|
||||
@@ -301,7 +303,8 @@ function loadEncryptData() {
|
||||
return; // already loaded
|
||||
}
|
||||
|
||||
encryptionKeyPath = privateConfig.getRawPrivateConfig().server.encryption_key_path;
|
||||
encryptionKeyPath =
|
||||
privateConfig.getRawPrivateConfig().server.encryption_key_path;
|
||||
|
||||
if (!fs.existsSync(encryptionKeyPath)) {
|
||||
throw new Error(
|
||||
@@ -1583,3 +1586,193 @@ hybridRouter.post(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
hybridRouter.get(
|
||||
"/org/:orgId/get-retention-days",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsedParams = getOrgLoginPageParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const remoteExitNode = req.remoteExitNode;
|
||||
|
||||
if (!remoteExitNode || !remoteExitNode.exitNodeId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Remote exit node not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) {
|
||||
// If the exit node is not allowed for the org, return an error
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Exit node not allowed for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
settingsLogRetentionDaysRequest:
|
||||
org.settingsLogRetentionDaysRequest
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Log retention days retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const batchLogsSchema = z.object({
|
||||
logs: z.array(
|
||||
z.object({
|
||||
timestamp: z.number(),
|
||||
orgId: z.string().optional(),
|
||||
actorType: z.string().optional(),
|
||||
actor: z.string().optional(),
|
||||
actorId: z.string().optional(),
|
||||
metadata: z.string().nullable(),
|
||||
action: z.boolean(),
|
||||
resourceId: z.number().optional(),
|
||||
reason: z.number(),
|
||||
location: z.string().optional(),
|
||||
originalRequestURL: z.string(),
|
||||
scheme: z.string(),
|
||||
host: z.string(),
|
||||
path: z.string(),
|
||||
method: z.string(),
|
||||
ip: z.string().optional(),
|
||||
tls: z.boolean()
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
hybridRouter.post(
|
||||
"/org/:orgId/logs/batch",
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const parsedParams = getOrgLoginPageParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const parsedBody = batchLogsSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { logs } = parsedBody.data;
|
||||
|
||||
const remoteExitNode = req.remoteExitNode;
|
||||
|
||||
if (!remoteExitNode || !remoteExitNode.exitNodeId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Remote exit node not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) {
|
||||
// If the exit node is not allowed for the org, return an error
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Exit node not allowed for this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Batch insert all logs in a single query
|
||||
const logEntries = logs.map((logEntry) => ({
|
||||
timestamp: logEntry.timestamp,
|
||||
orgId: logEntry.orgId,
|
||||
actorType: logEntry.actorType,
|
||||
actor: logEntry.actor,
|
||||
actorId: logEntry.actorId,
|
||||
metadata: logEntry.metadata,
|
||||
action: logEntry.action,
|
||||
resourceId: logEntry.resourceId,
|
||||
reason: logEntry.reason,
|
||||
location: logEntry.location,
|
||||
// userAgent: data.userAgent, // TODO: add this
|
||||
// headers: data.body.headers,
|
||||
// query: data.body.query,
|
||||
originalRequestURL: logEntry.originalRequestURL,
|
||||
scheme: logEntry.scheme,
|
||||
host: logEntry.host,
|
||||
path: logEntry.path,
|
||||
method: logEntry.method,
|
||||
ip: logEntry.ip,
|
||||
tls: logEntry.tls
|
||||
}));
|
||||
|
||||
await db.insert(requestAuditLog).values(logEntries);
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Logs saved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred..."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
||||
import { unauthenticated as ua, authenticated as a } from "@server/routers/integration";
|
||||
import { logActionAudit } from "#private/middlewares";
|
||||
|
||||
export const unauthenticated = ua;
|
||||
export const authenticated = a;
|
||||
@@ -31,12 +32,14 @@ authenticated.post(
|
||||
`/org/:orgId/send-usage-notification`,
|
||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
|
||||
org.sendUsageNotification
|
||||
logActionAudit(ActionsEnum.sendUsageNotification),
|
||||
org.sendUsageNotification,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/idp/:idpId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteIdp),
|
||||
orgIdp.deleteOrgIdp
|
||||
logActionAudit(ActionsEnum.deleteIdp),
|
||||
orgIdp.deleteOrgIdp,
|
||||
);
|
||||
68
server/routers/auditLogs/exportRequstAuditLog.ts
Normal file
68
server/routers/auditLogs/exportRequstAuditLog.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog";
|
||||
import { generateCSV } from "./generateCSV";
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/request",
|
||||
description: "Query the request audit log for an organization",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: queryAccessAuditLogsQuery,
|
||||
params: queryRequestAuditLogsParams
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function exportRequestAuditLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryRequest(data);
|
||||
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const csvData = generateCSV(log);
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`);
|
||||
|
||||
return res.send(csvData);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
16
server/routers/auditLogs/generateCSV.ts
Normal file
16
server/routers/auditLogs/generateCSV.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function generateCSV(data: any[]): string {
|
||||
if (data.length === 0) {
|
||||
return "orgId,action,actorType,timestamp,actor\n";
|
||||
}
|
||||
|
||||
const headers = Object.keys(data[0]).join(",");
|
||||
const rows = data.map(row =>
|
||||
Object.values(row).map(value =>
|
||||
typeof value === 'string' && value.includes(',')
|
||||
? `"${value.replace(/"/g, '""')}"`
|
||||
: value
|
||||
).join(",")
|
||||
);
|
||||
|
||||
return [headers, ...rows].join("\n");
|
||||
}
|
||||
2
server/routers/auditLogs/index.ts
Normal file
2
server/routers/auditLogs/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./queryRequstAuditLog";
|
||||
export * from "./exportRequstAuditLog";
|
||||
276
server/routers/auditLogs/queryRequstAuditLog.ts
Normal file
276
server/routers/auditLogs/queryRequstAuditLog.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { db, requestAuditLog, resources } from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { eq, gt, lt, and, count } from "drizzle-orm";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { z } from "zod";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export const queryAccessAuditLogsQuery = z.object({
|
||||
// iso string just validate its a parseable date
|
||||
timeStart: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: "timeStart must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000)),
|
||||
timeEnd: z
|
||||
.string()
|
||||
.refine((val) => !isNaN(Date.parse(val)), {
|
||||
message: "timeEnd must be a valid ISO date string"
|
||||
})
|
||||
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
|
||||
.optional()
|
||||
.default(new Date().toISOString()),
|
||||
action: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||
.optional(),
|
||||
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
|
||||
reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.optional(),
|
||||
resourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive())
|
||||
.optional(),
|
||||
actor: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
host: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().positive()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
});
|
||||
|
||||
export const queryRequestAuditLogsParams = z.object({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export const queryRequestAuditLogsCombined =
|
||||
queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams);
|
||||
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
||||
|
||||
function getWhere(data: Q) {
|
||||
return and(
|
||||
gt(requestAuditLog.timestamp, data.timeStart),
|
||||
lt(requestAuditLog.timestamp, data.timeEnd),
|
||||
eq(requestAuditLog.orgId, data.orgId),
|
||||
data.resourceId
|
||||
? eq(requestAuditLog.resourceId, data.resourceId)
|
||||
: undefined,
|
||||
data.actor ? eq(requestAuditLog.actor, data.actor) : undefined,
|
||||
data.method ? eq(requestAuditLog.method, data.method) : undefined,
|
||||
data.reason ? eq(requestAuditLog.reason, data.reason) : undefined,
|
||||
data.host ? eq(requestAuditLog.host, data.host) : undefined,
|
||||
data.location ? eq(requestAuditLog.location, data.location) : undefined,
|
||||
data.path ? eq(requestAuditLog.path, data.path) : undefined,
|
||||
data.action !== undefined
|
||||
? eq(requestAuditLog.action, data.action)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function queryRequest(data: Q) {
|
||||
return db
|
||||
.select({
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
orgId: requestAuditLog.orgId,
|
||||
action: requestAuditLog.action,
|
||||
reason: requestAuditLog.reason,
|
||||
actorType: requestAuditLog.actorType,
|
||||
actor: requestAuditLog.actor,
|
||||
actorId: requestAuditLog.actorId,
|
||||
resourceId: requestAuditLog.resourceId,
|
||||
ip: requestAuditLog.ip,
|
||||
location: requestAuditLog.location,
|
||||
userAgent: requestAuditLog.userAgent,
|
||||
metadata: requestAuditLog.metadata,
|
||||
headers: requestAuditLog.headers,
|
||||
query: requestAuditLog.query,
|
||||
originalRequestURL: requestAuditLog.originalRequestURL,
|
||||
scheme: requestAuditLog.scheme,
|
||||
host: requestAuditLog.host,
|
||||
path: requestAuditLog.path,
|
||||
method: requestAuditLog.method,
|
||||
tls: requestAuditLog.tls,
|
||||
resourceName: resources.name,
|
||||
resourceNiceId: resources.niceId
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(requestAuditLog.resourceId, resources.resourceId)
|
||||
) // TODO: Is this efficient?
|
||||
.where(getWhere(data))
|
||||
.orderBy(requestAuditLog.timestamp);
|
||||
}
|
||||
|
||||
export function countRequestQuery(data: Q) {
|
||||
const countQuery = db
|
||||
.select({ count: count() })
|
||||
.from(requestAuditLog)
|
||||
.where(getWhere(data));
|
||||
return countQuery;
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/logs/request",
|
||||
description: "Query the request audit log for an organization",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: queryAccessAuditLogsQuery,
|
||||
params: queryRequestAuditLogsParams
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
async function queryUniqueFilterAttributes(
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
orgId: string
|
||||
) {
|
||||
const baseConditions = and(
|
||||
gt(requestAuditLog.timestamp, timeStart),
|
||||
lt(requestAuditLog.timestamp, timeEnd),
|
||||
eq(requestAuditLog.orgId, orgId)
|
||||
);
|
||||
|
||||
// Get unique actors
|
||||
const uniqueActors = await db
|
||||
.selectDistinct({
|
||||
actor: requestAuditLog.actor
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique locations
|
||||
const uniqueLocations = await db
|
||||
.selectDistinct({
|
||||
locations: requestAuditLog.location
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique actors
|
||||
const uniqueHosts = await db
|
||||
.selectDistinct({
|
||||
hosts: requestAuditLog.host
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique actors
|
||||
const uniquePaths = await db
|
||||
.selectDistinct({
|
||||
paths: requestAuditLog.path
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.where(baseConditions);
|
||||
|
||||
// Get unique resources with names
|
||||
const uniqueResources = await db
|
||||
.selectDistinct({
|
||||
id: requestAuditLog.resourceId,
|
||||
name: resources.name
|
||||
})
|
||||
.from(requestAuditLog)
|
||||
.leftJoin(
|
||||
resources,
|
||||
eq(requestAuditLog.resourceId, resources.resourceId)
|
||||
)
|
||||
.where(baseConditions);
|
||||
|
||||
return {
|
||||
actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null),
|
||||
resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null),
|
||||
locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null),
|
||||
hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null),
|
||||
paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null)
|
||||
};
|
||||
}
|
||||
|
||||
export async function queryRequestAuditLogs(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryRequestAuditLogsParams.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = { ...parsedQuery.data, ...parsedParams.data };
|
||||
|
||||
const baseQuery = queryRequest(data);
|
||||
|
||||
const log = await baseQuery.limit(data.limit).offset(data.offset);
|
||||
|
||||
const totalCountResult = await countRequestQuery(data);
|
||||
const totalCount = totalCountResult[0].count;
|
||||
|
||||
const filterAttributes = await queryUniqueFilterAttributes(
|
||||
data.timeStart,
|
||||
data.timeEnd,
|
||||
data.orgId
|
||||
);
|
||||
|
||||
return response<QueryRequestAuditLogResponse>(res, {
|
||||
data: {
|
||||
log: log,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
limit: data.limit,
|
||||
offset: data.offset
|
||||
},
|
||||
filterAttributes
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Action audit logs retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
93
server/routers/auditLogs/types.ts
Normal file
93
server/routers/auditLogs/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type QueryActionAuditLogResponse = {
|
||||
log: {
|
||||
orgId: string;
|
||||
action: string;
|
||||
actorType: string;
|
||||
actorId: string;
|
||||
metadata: string | null;
|
||||
timestamp: number;
|
||||
actor: string;
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
filterAttributes: {
|
||||
actors: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryRequestAuditLogResponse = {
|
||||
log: {
|
||||
timestamp: number;
|
||||
action: boolean;
|
||||
reason: number;
|
||||
orgId: string | null;
|
||||
actorType: string | null;
|
||||
actor: string | null;
|
||||
actorId: string | null;
|
||||
resourceId: number | null;
|
||||
resourceNiceId: string | null;
|
||||
resourceName: string | null;
|
||||
ip: string | null;
|
||||
location: string | null;
|
||||
userAgent: string | null;
|
||||
metadata: string | null;
|
||||
headers: string | null;
|
||||
query: string | null;
|
||||
originalRequestURL: string | null;
|
||||
scheme: string | null;
|
||||
host: string | null;
|
||||
path: string | null;
|
||||
method: string | null;
|
||||
tls: boolean | null;
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
filterAttributes: {
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
hosts: string[];
|
||||
paths: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export type QueryAccessAuditLogResponse = {
|
||||
log: {
|
||||
orgId: string;
|
||||
action: boolean;
|
||||
actorType: string | null;
|
||||
actorId: string | null;
|
||||
resourceId: number | null;
|
||||
resourceName: string | null;
|
||||
resourceNiceId: string | null;
|
||||
ip: string | null;
|
||||
location: string | null;
|
||||
userAgent: string | null;
|
||||
metadata: string | null;
|
||||
type: string;
|
||||
timestamp: number;
|
||||
actor: string | null;
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
filterAttributes: {
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
};
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
generateSessionToken,
|
||||
serializeSessionCookie
|
||||
} from "@server/auth/sessions/app";
|
||||
import { db } from "@server/db";
|
||||
import { db, resources } from "@server/db";
|
||||
import { users, securityKeys } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
@@ -18,12 +18,14 @@ import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
|
||||
|
||||
export const loginBodySchema = z
|
||||
.object({
|
||||
email: z.string().toLowerCase().email(),
|
||||
password: z.string(),
|
||||
code: z.string().optional()
|
||||
code: z.string().optional(),
|
||||
resourceGuid: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -52,7 +54,7 @@ export async function login(
|
||||
);
|
||||
}
|
||||
|
||||
const { email, password, code } = parsedBody.data;
|
||||
const { email, password, code, resourceGuid } = parsedBody.data;
|
||||
|
||||
try {
|
||||
const { session: existingSession } = await verifySession(req);
|
||||
@@ -66,6 +68,28 @@ export async function login(
|
||||
});
|
||||
}
|
||||
|
||||
let resourceId: number | null = null;
|
||||
let orgId: string | null = null;
|
||||
if (resourceGuid) {
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceGuid, resourceGuid))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource with GUID ${resourceGuid} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
resourceId = resource.resourceId;
|
||||
orgId = resource.orgId;
|
||||
}
|
||||
|
||||
const existingUserRes = await db
|
||||
.select()
|
||||
.from(users)
|
||||
@@ -78,6 +102,18 @@ export async function login(
|
||||
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceId && orgId) {
|
||||
logAccessAudit({
|
||||
orgId: orgId,
|
||||
resourceId: resourceId,
|
||||
action: false,
|
||||
type: "login",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
@@ -98,6 +134,18 @@ export async function login(
|
||||
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceId && orgId) {
|
||||
logAccessAudit({
|
||||
orgId: orgId,
|
||||
resourceId: resourceId,
|
||||
action: false,
|
||||
type: "login",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
@@ -158,6 +206,18 @@ export async function login(
|
||||
`Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceId && orgId) {
|
||||
logAccessAudit({
|
||||
orgId: orgId,
|
||||
resourceId: resourceId,
|
||||
action: false,
|
||||
type: "login",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
|
||||
191
server/routers/badger/logRequestAudit.ts
Normal file
191
server/routers/badger/logRequestAudit.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { db, orgs, requestAuditLog } from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
/**
|
||||
|
||||
Reasons:
|
||||
100 - Allowed by Rule
|
||||
101 - Allowed No Auth
|
||||
102 - Valid Access Token
|
||||
103 - Valid header auth
|
||||
104 - Valid Pincode
|
||||
105 - Valid Password
|
||||
106 - Valid email
|
||||
107 - Valid SSO
|
||||
|
||||
201 - Resource Not Found
|
||||
202 - Resource Blocked
|
||||
203 - Dropped by Rule
|
||||
204 - No Sessions
|
||||
205 - Temporary Request Token
|
||||
299 - No More Auth Methods
|
||||
|
||||
*/
|
||||
|
||||
async function getRetentionDays(orgId: string): Promise<number> {
|
||||
// check cache first
|
||||
const cached = cache.get<number>(`org_${orgId}_retentionDays`);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
.select({
|
||||
settingsLogRetentionDaysRequest:
|
||||
orgs.settingsLogRetentionDaysRequest
|
||||
})
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// store the result in cache
|
||||
cache.set(
|
||||
`org_${orgId}_retentionDays`,
|
||||
org.settingsLogRetentionDaysRequest,
|
||||
300
|
||||
);
|
||||
|
||||
return org.settingsLogRetentionDaysRequest;
|
||||
}
|
||||
|
||||
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const cutoffTimestamp = now - retentionDays * 24 * 60 * 60;
|
||||
|
||||
try {
|
||||
const deleteResult = await db
|
||||
.delete(requestAuditLog)
|
||||
.where(
|
||||
and(
|
||||
lt(requestAuditLog.timestamp, cutoffTimestamp),
|
||||
eq(requestAuditLog.orgId, orgId)
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Cleaned up ${deleteResult.changes} request audit logs older than ${retentionDays} days`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error cleaning up old request audit logs:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function logRequestAudit(
|
||||
data: {
|
||||
action: boolean;
|
||||
reason: number;
|
||||
resourceId?: number;
|
||||
orgId?: string;
|
||||
location?: string;
|
||||
user?: { username: string; userId: string };
|
||||
apiKey?: { name: string | null; apiKeyId: string };
|
||||
metadata?: any;
|
||||
// userAgent?: string;
|
||||
},
|
||||
body: {
|
||||
path: string;
|
||||
originalRequestURL: string;
|
||||
scheme: string;
|
||||
host: string;
|
||||
method: string;
|
||||
tls: boolean;
|
||||
sessions?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string>;
|
||||
requestIp?: string;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
if (data.orgId) {
|
||||
const retentionDays = await getRetentionDays(data.orgId);
|
||||
if (retentionDays === 0) {
|
||||
// do not log
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let actorType: string | undefined;
|
||||
let actor: string | undefined;
|
||||
let actorId: string | undefined;
|
||||
|
||||
const user = data.user;
|
||||
if (user) {
|
||||
actorType = "user";
|
||||
actor = user.username;
|
||||
actorId = user.userId;
|
||||
}
|
||||
const apiKey = data.apiKey;
|
||||
if (apiKey) {
|
||||
actorType = "apiKey";
|
||||
actor = apiKey.name || apiKey.apiKeyId;
|
||||
actorId = apiKey.apiKeyId;
|
||||
}
|
||||
|
||||
// if (!actorType || !actor || !actorId) {
|
||||
// logger.warn("logRequestAudit: Incomplete actor information");
|
||||
// return;
|
||||
// }
|
||||
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
let metadata = null;
|
||||
if (metadata) {
|
||||
metadata = JSON.stringify(metadata);
|
||||
}
|
||||
|
||||
const clientIp = body.requestIp
|
||||
? (() => {
|
||||
if (
|
||||
body.requestIp.startsWith("[") &&
|
||||
body.requestIp.includes("]")
|
||||
) {
|
||||
// if brackets are found, extract the IPv6 address from between the brackets
|
||||
const ipv6Match = body.requestIp.match(/\[(.*?)\]/);
|
||||
if (ipv6Match) {
|
||||
return ipv6Match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// ivp4
|
||||
// split at last colon
|
||||
const lastColonIndex = body.requestIp.lastIndexOf(":");
|
||||
if (lastColonIndex !== -1) {
|
||||
return body.requestIp.substring(0, lastColonIndex);
|
||||
}
|
||||
return body.requestIp;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
await db.insert(requestAuditLog).values({
|
||||
timestamp,
|
||||
orgId: data.orgId,
|
||||
actorType,
|
||||
actor,
|
||||
actorId,
|
||||
metadata,
|
||||
action: data.action,
|
||||
resourceId: data.resourceId,
|
||||
reason: data.reason,
|
||||
location: data.location,
|
||||
// userAgent: data.userAgent, // TODO: add this
|
||||
// headers: data.body.headers,
|
||||
// query: data.body.query,
|
||||
originalRequestURL: body.originalRequestURL,
|
||||
scheme: body.scheme,
|
||||
host: body.host,
|
||||
path: body.path,
|
||||
method: body.method,
|
||||
ip: clientIp,
|
||||
tls: body.tls
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie,
|
||||
validateResourceSessionToken
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import {
|
||||
getResourceByDomain,
|
||||
@@ -35,6 +30,7 @@ import { getCountryCodeForIp } from "@server/lib/geoip";
|
||||
import { getOrgTierData } from "#dynamic/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import { logRequestAudit } from "./logRequestAudit";
|
||||
import cache from "@server/lib/cache";
|
||||
|
||||
const verifyResourceSessionSchema = z.object({
|
||||
@@ -121,6 +117,10 @@ export async function verifyResourceSession(
|
||||
|
||||
logger.debug("Client IP:", { clientIp });
|
||||
|
||||
const ipCC = clientIp
|
||||
? await getCountryCodeFromIp(clientIp)
|
||||
: undefined;
|
||||
|
||||
let cleanHost = host;
|
||||
// if the host ends with :port, strip it
|
||||
if (cleanHost.match(/:[0-9]{1,5}$/)) {
|
||||
@@ -143,6 +143,19 @@ export async function verifyResourceSession(
|
||||
|
||||
if (!result) {
|
||||
logger.debug(`Resource not found ${cleanHost}`);
|
||||
|
||||
// TODO: we cant log this for now because we dont know the org
|
||||
// eventually it would be cool to show this for the server admin
|
||||
|
||||
// logRequestAudit(
|
||||
// {
|
||||
// action: false,
|
||||
// reason: 201, //resource not found
|
||||
// location: ipCC
|
||||
// },
|
||||
// parsedBody.data
|
||||
// );
|
||||
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -154,6 +167,19 @@ export async function verifyResourceSession(
|
||||
|
||||
if (!resource) {
|
||||
logger.debug(`Resource not found ${cleanHost}`);
|
||||
|
||||
// TODO: we cant log this for now because we dont know the org
|
||||
// eventually it would be cool to show this for the server admin
|
||||
|
||||
// logRequestAudit(
|
||||
// {
|
||||
// action: false,
|
||||
// reason: 201, //resource not found
|
||||
// location: ipCC
|
||||
// },
|
||||
// parsedBody.data
|
||||
// );
|
||||
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -161,6 +187,18 @@ export async function verifyResourceSession(
|
||||
|
||||
if (blockAccess) {
|
||||
logger.debug("Resource blocked", host);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 202, //resource blocked
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -169,14 +207,40 @@ export async function verifyResourceSession(
|
||||
const action = await checkRules(
|
||||
resource.resourceId,
|
||||
clientIp,
|
||||
path
|
||||
path,
|
||||
ipCC
|
||||
);
|
||||
|
||||
if (action == "ACCEPT") {
|
||||
logger.debug("Resource allowed by rule");
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 100, // allowed by rule
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
} else if (action == "DROP") {
|
||||
logger.debug("Resource denied by rule");
|
||||
|
||||
// TODO: add rules type
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 203, // dropped by rules
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res);
|
||||
} else if (action == "PASS") {
|
||||
logger.debug(
|
||||
@@ -197,6 +261,18 @@ export async function verifyResourceSession(
|
||||
!headerAuth
|
||||
) {
|
||||
logger.debug("Resource allowed because no auth");
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 101, // allowed no auth
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
@@ -248,6 +324,21 @@ export async function verifyResourceSession(
|
||||
}
|
||||
|
||||
if (valid && tokenItem) {
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 102, // valid access token
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
apiKey: {
|
||||
name: tokenItem.title,
|
||||
apiKeyId: tokenItem.accessTokenId,
|
||||
}
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
}
|
||||
@@ -284,6 +375,21 @@ export async function verifyResourceSession(
|
||||
}
|
||||
|
||||
if (valid && tokenItem) {
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 102, // valid access token
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
apiKey: {
|
||||
name: tokenItem.title,
|
||||
apiKeyId: tokenItem.accessTokenId,
|
||||
}
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
}
|
||||
@@ -295,6 +401,18 @@ export async function verifyResourceSession(
|
||||
logger.debug(
|
||||
"Resource allowed because header auth is valid (cached)"
|
||||
);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 103, // valid header auth
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
} else if (
|
||||
await verifyPassword(
|
||||
@@ -304,15 +422,39 @@ export async function verifyResourceSession(
|
||||
) {
|
||||
cache.set(clientHeaderAuthKey, clientHeaderAuth, 5);
|
||||
logger.debug("Resource allowed because header auth is valid");
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 103, // valid header auth
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
if ( // we dont want to redirect if this is the only auth method and we did not pass here
|
||||
if (
|
||||
// we dont want to redirect if this is the only auth method and we did not pass here
|
||||
!sso &&
|
||||
!pincode &&
|
||||
!password &&
|
||||
!resource.emailWhitelistEnabled
|
||||
) {
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 299, // no more auth methods
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res);
|
||||
}
|
||||
} else if (headerAuth) {
|
||||
@@ -323,6 +465,17 @@ export async function verifyResourceSession(
|
||||
!password &&
|
||||
!resource.emailWhitelistEnabled
|
||||
) {
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 299, // no more auth methods
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res);
|
||||
}
|
||||
}
|
||||
@@ -335,6 +488,18 @@ export async function verifyResourceSession(
|
||||
}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 204, // no sessions
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -368,6 +533,18 @@ export async function verifyResourceSession(
|
||||
}. IP: ${clientIp}.`
|
||||
);
|
||||
}
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 205, // temporary request token
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
@@ -376,6 +553,18 @@ export async function verifyResourceSession(
|
||||
logger.debug(
|
||||
"Resource allowed because pincode session is valid"
|
||||
);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 104, // valid pincode
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
@@ -383,6 +572,18 @@ export async function verifyResourceSession(
|
||||
logger.debug(
|
||||
"Resource allowed because password session is valid"
|
||||
);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 105, // valid password
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
@@ -393,6 +594,18 @@ export async function verifyResourceSession(
|
||||
logger.debug(
|
||||
"Resource allowed because whitelist session is valid"
|
||||
);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 106, // valid email
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
@@ -400,6 +613,22 @@ export async function verifyResourceSession(
|
||||
logger.debug(
|
||||
"Resource allowed because access token session is valid"
|
||||
);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 102, // valid access token
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
apiKey: {
|
||||
name: resourceSession.accessTokenTitle,
|
||||
apiKeyId: resourceSession.accessTokenId,
|
||||
}
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res);
|
||||
}
|
||||
|
||||
@@ -427,6 +656,22 @@ export async function verifyResourceSession(
|
||||
logger.debug(
|
||||
"Resource allowed because user session is valid"
|
||||
);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: true,
|
||||
reason: 107, // valid sso
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
user: {
|
||||
username: allowedUserData.username,
|
||||
userId: resourceSession.userId
|
||||
}
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return allowed(res, allowedUserData);
|
||||
}
|
||||
}
|
||||
@@ -445,6 +690,17 @@ export async function verifyResourceSession(
|
||||
|
||||
logger.debug(`Redirecting to login at ${redirectPath}`);
|
||||
|
||||
logRequestAudit(
|
||||
{
|
||||
action: false,
|
||||
reason: 299, // no more auth methods
|
||||
resourceId: resource.resourceId,
|
||||
orgId: resource.orgId,
|
||||
location: ipCC
|
||||
},
|
||||
parsedBody.data
|
||||
);
|
||||
|
||||
return notAllowed(res, redirectPath, resource.orgId);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -615,7 +871,8 @@ async function isUserAllowedToAccessResource(
|
||||
async function checkRules(
|
||||
resourceId: number,
|
||||
clientIp: string | undefined,
|
||||
path: string | undefined
|
||||
path: string | undefined,
|
||||
ipCC?: string
|
||||
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
|
||||
const ruleCacheKey = `rules:${resourceId}`;
|
||||
|
||||
@@ -784,11 +1041,20 @@ export function isPathAllowed(pattern: string, path: string): boolean {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function isIpInGeoIP(ip: string, countryCode: string): Promise<boolean> {
|
||||
if (countryCode == "ALL") {
|
||||
async function isIpInGeoIP(
|
||||
ipCountryCode: string,
|
||||
checkCountryCode: string
|
||||
): Promise<boolean> {
|
||||
if (checkCountryCode == "ALL") {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.debug(`IP ${ipCountryCode} is in country: ${checkCountryCode}`);
|
||||
|
||||
return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase();
|
||||
}
|
||||
|
||||
async function getCountryCodeFromIp(ip: string): Promise<string | undefined> {
|
||||
const geoIpCacheKey = `geoip:${ip}`;
|
||||
|
||||
let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey);
|
||||
@@ -799,9 +1065,7 @@ async function isIpInGeoIP(ip: string, countryCode: string): Promise<boolean> {
|
||||
cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes
|
||||
}
|
||||
|
||||
logger.debug(`IP ${ip} is in country: ${cachedCountryCode}`);
|
||||
|
||||
return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase();
|
||||
return cachedCountryCode;
|
||||
}
|
||||
|
||||
function extractBasicAuth(
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as supporterKey from "./supporterKey";
|
||||
import * as accessToken from "./accessToken";
|
||||
import * as idp from "./idp";
|
||||
import * as apiKeys from "./apiKeys";
|
||||
import * as logs from "./auditLogs";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
verifyAccessTokenAccess,
|
||||
@@ -44,6 +45,8 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
import createHttpError from "http-errors";
|
||||
import { build } from "@server/build";
|
||||
import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||
import { logActionAudit } from "#dynamic/middlewares";
|
||||
import { log } from "console";
|
||||
|
||||
// Root routes
|
||||
export const unauthenticated = Router();
|
||||
@@ -75,7 +78,8 @@ authenticated.post(
|
||||
"/org/:orgId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateOrg),
|
||||
org.updateOrg
|
||||
logActionAudit(ActionsEnum.updateOrg),
|
||||
org.updateOrg,
|
||||
);
|
||||
|
||||
if (build !== "saas") {
|
||||
@@ -84,7 +88,8 @@ if (build !== "saas") {
|
||||
verifyOrgAccess,
|
||||
verifyUserIsOrgOwner,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
logActionAudit(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +97,7 @@ authenticated.put(
|
||||
"/org/:orgId/site",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createSite),
|
||||
logActionAudit(ActionsEnum.createSite),
|
||||
site.createSite
|
||||
);
|
||||
authenticated.get(
|
||||
@@ -149,7 +155,8 @@ authenticated.put(
|
||||
verifyClientsEnabled,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createClient),
|
||||
client.createClient
|
||||
logActionAudit(ActionsEnum.createClient),
|
||||
client.createClient,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -157,7 +164,8 @@ authenticated.delete(
|
||||
verifyClientsEnabled,
|
||||
verifyClientAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteClient),
|
||||
client.deleteClient
|
||||
logActionAudit(ActionsEnum.deleteClient),
|
||||
client.deleteClient,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -165,7 +173,8 @@ authenticated.post(
|
||||
verifyClientsEnabled,
|
||||
verifyClientAccess, // this will check if the user has access to the client
|
||||
verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client
|
||||
client.updateClient
|
||||
logActionAudit(ActionsEnum.updateClient),
|
||||
client.updateClient,
|
||||
);
|
||||
|
||||
// authenticated.get(
|
||||
@@ -178,15 +187,18 @@ authenticated.post(
|
||||
"/site/:siteId",
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateSite),
|
||||
site.updateSite
|
||||
logActionAudit(ActionsEnum.updateSite),
|
||||
site.updateSite,
|
||||
);
|
||||
authenticated.delete(
|
||||
"/site/:siteId",
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteSite),
|
||||
site.deleteSite
|
||||
logActionAudit(ActionsEnum.deleteSite),
|
||||
site.deleteSite,
|
||||
);
|
||||
|
||||
// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite"
|
||||
authenticated.get(
|
||||
"/site/:siteId/docker/status",
|
||||
verifySiteAccess,
|
||||
@@ -203,13 +215,13 @@ authenticated.post(
|
||||
"/site/:siteId/docker/check",
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.getSite),
|
||||
site.checkDockerSocket
|
||||
site.checkDockerSocket,
|
||||
);
|
||||
authenticated.post(
|
||||
"/site/:siteId/docker/trigger",
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.getSite),
|
||||
site.triggerFetchContainers
|
||||
site.triggerFetchContainers,
|
||||
);
|
||||
authenticated.get(
|
||||
"/site/:siteId/docker/containers",
|
||||
@@ -224,7 +236,8 @@ authenticated.put(
|
||||
verifyOrgAccess,
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.createSiteResource),
|
||||
siteResource.createSiteResource
|
||||
logActionAudit(ActionsEnum.createSiteResource),
|
||||
siteResource.createSiteResource,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -257,7 +270,8 @@ authenticated.post(
|
||||
verifySiteAccess,
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateSiteResource),
|
||||
siteResource.updateSiteResource
|
||||
logActionAudit(ActionsEnum.updateSiteResource),
|
||||
siteResource.updateSiteResource,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -266,14 +280,16 @@ authenticated.delete(
|
||||
verifySiteAccess,
|
||||
verifySiteResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteSiteResource),
|
||||
siteResource.deleteSiteResource
|
||||
logActionAudit(ActionsEnum.deleteSiteResource),
|
||||
siteResource.deleteSiteResource,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createResource),
|
||||
resource.createResource
|
||||
logActionAudit(ActionsEnum.createResource),
|
||||
resource.createResource,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -334,15 +350,18 @@ authenticated.delete(
|
||||
"/org/:orgId/invitations/:inviteId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.removeInvitation),
|
||||
user.removeInvitation
|
||||
logActionAudit(ActionsEnum.removeInvitation),
|
||||
user.removeInvitation,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/create-invite",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.inviteUser),
|
||||
user.inviteUser
|
||||
logActionAudit(ActionsEnum.inviteUser),
|
||||
user.inviteUser,
|
||||
); // maybe make this /invite/create instead
|
||||
|
||||
unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated
|
||||
|
||||
authenticated.get(
|
||||
@@ -375,20 +394,23 @@ authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResource),
|
||||
resource.updateResource
|
||||
logActionAudit(ActionsEnum.updateResource),
|
||||
resource.updateResource,
|
||||
);
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteResource),
|
||||
resource.deleteResource
|
||||
logActionAudit(ActionsEnum.deleteResource),
|
||||
resource.deleteResource,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource/:resourceId/target",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.createTarget),
|
||||
target.createTarget
|
||||
logActionAudit(ActionsEnum.createTarget),
|
||||
target.createTarget,
|
||||
);
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/targets",
|
||||
@@ -401,7 +423,8 @@ authenticated.put(
|
||||
"/resource/:resourceId/rule",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.createResourceRule),
|
||||
resource.createResourceRule
|
||||
logActionAudit(ActionsEnum.createResourceRule),
|
||||
resource.createResourceRule,
|
||||
);
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/rules",
|
||||
@@ -413,13 +436,15 @@ authenticated.post(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResourceRule),
|
||||
resource.updateResourceRule
|
||||
logActionAudit(ActionsEnum.updateResourceRule),
|
||||
resource.updateResourceRule,
|
||||
);
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteResourceRule),
|
||||
resource.deleteResourceRule
|
||||
logActionAudit(ActionsEnum.deleteResourceRule),
|
||||
resource.deleteResourceRule,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -432,20 +457,23 @@ authenticated.post(
|
||||
"/target/:targetId",
|
||||
verifyTargetAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateTarget),
|
||||
target.updateTarget
|
||||
logActionAudit(ActionsEnum.updateTarget),
|
||||
target.updateTarget,
|
||||
);
|
||||
authenticated.delete(
|
||||
"/target/:targetId",
|
||||
verifyTargetAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteTarget),
|
||||
target.deleteTarget
|
||||
logActionAudit(ActionsEnum.deleteTarget),
|
||||
target.deleteTarget,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/role",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createRole),
|
||||
role.createRole
|
||||
logActionAudit(ActionsEnum.createRole),
|
||||
role.createRole,
|
||||
);
|
||||
authenticated.get(
|
||||
"/org/:orgId/roles",
|
||||
@@ -470,14 +498,16 @@ authenticated.delete(
|
||||
"/role/:roleId",
|
||||
verifyRoleAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteRole),
|
||||
role.deleteRole
|
||||
logActionAudit(ActionsEnum.deleteRole),
|
||||
role.deleteRole,
|
||||
);
|
||||
authenticated.post(
|
||||
"/role/:roleId/add/:userId",
|
||||
verifyRoleAccess,
|
||||
verifyUserAccess,
|
||||
verifyUserHasAction(ActionsEnum.addUserRole),
|
||||
user.addUserRole
|
||||
logActionAudit(ActionsEnum.addUserRole),
|
||||
user.addUserRole,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -485,7 +515,8 @@ authenticated.post(
|
||||
verifyResourceAccess,
|
||||
verifyRoleAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourceRoles),
|
||||
resource.setResourceRoles
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
resource.setResourceRoles,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -493,35 +524,40 @@ authenticated.post(
|
||||
verifyResourceAccess,
|
||||
verifySetResourceUsers,
|
||||
verifyUserHasAction(ActionsEnum.setResourceUsers),
|
||||
resource.setResourceUsers
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
resource.setResourceUsers,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePassword),
|
||||
resource.setResourcePassword
|
||||
logActionAudit(ActionsEnum.setResourcePassword),
|
||||
resource.setResourcePassword,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/pincode`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePincode),
|
||||
resource.setResourcePincode
|
||||
logActionAudit(ActionsEnum.setResourcePincode),
|
||||
resource.setResourcePincode,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/header-auth`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
|
||||
resource.setResourceHeaderAuth
|
||||
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
||||
resource.setResourceHeaderAuth,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/whitelist`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourceWhitelist),
|
||||
resource.setResourceWhitelist
|
||||
logActionAudit(ActionsEnum.setResourceWhitelist),
|
||||
resource.setResourceWhitelist,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -535,14 +571,16 @@ authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.generateAccessToken),
|
||||
accessToken.generateAccessToken
|
||||
logActionAudit(ActionsEnum.generateAccessToken),
|
||||
accessToken.generateAccessToken,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
`/access-token/:accessTokenId`,
|
||||
verifyAccessTokenAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteAcessToken),
|
||||
accessToken.deleteAccessToken
|
||||
logActionAudit(ActionsEnum.deleteAcessToken),
|
||||
accessToken.deleteAccessToken,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -615,7 +653,8 @@ authenticated.put(
|
||||
"/org/:orgId/user",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser
|
||||
logActionAudit(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -623,7 +662,8 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
verifyUserAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
logActionAudit(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser,
|
||||
);
|
||||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
@@ -645,7 +685,8 @@ authenticated.delete(
|
||||
verifyOrgAccess,
|
||||
verifyUserAccess,
|
||||
verifyUserHasAction(ActionsEnum.removeUser),
|
||||
user.removeUserOrg
|
||||
logActionAudit(ActionsEnum.removeUser),
|
||||
user.removeUserOrg,
|
||||
);
|
||||
|
||||
// authenticated.put(
|
||||
@@ -778,7 +819,8 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
verifyApiKeyAccess,
|
||||
verifyUserHasAction(ActionsEnum.setApiKeyActions),
|
||||
apiKeys.setApiKeyActions
|
||||
logActionAudit(ActionsEnum.setApiKeyActions),
|
||||
apiKeys.setApiKeyActions,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -793,7 +835,8 @@ authenticated.put(
|
||||
`/org/:orgId/api-key`,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createApiKey),
|
||||
apiKeys.createOrgApiKey
|
||||
logActionAudit(ActionsEnum.createApiKey),
|
||||
apiKeys.createOrgApiKey,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -801,7 +844,8 @@ authenticated.delete(
|
||||
verifyOrgAccess,
|
||||
verifyApiKeyAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteApiKey),
|
||||
apiKeys.deleteOrgApiKey
|
||||
logActionAudit(ActionsEnum.deleteApiKey),
|
||||
apiKeys.deleteOrgApiKey,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -816,7 +860,8 @@ authenticated.put(
|
||||
`/org/:orgId/domain`,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.createOrgDomain),
|
||||
domain.createOrgDomain
|
||||
logActionAudit(ActionsEnum.createOrgDomain),
|
||||
domain.createOrgDomain,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -824,7 +869,8 @@ authenticated.post(
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction(ActionsEnum.restartOrgDomain),
|
||||
domain.restartOrgDomain
|
||||
logActionAudit(ActionsEnum.restartOrgDomain),
|
||||
domain.restartOrgDomain,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -832,7 +878,23 @@ authenticated.delete(
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteOrgDomain),
|
||||
domain.deleteAccountDomain
|
||||
logActionAudit(ActionsEnum.deleteOrgDomain),
|
||||
domain.deleteAccountDomain,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/request",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.viewLogs),
|
||||
logs.queryRequestAuditLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/logs/request/export",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.exportLogs),
|
||||
logActionAudit(ActionsEnum.exportLogs),
|
||||
logs.exportRequestAuditLogs
|
||||
);
|
||||
|
||||
// Auth routes
|
||||
@@ -1155,4 +1217,4 @@ authRouter.delete(
|
||||
store: createStore()
|
||||
}),
|
||||
auth.deleteSecurityKey
|
||||
);
|
||||
);
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Router } from "express";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { build } from "@server/build";
|
||||
import { logActionAudit } from "#dynamic/middlewares";
|
||||
|
||||
export const unauthenticated = Router();
|
||||
|
||||
@@ -51,7 +51,8 @@ authenticated.put(
|
||||
"/org",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.createOrg),
|
||||
org.createOrg
|
||||
logActionAudit(ActionsEnum.createOrg),
|
||||
org.createOrg,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -72,21 +73,24 @@ authenticated.post(
|
||||
"/org/:orgId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateOrg),
|
||||
org.updateOrg
|
||||
logActionAudit(ActionsEnum.updateOrg),
|
||||
org.updateOrg,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg
|
||||
logActionAudit(ActionsEnum.deleteOrg),
|
||||
org.deleteOrg,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/site",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createSite),
|
||||
site.createSite
|
||||
logActionAudit(ActionsEnum.createSite),
|
||||
site.createSite,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -121,14 +125,16 @@ authenticated.post(
|
||||
"/site/:siteId",
|
||||
verifyApiKeySiteAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateSite),
|
||||
site.updateSite
|
||||
logActionAudit(ActionsEnum.updateSite),
|
||||
site.updateSite,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/site/:siteId",
|
||||
verifyApiKeySiteAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteSite),
|
||||
site.deleteSite
|
||||
logActionAudit(ActionsEnum.deleteSite),
|
||||
site.deleteSite,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -142,7 +148,8 @@ authenticated.put(
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeySiteAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createSiteResource),
|
||||
siteResource.createSiteResource
|
||||
logActionAudit(ActionsEnum.createSiteResource),
|
||||
siteResource.createSiteResource,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -175,7 +182,8 @@ authenticated.post(
|
||||
verifyApiKeySiteAccess,
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
|
||||
siteResource.updateSiteResource
|
||||
logActionAudit(ActionsEnum.updateSiteResource),
|
||||
siteResource.updateSiteResource,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -184,21 +192,24 @@ authenticated.delete(
|
||||
verifyApiKeySiteAccess,
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteSiteResource),
|
||||
siteResource.deleteSiteResource
|
||||
logActionAudit(ActionsEnum.deleteSiteResource),
|
||||
siteResource.deleteSiteResource,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/resource",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createResource),
|
||||
resource.createResource
|
||||
logActionAudit(ActionsEnum.createResource),
|
||||
resource.createResource,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/site/:siteId/resource",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createResource),
|
||||
resource.createResource
|
||||
logActionAudit(ActionsEnum.createResource),
|
||||
resource.createResource,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -233,7 +244,8 @@ authenticated.post(
|
||||
"/org/:orgId/create-invite",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.inviteUser),
|
||||
user.inviteUser
|
||||
logActionAudit(ActionsEnum.inviteUser),
|
||||
user.inviteUser,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -261,21 +273,24 @@ authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResource),
|
||||
resource.updateResource
|
||||
logActionAudit(ActionsEnum.updateResource),
|
||||
resource.updateResource,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteResource),
|
||||
resource.deleteResource
|
||||
logActionAudit(ActionsEnum.deleteResource),
|
||||
resource.deleteResource,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource/:resourceId/target",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createTarget),
|
||||
target.createTarget
|
||||
logActionAudit(ActionsEnum.createTarget),
|
||||
target.createTarget,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -289,7 +304,8 @@ authenticated.put(
|
||||
"/resource/:resourceId/rule",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createResourceRule),
|
||||
resource.createResourceRule
|
||||
logActionAudit(ActionsEnum.createResourceRule),
|
||||
resource.createResourceRule,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -303,14 +319,16 @@ authenticated.post(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResourceRule),
|
||||
resource.updateResourceRule
|
||||
logActionAudit(ActionsEnum.updateResourceRule),
|
||||
resource.updateResourceRule,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId/rule/:ruleId",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteResourceRule),
|
||||
resource.deleteResourceRule
|
||||
logActionAudit(ActionsEnum.deleteResourceRule),
|
||||
resource.deleteResourceRule,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -324,21 +342,24 @@ authenticated.post(
|
||||
"/target/:targetId",
|
||||
verifyApiKeyTargetAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateTarget),
|
||||
target.updateTarget
|
||||
logActionAudit(ActionsEnum.updateTarget),
|
||||
target.updateTarget,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/target/:targetId",
|
||||
verifyApiKeyTargetAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteTarget),
|
||||
target.deleteTarget
|
||||
logActionAudit(ActionsEnum.deleteTarget),
|
||||
target.deleteTarget,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/role",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createRole),
|
||||
role.createRole
|
||||
logActionAudit(ActionsEnum.createRole),
|
||||
role.createRole,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -352,7 +373,8 @@ authenticated.delete(
|
||||
"/role/:roleId",
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteRole),
|
||||
role.deleteRole
|
||||
logActionAudit(ActionsEnum.deleteRole),
|
||||
role.deleteRole,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -367,7 +389,8 @@ authenticated.post(
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.addUserRole),
|
||||
user.addUserRole
|
||||
logActionAudit(ActionsEnum.addUserRole),
|
||||
user.addUserRole,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -375,7 +398,8 @@ authenticated.post(
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceRoles),
|
||||
resource.setResourceRoles
|
||||
logActionAudit(ActionsEnum.setResourceRoles),
|
||||
resource.setResourceRoles,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -383,35 +407,40 @@ authenticated.post(
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeySetResourceUsers,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceUsers),
|
||||
resource.setResourceUsers
|
||||
logActionAudit(ActionsEnum.setResourceUsers),
|
||||
resource.setResourceUsers,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePassword),
|
||||
resource.setResourcePassword
|
||||
logActionAudit(ActionsEnum.setResourcePassword),
|
||||
resource.setResourcePassword,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/pincode`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePincode),
|
||||
resource.setResourcePincode
|
||||
logActionAudit(ActionsEnum.setResourcePincode),
|
||||
resource.setResourcePincode,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/header-auth`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth),
|
||||
resource.setResourceHeaderAuth
|
||||
logActionAudit(ActionsEnum.setResourceHeaderAuth),
|
||||
resource.setResourceHeaderAuth,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/whitelist`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist),
|
||||
resource.setResourceWhitelist
|
||||
logActionAudit(ActionsEnum.setResourceWhitelist),
|
||||
resource.setResourceWhitelist,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -439,14 +468,16 @@ authenticated.post(
|
||||
`/resource/:resourceId/access-token`,
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.generateAccessToken),
|
||||
accessToken.generateAccessToken
|
||||
logActionAudit(ActionsEnum.generateAccessToken),
|
||||
accessToken.generateAccessToken,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
`/access-token/:accessTokenId`,
|
||||
verifyApiKeyAccessTokenAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteAcessToken),
|
||||
accessToken.deleteAccessToken
|
||||
logActionAudit(ActionsEnum.deleteAcessToken),
|
||||
accessToken.deleteAccessToken,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -474,7 +505,8 @@ authenticated.post(
|
||||
"/user/:userId/2fa",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateUser),
|
||||
user.updateUser2FA
|
||||
logActionAudit(ActionsEnum.updateUser),
|
||||
user.updateUser2FA,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -495,7 +527,8 @@ authenticated.put(
|
||||
"/org/:orgId/user",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser
|
||||
logActionAudit(ActionsEnum.createOrgUser),
|
||||
user.createOrgUser,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -503,7 +536,8 @@ authenticated.post(
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser
|
||||
logActionAudit(ActionsEnum.updateOrgUser),
|
||||
user.updateOrgUser,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -511,7 +545,8 @@ authenticated.delete(
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyUserAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.removeUser),
|
||||
user.removeUserOrg
|
||||
logActionAudit(ActionsEnum.removeUser),
|
||||
user.removeUserOrg,
|
||||
);
|
||||
|
||||
// authenticated.put(
|
||||
@@ -531,7 +566,8 @@ authenticated.post(
|
||||
`/org/:orgId/api-key/:apiKeyId/actions`,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.setApiKeyActions),
|
||||
apiKeys.setApiKeyActions
|
||||
logActionAudit(ActionsEnum.setApiKeyActions),
|
||||
apiKeys.setApiKeyActions,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -545,28 +581,32 @@ authenticated.put(
|
||||
`/org/:orgId/api-key`,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.createApiKey),
|
||||
apiKeys.createOrgApiKey
|
||||
logActionAudit(ActionsEnum.createApiKey),
|
||||
apiKeys.createOrgApiKey,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
`/org/:orgId/api-key/:apiKeyId`,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteApiKey),
|
||||
apiKeys.deleteApiKey
|
||||
logActionAudit(ActionsEnum.deleteApiKey),
|
||||
apiKeys.deleteApiKey,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/idp/oidc",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.createIdp),
|
||||
idp.createOidcIdp
|
||||
logActionAudit(ActionsEnum.createIdp),
|
||||
idp.createOidcIdp,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/idp/:idpId/oidc",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateIdp),
|
||||
idp.updateOidcIdp
|
||||
logActionAudit(ActionsEnum.updateIdp),
|
||||
idp.updateOidcIdp,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -587,21 +627,24 @@ authenticated.put(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.createIdpOrg),
|
||||
idp.createIdpOrgPolicy
|
||||
logActionAudit(ActionsEnum.createIdpOrg),
|
||||
idp.createIdpOrgPolicy,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateIdpOrg),
|
||||
idp.updateIdpOrgPolicy
|
||||
logActionAudit(ActionsEnum.updateIdpOrg),
|
||||
idp.updateIdpOrgPolicy,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/idp/:idpId/org/:orgId",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg),
|
||||
idp.deleteIdpOrgPolicy
|
||||
logActionAudit(ActionsEnum.deleteIdpOrg),
|
||||
idp.deleteIdpOrgPolicy,
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
@@ -640,7 +683,8 @@ authenticated.put(
|
||||
verifyClientsEnabled,
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.createClient),
|
||||
client.createClient
|
||||
logActionAudit(ActionsEnum.createClient),
|
||||
client.createClient,
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
@@ -648,7 +692,8 @@ authenticated.delete(
|
||||
verifyClientsEnabled,
|
||||
verifyApiKeyClientAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteClient),
|
||||
client.deleteClient
|
||||
logActionAudit(ActionsEnum.deleteClient),
|
||||
client.deleteClient,
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
@@ -656,12 +701,14 @@ authenticated.post(
|
||||
verifyClientsEnabled,
|
||||
verifyApiKeyClientAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateClient),
|
||||
client.updateClient
|
||||
logActionAudit(ActionsEnum.updateClient),
|
||||
client.updateClient,
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/blueprint",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
|
||||
org.applyBlueprint
|
||||
logActionAudit(ActionsEnum.applyBlueprint),
|
||||
org.applyBlueprint,
|
||||
);
|
||||
|
||||
@@ -49,13 +49,13 @@ export async function getOrg(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const org = await db
|
||||
const [org] = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (org.length === 0) {
|
||||
if (!org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -66,7 +66,7 @@ export async function getOrg(
|
||||
|
||||
return response<GetOrgResponse>(res, {
|
||||
data: {
|
||||
org: org[0]
|
||||
org
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -9,6 +9,9 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { getOrgTierData } from "@server/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
|
||||
const updateOrgParamsSchema = z
|
||||
.object({
|
||||
@@ -18,7 +21,19 @@ const updateOrgParamsSchema = z
|
||||
|
||||
const updateOrgBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255).optional()
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
settingsLogRetentionDaysRequest: z
|
||||
.number()
|
||||
.min(build === "saas" ? 0 : -1)
|
||||
.optional(),
|
||||
settingsLogRetentionDaysAccess: z
|
||||
.number()
|
||||
.min(build === "saas" ? 0 : -1)
|
||||
.optional(),
|
||||
settingsLogRetentionDaysAction: z
|
||||
.number()
|
||||
.min(build === "saas" ? 0 : -1)
|
||||
.optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
@@ -71,10 +86,24 @@ export async function updateOrg(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
const { tier } = await getOrgTierData(orgId); // returns null in oss
|
||||
if (
|
||||
tier != TierId.STANDARD &&
|
||||
parsedBody.data.settingsLogRetentionDaysRequest &&
|
||||
parsedBody.data.settingsLogRetentionDaysRequest > 30
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"You are not allowed to set log retention days greater than 30 because you are not subscribed to the Standard tier"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updatedOrg = await db
|
||||
.update(orgs)
|
||||
.set({
|
||||
name: parsedBody.data.name
|
||||
...parsedBody.data
|
||||
})
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.returning();
|
||||
|
||||
@@ -10,11 +10,10 @@ import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import logger from "@server/logger";
|
||||
import {
|
||||
verifyResourceAccessToken
|
||||
} from "@server/auth/verifyResourceAccessToken";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
import config from "@server/lib/config";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
|
||||
|
||||
const authWithAccessTokenBodySchema = z
|
||||
.object({
|
||||
@@ -131,6 +130,16 @@ export async function authWithAccessToken(
|
||||
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
logAccessAudit({
|
||||
orgId: resource.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "accessToken",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
@@ -150,6 +159,15 @@ export async function authWithAccessToken(
|
||||
doNotExtend: true
|
||||
});
|
||||
|
||||
logAccessAudit({
|
||||
orgId: resource.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: true,
|
||||
type: "accessToken",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return response<AuthWithAccessTokenResponse>(res, {
|
||||
data: {
|
||||
session: token,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import config from "@server/lib/config";
|
||||
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
|
||||
|
||||
export const authWithPasswordBodySchema = z
|
||||
.object({
|
||||
@@ -113,6 +114,16 @@ export async function authWithPassword(
|
||||
`Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "password",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
|
||||
);
|
||||
@@ -129,6 +140,15 @@ export async function authWithPassword(
|
||||
doNotExtend: true
|
||||
});
|
||||
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: true,
|
||||
type: "password",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return response<AuthWithPasswordResponse>(res, {
|
||||
data: {
|
||||
session: token
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import logger from "@server/logger";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import config from "@server/lib/config";
|
||||
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
|
||||
|
||||
export const authWithPincodeBodySchema = z
|
||||
.object({
|
||||
@@ -112,6 +113,16 @@ export async function authWithPincode(
|
||||
`Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "pincode",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
|
||||
);
|
||||
@@ -128,6 +139,15 @@ export async function authWithPincode(
|
||||
doNotExtend: true
|
||||
});
|
||||
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: true,
|
||||
type: "pincode",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return response<AuthWithPincodeResponse>(res, {
|
||||
data: {
|
||||
session: token
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
orgs,
|
||||
resourceOtp,
|
||||
resources,
|
||||
resourceWhitelist
|
||||
} from "@server/db";
|
||||
import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -17,13 +12,11 @@ import { createResourceSession } from "@server/auth/sessions/resource";
|
||||
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
|
||||
|
||||
const authWithWhitelistBodySchema = z
|
||||
.object({
|
||||
email: z
|
||||
.string()
|
||||
.toLowerCase()
|
||||
.email(),
|
||||
email: z.string().toLowerCase().email(),
|
||||
otp: z.string().optional()
|
||||
})
|
||||
.strict();
|
||||
@@ -126,6 +119,19 @@ export async function authWithWhitelist(
|
||||
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (org && resource) {
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: false,
|
||||
type: "whitelistedEmail",
|
||||
metadata: { email },
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
@@ -219,6 +225,16 @@ export async function authWithWhitelist(
|
||||
doNotExtend: true
|
||||
});
|
||||
|
||||
logAccessAudit({
|
||||
orgId: org.orgId,
|
||||
resourceId: resource.resourceId,
|
||||
action: true,
|
||||
metadata: { email },
|
||||
type: "whitelistedEmail",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
|
||||
return response<AuthWithWhitelistResponse>(res, {
|
||||
data: {
|
||||
session: token
|
||||
|
||||
@@ -10,11 +10,10 @@ import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { generateSessionToken } from "@server/auth/sessions/app";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
encodeHexLowerCase
|
||||
} from "@oslojs/encoding";
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
import { sha256 } from "@oslojs/crypto/sha2";
|
||||
import { response } from "@server/lib/response";
|
||||
import { logAccessAudit } from "@server/private/lib/logAccessAudit";
|
||||
|
||||
const getExchangeTokenParams = z
|
||||
.object({
|
||||
@@ -47,13 +46,13 @@ export async function getExchangeToken(
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const resource = await db
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (resource.length === 0) {
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -89,6 +88,21 @@ export async function getExchangeToken(
|
||||
doNotExtend: true
|
||||
});
|
||||
|
||||
if (req.user) {
|
||||
logAccessAudit({
|
||||
orgId: resource.orgId,
|
||||
resourceId: resourceId,
|
||||
user: {
|
||||
username: req.user.username,
|
||||
userId: req.user.userId
|
||||
},
|
||||
action: true,
|
||||
type: "login",
|
||||
userAgent: req.headers["user-agent"],
|
||||
requestIp: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("Request token created successfully");
|
||||
|
||||
return response<GetExchangeTokenResponse>(res, {
|
||||
|
||||
@@ -80,10 +80,12 @@ async function makeApiRequest<T>(
|
||||
|
||||
const headersList = await reqHeaders();
|
||||
const host = headersList.get("host");
|
||||
const xForwardedFor = headersList.get("x-forwarded-for");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": "x-csrf-protection",
|
||||
...(xForwardedFor ? { "X-Forwarded-For": xForwardedFor } : {}),
|
||||
...(cookieHeader && { Cookie: cookieHeader }),
|
||||
...additionalHeaders
|
||||
};
|
||||
@@ -202,6 +204,7 @@ export type LoginRequest = {
|
||||
email: string;
|
||||
password: string;
|
||||
code?: string;
|
||||
resourceGuid?: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
|
||||
@@ -42,15 +42,38 @@ import {
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { ChevronDown, SubscriptIcon } from "lucide-react";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
// Schema for general organization settings
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string(),
|
||||
subnet: z.string().optional()
|
||||
subnet: z.string().optional(),
|
||||
settingsLogRetentionDaysRequest: z.number(),
|
||||
settingsLogRetentionDaysAccess: z.number(),
|
||||
settingsLogRetentionDaysAction: z.number()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
const LOG_RETENTION_OPTIONS = [
|
||||
{ label: "logRetentionDisabled", value: 0 },
|
||||
{ label: "logRetention3Days", value: 3 },
|
||||
{ label: "logRetention7Days", value: 7 },
|
||||
{ label: "logRetention14Days", value: 14 },
|
||||
{ label: "logRetention30Days", value: 30 },
|
||||
{ label: "logRetention90Days", value: 90 },
|
||||
...(build != "saas" ? [{ label: "logRetentionForever", value: -1 }] : [])
|
||||
];
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const { orgUser } = userOrgUserContext();
|
||||
@@ -60,6 +83,8 @@ export default function GeneralPage() {
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
@@ -69,7 +94,13 @@ export default function GeneralPage() {
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
name: org?.org.name,
|
||||
subnet: org?.org.subnet || "" // Add default value for subnet
|
||||
subnet: org?.org.subnet || "", // Add default value for subnet
|
||||
settingsLogRetentionDaysRequest:
|
||||
org.org.settingsLogRetentionDaysRequest ?? 15,
|
||||
settingsLogRetentionDaysAccess:
|
||||
org.org.settingsLogRetentionDaysAccess ?? 15,
|
||||
settingsLogRetentionDaysAction:
|
||||
org.org.settingsLogRetentionDaysAction ?? 15
|
||||
},
|
||||
mode: "onChange"
|
||||
});
|
||||
@@ -131,8 +162,14 @@ export default function GeneralPage() {
|
||||
try {
|
||||
// Update organization
|
||||
await api.post(`/org/${org?.org.orgId}`, {
|
||||
name: data.name
|
||||
name: data.name,
|
||||
// subnet: data.subnet // Include subnet in the API request
|
||||
settingsLogRetentionDaysRequest:
|
||||
data.settingsLogRetentionDaysRequest,
|
||||
settingsLogRetentionDaysAccess:
|
||||
data.settingsLogRetentionDaysAccess,
|
||||
settingsLogRetentionDaysAction:
|
||||
data.settingsLogRetentionDaysAction
|
||||
});
|
||||
|
||||
// Also save auth page settings if they have unsaved changes
|
||||
@@ -159,6 +196,11 @@ export default function GeneralPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getLabelForValue = (value: number) => {
|
||||
const option = LOG_RETENTION_OPTIONS.find((opt) => opt.value === value);
|
||||
return option ? t(option.label) : `${value} days`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<ConfirmDeleteDialog
|
||||
@@ -168,9 +210,7 @@ export default function GeneralPage() {
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>
|
||||
{t("orgQuestionRemove")}
|
||||
</p>
|
||||
<p>{t("orgQuestionRemove")}</p>
|
||||
<p>{t("orgMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -179,23 +219,24 @@ export default function GeneralPage() {
|
||||
string={org?.org.name || ""}
|
||||
title={t("orgDelete")}
|
||||
/>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("orgGeneralSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("orgGeneralSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="org-settings-form"
|
||||
>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="org-settings-form"
|
||||
>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("orgGeneralSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("orgGeneralSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -235,15 +276,250 @@ export default function GeneralPage() {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{(build === "saas") && (
|
||||
<AuthPageSettings ref={authPageSettingsRef} />
|
||||
)}
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("logRetention")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t("logRetentionDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysRequest"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("logRetentionRequestLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{getLabelForValue(
|
||||
field.value
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{LOG_RETENTION_OPTIONS.filter((option) => {
|
||||
if (build == "saas" && !subscription?.subscribed && option.value > 30) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map(
|
||||
(option) => (
|
||||
<DropdownMenuItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
onClick={() =>
|
||||
field.onChange(
|
||||
option.value
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"logRetentionRequestDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{build != "oss" && (
|
||||
<>
|
||||
{build == "saas" &&
|
||||
!subscription?.subscribed ? (
|
||||
<Alert
|
||||
variant="info"
|
||||
className="mb-6"
|
||||
>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"subscriptionRequiredToUse"
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build == "enterprise" &&
|
||||
!isUnlocked() ? (
|
||||
<Alert
|
||||
variant="info"
|
||||
className="mb-6"
|
||||
>
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAccess"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionAccessLabel"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
disabled={
|
||||
(build ==
|
||||
"saas" &&
|
||||
!subscription?.subscribed) ||
|
||||
(build ==
|
||||
"enterprise" &&
|
||||
!isUnlocked())
|
||||
}
|
||||
>
|
||||
{getLabelForValue(
|
||||
field.value
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{LOG_RETENTION_OPTIONS.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<DropdownMenuItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
onClick={() =>
|
||||
field.onChange(
|
||||
option.value
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"logRetentionAccessDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsLogRetentionDaysAction"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"logRetentionActionLabel"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-between"
|
||||
disabled={
|
||||
(build ==
|
||||
"saas" &&
|
||||
!subscription?.subscribed) ||
|
||||
(build ==
|
||||
"enterprise" &&
|
||||
!isUnlocked())
|
||||
}
|
||||
>
|
||||
{getLabelForValue(
|
||||
field.value
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{LOG_RETENTION_OPTIONS.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<DropdownMenuItem
|
||||
key={
|
||||
option.value
|
||||
}
|
||||
onClick={() =>
|
||||
field.onChange(
|
||||
option.value
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
option.label
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"logRetentionActionDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{build === "saas" && <AuthPageSettings ref={authPageSettingsRef} />}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
|
||||
662
src/app/[orgId]/settings/logs/access/page.tsx
Normal file
662
src/app/[orgId]/settings/logs/access/page.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
getStoredPageSize,
|
||||
LogDataTable,
|
||||
setStoredPageSize
|
||||
} from "@app/components/LogDataTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { ArrowUpRight, Key, User } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { build } from "@server/build";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { orgId } = useParams();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
type?: string;
|
||||
resourceId?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
}>({
|
||||
action: searchParams.get("action") || undefined,
|
||||
type: searchParams.get("type") || undefined,
|
||||
resourceId: searchParams.get("resourceId") || undefined,
|
||||
location: searchParams.get("location") || undefined,
|
||||
actor: searchParams.get("actor") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useState<number>(() => {
|
||||
return getStoredPageSize("access-audit-logs", 20);
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: yesterday
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, "access-audit-logs");
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
newFilters:
|
||||
| typeof filters
|
||||
| {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: {
|
||||
action?: string;
|
||||
type?: string;
|
||||
resourceId?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
) {
|
||||
console.log(
|
||||
"Access denied: subscription inactive or license locked"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/access`, { params });
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
: undefined,
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
};
|
||||
|
||||
const response = await api.get(`/org/${orgId}/logs/access/export`, {
|
||||
responseType: "blob",
|
||||
params
|
||||
});
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`access-audit-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
setIsExporting(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "true", label: "Allowed" },
|
||||
{ value: "false", label: "Denied" }
|
||||
]}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.action ? <>Allowed</> : <>Denied</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: ({ column }) => {
|
||||
return t("ip");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("location")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.locations.map(
|
||||
(location) => ({
|
||||
value: location,
|
||||
label: location
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.location}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("location", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.location ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.location}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.resources.map((res) => ({
|
||||
value: res.id.toString(),
|
||||
label: res.name || "Unnamed Resource"
|
||||
}))}
|
||||
selectedValue={filters.resourceId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("type")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "password", label: "Password" },
|
||||
{ value: "pincode", label: "Pincode" },
|
||||
{ value: "login", label: "Login" },
|
||||
{
|
||||
value: "whitelistedEmail",
|
||||
label: "Whitelisted Email"
|
||||
}
|
||||
]}
|
||||
selectedValue={filters.type}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("type", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
// should be capitalized first letter
|
||||
return (
|
||||
<span>
|
||||
{row.original.type.charAt(0).toUpperCase() +
|
||||
row.original.type.slice(1) || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actors.map((actor) => ({
|
||||
value: actor,
|
||||
label: actor
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actor ? (
|
||||
<>
|
||||
{row.original.actorType == "user" ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.actor}
|
||||
</>
|
||||
) : (
|
||||
<>-</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
{row.userAgent != "node" && (
|
||||
<div>
|
||||
<strong>User Agent:</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.userAgent || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>Metadata:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{row.metadata
|
||||
? JSON.stringify(
|
||||
JSON.parse(row.metadata),
|
||||
null,
|
||||
2
|
||||
)
|
||||
: "N/A"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("accessLogs")}
|
||||
description={t("accessLogsDescription")}
|
||||
/>
|
||||
|
||||
{build == "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build == "enterprise" && !isUnlocked() ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("accessLogs")}
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={exportData}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "timestamp",
|
||||
desc: false
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
515
src/app/[orgId]/settings/logs/action/page.tsx
Normal file
515
src/app/[orgId]/settings/logs/action/page.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
getStoredPageSize,
|
||||
LogDataTable,
|
||||
setStoredPageSize
|
||||
} from "@app/components/LogDataTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { Key, User } from "lucide-react";
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { build } from "@server/build";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
actions: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
actions: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
actor?: string;
|
||||
}>({
|
||||
action: searchParams.get("action") || undefined,
|
||||
actor: searchParams.get("actor") || undefined
|
||||
});
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useState<number>(() => {
|
||||
return getStoredPageSize("action-audit-logs", 20);
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: yesterday
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, "action-audit-logs");
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
newFilters:
|
||||
| typeof filters
|
||||
| {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: {
|
||||
action?: string;
|
||||
actor?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
if (
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
) {
|
||||
console.log(
|
||||
"Access denied: subscription inactive or license locked"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/action`, { params });
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
: undefined,
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
};
|
||||
|
||||
const response = await api.get(`/org/${orgId}/logs/action/export`, {
|
||||
responseType: "blob",
|
||||
params
|
||||
});
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`action-audit-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
setIsExporting(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actions.map((action) => ({
|
||||
label:
|
||||
action.charAt(0).toUpperCase() +
|
||||
action.slice(1),
|
||||
value: action
|
||||
}))}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="hitespace-nowrap">
|
||||
{row.original.action.charAt(0).toUpperCase() +
|
||||
row.original.action.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actors.map((actor) => ({
|
||||
value: actor,
|
||||
label: actor
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorType == "user" ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.actor}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actorId",
|
||||
header: ({ column }) => {
|
||||
return t("actorId");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actorId}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>Metadata:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{row.metadata
|
||||
? JSON.stringify(
|
||||
JSON.parse(row.metadata),
|
||||
null,
|
||||
2
|
||||
)
|
||||
: "N/A"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t("actionLogs")}
|
||||
description={t("actionLogsDescription")}
|
||||
/>
|
||||
|
||||
{build == "saas" && !subscription?.subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{build == "enterprise" && !isUnlocked() ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("actionLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="action"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={exportData}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "timestamp",
|
||||
desc: false
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
pageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
disabled={
|
||||
(build == "saas" && !subscription?.subscribed) ||
|
||||
(build == "enterprise" && !isUnlocked())
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
src/app/[orgId]/settings/logs/layout.tsx
Normal file
22
src/app/[orgId]/settings/logs/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export default async function GeneralSettingsPage({
|
||||
children,
|
||||
params
|
||||
}: GeneralSettingsProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
54
src/app/[orgId]/settings/logs/page.tsx
Normal file
54
src/app/[orgId]/settings/logs/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import AuthPageSettings, {
|
||||
AuthPageSettingsRef
|
||||
} from "@app/components/private/AuthPageSettings";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef } from "react";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
return <p>dfas</p>;
|
||||
}
|
||||
796
src/app/[orgId]/settings/logs/request/page.tsx
Normal file
796
src/app/[orgId]/settings/logs/request/page.tsx
Normal file
@@ -0,0 +1,796 @@
|
||||
"use client";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { orgId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [rows, setRows] = useState<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [currentPage, setCurrentPage] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Initialize page size from storage or default
|
||||
const [pageSize, setPageSize] = useState<number>(() => {
|
||||
return getStoredPageSize("request-audit-logs", 20);
|
||||
});
|
||||
|
||||
const [filterAttributes, setFilterAttributes] = useState<{
|
||||
actors: string[];
|
||||
resources: {
|
||||
id: number;
|
||||
name: string | null;
|
||||
}[];
|
||||
locations: string[];
|
||||
hosts: string[];
|
||||
paths: string[];
|
||||
}>({
|
||||
actors: [],
|
||||
resources: [],
|
||||
locations: [],
|
||||
hosts: [],
|
||||
paths: []
|
||||
});
|
||||
|
||||
// Filter states - unified object for all filters
|
||||
const [filters, setFilters] = useState<{
|
||||
action?: string;
|
||||
resourceId?: string;
|
||||
host?: string;
|
||||
location?: string;
|
||||
actor?: string;
|
||||
method?: string;
|
||||
reason?: string;
|
||||
path?: string;
|
||||
}>({
|
||||
action: searchParams.get("action") || undefined,
|
||||
host: searchParams.get("host") || undefined,
|
||||
resourceId: searchParams.get("resourceId") || undefined,
|
||||
location: searchParams.get("location") || undefined,
|
||||
actor: searchParams.get("actor") || undefined,
|
||||
method: searchParams.get("method") || undefined,
|
||||
reason: searchParams.get("reason") || undefined,
|
||||
path: searchParams.get("path") || undefined
|
||||
});
|
||||
|
||||
// Set default date range to last 24 hours
|
||||
const getDefaultDateRange = () => {
|
||||
// if the time is in the url params, use that instead
|
||||
const startParam = searchParams.get("start");
|
||||
const endParam = searchParams.get("end");
|
||||
if (startParam && endParam) {
|
||||
return {
|
||||
startDate: {
|
||||
date: new Date(startParam)
|
||||
},
|
||||
endDate: {
|
||||
date: new Date(endParam)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
return {
|
||||
startDate: {
|
||||
date: yesterday
|
||||
},
|
||||
endDate: {
|
||||
date: now
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
startDate: DateTimeValue;
|
||||
endDate: DateTimeValue;
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Trigger search with default values on component mount
|
||||
useEffect(() => {
|
||||
const defaultRange = getDefaultDateRange();
|
||||
queryDateTime(
|
||||
defaultRange.startDate,
|
||||
defaultRange.endDate,
|
||||
0,
|
||||
pageSize
|
||||
);
|
||||
}, [orgId]); // Re-run if orgId changes
|
||||
|
||||
const handleDateRangeChange = (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => {
|
||||
setDateRange({ startDate, endDate });
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
// put the search params in the url for the time
|
||||
updateUrlParamsForAllFilters({
|
||||
start: startDate.date?.toISOString() || "",
|
||||
end: endDate.date?.toISOString() || ""
|
||||
});
|
||||
|
||||
queryDateTime(startDate, endDate, 0, pageSize);
|
||||
};
|
||||
|
||||
// Handle page changes
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setCurrentPage(newPage);
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
newPage,
|
||||
pageSize
|
||||
);
|
||||
};
|
||||
|
||||
// Handle page size changes
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setPageSize(newPageSize);
|
||||
setStoredPageSize(newPageSize, "request-audit-logs");
|
||||
setCurrentPage(0); // Reset to first page when changing page size
|
||||
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
|
||||
};
|
||||
|
||||
// Handle filter changes generically
|
||||
const handleFilterChange = (
|
||||
filterType: keyof typeof filters,
|
||||
value: string | undefined
|
||||
) => {
|
||||
console.log(`${filterType} filter changed:`, value);
|
||||
|
||||
// Create new filters object with updated value
|
||||
const newFilters = {
|
||||
...filters,
|
||||
[filterType]: value
|
||||
};
|
||||
|
||||
setFilters(newFilters);
|
||||
setCurrentPage(0); // Reset to first page when filtering
|
||||
|
||||
// Update URL params
|
||||
updateUrlParamsForAllFilters(newFilters);
|
||||
|
||||
// Trigger new query with updated filters (pass directly to avoid async state issues)
|
||||
queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
0,
|
||||
pageSize,
|
||||
newFilters
|
||||
);
|
||||
};
|
||||
|
||||
const updateUrlParamsForAllFilters = (
|
||||
newFilters:
|
||||
| typeof filters
|
||||
| {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(newFilters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
} else {
|
||||
params.delete(key);
|
||||
}
|
||||
});
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const queryDateTime = async (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue,
|
||||
page: number = currentPage,
|
||||
size: number = pageSize,
|
||||
filtersParam?: {
|
||||
action?: string;
|
||||
type?: string;
|
||||
}
|
||||
) => {
|
||||
console.log("Date range changed:", { startDate, endDate, page, size });
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Use the provided filters or fall back to current state
|
||||
const activeFilters = filtersParam || filters;
|
||||
|
||||
// Convert the date/time values to API parameters
|
||||
const params: any = {
|
||||
limit: size,
|
||||
offset: page * size,
|
||||
...activeFilters
|
||||
};
|
||||
|
||||
if (startDate?.date) {
|
||||
const startDateTime = new Date(startDate.date);
|
||||
if (startDate.time) {
|
||||
const [hours, minutes, seconds] = startDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
startDateTime.setHours(hours, minutes, seconds || 0);
|
||||
}
|
||||
params.timeStart = startDateTime.toISOString();
|
||||
}
|
||||
|
||||
if (endDate?.date) {
|
||||
const endDateTime = new Date(endDate.date);
|
||||
if (endDate.time) {
|
||||
const [hours, minutes, seconds] = endDate.time
|
||||
.split(":")
|
||||
.map(Number);
|
||||
endDateTime.setHours(hours, minutes, seconds || 0);
|
||||
} else {
|
||||
// If no time is specified, set to NOW
|
||||
const now = new Date();
|
||||
endDateTime.setHours(
|
||||
now.getHours(),
|
||||
now.getMinutes(),
|
||||
now.getSeconds(),
|
||||
now.getMilliseconds()
|
||||
);
|
||||
}
|
||||
params.timeEnd = endDateTime.toISOString();
|
||||
}
|
||||
|
||||
const res = await api.get(`/org/${orgId}/logs/request`, { params });
|
||||
if (res.status === 200) {
|
||||
setRows(res.data.data.log || []);
|
||||
setTotalCount(res.data.data.pagination?.total || 0);
|
||||
setFilterAttributes(res.data.data.filterAttributes);
|
||||
console.log("Fetched logs:", res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("Failed to filter logs"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Refresh data with current date range and pagination
|
||||
await queryDateTime(
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
currentPage,
|
||||
pageSize
|
||||
);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportData = async () => {
|
||||
try {
|
||||
setIsExporting(true);
|
||||
|
||||
// Prepare query params for export
|
||||
const params: any = {
|
||||
timeStart: dateRange.startDate?.date
|
||||
? new Date(dateRange.startDate.date).toISOString()
|
||||
: undefined,
|
||||
timeEnd: dateRange.endDate?.date
|
||||
? new Date(dateRange.endDate.date).toISOString()
|
||||
: undefined,
|
||||
...filters
|
||||
};
|
||||
|
||||
const response = await api.get(
|
||||
`/org/${orgId}/logs/request/export`,
|
||||
{
|
||||
responseType: "blob",
|
||||
params
|
||||
}
|
||||
);
|
||||
|
||||
// Create a URL for the blob and trigger a download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`request-audit-logs-${orgId}-${epoch}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
setIsExporting(false);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("exportError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 100 - Allowed by Rule
|
||||
// 101 - Allowed No Auth
|
||||
// 102 - Valid Access Token
|
||||
// 103 - Valid header auth
|
||||
// 104 - Valid Pincode
|
||||
// 105 - Valid Password
|
||||
// 106 - Valid email
|
||||
// 107 - Valid SSO
|
||||
|
||||
// 201 - Resource Not Found
|
||||
// 202 - Resource Blocked
|
||||
// 203 - Dropped by Rule
|
||||
// 204 - No Sessions
|
||||
// 205 - Temporary Request Token
|
||||
// 299 - No More Auth Methods
|
||||
|
||||
const reasonMap: any = {
|
||||
100: t("allowedByRule"),
|
||||
101: t("allowedNoAuth"),
|
||||
102: t("validAccessToken"),
|
||||
103: t("validHeaderAuth"),
|
||||
104: t("validPincode"),
|
||||
105: t("validPassword"),
|
||||
106: t("validEmail"),
|
||||
107: t("validSSO"),
|
||||
201: t("resourceNotFound"),
|
||||
202: t("resourceBlocked"),
|
||||
203: t("droppedByRule"),
|
||||
204: t("noSessions"),
|
||||
205: t("temporaryRequestToken"),
|
||||
299: t("noMoreAuthMethods")
|
||||
};
|
||||
|
||||
// resourceId: integer("resourceId"),
|
||||
// userAgent: text("userAgent"),
|
||||
// metadata: text("details"),
|
||||
// headers: text("headers"), // JSON blob
|
||||
// query: text("query"), // JSON blob
|
||||
// originalRequestURL: text("originalRequestURL"),
|
||||
// scheme: text("scheme"),
|
||||
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
accessorKey: "timestamp",
|
||||
header: ({ column }) => {
|
||||
return t("timestamp");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="whitespace-nowrap">
|
||||
{new Date(
|
||||
row.original.timestamp * 1000
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("action")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "true", label: "Allowed" },
|
||||
{ value: "false", label: "Denied" }
|
||||
]}
|
||||
selectedValue={filters.action}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("action", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.action ? <>Allowed</> : <>Denied</>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: ({ column }) => {
|
||||
return t("ip");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "location",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("location")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.locations.map(
|
||||
(location) => ({
|
||||
value: location,
|
||||
label: location
|
||||
})
|
||||
)}
|
||||
selectedValue={filters.location}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("location", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.location ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.location}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "resourceName",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("resource")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.resources.map((res) => ({
|
||||
value: res.id.toString(),
|
||||
label: res.name || "Unnamed Resource"
|
||||
}))}
|
||||
selectedValue={filters.resourceId}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("resourceId", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Link
|
||||
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-6"
|
||||
>
|
||||
{row.original.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "host",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("host")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.hosts.map((host) => ({
|
||||
value: host,
|
||||
label: host
|
||||
}))}
|
||||
selectedValue={filters.host}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("host", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.tls ? (
|
||||
<Lock className="h-4 w-4" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.host}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "path",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("path")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.paths.map((path) => ({
|
||||
value: path,
|
||||
label: path
|
||||
}))}
|
||||
selectedValue={filters.path}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("path", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// {
|
||||
// accessorKey: "scheme",
|
||||
// header: ({ column }) => {
|
||||
// return t("scheme");
|
||||
// },
|
||||
// },
|
||||
{
|
||||
accessorKey: "method",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("method")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "GET", label: "GET" },
|
||||
{ value: "POST", label: "POST" },
|
||||
{ value: "PUT", label: "PUT" },
|
||||
{ value: "DELETE", label: "DELETE" },
|
||||
{ value: "PATCH", label: "PATCH" },
|
||||
{ value: "HEAD", label: "HEAD" },
|
||||
{ value: "OPTIONS", label: "OPTIONS" }
|
||||
]}
|
||||
selectedValue={filters.method}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("method", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "reason",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("reason")}</span>
|
||||
<ColumnFilter
|
||||
options={[
|
||||
{ value: "100", label: t("allowedByRule") },
|
||||
{ value: "101", label: t("allowedNoAuth") },
|
||||
{ value: "102", label: t("validAccessToken") },
|
||||
{ value: "103", label: t("validHeaderAuth") },
|
||||
{ value: "104", label: t("validPincode") },
|
||||
{ value: "105", label: t("validPassword") },
|
||||
{ value: "106", label: t("validEmail") },
|
||||
{ value: "107", label: t("validSSO") },
|
||||
{ value: "201", label: t("resourceNotFound") },
|
||||
{ value: "202", label: t("resourceBlocked") },
|
||||
{ value: "203", label: t("droppedByRule") },
|
||||
{ value: "204", label: t("noSessions") },
|
||||
{
|
||||
value: "205",
|
||||
label: t("temporaryRequestToken")
|
||||
},
|
||||
{ value: "299", label: t("noMoreAuthMethods") }
|
||||
]}
|
||||
selectedValue={filters.reason}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("reason", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{reasonMap[row.original.reason]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "actor",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("actor")}</span>
|
||||
<ColumnFilter
|
||||
options={filterAttributes.actors.map((actor) => ({
|
||||
value: actor,
|
||||
label: actor
|
||||
}))}
|
||||
selectedValue={filters.actor}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("actor", value)
|
||||
}
|
||||
// placeholder=""
|
||||
searchPlaceholder="Search..."
|
||||
emptyMessage="None found"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{row.original.actor ? (
|
||||
<>
|
||||
{row.original.actorType == "user" ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Key className="h-4 w-4" />
|
||||
)}
|
||||
{row.original.actor}
|
||||
</>
|
||||
) : (
|
||||
<>-</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const renderExpandedRow = (row: any) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<strong>User Agent:</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.userAgent || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Original URL:</strong>
|
||||
<p className="text-muted-foreground mt-1 break-all">
|
||||
{row.originalRequestURL || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Scheme:</strong>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{row.scheme || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Metadata:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{row.metadata
|
||||
? JSON.stringify(
|
||||
JSON.parse(row.metadata),
|
||||
null,
|
||||
2
|
||||
)
|
||||
: "N/A"}
|
||||
</pre>
|
||||
</div>
|
||||
{row.headers && (
|
||||
<div className="md:col-span-2">
|
||||
<strong>Headers:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{JSON.stringify(
|
||||
JSON.parse(row.headers),
|
||||
null,
|
||||
2
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{row.query && (
|
||||
<div className="md:col-span-2">
|
||||
<strong>Query Parameters:</strong>
|
||||
<pre className="text-muted-foreground mt-1 text-xs bg-background p-2 rounded border overflow-auto">
|
||||
{JSON.stringify(JSON.parse(row.query), null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t('requestLogs')}
|
||||
description={t('requestLogsDescription')}
|
||||
/>
|
||||
|
||||
<LogDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
title={t("requestLogs")}
|
||||
searchPlaceholder={t("searchLogs")}
|
||||
searchColumn="host"
|
||||
onRefresh={refreshData}
|
||||
isRefreshing={isRefreshing}
|
||||
onExport={exportData}
|
||||
isExporting={isExporting}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
dateRange={{
|
||||
start: dateRange.startDate,
|
||||
end: dateRange.endDate
|
||||
}}
|
||||
defaultSort={{
|
||||
id: "timestamp",
|
||||
desc: false
|
||||
}}
|
||||
// Server-side pagination props
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
isLoading={isLoading}
|
||||
pageSize={pageSize}
|
||||
// Row expansion props
|
||||
expandable={true}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
MonitorUp, // Added from 'dev' branch
|
||||
Server,
|
||||
Zap,
|
||||
CreditCard
|
||||
CreditCard,
|
||||
Logs,
|
||||
SquareMousePointer,
|
||||
ScanEye
|
||||
} from "lucide-react";
|
||||
|
||||
export type SidebarNavSection = {
|
||||
@@ -112,6 +115,30 @@ export const orgNavSections = (
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "Analytics",
|
||||
items: [
|
||||
{
|
||||
title: "sidebarLogsRequest",
|
||||
href: "/{orgId}/settings/logs/request",
|
||||
icon: <SquareMousePointer className="h-4 w-4" />
|
||||
},
|
||||
...(build != "oss"
|
||||
? [
|
||||
{
|
||||
title: "sidebarLogsAccess",
|
||||
href: "/{orgId}/settings/logs/access",
|
||||
icon: <ScanEye className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
title: "sidebarLogsAction",
|
||||
href: "/{orgId}/settings/logs/action",
|
||||
icon: <Logs className="h-4 w-4" />
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]
|
||||
},
|
||||
{
|
||||
heading: "Organization",
|
||||
items: [
|
||||
|
||||
104
src/components/ColumnFilter.tsx
Normal file
104
src/components/ColumnFilter.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command";
|
||||
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ColumnFilterProps {
|
||||
options: FilterOption[];
|
||||
selectedValue?: string;
|
||||
onValueChange: (value: string | undefined) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColumnFilter({
|
||||
options,
|
||||
selectedValue,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
searchPlaceholder = "Search...",
|
||||
emptyMessage = "No options found",
|
||||
className
|
||||
}: ColumnFilterProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedOption = options.find(option => option.value === selectedValue);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"justify-between text-sm h-8 px-2",
|
||||
!selectedValue && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-[200px]" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{/* Clear filter option */}
|
||||
{selectedValue && (
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
onValueChange(undefined);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Clear filter
|
||||
</CommandItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
onValueChange(
|
||||
selectedValue === option.value ? undefined : option.value
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValue === option.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -19,11 +19,21 @@ import { useTranslations } from "next-intl";
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
onPageChange?: (pageIndex: number) => void;
|
||||
totalCount?: number;
|
||||
isServerPagination?: boolean;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
onPageSizeChange
|
||||
onPageSizeChange,
|
||||
onPageChange,
|
||||
totalCount,
|
||||
isServerPagination = false,
|
||||
isLoading = false,
|
||||
disabled = false
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -37,14 +47,60 @@ export function DataTablePagination<TData>({
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageNavigation = (action: 'first' | 'previous' | 'next' | 'last') => {
|
||||
if (isServerPagination && onPageChange) {
|
||||
const currentPage = table.getState().pagination.pageIndex;
|
||||
const pageCount = table.getPageCount();
|
||||
|
||||
let newPage: number;
|
||||
switch (action) {
|
||||
case 'first':
|
||||
newPage = 0;
|
||||
break;
|
||||
case 'previous':
|
||||
newPage = Math.max(0, currentPage - 1);
|
||||
break;
|
||||
case 'next':
|
||||
newPage = Math.min(pageCount - 1, currentPage + 1);
|
||||
break;
|
||||
case 'last':
|
||||
newPage = pageCount - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPage !== currentPage) {
|
||||
onPageChange(newPage);
|
||||
}
|
||||
} else {
|
||||
// Use table's built-in navigation for client-side pagination
|
||||
switch (action) {
|
||||
case 'first':
|
||||
table.setPageIndex(0);
|
||||
break;
|
||||
case 'previous':
|
||||
table.previousPage();
|
||||
break;
|
||||
case 'next':
|
||||
table.nextPage();
|
||||
break;
|
||||
case 'last':
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={handlePageSizeChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectTrigger className="h-8 w-[73px]" disabled={disabled}>
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
@@ -61,14 +117,21 @@ export function DataTablePagination<TData>({
|
||||
|
||||
<div className="flex items-center space-x-3 lg:space-x-8">
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
{t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})}
|
||||
{isServerPagination && totalCount !== undefined ? (
|
||||
t('paginator', {
|
||||
current: table.getState().pagination.pageIndex + 1,
|
||||
last: Math.ceil(totalCount / table.getState().pagination.pageSize)
|
||||
})
|
||||
) : (
|
||||
t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
onClick={() => handlePageNavigation('first')}
|
||||
disabled={!table.getCanPreviousPage() || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToFirst')}</span>
|
||||
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||
@@ -76,8 +139,8 @@ export function DataTablePagination<TData>({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
onClick={() => handlePageNavigation('previous')}
|
||||
disabled={!table.getCanPreviousPage() || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToPrevious')}</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
@@ -85,8 +148,8 @@ export function DataTablePagination<TData>({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
onClick={() => handlePageNavigation('next')}
|
||||
disabled={!table.getCanNextPage() || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToNext')}</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
@@ -94,10 +157,8 @@ export function DataTablePagination<TData>({
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() =>
|
||||
table.setPageIndex(table.getPageCount() - 1)
|
||||
}
|
||||
disabled={!table.getCanNextPage()}
|
||||
onClick={() => handlePageNavigation('last')}
|
||||
disabled={!table.getCanNextPage() || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToLast')}</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
|
||||
215
src/components/DateTimePicker.tsx
Normal file
215
src/components/DateTimePicker.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, CalendarIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Calendar } from "@app/components/ui/calendar";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export interface DateTimeValue {
|
||||
date?: Date;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
label?: string;
|
||||
value?: DateTimeValue;
|
||||
onChange?: (value: DateTimeValue) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select date & time",
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true,
|
||||
}: DateTimePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [internalDate, setInternalDate] = useState<Date | undefined>(value?.date);
|
||||
const [internalTime, setInternalTime] = useState<string>(value?.time || "");
|
||||
|
||||
// Sync internal state with external value prop
|
||||
useEffect(() => {
|
||||
setInternalDate(value?.date);
|
||||
setInternalTime(value?.time || "");
|
||||
}, [value?.date, value?.time]);
|
||||
|
||||
const handleDateChange = (date: Date | undefined) => {
|
||||
setInternalDate(date);
|
||||
const newValue = { date, time: internalTime };
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const handleTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const time = event.target.value;
|
||||
setInternalTime(time);
|
||||
const newValue = { date: internalDate, time };
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (!internalDate) return placeholder;
|
||||
|
||||
const dateStr = internalDate.toLocaleDateString();
|
||||
if (!showTime || !internalTime) return dateStr;
|
||||
|
||||
// Parse time and format in local timezone
|
||||
const [hours, minutes, seconds] = internalTime.split(':');
|
||||
const timeDate = new Date();
|
||||
timeDate.setHours(parseInt(hours, 10), parseInt(minutes, 10), parseInt(seconds || '0', 10));
|
||||
const timeStr = timeDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return `${dateStr} ${timeStr}`;
|
||||
};
|
||||
|
||||
const hasValue = internalDate || (showTime && internalTime);
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4", className)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{label && (
|
||||
<Label htmlFor="date-picker">
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
id="date-picker"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
showTime ? "w-48" : "w-32",
|
||||
!hasValue && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{getDisplayText()}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
{showTime ? (
|
||||
<div className="flex">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={internalDate}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
handleDateChange(date);
|
||||
if (!showTime) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
className="flex-grow w-[250px]"
|
||||
/>
|
||||
<div className="p-3 border-l">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="time-input" className="text-sm font-medium">
|
||||
Time
|
||||
</Label>
|
||||
<Input
|
||||
id="time-input"
|
||||
type="time"
|
||||
step="1"
|
||||
value={internalTime}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={internalDate}
|
||||
captionLayout="dropdown"
|
||||
onSelect={(date) => {
|
||||
handleDateChange(date);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
startLabel?: string;
|
||||
endLabel?: string;
|
||||
startValue?: DateTimeValue;
|
||||
endValue?: DateTimeValue;
|
||||
onStartChange?: (value: DateTimeValue) => void;
|
||||
onEndChange?: (value: DateTimeValue) => void;
|
||||
onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showTime?: boolean;
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
// startLabel = "From",
|
||||
// endLabel = "To",
|
||||
startValue,
|
||||
endValue,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
onRangeChange,
|
||||
className,
|
||||
disabled = false,
|
||||
showTime = true,
|
||||
}: DateRangePickerProps) {
|
||||
const handleStartChange = (value: DateTimeValue) => {
|
||||
onStartChange?.(value);
|
||||
if (onRangeChange && endValue) {
|
||||
onRangeChange(value, endValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndChange = (value: DateTimeValue) => {
|
||||
onEndChange?.(value);
|
||||
if (onRangeChange && startValue) {
|
||||
onRangeChange(startValue, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-4 items-center", className)}>
|
||||
<DateTimePicker
|
||||
label="Start"
|
||||
value={startValue}
|
||||
onChange={handleStartChange}
|
||||
placeholder="Start date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
<DateTimePicker
|
||||
label="End"
|
||||
value={endValue}
|
||||
onChange={handleEndChange}
|
||||
placeholder="End date & time"
|
||||
disabled={disabled}
|
||||
showTime={showTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
|
||||
export type HorizontalTabs = Array<{
|
||||
title: string;
|
||||
@@ -30,6 +30,7 @@ export function HorizontalTabs({
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||
const subscription = useSubscriptionStatusContext();
|
||||
const t = useTranslations();
|
||||
|
||||
function hydrateHref(href: string) {
|
||||
|
||||
529
src/components/LogDataTable.tsx
Normal file
529
src/components/LogDataTable.tsx
Normal file
@@ -0,0 +1,529 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
X,
|
||||
Download,
|
||||
ChevronRight,
|
||||
ChevronDown
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: "datatable-page-size",
|
||||
getTablePageSize: (tableId?: string) =>
|
||||
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
|
||||
};
|
||||
|
||||
export const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
|
||||
if (typeof window === "undefined") return defaultSize;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
// Validate that it's a reasonable page size
|
||||
if (parsed > 0 && parsed <= 1000) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read page size from localStorage:", error);
|
||||
}
|
||||
return defaultSize;
|
||||
};
|
||||
|
||||
export const setStoredPageSize = (pageSize: number, tableId?: string): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const key = STORAGE_KEYS.getTablePageSize(tableId);
|
||||
localStorage.setItem(key, pageSize.toString());
|
||||
} catch (error) {
|
||||
console.warn("Failed to save page size to localStorage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
type TabFilter = {
|
||||
id: string;
|
||||
label: string;
|
||||
filterFn: (row: any) => boolean;
|
||||
};
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
title?: string;
|
||||
addButtonText?: string;
|
||||
onRefresh?: () => void;
|
||||
onExport?: () => void;
|
||||
isExporting?: boolean;
|
||||
isRefreshing?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
searchColumn?: string;
|
||||
defaultSort?: {
|
||||
id: string;
|
||||
desc: boolean;
|
||||
};
|
||||
tabs?: TabFilter[];
|
||||
defaultTab?: string;
|
||||
disabled?: boolean;
|
||||
onDateRangeChange?: (
|
||||
startDate: DateTimeValue,
|
||||
endDate: DateTimeValue
|
||||
) => void;
|
||||
dateRange?: {
|
||||
start: DateTimeValue;
|
||||
end: DateTimeValue;
|
||||
};
|
||||
// Server-side pagination props
|
||||
totalCount?: number;
|
||||
pageSize: number;
|
||||
currentPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
isLoading?: boolean;
|
||||
// Row expansion props
|
||||
expandable?: boolean;
|
||||
renderExpandedRow?: (row: TData) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function LogDataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
title,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
onExport,
|
||||
isExporting,
|
||||
// searchPlaceholder = "Search...",
|
||||
// searchColumn = "name",
|
||||
defaultSort,
|
||||
tabs,
|
||||
defaultTab,
|
||||
onDateRangeChange,
|
||||
pageSize,
|
||||
dateRange,
|
||||
totalCount,
|
||||
currentPage = 0,
|
||||
onPageChange,
|
||||
onPageSizeChange: onPageSizeChangeProp,
|
||||
isLoading = false,
|
||||
expandable = false,
|
||||
disabled=false,
|
||||
renderExpandedRow
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>(
|
||||
defaultSort ? [defaultSort] : []
|
||||
);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState<any>([]);
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTab || tabs?.[0]?.id || ""
|
||||
);
|
||||
|
||||
const [startDate, setStartDate] = useState<DateTimeValue>(
|
||||
dateRange?.start || {}
|
||||
);
|
||||
const [endDate, setEndDate] = useState<DateTimeValue>(dateRange?.end || {});
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
// Sync internal date state with external dateRange prop
|
||||
useEffect(() => {
|
||||
if (dateRange?.start) {
|
||||
setStartDate(dateRange.start);
|
||||
}
|
||||
if (dateRange?.end) {
|
||||
setEndDate(dateRange.end);
|
||||
}
|
||||
}, [dateRange?.start, dateRange?.end]);
|
||||
|
||||
// Apply tab filter to data
|
||||
const filteredData = useMemo(() => {
|
||||
// If disabled, return empty array to prevent data loading
|
||||
if (disabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!tabs || activeTab === "") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const activeTabFilter = tabs.find((tab) => tab.id === activeTab);
|
||||
if (!activeTabFilter) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.filter(activeTabFilter.filterFn);
|
||||
}, [data, tabs, activeTab, disabled]);
|
||||
|
||||
// Toggle row expansion
|
||||
const toggleRowExpansion = (rowId: string) => {
|
||||
setExpandedRows((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(rowId)) {
|
||||
newSet.delete(rowId);
|
||||
} else {
|
||||
newSet.add(rowId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Determine if using server-side pagination
|
||||
const isServerPagination = totalCount !== undefined;
|
||||
|
||||
// Create columns with expansion column if expandable
|
||||
const enhancedColumns = useMemo(() => {
|
||||
if (!expandable) {
|
||||
return columns;
|
||||
}
|
||||
|
||||
const expansionColumn: ColumnDef<TData, TValue> = {
|
||||
id: "expand",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const isExpanded = expandedRows.has(row.id);
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
if (!disabled) {
|
||||
toggleRowExpansion(row.id);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
size: 40
|
||||
};
|
||||
|
||||
return [expansionColumn, ...columns];
|
||||
}, [columns, expandable, expandedRows, toggleRowExpansion, disabled]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns: enhancedColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// Only use client-side pagination if totalCount is not provided
|
||||
...(isServerPagination
|
||||
? {}
|
||||
: { getPaginationRowModel: getPaginationRowModel() }),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
// Configure pagination state
|
||||
...(isServerPagination
|
||||
? {
|
||||
manualPagination: true,
|
||||
pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0
|
||||
}
|
||||
: {}),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: pageSize,
|
||||
pageIndex: currentPage
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination: {
|
||||
pageSize: pageSize,
|
||||
pageIndex: currentPage
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// useEffect(() => {
|
||||
// const currentPageSize = table.getState().pagination.pageSize;
|
||||
// if (currentPageSize !== pageSize) {
|
||||
// table.setPageSize(pageSize);
|
||||
|
||||
// // Persist to localStorage if enabled
|
||||
// if (persistPageSize) {
|
||||
// setStoredPageSize(pageSize, tableId);
|
||||
// }
|
||||
// }
|
||||
// }, [pageSize, table, persistPageSize, tableId]);
|
||||
|
||||
// Update table page index when currentPage prop changes (server pagination)
|
||||
useEffect(() => {
|
||||
if (isServerPagination) {
|
||||
const currentPageIndex = table.getState().pagination.pageIndex;
|
||||
if (currentPageIndex !== currentPage) {
|
||||
table.setPageIndex(currentPage);
|
||||
}
|
||||
}
|
||||
}, [currentPage, table, isServerPagination]);
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
setActiveTab(value);
|
||||
// Reset to first page when changing tabs
|
||||
table.setPageIndex(0);
|
||||
};
|
||||
|
||||
// Enhanced pagination component that updates our local state
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
if (disabled) return;
|
||||
|
||||
// setPageSize(newPageSize);
|
||||
table.setPageSize(newPageSize);
|
||||
|
||||
// Persist immediately when changed
|
||||
// if (persistPageSize) {
|
||||
// setStoredPageSize(newPageSize, tableId);
|
||||
// }
|
||||
|
||||
// For server pagination, notify parent component
|
||||
if (isServerPagination && onPageSizeChangeProp) {
|
||||
onPageSizeChangeProp(newPageSize);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page changes for server pagination
|
||||
const handlePageChange = (newPageIndex: number) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (isServerPagination && onPageChange) {
|
||||
onPageChange(newPageIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (
|
||||
start: DateTimeValue,
|
||||
end: DateTimeValue
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
onDateRangeChange?.(start, end);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-12xl">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
|
||||
<div className="flex flex-row items-start w-full sm:mr-2 gap-2">
|
||||
{/* <div className="relative w-full sm:max-w-sm">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={globalFilter ?? ""}
|
||||
onChange={(e) =>
|
||||
table.setGlobalFilter(
|
||||
String(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full pl-8 m-0"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
|
||||
</div> */}
|
||||
<DateRangePicker
|
||||
startValue={startDate}
|
||||
endValue={endDate}
|
||||
onRangeChange={handleDateRangeChange}
|
||||
className="flex-wrap gap-2"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 sm:justify-end">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => !disabled && onRefresh()}
|
||||
disabled={isRefreshing || disabled}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
)}
|
||||
{onExport && (
|
||||
<Button onClick={() => !disabled && onExport()} disabled={isExporting || disabled}>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("exportCsv")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const isExpanded =
|
||||
expandable && expandedRows.has(row.id);
|
||||
return [
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
onClick={() =>
|
||||
expandable && !disabled
|
||||
? toggleRowExpansion(
|
||||
row.id
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
className="text-xs" // made smaller
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const originalRow =
|
||||
row.original as any;
|
||||
const actionValue =
|
||||
originalRow?.action;
|
||||
let className = "";
|
||||
|
||||
if (
|
||||
typeof actionValue ===
|
||||
"boolean"
|
||||
) {
|
||||
className =
|
||||
actionValue
|
||||
? "bg-green-100 dark:bg-green-900/50"
|
||||
: "bg-red-100 dark:bg-red-900/50";
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`${className} py-2`} // made smaller
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>,
|
||||
isExpanded &&
|
||||
renderExpandedRow && (
|
||||
<TableRow
|
||||
key={`${row.id}-expanded`}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={
|
||||
enhancedColumns.length
|
||||
}
|
||||
className="p-4 bg-muted/50"
|
||||
>
|
||||
{renderExpandedRow(
|
||||
row.original
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
].filter(Boolean);
|
||||
}).flat()
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={enhancedColumns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onPageChange={
|
||||
isServerPagination
|
||||
? handlePageChange
|
||||
: undefined
|
||||
}
|
||||
totalCount={totalCount}
|
||||
isServerPagination={isServerPagination}
|
||||
isLoading={isLoading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { LockIcon, FingerprintIcon } from "lucide-react";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import {
|
||||
@@ -74,6 +74,8 @@ export default function LoginForm({
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const { resourceGuid } = useParams();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hasIdp = idps && idps.length > 0;
|
||||
@@ -235,7 +237,8 @@ export default function LoginForm({
|
||||
const response = await loginProxy({
|
||||
email,
|
||||
password,
|
||||
code
|
||||
code,
|
||||
resourceGuid: resourceGuid as string
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
213
src/components/ui/calendar.tsx
Normal file
213
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[--cell-size] select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
);
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
Reference in New Issue
Block a user