From dce84b9b092bf87b4bda66741cdbf29e39d321e1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 17:58:52 -0700 Subject: [PATCH 01/69] Add action audit middleware and tables --- server/db/pg/schema/privateSchema.ts | 20 ++++- server/db/sqlite/schema/privateSchema.ts | 21 ++++- server/private/middlewares/index.ts | 2 +- server/private/middlewares/logActionAudit.ts | 88 ++++++++++++++++++++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 server/private/middlewares/logActionAudit.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 67fb28ec..f33b88e1 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -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,22 @@ 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 type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -230,3 +247,4 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 557ebfd6..cbf61cc1 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -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,22 @@ 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 type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -224,3 +242,4 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index c92b0d3d..32aa1a1f 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -15,4 +15,4 @@ export * from "./verifyCertificateAccess"; export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; -export * from "../../lib/corsWithLoginPage"; \ No newline at end of file +export * from "./logActionAudit"; \ No newline at end of file diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts new file mode 100644 index 00000000..488aff88 --- /dev/null +++ b/server/private/middlewares/logActionAudit.ts @@ -0,0 +1,88 @@ +/* + * 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 } 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"; + +export function logActionAudit(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + 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 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" + ) + ); + } + }; +} From 1f50bc3752d355c07f80ac9e7f90c09ccdb7f6f0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 21:53:00 -0700 Subject: [PATCH 02/69] Add logActionAudit and query endpoint --- server/db/pg/schema/privateSchema.ts | 25 ++- server/db/pg/schema/schema.ts | 29 +++- server/db/sqlite/schema/privateSchema.ts | 22 +++ server/db/sqlite/schema/schema.ts | 28 +++- server/middlewares/logActionAudit.ts | 12 ++ server/private/routers/auditLogs/index.ts | 0 .../routers/auditLogs/queryActionAuditLog.ts | 147 ++++++++++++++++++ server/private/routers/external.ts | 34 ++-- server/private/routers/integration.ts | 7 +- server/routers/auditLogs/types.ts | 14 ++ server/routers/external.ts | 135 ++++++++++------ server/routers/integration.ts | 143 +++++++++++------ 12 files changed, 488 insertions(+), 108 deletions(-) create mode 100644 server/middlewares/logActionAudit.ts create mode 100644 server/private/routers/auditLogs/index.ts create mode 100644 server/private/routers/auditLogs/queryActionAuditLog.ts create mode 100644 server/routers/auditLogs/types.ts diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index f33b88e1..d2bb8841 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -230,6 +230,28 @@ export const actionAuditLog = pgTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); +export const identityAuditLog = pgTable("identityAuditLog", { + 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(), + resourceId: integer("resourceId"), + ip: varchar("ip", { length: 45 }).notNull(), + type: varchar("type", { length: 100 }).notNull(), + action: varchar("action", { length: 100 }).notNull(), + location: text("location"), + path: text("path"), + userAgent: text("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -247,4 +269,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; -export type ActionAuditLog = InferSelectModel; \ No newline at end of file +export type ActionAuditLog = InferSelectModel; +export type IdentityAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4bed23f8..2dc937d9 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -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"; @@ -671,6 +672,28 @@ 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: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType").notNull(), + actor: varchar("actor").notNull(), + actorId: varchar("actorId").notNull(), + resourceId: integer("resourceId"), + ip: varchar("ip").notNull(), + type: varchar("type").notNull(), + action: varchar("action").notNull(), + event: varchar("event").notNull(), + location: varchar("location"), + userAgent: varchar("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -722,3 +745,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index cbf61cc1..bbd0aa3e 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -225,6 +225,28 @@ export const actionAuditLog = sqliteTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); +export const identityAuditLog = sqliteTable("identityAuditLog", { + 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(), + resourceId: integer("resourceId"), + ip: text("ip").notNull(), + type: text("type").notNull(), + action: text("action").notNull(), + location: text("location"), + path: text("path"), + userAgent: text("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3d6c6b0d..59b9bc2e 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,6 @@ 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"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -710,6 +710,28 @@ 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") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType").notNull(), + actor: text("actor").notNull(), + actorId: text("actorId").notNull(), + resourceId: integer("resourceId"), + ip: text("ip").notNull(), + type: text("type").notNull(), + action: text("action").notNull(), + event: text("event").notNull(), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("details") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -761,3 +783,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/middlewares/logActionAudit.ts b/server/middlewares/logActionAudit.ts new file mode 100644 index 00000000..a1521883 --- /dev/null +++ b/server/middlewares/logActionAudit.ts @@ -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 { + next(); + }; +} diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts new file mode 100644 index 00000000..4c10a3f0 --- /dev/null +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -0,0 +1,147 @@ +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 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()), + 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() +}); + +function querySites(timeStart: number, timeEnd: number, orgId: string) { + return db + .select({ + orgId: actionAuditLog.orgId, + action: actionAuditLog.action, + actorType: actionAuditLog.actorType, + timestamp: actionAuditLog.timestamp, + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where( + and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ) + ) + .orderBy(actionAuditLog.timestamp); +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action", + description: "Query the action 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 { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { timeStart, timeEnd, limit, offset } = parsedQuery.data; + + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const baseQuery = querySites(timeStart, timeEnd, orgId); + + const log = await baseQuery.limit(limit).offset(offset); + + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where( + and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ) + ); + + const totalCountResult = await countQuery; + const totalCount = totalCountResult[0].count; + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit, + offset + } + }, + 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") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 74cd6b0c..a7c927f2 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,7 @@ import { } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { + logActionAudit, verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, @@ -72,7 +73,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), - orgIdp.createOrgOidcIdp + orgIdp.createOrgOidcIdp, + logActionAudit(ActionsEnum.createIdp) ); authenticated.post( @@ -81,7 +83,8 @@ authenticated.post( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), - orgIdp.updateOrgOidcIdp + orgIdp.updateOrgOidcIdp, + logActionAudit(ActionsEnum.updateIdp) ); authenticated.delete( @@ -90,7 +93,8 @@ authenticated.delete( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + orgIdp.deleteOrgIdp, + logActionAudit(ActionsEnum.deleteIdp) ); authenticated.get( @@ -127,7 +131,8 @@ authenticated.post( verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), - certificates.restartCertificate + certificates.restartCertificate, + logActionAudit(ActionsEnum.restartCertificate) ); if (build === "saas") { @@ -152,14 +157,16 @@ if (build === "saas") { "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createCheckoutSession + billing.createCheckoutSession, + logActionAudit(ActionsEnum.billing) ); authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createPortalSession + billing.createPortalSession, + logActionAudit(ActionsEnum.billing) ); authenticated.get( @@ -206,7 +213,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), - remoteExitNode.createRemoteExitNode + remoteExitNode.createRemoteExitNode, + logActionAudit(ActionsEnum.createRemoteExitNode) ); authenticated.get( @@ -240,7 +248,8 @@ authenticated.delete( verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), - remoteExitNode.deleteRemoteExitNode + remoteExitNode.deleteRemoteExitNode, + logActionAudit(ActionsEnum.deleteRemoteExitNode) ); authenticated.put( @@ -248,7 +257,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), - loginPage.createLoginPage + loginPage.createLoginPage, + logActionAudit(ActionsEnum.createLoginPage) ); authenticated.post( @@ -257,7 +267,8 @@ authenticated.post( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), - loginPage.updateLoginPage + loginPage.updateLoginPage, + logActionAudit(ActionsEnum.updateLoginPage) ); authenticated.delete( @@ -266,7 +277,8 @@ authenticated.delete( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), - loginPage.deleteLoginPage + loginPage.deleteLoginPage, + logActionAudit(ActionsEnum.deleteLoginPage) ); authenticated.get( diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index d767424a..00bc167a 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -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 + org.sendUsageNotification, + logActionAudit(ActionsEnum.sendUsageNotification) ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + orgIdp.deleteOrgIdp, + logActionAudit(ActionsEnum.deleteIdp) ); \ No newline at end of file diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts new file mode 100644 index 00000000..4530de79 --- /dev/null +++ b/server/routers/auditLogs/types.ts @@ -0,0 +1,14 @@ +export type QueryActionAuditLogResponse = { + log: { + orgId: string; + action: string; + actorType: string; + timestamp: number; + actor: string; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/external.ts b/server/routers/external.ts index 8bd72f62..55b98242 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -44,6 +44,7 @@ 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"; // Root routes export const unauthenticated = Router(); @@ -75,7 +76,8 @@ authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), - org.updateOrg + org.updateOrg, + logActionAudit(ActionsEnum.updateOrg) ); if (build !== "saas") { @@ -84,7 +86,8 @@ if (build !== "saas") { verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + org.deleteOrg, + logActionAudit(ActionsEnum.deleteOrg) ); } @@ -92,7 +95,8 @@ authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), - site.createSite + site.createSite, + logActionAudit(ActionsEnum.createSite) ); authenticated.get( "/org/:orgId/sites", @@ -149,7 +153,8 @@ authenticated.put( verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), - client.createClient + client.createClient, + logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -157,7 +162,8 @@ authenticated.delete( verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), - client.deleteClient + client.deleteClient, + logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -165,7 +171,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 + client.updateClient, + logActionAudit(ActionsEnum.updateClient) ); // authenticated.get( @@ -178,15 +185,18 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), - site.updateSite + site.updateSite, + logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), - site.deleteSite + site.deleteSite, + logActionAudit(ActionsEnum.deleteSite) ); +// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", verifySiteAccess, @@ -203,13 +213,15 @@ authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.checkDockerSocket + site.checkDockerSocket, + // logActionAudit(ActionsEnum.getSite) ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.triggerFetchContainers + site.triggerFetchContainers, + // logActionAudit(ActionsEnum.getSite) ); authenticated.get( "/site/:siteId/docker/containers", @@ -224,7 +236,8 @@ authenticated.put( verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + siteResource.createSiteResource, + logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -257,7 +270,8 @@ authenticated.post( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + siteResource.updateSiteResource, + logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -266,14 +280,16 @@ authenticated.delete( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + siteResource.deleteSiteResource, + logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), - resource.createResource + resource.createResource, + logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -313,15 +329,18 @@ authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), - user.removeInvitation + user.removeInvitation, + logActionAudit(ActionsEnum.removeInvitation) ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), - user.inviteUser + user.inviteUser, + logActionAudit(ActionsEnum.inviteUser) ); // maybe make this /invite/create instead + unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated authenticated.get( @@ -354,20 +373,23 @@ authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), - resource.updateResource + resource.updateResource, + logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), - resource.deleteResource + resource.deleteResource, + logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), - target.createTarget + target.createTarget, + logActionAudit(ActionsEnum.createTarget) ); authenticated.get( "/resource/:resourceId/targets", @@ -380,7 +402,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + resource.createResourceRule, + logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( "/resource/:resourceId/rules", @@ -392,13 +415,15 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + resource.updateResourceRule, + logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + resource.deleteResourceRule, + logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -411,20 +436,23 @@ authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), - target.updateTarget + target.updateTarget, + logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + target.deleteTarget, + logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), - role.createRole + role.createRole, + logActionAudit(ActionsEnum.createRole) ); authenticated.get( "/org/:orgId/roles", @@ -449,14 +477,16 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), - role.deleteRole + role.deleteRole, + logActionAudit(ActionsEnum.deleteRole) ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRole, + logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -464,7 +494,8 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + resource.setResourceRoles, + logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -472,35 +503,40 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + resource.setResourceUsers, + logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + resource.setResourcePassword, + logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + resource.setResourcePincode, + logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + resource.setResourceHeaderAuth, + logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + resource.setResourceWhitelist, + logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -514,14 +550,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + accessToken.generateAccessToken, + logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + accessToken.deleteAccessToken, + logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -594,7 +632,8 @@ authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + user.createOrgUser, + logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -602,7 +641,8 @@ authenticated.post( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + user.updateOrgUser, + logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -624,7 +664,8 @@ authenticated.delete( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), - user.removeUserOrg + user.removeUserOrg, + logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -757,7 +798,8 @@ authenticated.post( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + apiKeys.setApiKeyActions, + logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -772,7 +814,8 @@ authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + apiKeys.createOrgApiKey, + logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( @@ -780,7 +823,8 @@ authenticated.delete( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey + apiKeys.deleteOrgApiKey, + logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.get( @@ -795,7 +839,8 @@ authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), - domain.createOrgDomain + domain.createOrgDomain, + logActionAudit(ActionsEnum.createOrgDomain) ); authenticated.post( @@ -803,7 +848,8 @@ authenticated.post( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain + domain.restartOrgDomain, + logActionAudit(ActionsEnum.restartOrgDomain) ); authenticated.delete( @@ -811,7 +857,8 @@ authenticated.delete( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain + domain.deleteAccountDomain, + logActionAudit(ActionsEnum.deleteOrgDomain) ); // Auth routes diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 8808c931..f54189e6 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -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 + org.createOrg, + logActionAudit(ActionsEnum.createOrg) ); authenticated.get( @@ -72,21 +73,24 @@ authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), - org.updateOrg + org.updateOrg, + logActionAudit(ActionsEnum.updateOrg) ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + org.deleteOrg, + logActionAudit(ActionsEnum.deleteOrg) ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), - site.createSite + site.createSite, + logActionAudit(ActionsEnum.createSite) ); authenticated.get( @@ -121,14 +125,16 @@ authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), - site.updateSite + site.updateSite, + logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), - site.deleteSite + site.deleteSite, + logActionAudit(ActionsEnum.deleteSite) ); authenticated.get( @@ -142,7 +148,8 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + siteResource.createSiteResource, + logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -175,7 +182,8 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + siteResource.updateSiteResource, + logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -184,21 +192,24 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + siteResource.deleteSiteResource, + logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + resource.createResource, + logActionAudit(ActionsEnum.createResource) ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + resource.createResource, + logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -233,7 +244,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), - user.inviteUser + user.inviteUser, + logActionAudit(ActionsEnum.inviteUser) ); authenticated.get( @@ -261,21 +273,24 @@ authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), - resource.updateResource + resource.updateResource, + logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), - resource.deleteResource + resource.deleteResource, + logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), - target.createTarget + target.createTarget, + logActionAudit(ActionsEnum.createTarget) ); authenticated.get( @@ -289,7 +304,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + resource.createResourceRule, + logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( @@ -303,14 +319,16 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + resource.updateResourceRule, + logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + resource.deleteResourceRule, + logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -324,21 +342,24 @@ authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), - target.updateTarget + target.updateTarget, + logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + target.deleteTarget, + logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), - role.createRole + role.createRole, + logActionAudit(ActionsEnum.createRole) ); authenticated.get( @@ -352,7 +373,8 @@ authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), - role.deleteRole + role.deleteRole, + logActionAudit(ActionsEnum.deleteRole) ); authenticated.get( @@ -367,7 +389,8 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRole, + logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -375,7 +398,8 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + resource.setResourceRoles, + logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -383,35 +407,40 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + resource.setResourceUsers, + logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + resource.setResourcePassword, + logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + resource.setResourcePincode, + logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + resource.setResourceHeaderAuth, + logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + resource.setResourceWhitelist, + logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -439,14 +468,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + accessToken.generateAccessToken, + logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + accessToken.deleteAccessToken, + logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -474,7 +505,8 @@ authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), - user.updateUser2FA + user.updateUser2FA, + logActionAudit(ActionsEnum.updateUser) ); authenticated.get( @@ -495,7 +527,8 @@ authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + user.createOrgUser, + logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -503,7 +536,8 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + user.updateOrgUser, + logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.delete( @@ -511,7 +545,8 @@ authenticated.delete( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), - user.removeUserOrg + user.removeUserOrg, + logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -531,7 +566,8 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + apiKeys.setApiKeyActions, + logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -545,28 +581,32 @@ authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + apiKeys.createOrgApiKey, + logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteApiKey + apiKeys.deleteApiKey, + logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), - idp.createOidcIdp + idp.createOidcIdp, + logActionAudit(ActionsEnum.createIdp) ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), - idp.updateOidcIdp + idp.updateOidcIdp, + logActionAudit(ActionsEnum.updateIdp) ); authenticated.get( @@ -587,21 +627,24 @@ authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), - idp.createIdpOrgPolicy + idp.createIdpOrgPolicy, + logActionAudit(ActionsEnum.createIdpOrg) ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), - idp.updateIdpOrgPolicy + idp.updateIdpOrgPolicy, + logActionAudit(ActionsEnum.updateIdpOrg) ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), - idp.deleteIdpOrgPolicy + idp.deleteIdpOrgPolicy, + logActionAudit(ActionsEnum.deleteIdpOrg) ); authenticated.get( @@ -640,7 +683,8 @@ authenticated.put( verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), - client.createClient + client.createClient, + logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -648,7 +692,8 @@ authenticated.delete( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), - client.deleteClient + client.deleteClient, + logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -656,12 +701,14 @@ authenticated.post( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), - client.updateClient + client.updateClient, + logActionAudit(ActionsEnum.updateClient) ); authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), - org.applyBlueprint + org.applyBlueprint, + logActionAudit(ActionsEnum.applyBlueprint) ); From bc941239ecc16c89ebd4ca804ae2696571845147 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 21:59:41 -0700 Subject: [PATCH 03/69] Fix the indexes --- server/db/pg/schema/schema.ts | 4 ++-- server/db/sqlite/schema/privateSchema.ts | 4 ++-- server/db/sqlite/schema/schema.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 2dc937d9..2170192b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -690,8 +690,8 @@ export const requestAuditLog = pgTable("requestAuditLog", { userAgent: varchar("userAgent"), metadata: text("details") }, (table) => ([ - index("idx_actionAuditLog_timestamp").on(table.timestamp), - index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); export type Org = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index bbd0aa3e..81c76799 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -243,8 +243,8 @@ export const identityAuditLog = sqliteTable("identityAuditLog", { userAgent: text("userAgent"), metadata: text("details") }, (table) => ([ - index("idx_actionAuditLog_timestamp").on(table.timestamp), - index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); export type Limit = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 59b9bc2e..3dabb962 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -728,8 +728,8 @@ export const requestAuditLog = sqliteTable("requestAuditLog", { userAgent: text("userAgent"), metadata: text("details") }, (table) => ([ - index("idx_actionAuditLog_timestamp").on(table.timestamp), - index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); export type Org = InferSelectModel; From 1ee52ad86b76c3e6a0e140e46294997db9301d00 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 21:59:51 -0700 Subject: [PATCH 04/69] Add headers --- server/private/routers/auditLogs/index.ts | 13 +++++++++++++ .../routers/auditLogs/queryActionAuditLog.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index e69de29b..de0b2d2b 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 4c10a3f0..27b8b658 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -1,3 +1,16 @@ +/* + * 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"; From 58443ef53fadfa3a7c5fca6ef98e05753cf681cd Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 19 Oct 2025 22:25:00 -0700 Subject: [PATCH 05/69] Reorder log middleware --- server/private/routers/external.ts | 23 ++++--- server/private/routers/integration.ts | 4 +- server/routers/external.ts | 88 ++++++++++++------------- server/routers/integration.ts | 94 +++++++++++++-------------- 4 files changed, 103 insertions(+), 106 deletions(-) diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index a7c927f2..36a29788 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -26,7 +26,6 @@ import { Router } from "express"; import { verifyOrgAccess, verifyUserHasAction, - verifyUserIsOrgOwner, verifyUserIsServerAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; @@ -73,8 +72,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), orgIdp.createOrgOidcIdp, - logActionAudit(ActionsEnum.createIdp) ); authenticated.post( @@ -83,8 +82,8 @@ authenticated.post( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), + logActionAudit(ActionsEnum.updateIdp), orgIdp.updateOrgOidcIdp, - logActionAudit(ActionsEnum.updateIdp) ); authenticated.delete( @@ -93,8 +92,8 @@ authenticated.delete( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp, - logActionAudit(ActionsEnum.deleteIdp) ); authenticated.get( @@ -131,8 +130,8 @@ authenticated.post( verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), + logActionAudit(ActionsEnum.restartCertificate), certificates.restartCertificate, - logActionAudit(ActionsEnum.restartCertificate) ); if (build === "saas") { @@ -157,16 +156,16 @@ if (build === "saas") { "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), billing.createCheckoutSession, - logActionAudit(ActionsEnum.billing) ); authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), billing.createPortalSession, - logActionAudit(ActionsEnum.billing) ); authenticated.get( @@ -213,8 +212,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), + logActionAudit(ActionsEnum.createRemoteExitNode), remoteExitNode.createRemoteExitNode, - logActionAudit(ActionsEnum.createRemoteExitNode) ); authenticated.get( @@ -248,8 +247,8 @@ authenticated.delete( verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), + logActionAudit(ActionsEnum.deleteRemoteExitNode), remoteExitNode.deleteRemoteExitNode, - logActionAudit(ActionsEnum.deleteRemoteExitNode) ); authenticated.put( @@ -257,8 +256,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), + logActionAudit(ActionsEnum.createLoginPage), loginPage.createLoginPage, - logActionAudit(ActionsEnum.createLoginPage) ); authenticated.post( @@ -267,8 +266,8 @@ authenticated.post( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), + logActionAudit(ActionsEnum.updateLoginPage), loginPage.updateLoginPage, - logActionAudit(ActionsEnum.updateLoginPage) ); authenticated.delete( @@ -277,8 +276,8 @@ authenticated.delete( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), + logActionAudit(ActionsEnum.deleteLoginPage), loginPage.deleteLoginPage, - logActionAudit(ActionsEnum.deleteLoginPage) ); authenticated.get( diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 00bc167a..21c74624 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -32,14 +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), + logActionAudit(ActionsEnum.sendUsageNotification), org.sendUsageNotification, - logActionAudit(ActionsEnum.sendUsageNotification) ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp, - logActionAudit(ActionsEnum.deleteIdp) ); \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 55b98242..ecdfe352 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -76,8 +76,8 @@ authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), + logActionAudit(ActionsEnum.updateOrg), org.updateOrg, - logActionAudit(ActionsEnum.updateOrg) ); if (build !== "saas") { @@ -86,8 +86,8 @@ if (build !== "saas") { verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), + logActionAudit(ActionsEnum.deleteOrg), org.deleteOrg, - logActionAudit(ActionsEnum.deleteOrg) ); } @@ -95,8 +95,8 @@ authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), - site.createSite, - logActionAudit(ActionsEnum.createSite) + logActionAudit(ActionsEnum.createSite), + site.createSite ); authenticated.get( "/org/:orgId/sites", @@ -153,8 +153,8 @@ authenticated.put( verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), + logActionAudit(ActionsEnum.createClient), client.createClient, - logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -162,8 +162,8 @@ authenticated.delete( verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), + logActionAudit(ActionsEnum.deleteClient), client.deleteClient, - logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -171,8 +171,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 + logActionAudit(ActionsEnum.updateClient), client.updateClient, - logActionAudit(ActionsEnum.updateClient) ); // authenticated.get( @@ -185,15 +185,15 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), site.updateSite, - logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), + logActionAudit(ActionsEnum.deleteSite), site.deleteSite, - logActionAudit(ActionsEnum.deleteSite) ); // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" @@ -214,14 +214,12 @@ authenticated.post( verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.checkDockerSocket, - // logActionAudit(ActionsEnum.getSite) ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.triggerFetchContainers, - // logActionAudit(ActionsEnum.getSite) ); authenticated.get( "/site/:siteId/docker/containers", @@ -236,8 +234,8 @@ authenticated.put( verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), + logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource, - logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -270,8 +268,8 @@ authenticated.post( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), + logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource, - logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -280,16 +278,16 @@ authenticated.delete( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), + logActionAudit(ActionsEnum.deleteSiteResource), siteResource.deleteSiteResource, - logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), + logActionAudit(ActionsEnum.createResource), resource.createResource, - logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -329,16 +327,16 @@ authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), + logActionAudit(ActionsEnum.removeInvitation), user.removeInvitation, - logActionAudit(ActionsEnum.removeInvitation) ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), + logActionAudit(ActionsEnum.inviteUser), user.inviteUser, - logActionAudit(ActionsEnum.inviteUser) ); // maybe make this /invite/create instead unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated @@ -373,23 +371,23 @@ authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), + logActionAudit(ActionsEnum.updateResource), resource.updateResource, - logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), + logActionAudit(ActionsEnum.deleteResource), resource.deleteResource, - logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), target.createTarget, - logActionAudit(ActionsEnum.createTarget) ); authenticated.get( "/resource/:resourceId/targets", @@ -402,8 +400,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), + logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule, - logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( "/resource/:resourceId/rules", @@ -415,15 +413,15 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), + logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule, - logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), + logActionAudit(ActionsEnum.deleteResourceRule), resource.deleteResourceRule, - logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -436,23 +434,23 @@ authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), + logActionAudit(ActionsEnum.updateTarget), target.updateTarget, - logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), + logActionAudit(ActionsEnum.deleteTarget), target.deleteTarget, - logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), + logActionAudit(ActionsEnum.createRole), role.createRole, - logActionAudit(ActionsEnum.createRole) ); authenticated.get( "/org/:orgId/roles", @@ -477,16 +475,16 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), + logActionAudit(ActionsEnum.deleteRole), role.deleteRole, - logActionAudit(ActionsEnum.deleteRole) ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), user.addUserRole, - logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -494,8 +492,8 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles, - logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -503,40 +501,40 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers, - logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), + logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword, - logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), + logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode, - logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), + logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth, - logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), + logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist, - logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -550,16 +548,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), + logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken, - logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), + logActionAudit(ActionsEnum.deleteAcessToken), accessToken.deleteAccessToken, - logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -632,8 +630,8 @@ authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), + logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser, - logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -641,8 +639,8 @@ authenticated.post( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), + logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser, - logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -664,8 +662,8 @@ authenticated.delete( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), + logActionAudit(ActionsEnum.removeUser), user.removeUserOrg, - logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -798,8 +796,8 @@ authenticated.post( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), + logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions, - logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -814,8 +812,8 @@ authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), + logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey, - logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( @@ -823,8 +821,8 @@ authenticated.delete( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), + logActionAudit(ActionsEnum.deleteApiKey), apiKeys.deleteOrgApiKey, - logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.get( @@ -839,8 +837,8 @@ authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), + logActionAudit(ActionsEnum.createOrgDomain), domain.createOrgDomain, - logActionAudit(ActionsEnum.createOrgDomain) ); authenticated.post( @@ -848,8 +846,8 @@ authenticated.post( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), + logActionAudit(ActionsEnum.restartOrgDomain), domain.restartOrgDomain, - logActionAudit(ActionsEnum.restartOrgDomain) ); authenticated.delete( @@ -857,8 +855,8 @@ authenticated.delete( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), + logActionAudit(ActionsEnum.deleteOrgDomain), domain.deleteAccountDomain, - logActionAudit(ActionsEnum.deleteOrgDomain) ); // Auth routes diff --git a/server/routers/integration.ts b/server/routers/integration.ts index f54189e6..1359ea5e 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -51,8 +51,8 @@ authenticated.put( "/org", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createOrg), + logActionAudit(ActionsEnum.createOrg), org.createOrg, - logActionAudit(ActionsEnum.createOrg) ); authenticated.get( @@ -73,24 +73,24 @@ authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), + logActionAudit(ActionsEnum.updateOrg), org.updateOrg, - logActionAudit(ActionsEnum.updateOrg) ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), + logActionAudit(ActionsEnum.deleteOrg), org.deleteOrg, - logActionAudit(ActionsEnum.deleteOrg) ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), + logActionAudit(ActionsEnum.createSite), site.createSite, - logActionAudit(ActionsEnum.createSite) ); authenticated.get( @@ -125,16 +125,16 @@ authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), + logActionAudit(ActionsEnum.updateSite), site.updateSite, - logActionAudit(ActionsEnum.updateSite) ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), + logActionAudit(ActionsEnum.deleteSite), site.deleteSite, - logActionAudit(ActionsEnum.deleteSite) ); authenticated.get( @@ -148,8 +148,8 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), + logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource, - logActionAudit(ActionsEnum.createSiteResource) ); authenticated.get( @@ -182,8 +182,8 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), + logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource, - logActionAudit(ActionsEnum.updateSiteResource) ); authenticated.delete( @@ -192,24 +192,24 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), + logActionAudit(ActionsEnum.deleteSiteResource), siteResource.deleteSiteResource, - logActionAudit(ActionsEnum.deleteSiteResource) ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), + logActionAudit(ActionsEnum.createResource), resource.createResource, - logActionAudit(ActionsEnum.createResource) ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), + logActionAudit(ActionsEnum.createResource), resource.createResource, - logActionAudit(ActionsEnum.createResource) ); authenticated.get( @@ -244,8 +244,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), + logActionAudit(ActionsEnum.inviteUser), user.inviteUser, - logActionAudit(ActionsEnum.inviteUser) ); authenticated.get( @@ -273,24 +273,24 @@ authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), + logActionAudit(ActionsEnum.updateResource), resource.updateResource, - logActionAudit(ActionsEnum.updateResource) ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), + logActionAudit(ActionsEnum.deleteResource), resource.deleteResource, - logActionAudit(ActionsEnum.deleteResource) ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), + logActionAudit(ActionsEnum.createTarget), target.createTarget, - logActionAudit(ActionsEnum.createTarget) ); authenticated.get( @@ -304,8 +304,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), + logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule, - logActionAudit(ActionsEnum.createResourceRule) ); authenticated.get( @@ -319,16 +319,16 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), + logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule, - logActionAudit(ActionsEnum.updateResourceRule) ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), + logActionAudit(ActionsEnum.deleteResourceRule), resource.deleteResourceRule, - logActionAudit(ActionsEnum.deleteResourceRule) ); authenticated.get( @@ -342,24 +342,24 @@ authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), + logActionAudit(ActionsEnum.updateTarget), target.updateTarget, - logActionAudit(ActionsEnum.updateTarget) ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), + logActionAudit(ActionsEnum.deleteTarget), target.deleteTarget, - logActionAudit(ActionsEnum.deleteTarget) ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), + logActionAudit(ActionsEnum.createRole), role.createRole, - logActionAudit(ActionsEnum.createRole) ); authenticated.get( @@ -373,8 +373,8 @@ authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), + logActionAudit(ActionsEnum.deleteRole), role.deleteRole, - logActionAudit(ActionsEnum.deleteRole) ); authenticated.get( @@ -389,8 +389,8 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), user.addUserRole, - logActionAudit(ActionsEnum.addUserRole) ); authenticated.post( @@ -398,8 +398,8 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles, - logActionAudit(ActionsEnum.setResourceRoles) ); authenticated.post( @@ -407,40 +407,40 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers, - logActionAudit(ActionsEnum.setResourceUsers) ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), + logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword, - logActionAudit(ActionsEnum.setResourcePassword) ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), + logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode, - logActionAudit(ActionsEnum.setResourcePincode) ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), + logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth, - logActionAudit(ActionsEnum.setResourceHeaderAuth) ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), + logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist, - logActionAudit(ActionsEnum.setResourceWhitelist) ); authenticated.get( @@ -468,16 +468,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), + logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken, - logActionAudit(ActionsEnum.generateAccessToken) ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), + logActionAudit(ActionsEnum.deleteAcessToken), accessToken.deleteAccessToken, - logActionAudit(ActionsEnum.deleteAcessToken) ); authenticated.get( @@ -505,8 +505,8 @@ authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), + logActionAudit(ActionsEnum.updateUser), user.updateUser2FA, - logActionAudit(ActionsEnum.updateUser) ); authenticated.get( @@ -527,8 +527,8 @@ authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), + logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser, - logActionAudit(ActionsEnum.createOrgUser) ); authenticated.post( @@ -536,8 +536,8 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), + logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser, - logActionAudit(ActionsEnum.updateOrgUser) ); authenticated.delete( @@ -545,8 +545,8 @@ authenticated.delete( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), + logActionAudit(ActionsEnum.removeUser), user.removeUserOrg, - logActionAudit(ActionsEnum.removeUser) ); // authenticated.put( @@ -566,8 +566,8 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), + logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions, - logActionAudit(ActionsEnum.setApiKeyActions) ); authenticated.get( @@ -581,32 +581,32 @@ authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), + logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey, - logActionAudit(ActionsEnum.createApiKey) ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), + logActionAudit(ActionsEnum.deleteApiKey), apiKeys.deleteApiKey, - logActionAudit(ActionsEnum.deleteApiKey) ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), idp.createOidcIdp, - logActionAudit(ActionsEnum.createIdp) ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), + logActionAudit(ActionsEnum.updateIdp), idp.updateOidcIdp, - logActionAudit(ActionsEnum.updateIdp) ); authenticated.get( @@ -627,24 +627,24 @@ authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), + logActionAudit(ActionsEnum.createIdpOrg), idp.createIdpOrgPolicy, - logActionAudit(ActionsEnum.createIdpOrg) ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), + logActionAudit(ActionsEnum.updateIdpOrg), idp.updateIdpOrgPolicy, - logActionAudit(ActionsEnum.updateIdpOrg) ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), + logActionAudit(ActionsEnum.deleteIdpOrg), idp.deleteIdpOrgPolicy, - logActionAudit(ActionsEnum.deleteIdpOrg) ); authenticated.get( @@ -683,8 +683,8 @@ authenticated.put( verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), + logActionAudit(ActionsEnum.createClient), client.createClient, - logActionAudit(ActionsEnum.createClient) ); authenticated.delete( @@ -692,8 +692,8 @@ authenticated.delete( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), + logActionAudit(ActionsEnum.deleteClient), client.deleteClient, - logActionAudit(ActionsEnum.deleteClient) ); authenticated.post( @@ -701,14 +701,14 @@ authenticated.post( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), + logActionAudit(ActionsEnum.updateClient), client.updateClient, - logActionAudit(ActionsEnum.updateClient) ); authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), + logActionAudit(ActionsEnum.applyBlueprint), org.applyBlueprint, - logActionAudit(ActionsEnum.applyBlueprint) ); From f3149e46cd9d63cb8e3f8b4b3384a6751402d3f9 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Oct 2025 20:40:04 -0700 Subject: [PATCH 06/69] Starting to create frontend --- messages/en-US.json | 4 +- src/app/[orgId]/settings/logs/access/page.tsx | 54 ++++++++++++++++++ src/app/[orgId]/settings/logs/layout.tsx | 56 +++++++++++++++++++ src/app/[orgId]/settings/logs/page.tsx | 54 ++++++++++++++++++ src/app/navigation.tsx | 8 ++- 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/app/[orgId]/settings/logs/access/page.tsx create mode 100644 src/app/[orgId]/settings/logs/layout.tsx create mode 100644 src/app/[orgId]/settings/logs/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4cffaf98..79042b7e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1891,5 +1891,7 @@ "cannotbeUndone": "This can not be undone.", "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." + "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", + "sidebarLogs": "Logs", + "request": "Request" } diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx new file mode 100644 index 00000000..80e4e4a6 --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -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

access

; +} diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx new file mode 100644 index 00000000..01e0d248 --- /dev/null +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -0,0 +1,56 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { verifySession } from "@app/lib/auth/verifySession"; +import OrgProvider from "@app/providers/OrgProvider"; +import OrgUserProvider from "@app/providers/OrgUserProvider"; +import { GetOrgResponse } from "@server/routers/org"; +import { GetOrgUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; +import { getTranslations } from "next-intl/server"; + +type GeneralSettingsProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function GeneralSettingsPage({ + children, + params +}: GeneralSettingsProps) { + const { orgId } = await params; + + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/`); + } + + const t = await getTranslations(); + + const navItems = [ + { + title: t("access"), + href: `/{orgId}/settings/logs/access` + }, + { + title: t("request"), + href: `/{orgId}/settings/logs/request` + } + ]; + + return ( + <> + + + {children} + + ); +} diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx new file mode 100644 index 00000000..45b5a7de --- /dev/null +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -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

dfas

; +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 2d6aaec8..6b453811 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -16,7 +16,8 @@ import { MonitorUp, // Added from 'dev' branch Server, Zap, - CreditCard + CreditCard, + Logs } from "lucide-react"; export type SidebarNavSection = { @@ -138,6 +139,11 @@ export const orgNavSections = ( } ] : []), + { + title: "sidebarLogs", + href: "/{orgId}/settings/logs", + icon: + }, { title: "sidebarSettings", href: "/{orgId}/settings/general", From 3f3e9cf1bb8fd280482db6e68661f516e6650858 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 6 Oct 2025 21:00:07 +0530 Subject: [PATCH 07/69] add cert resolver --- messages/en-US.json | 6 +- server/db/sqlite/schema/schema.ts | 4 +- server/routers/domain/createOrgDomain.ts | 17 +++-- server/routers/domain/listDomains.ts | 4 +- src/components/CreateDomainForm.tsx | 83 +++++++++++++++++++----- 5 files changed, 92 insertions(+), 22 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4cffaf98..2f7fd3e0 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1891,5 +1891,9 @@ "cannotbeUndone": "This can not be undone.", "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." + "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", + "certResolver": "Certificate Resolver", + "certResolverDescription": "Select the certificate resolver to use for this resource.", + "selectCertResolver": "Select Certificate Resolver", + "enterCustomResolver": "Enter Custom Resolver" } diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3d6c6b0d..d0d972ff 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -11,7 +11,9 @@ export const domains = sqliteTable("domains", { type: text("type"), // "ns", "cname", "wildcard" verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: text("certResolver").default("letsencrypt"), + customCertResolver: text("customCertResolver") }); export const orgs = sqliteTable("orgs", { diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index d0e8a72b..54cad172 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -24,16 +24,21 @@ const paramsSchema = z const bodySchema = z .object({ type: z.enum(["ns", "cname", "wildcard"]), - baseDomain: subdomainSchema + baseDomain: subdomainSchema, + certResolver: z.enum(["letsencrypt", "custom"]).optional(), // optional, only for wildcard + customCertResolver: z.string().optional() // required if certResolver === "custom" }) .strict(); + export type CreateDomainResponse = { domainId: string; nsRecords?: string[]; cnameRecords?: { baseDomain: string; value: string }[]; aRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[]; + certResolver?: string | null; + customCertResolver?: string | null; }; // Helper to check if a domain is a subdomain or equal to another domain @@ -71,7 +76,7 @@ export async function createOrgDomain( } const { orgId } = parsedParams.data; - const { type, baseDomain } = parsedBody.data; + const { type, baseDomain, certResolver, customCertResolver } = parsedBody.data; if (build == "oss") { if (type !== "wildcard") { @@ -254,7 +259,9 @@ export async function createOrgDomain( domainId, baseDomain, type, - verified: type === "wildcard" ? true : false + verified: type === "wildcard" ? true : false, + certResolver: certResolver || null, + customCertResolver: customCertResolver || null }) .returning(); @@ -325,7 +332,9 @@ export async function createOrgDomain( cnameRecords, txtRecords, nsRecords, - aRecords + aRecords, + certResolver: returned.certResolver, + customCertResolver: returned.customCertResolver }, success: true, error: false, diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index fe51cde6..85bc3caa 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -42,7 +42,9 @@ async function queryDomains(orgId: string, limit: number, offset: number) { type: domains.type, failed: domains.failed, tries: domains.tries, - configManaged: domains.configManaged + configManaged: domains.configManaged, + certResolver: domains.certResolver, + customCertResolver: domains.customCertResolver, }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx index 258aee49..24b1d466 100644 --- a/src/components/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -45,6 +45,7 @@ import { import { useOrgContext } from "@app/hooks/useOrgContext"; import { build } from "@server/build"; import { toASCII, toUnicode } from 'punycode'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; // Helper functions for Unicode domain handling @@ -96,7 +97,9 @@ const formSchema = z.object({ .min(1, "Domain is required") .refine((val) => isValidDomainFormat(val), "Invalid domain format") .transform((val) => toPunycode(val)), - type: z.enum(["ns", "cname", "wildcard"]) + type: z.enum(["ns", "cname", "wildcard"]), + certResolver: z.string().optional(), + customCertResolver: z.string().optional() }); type FormValues = z.infer; @@ -107,6 +110,12 @@ type CreateDomainFormProps = { onCreated?: (domain: CreateDomainResponse) => void; }; +// Example cert resolver options (replace with real API/fetch if needed) +const certResolverOptions = [ + { id: "letsencrypt", title: "Let's Encrypt" }, + { id: "custom", title: "Custom Resolver" } +]; + export default function CreateDomainForm({ open, setOpen, @@ -125,15 +134,26 @@ export default function CreateDomainForm({ resolver: zodResolver(formSchema), defaultValues: { baseDomain: "", - type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns" + type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", + certResolver: "", + customCertResolver: "" } }); - function reset() { + const baseDomain = form.watch("baseDomain"); + const domainType = form.watch("type"); + + const punycodePreview = useMemo(() => { + if (!baseDomain) return ""; + const punycode = toPunycode(baseDomain); + return punycode !== baseDomain.toLowerCase() ? punycode : ""; + }, [baseDomain]); + + const reset = () => { form.reset(); setLoading(false); setCreatedDomain(null); - } + }; async function onSubmit(values: FormValues) { setLoading(true); @@ -158,17 +178,9 @@ export default function CreateDomainForm({ } finally { setLoading(false); } - } - - const baseDomain = form.watch("baseDomain"); - const domainInputValue = form.watch("baseDomain") || ""; - - const punycodePreview = useMemo(() => { - if (!domainInputValue) return ""; - const punycode = toPunycode(domainInputValue); - return punycode !== domainInputValue.toLowerCase() ? punycode : ""; - }, [domainInputValue]); + }; + // Domain type options let domainOptions: any = []; if (build != "oss" && env.flags.usePangolinDns) { domainOptions = [ @@ -250,7 +262,7 @@ export default function CreateDomainForm({ {t("internationaldomaindetected")}
-

{t("willbestoredas")} {punycodePreview}

+

{t("willbestoredas")} {punycodePreview}

@@ -260,6 +272,47 @@ export default function CreateDomainForm({ )} /> + {domainType === "wildcard" && ( + ( + + {t("certResolver")} + + + + + {field.value === "custom" && ( + + + form.setValue("customCertResolver", e.target.value) + } + /> + + )} + + )} + /> + + )} ) : ( From d30e0a3c514434d537cf3caa1271826a7439ba44 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 6 Oct 2025 21:39:00 +0530 Subject: [PATCH 08/69] schema add --- server/db/pg/schema/schema.ts | 4 +++- src/components/DomainsTable.tsx | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 4bed23f8..01fdd337 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -18,7 +18,9 @@ export const domains = pgTable("domains", { type: varchar("type"), // "ns", "cname", "wildcard" verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: varchar("certResolver").default("letsencrypt"), + customCertResolver: varchar("customCertResolver") }); export const orgs = pgTable("orgs", { diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index ca8d2a7c..87d869df 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -24,6 +24,8 @@ export type DomainRow = { failed: boolean; tries: number; configManaged: boolean; + certResolver: string; + customCertResolver: string; }; type Props = { From 2f1aec02f0f7678a59f7179bd49830dadd3672f5 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Tue, 7 Oct 2025 00:37:54 +0530 Subject: [PATCH 09/69] traefik config update for custom Cert Resolver --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/readConfigFile.ts | 2 + server/lib/traefik/getTraefikConfig.ts | 84 ++++++++++++++++++++++---- 4 files changed, 77 insertions(+), 13 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 01fdd337..33b2ae28 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -19,7 +19,7 @@ export const domains = pgTable("domains", { verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), tries: integer("tries").notNull().default(0), - certResolver: varchar("certResolver").default("letsencrypt"), + certResolver: varchar("certResolver"), customCertResolver: varchar("customCertResolver") }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index d0d972ff..ebfe7bcf 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -12,7 +12,7 @@ export const domains = sqliteTable("domains", { verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), - certResolver: text("certResolver").default("letsencrypt"), + certResolver: text("certResolver"), customCertResolver: text("customCertResolver") }); diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 9aee8531..a1899f4e 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -51,6 +51,7 @@ export const configSchema = z .nonempty("base_domain must not be empty") .transform((url) => url.toLowerCase()), cert_resolver: z.string().optional().default("letsencrypt"), + custom_cert_resolver: z.string().optional(), prefer_wildcard_cert: z.boolean().optional().default(false) }) ) @@ -187,6 +188,7 @@ export const configSchema = z https_entrypoint: z.string().optional().default("websecure"), additional_middlewares: z.array(z.string()).optional(), cert_resolver: z.string().optional().default("letsencrypt"), + custom_cert_resolver: z.string().optional(), prefer_wildcard_cert: z.boolean().optional().default(false), certificates_path: z.string().default("/var/certificates"), monitor_interval: z.number().default(5000), diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 75ea907f..734327e2 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,4 +1,4 @@ -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, domains } from "@server/db"; import { and, eq, @@ -75,11 +75,15 @@ export async function getTraefikConfig( siteType: sites.type, siteOnline: sites.online, subnet: sites.subnet, - exitNodeId: sites.exitNodeId + exitNodeId: sites.exitNodeId, + // Domain cert resolver fields + domainCertResolver: domains.certResolver, + domainCustomCertResolver: domains.customCertResolver }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -161,7 +165,10 @@ export async function getTraefikConfig( pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, + // Store domain cert resolver fields + domainCertResolver: row.domainCertResolver, + domainCustomCertResolver: row.domainCustomCertResolver }); } @@ -242,18 +249,73 @@ export async function getTraefikConfig( const configDomain = config.getDomain(resource.domainId); - let certResolver: string, preferWildcardCert: boolean; - if (!configDomain) { - certResolver = config.getRawConfig().traefik.cert_resolver; - preferWildcardCert = - config.getRawConfig().traefik.prefer_wildcard_cert; + let certResolverFromConfig: string | undefined; + let preferWildcardCert = false; + + const rawTraefikCfg = config.getRawConfig().traefik || {}; + const globalDefaultResolver: string | undefined = rawTraefikCfg.cert_resolver; + const availableResolvers = rawTraefikCfg.custom_cert_resolver + ? Object.keys(rawTraefikCfg.custom_cert_resolver) + : []; + + // Priority 1: Read from YAML config (if exists) + if (configDomain) { + certResolverFromConfig = + configDomain.cert_resolver ?? + configDomain.custom_cert_resolver; + preferWildcardCert = !!(configDomain.prefer_wildcard_cert); + } + + // Priority 2: Override with database domain settings (if exists) + let finalCertResolver: string | undefined; + let finalCustomCertResolver: string | undefined; + + if (resource.domainCertResolver) { + finalCertResolver = resource.domainCertResolver; + if (resource.domainCertResolver === "custom" && resource.domainCustomCertResolver) { + finalCustomCertResolver = resource.domainCustomCertResolver; + } } else { - certResolver = configDomain.cert_resolver; - preferWildcardCert = configDomain.prefer_wildcard_cert; + // Fall back to config + finalCertResolver = certResolverFromConfig; + } + + // Resolve the final resolver name + let resolverName: string | undefined; + + if (finalCertResolver) { + if (finalCertResolver === "custom") { + // Check database custom resolver first, then config + const customResolver = finalCustomCertResolver || configDomain?.custom_cert_resolver; + + if (customResolver && typeof customResolver === "string" && customResolver.trim()) { + resolverName = customResolver.trim(); + } else { + resolverName = globalDefaultResolver; + logger.warn( + `Domain ${resource.domainId} requested custom cert resolver but none set; falling back to global resolver ${resolverName}` + ); + } + } else { + // Validate against available resolvers + if ( + availableResolvers.length === 0 || + availableResolvers.includes(finalCertResolver) + ) { + resolverName = finalCertResolver; + } else { + logger.warn( + `Unknown cert resolver "${finalCertResolver}" for domain ${resource.domainId}; falling back to global resolver.` + ); + resolverName = globalDefaultResolver; + } + } + } else { + resolverName = globalDefaultResolver; } const tls = { - certResolver: certResolver, + certResolver: resolverName, ...(preferWildcardCert ? { domains: [ From d6681733ddd1515a9bc70099daacd2d3960a33a4 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Tue, 7 Oct 2025 12:28:29 +0530 Subject: [PATCH 10/69] remove custom cery type form config file --- server/lib/readConfigFile.ts | 2 - server/lib/traefik/getTraefikConfig.ts | 69 +++---------- .../private/lib/traefik/getTraefikConfig.ts | 99 +++++++++++++------ 3 files changed, 84 insertions(+), 86 deletions(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index a1899f4e..9aee8531 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -51,7 +51,6 @@ export const configSchema = z .nonempty("base_domain must not be empty") .transform((url) => url.toLowerCase()), cert_resolver: z.string().optional().default("letsencrypt"), - custom_cert_resolver: z.string().optional(), prefer_wildcard_cert: z.boolean().optional().default(false) }) ) @@ -188,7 +187,6 @@ export const configSchema = z https_entrypoint: z.string().optional().default("websecure"), additional_middlewares: z.array(z.string()).optional(), cert_resolver: z.string().optional().default("letsencrypt"), - custom_cert_resolver: z.string().optional(), prefer_wildcard_cert: z.boolean().optional().default(false), certificates_path: z.string().default("/var/certificates"), monitor_interval: z.number().default(5000), diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 734327e2..436c76a6 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -248,68 +248,24 @@ export async function getTraefikConfig( } const configDomain = config.getDomain(resource.domainId); - - let certResolverFromConfig: string | undefined; - let preferWildcardCert = false; - const rawTraefikCfg = config.getRawConfig().traefik || {}; - const globalDefaultResolver: string | undefined = rawTraefikCfg.cert_resolver; - const availableResolvers = rawTraefikCfg.custom_cert_resolver - ? Object.keys(rawTraefikCfg.custom_cert_resolver) - : []; + const globalDefaultResolver = rawTraefikCfg.cert_resolver; - // Priority 1: Read from YAML config (if exists) - if (configDomain) { - certResolverFromConfig = - configDomain.cert_resolver ?? - configDomain.custom_cert_resolver; - preferWildcardCert = !!(configDomain.prefer_wildcard_cert); - } - // Priority 2: Override with database domain settings (if exists) - let finalCertResolver: string | undefined; - let finalCustomCertResolver: string | undefined; + const domainCertResolver = + resource.domainCertResolver ?? configDomain?.cert_resolver; + const domainCustomResolver = + resource.domainCustomCertResolver; + const preferWildcardCert = + resource.preferWildcardCert ?? configDomain?.prefer_wildcard_cert ?? false; - if (resource.domainCertResolver) { - finalCertResolver = resource.domainCertResolver; - if (resource.domainCertResolver === "custom" && resource.domainCustomCertResolver) { - finalCustomCertResolver = resource.domainCustomCertResolver; - } - } else { - // Fall back to config - finalCertResolver = certResolverFromConfig; - } - - // Resolve the final resolver name let resolverName: string | undefined; - if (finalCertResolver) { - if (finalCertResolver === "custom") { - // Check database custom resolver first, then config - const customResolver = finalCustomCertResolver || configDomain?.custom_cert_resolver; - - if (customResolver && typeof customResolver === "string" && customResolver.trim()) { - resolverName = customResolver.trim(); - } else { - resolverName = globalDefaultResolver; - logger.warn( - `Domain ${resource.domainId} requested custom cert resolver but none set; falling back to global resolver ${resolverName}` - ); - } - } else { - // Validate against available resolvers - if ( - availableResolvers.length === 0 || - availableResolvers.includes(finalCertResolver) - ) { - resolverName = finalCertResolver; - } else { - logger.warn( - `Unknown cert resolver "${finalCertResolver}" for domain ${resource.domainId}; falling back to global resolver.` - ); - resolverName = globalDefaultResolver; - } - } + // Handle both letsencrypt & custom cases + if (domainCertResolver === "custom") { + resolverName = domainCustomResolver?.trim(); + } else if (domainCertResolver) { + resolverName = domainCertResolver; } else { resolverName = globalDefaultResolver; } @@ -327,6 +283,7 @@ export async function getTraefikConfig( : {}) }; + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 5e919fda..634bc818 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -15,6 +15,7 @@ import { certificates, db, domainNamespaces, + domains, exitNodes, loginPage, targetHealthCheck @@ -103,11 +104,17 @@ export async function getTraefikConfig( subnet: sites.subnet, exitNodeId: sites.exitNodeId, // Namespace - domainNamespaceId: domainNamespaces.domainNamespaceId + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Certificate + certificateStatus: certificates.status, + domainCertResolver: domains.certResolver, + domainCustomCertResolver: domains.customCertResolver }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(certificates, eq(certificates.domainId, resources.domainId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -197,7 +204,9 @@ export async function getTraefikConfig( pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, // may be null, we fallback later + domainCertResolver: row.domainCertResolver, + domainCustomCertResolver: row.domainCustomCertResolver }); } @@ -285,6 +294,41 @@ export async function getTraefikConfig( config_output.http.services = {}; } + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + + if (!resource.subdomain) { + wildCard = resource.fullDomain; + } + + const configDomain = config.getDomain(resource.domainId); + const rawTraefikCfg = config.getRawConfig().traefik || {}; + const globalDefaultResolver = rawTraefikCfg.cert_resolver; + + + const domainCertResolver = + resource.domainCertResolver ?? configDomain?.cert_resolver; + const domainCustomResolver = + resource.domainCustomCertResolver; + const preferWildcardCert = + resource.preferWildcardCert ?? configDomain?.prefer_wildcard_cert ?? false; + + let resolverName: string | undefined; + + // Handle both letsencrypt & custom cases + if (domainCertResolver === "custom") { + resolverName = domainCustomResolver?.trim(); + } else if (domainCertResolver) { + resolverName = domainCertResolver; + } else { + resolverName = globalDefaultResolver; + } + let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); @@ -312,16 +356,16 @@ export async function getTraefikConfig( } tls = { - certResolver: certResolver, + certResolver: resolverName, ...(preferWildcardCert ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) + domains: [ + { + main: wildCard, + }, + ], + } + : {}), }; } else { // find a cert that matches the full domain, if not continue @@ -573,14 +617,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -681,13 +725,13 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; @@ -735,10 +779,9 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: `http://${ - config.getRawConfig().server + url: `http://${config.getRawConfig().server .internal_hostname - }:${config.getRawConfig().server.next_port}` + }:${config.getRawConfig().server.next_port}` } ] } From d938345debe8a515a8d251cdf4bd448e75ef811e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 8 Oct 2025 14:43:24 -0700 Subject: [PATCH 11/69] Copy in config to db, remove 2nd column, + prefer --- messages/en-US.json | 3 +- server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 2 +- server/lib/traefik/getTraefikConfig.ts | 65 +++++++++--------- .../private/lib/traefik/getTraefikConfig.ts | 25 ------- server/routers/domain/createOrgDomain.ts | 12 ++-- server/routers/domain/listDomains.ts | 2 +- server/setup/copyInConfig.ts | 12 ++-- src/components/CreateDomainForm.tsx | 66 ++++++++++++++----- 9 files changed, 103 insertions(+), 87 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2f7fd3e0..c3e93ba8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1895,5 +1895,6 @@ "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", - "enterCustomResolver": "Enter Custom Resolver" + "enterCustomResolver": "Enter Custom Resolver", + "preferWildcardCert": "Prefer Wildcard Certificate" } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 33b2ae28..ae5205bb 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -20,7 +20,8 @@ export const domains = pgTable("domains", { failed: boolean("failed").notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: varchar("certResolver"), - customCertResolver: varchar("customCertResolver") + customCertResolver: varchar("customCertResolver"), + preferWildcardCert: boolean("preferWildcardCert") }); export const orgs = pgTable("orgs", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index ebfe7bcf..30841a4b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -13,7 +13,7 @@ export const domains = sqliteTable("domains", { failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: text("certResolver"), - customCertResolver: text("customCertResolver") + preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }) }); export const orgs = sqliteTable("orgs", { diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 436c76a6..45729a30 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -77,8 +77,7 @@ export async function getTraefikConfig( subnet: sites.subnet, exitNodeId: sites.exitNodeId, // Domain cert resolver fields - domainCertResolver: domains.certResolver, - domainCustomCertResolver: domains.customCertResolver + domainCertResolver: domains.certResolver }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) @@ -167,8 +166,7 @@ export async function getTraefikConfig( rewritePathType: row.rewritePathType, priority: priority, // Store domain cert resolver fields - domainCertResolver: row.domainCertResolver, - domainCustomCertResolver: row.domainCustomCertResolver + domainCertResolver: row.domainCertResolver }); } @@ -247,42 +245,47 @@ export async function getTraefikConfig( wildCard = resource.fullDomain; } - const configDomain = config.getDomain(resource.domainId); - const rawTraefikCfg = config.getRawConfig().traefik || {}; - const globalDefaultResolver = rawTraefikCfg.cert_resolver; + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; - - const domainCertResolver = - resource.domainCertResolver ?? configDomain?.cert_resolver; - const domainCustomResolver = - resource.domainCustomCertResolver; - const preferWildcardCert = - resource.preferWildcardCert ?? configDomain?.prefer_wildcard_cert ?? false; + const domainCertResolver = resource.domainCertResolver; + const preferWildcardCert = resource.preferWildcardCert; let resolverName: string | undefined; - + let preferWildcard: boolean | undefined; // Handle both letsencrypt & custom cases - if (domainCertResolver === "custom") { - resolverName = domainCustomResolver?.trim(); - } else if (domainCertResolver) { - resolverName = domainCertResolver; + if (domainCertResolver) { + resolverName = domainCertResolver.trim(); } else { resolverName = globalDefaultResolver; } - const tls = { - certResolver: resolverName, - ...(preferWildcardCert - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; + if ( + preferWildcardCert !== undefined && + preferWildcardCert !== null + ) { + preferWildcard = preferWildcardCert; + } else { + preferWildcard = globalDefaultPreferWildcard; + } + let tls = {}; + if (build == "oss") { + tls = { + certResolver: resolverName, + ...(preferWildcard + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + } const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 634bc818..c0f934cd 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -108,7 +108,6 @@ export async function getTraefikConfig( // Certificate certificateStatus: certificates.status, domainCertResolver: domains.certResolver, - domainCustomCertResolver: domains.customCertResolver }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) @@ -206,7 +205,6 @@ export async function getTraefikConfig( rewritePathType: row.rewritePathType, priority: priority, // may be null, we fallback later domainCertResolver: row.domainCertResolver, - domainCustomCertResolver: row.domainCustomCertResolver }); } @@ -306,29 +304,6 @@ export async function getTraefikConfig( wildCard = resource.fullDomain; } - const configDomain = config.getDomain(resource.domainId); - const rawTraefikCfg = config.getRawConfig().traefik || {}; - const globalDefaultResolver = rawTraefikCfg.cert_resolver; - - - const domainCertResolver = - resource.domainCertResolver ?? configDomain?.cert_resolver; - const domainCustomResolver = - resource.domainCustomCertResolver; - const preferWildcardCert = - resource.preferWildcardCert ?? configDomain?.prefer_wildcard_cert ?? false; - - let resolverName: string | undefined; - - // Handle both letsencrypt & custom cases - if (domainCertResolver === "custom") { - resolverName = domainCustomResolver?.trim(); - } else if (domainCertResolver) { - resolverName = domainCertResolver; - } else { - resolverName = globalDefaultResolver; - } - let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 54cad172..f9a9dcbd 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -25,8 +25,8 @@ const bodySchema = z .object({ type: z.enum(["ns", "cname", "wildcard"]), baseDomain: subdomainSchema, - certResolver: z.enum(["letsencrypt", "custom"]).optional(), // optional, only for wildcard - customCertResolver: z.string().optional() // required if certResolver === "custom" + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() // optional, only for wildcard }) .strict(); @@ -38,7 +38,7 @@ export type CreateDomainResponse = { aRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[]; certResolver?: string | null; - customCertResolver?: string | null; + preferWildcardCert?: boolean; }; // Helper to check if a domain is a subdomain or equal to another domain @@ -76,7 +76,7 @@ export async function createOrgDomain( } const { orgId } = parsedParams.data; - const { type, baseDomain, certResolver, customCertResolver } = parsedBody.data; + const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data; if (build == "oss") { if (type !== "wildcard") { @@ -261,7 +261,7 @@ export async function createOrgDomain( type, verified: type === "wildcard" ? true : false, certResolver: certResolver || null, - customCertResolver: customCertResolver || null + preferWildcardCert: preferWildcardCert || false }) .returning(); @@ -334,7 +334,7 @@ export async function createOrgDomain( nsRecords, aRecords, certResolver: returned.certResolver, - customCertResolver: returned.customCertResolver + preferWildcardCert: returned.preferWildcardCert }, success: true, error: false, diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index 85bc3caa..55ea99cb 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -44,7 +44,7 @@ async function queryDomains(orgId: string, limit: number, offset: number) { tries: domains.tries, configManaged: domains.configManaged, certResolver: domains.certResolver, - customCertResolver: domains.customCertResolver, + preferWildcardCert: domains.preferWildcardCert }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index b8c00192..a8627d5e 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -37,7 +37,9 @@ async function copyInDomains() { const configDomains = Object.entries(rawDomains).map( ([key, value]) => ({ domainId: key, - baseDomain: value.base_domain.toLowerCase() + baseDomain: value.base_domain.toLowerCase(), + certResolver: value.cert_resolver || null, + preferWildcardCert: value.prefer_wildcard_cert || null }) ); @@ -59,11 +61,11 @@ async function copyInDomains() { } } - for (const { domainId, baseDomain } of configDomains) { + for (const { domainId, baseDomain, certResolver, preferWildcardCert } of configDomains) { if (existingDomainKeys.has(domainId)) { await trx .update(domains) - .set({ baseDomain, verified: true, type: "wildcard" }) + .set({ baseDomain, verified: true, type: "wildcard", certResolver, preferWildcardCert }) .where(eq(domains.domainId, domainId)) .execute(); } else { @@ -74,7 +76,9 @@ async function copyInDomains() { baseDomain, configManaged: true, type: "wildcard", - verified: true + verified: true, + certResolver, + preferWildcardCert }) .execute(); } diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx index 24b1d466..7e7fcc66 100644 --- a/src/components/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -11,6 +11,7 @@ import { FormDescription } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useMemo } from "react"; @@ -98,8 +99,8 @@ const formSchema = z.object({ .refine((val) => isValidDomainFormat(val), "Invalid domain format") .transform((val) => toPunycode(val)), type: z.enum(["ns", "cname", "wildcard"]), - certResolver: z.string().optional(), - customCertResolver: z.string().optional() + certResolver: z.string().nullable().optional(), + preferWildcardCert: z.boolean().optional() }); type FormValues = z.infer; @@ -112,7 +113,7 @@ type CreateDomainFormProps = { // Example cert resolver options (replace with real API/fetch if needed) const certResolverOptions = [ - { id: "letsencrypt", title: "Let's Encrypt" }, + { id: "default", title: "Default" }, { id: "custom", title: "Custom Resolver" } ]; @@ -135,8 +136,8 @@ export default function CreateDomainForm({ defaultValues: { baseDomain: "", type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", - certResolver: "", - customCertResolver: "" + certResolver: null, + preferWildcardCert: false } }); @@ -281,8 +282,20 @@ export default function CreateDomainForm({ {t("certResolver")} - {field.value === "custom" && ( - - - form.setValue("customCertResolver", e.target.value) - } + {field.value !== null && field.value !== "default" && ( +
+ + field.onChange(e.target.value)} + /> + + ( + + + + + {/*
+ + {t("preferWildcardCert")} + +
*/} +
+ )} /> - +
)} )} /> - )} From df24525105cb25b479760d2f99ca3ee6b7a9a07b Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 8 Oct 2025 14:48:21 -0700 Subject: [PATCH 12/69] Fix type issues --- server/routers/domain/createOrgDomain.ts | 2 +- server/setup/copyInConfig.ts | 2 +- src/components/DomainsTable.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index f9a9dcbd..b35a3de8 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -38,7 +38,7 @@ export type CreateDomainResponse = { aRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[]; certResolver?: string | null; - preferWildcardCert?: boolean; + preferWildcardCert?: boolean | null; }; // Helper to check if a domain is a subdomain or equal to another domain diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index a8627d5e..e003d089 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -39,7 +39,7 @@ async function copyInDomains() { domainId: key, baseDomain: value.base_domain.toLowerCase(), certResolver: value.cert_resolver || null, - preferWildcardCert: value.prefer_wildcard_cert || null + preferWildcardCert: value.prefer_wildcard_cert || null, }) ); diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index 87d869df..02f1854d 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -25,7 +25,7 @@ export type DomainRow = { tries: number; configManaged: boolean; certResolver: string; - customCertResolver: string; + preferWildcardCert: boolean; }; type Props = { From 156fe529b5dd19946afd97c08d3c7a66db5901c6 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Wed, 15 Oct 2025 19:38:09 +0530 Subject: [PATCH 13/69] fix code conflicts and match dev change --- server/lib/traefik/getTraefikConfig.ts | 82 ++++++++++--------- .../private/lib/traefik/getTraefikConfig.ts | 4 +- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 45729a30..74080f6f 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -15,6 +15,7 @@ import config from "@server/lib/config"; import { resources, sites, Target, targets } from "@server/db"; import createPathRewriteMiddleware from "./middleware"; import { sanitize, validatePathRewriteConfig } from "./utils"; +import { privateConfig } from "../../private/lib/config"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; @@ -253,36 +254,39 @@ export async function getTraefikConfig( const domainCertResolver = resource.domainCertResolver; const preferWildcardCert = resource.preferWildcardCert; - let resolverName: string | undefined; - let preferWildcard: boolean | undefined; - // Handle both letsencrypt & custom cases - if (domainCertResolver) { - resolverName = domainCertResolver.trim(); - } else { - resolverName = globalDefaultResolver; - } - - if ( - preferWildcardCert !== undefined && - preferWildcardCert !== null - ) { - preferWildcard = preferWildcardCert; - } else { - preferWildcard = globalDefaultPreferWildcard; - } let tls = {}; - if (build == "oss") { + if (!privateConfig.getRawPrivateConfig().flags.generate_own_certificates) { + + let resolverName: string | undefined; + let preferWildcard: boolean | undefined; + // Handle both letsencrypt & custom cases + if (domainCertResolver) { + resolverName = domainCertResolver.trim(); + } else { + resolverName = globalDefaultResolver; + } + + if ( + preferWildcardCert !== undefined && + preferWildcardCert !== null + ) { + preferWildcard = preferWildcardCert; + } else { + preferWildcard = globalDefaultPreferWildcard; + } + + tls = { certResolver: resolverName, ...(preferWildcard ? { - domains: [ - { - main: wildCard - } - ] - } + domains: [ + { + main: wildCard + } + ] + } : {}) }; } @@ -524,14 +528,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -632,13 +636,13 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index c0f934cd..a74c2ec0 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -304,6 +304,8 @@ export async function getTraefikConfig( wildCard = resource.fullDomain; } + const configDomain = config.getDomain(resource.domainId); + let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); @@ -331,7 +333,7 @@ export async function getTraefikConfig( } tls = { - certResolver: resolverName, + certResolver: certResolver, ...(preferWildcardCert ? { domains: [ From 9d452efc7d3f996862995814efc719ab354cd370 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Wed, 15 Oct 2025 19:52:31 +0530 Subject: [PATCH 14/69] fix treafik config mismatch --- server/lib/traefik/getTraefikConfig.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 74080f6f..a5552c8c 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -254,11 +254,7 @@ export async function getTraefikConfig( const domainCertResolver = resource.domainCertResolver; const preferWildcardCert = resource.preferWildcardCert; - - let tls = {}; - if (!privateConfig.getRawPrivateConfig().flags.generate_own_certificates) { - - let resolverName: string | undefined; + let resolverName: string | undefined; let preferWildcard: boolean | undefined; // Handle both letsencrypt & custom cases if (domainCertResolver) { @@ -276,8 +272,7 @@ export async function getTraefikConfig( preferWildcard = globalDefaultPreferWildcard; } - - tls = { + const tls = { certResolver: resolverName, ...(preferWildcard ? { @@ -289,7 +284,7 @@ export async function getTraefikConfig( } : {}) }; - } + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; From f10271890144224cba8f9e2e4025368ecd5b2b47 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Thu, 16 Oct 2025 16:27:31 +0530 Subject: [PATCH 15/69] add edit button to domain table --- src/app/[orgId]/settings/domains/page.tsx | 2 +- src/components/DomainsTable.tsx | 54 ++++++++++++++++++++--- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index cb587d92..2c667b3a 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -60,7 +60,7 @@ export default async function DomainsPage(props: Props) { title={t("domains")} description={t("domainsDescription")} /> - + ); diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index 02f1854d..51aa951a 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; @@ -15,6 +15,8 @@ import { useTranslations } from "next-intl"; import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; import { useOrgContext } from "@app/hooks/useOrgContext"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import Link from "next/link"; export type DomainRow = { domainId: string; @@ -30,9 +32,10 @@ export type DomainRow = { type Props = { domains: DomainRow[]; + orgId: string; }; -export default function DomainsTable({ domains }: Props) { +export default function DomainsTable({ domains, orgId }: Props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [selectedDomain, setSelectedDomain] = useState( @@ -207,12 +210,51 @@ export default function DomainsTable({ domains }: Props) { > {isRestarting ? t("restarting", { - fallback: "Restarting..." - }) + fallback: "Restarting..." + }) : t("restart", { fallback: "Restart" })} )} - + + + + + {t("viewSettings")} + + + { + setSelectedDomain(domain); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + + + + {/* + */} ); } From ae670e1eb54b3ad5fa94e43f77a89753177b4e4d Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Thu, 16 Oct 2025 19:11:29 +0530 Subject: [PATCH 16/69] initial setup for viewing domain details --- messages/en-US.json | 5 +- server/auth/actions.ts | 1 + server/routers/domain/getDomain.ts | 86 +++++++++++++++++++ server/routers/domain/index.ts | 3 +- server/routers/external.ts | 7 ++ .../settings/domains/[domainId]/layout.tsx | 50 +++++++++++ .../settings/domains/[domainId]/page.tsx | 8 ++ src/components/DomainInfoCard.tsx | 59 +++++++++++++ src/contexts/domainContext.ts | 11 +++ src/hooks/useDomainContext.ts | 10 +++ src/providers/DomainProvider.tsx | 43 ++++++++++ 11 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 server/routers/domain/getDomain.ts create mode 100644 src/app/[orgId]/settings/domains/[domainId]/layout.tsx create mode 100644 src/app/[orgId]/settings/domains/[domainId]/page.tsx create mode 100644 src/components/DomainInfoCard.tsx create mode 100644 src/contexts/domainContext.ts create mode 100644 src/hooks/useDomainContext.ts create mode 100644 src/providers/DomainProvider.tsx diff --git a/messages/en-US.json b/messages/en-US.json index c3e93ba8..a60e432c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1896,5 +1896,8 @@ "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", "enterCustomResolver": "Enter Custom Resolver", - "preferWildcardCert": "Prefer Wildcard Certificate" + "preferWildcardCert": "Prefer Wildcard Certificate", + "unverified": "Unverified", + "domainSetting": "DomainSetting", + "domainSettingDescription": "Configure settings for your domain" } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index e48bc502..4e2738e1 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -81,6 +81,7 @@ export enum ActionsEnum { listClients = "listClients", getClient = "getClient", listOrgDomains = "listOrgDomains", + getDomain = "getDomain", createNewt = "createNewt", createIdp = "createIdp", updateIdp = "updateIdp", diff --git a/server/routers/domain/getDomain.ts b/server/routers/domain/getDomain.ts new file mode 100644 index 00000000..77bd18ae --- /dev/null +++ b/server/routers/domain/getDomain.ts @@ -0,0 +1,86 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { domain } from "zod/v4/core/regexes"; + +const getDomainSchema = z + .object({ + domainId: z + .string() + .optional(), + orgId: z.string().optional() + }) + .strict(); + +async function query(domainId?: string, orgId?: string) { + if (domainId) { + const [res] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + return res; + } +} + +export type GetDomainResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}", + description: "Get a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDomainSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + + const domain = await query(domainId, orgId); + + if (!domain) { + return next(createHttpError(HttpCode.NOT_FOUND, "Domain not found")); + } + + return response(res, { + data: domain, + success: true, + error: false, + message: "Domain retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index c0cafafe..e131e6a4 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -1,4 +1,5 @@ export * from "./listDomains"; export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; -export * from "./restartOrgDomain"; \ No newline at end of file +export * from "./restartOrgDomain"; +export * from "./getDomain"; \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 8bd72f62..cadbbad7 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -302,6 +302,13 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDomain), + domain.getDomain +); + authenticated.get( "/org/:orgId/invitations", verifyOrgAccess, diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx new file mode 100644 index 00000000..1e1fec7b --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -0,0 +1,50 @@ +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { getTranslations } from "next-intl/server"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import DomainProvider from "@app/providers/DomainProvider"; +import DomainInfoCard from "@app/components/DomainInfoCard"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ domainId: string; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let domain = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/domain/${params.domainId}`, + await authCookieHeader() + ); + domain = res.data.data; + console.log(JSON.stringify(domain)); + } catch { + redirect(`/${params.orgId}/settings/domains`); + } + + const t = await getTranslations(); + + + return ( + <> + + + +
+ +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx new file mode 100644 index 00000000..e8187b95 --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function DomainPage(props: { + params: Promise<{ orgId: string; domainId: string }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/domains/${params.domainId}`); +} \ No newline at end of file diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx new file mode 100644 index 00000000..951633f2 --- /dev/null +++ b/src/components/DomainInfoCard.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useTranslations } from "next-intl"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useDomainContext } from "@app/hooks/useDomainContext"; + +type DomainInfoCardProps = {}; + +export default function DomainInfoCard({ }: DomainInfoCardProps) { + const { domain, updateDomain } = useDomainContext(); + const t = useTranslations(); + const { env } = useEnvContext(); + + + return ( + + + + + + {t("type")} + + + + {domain.type} + + + + + + {t("status")} + + + {domain.verified ? ( +
+
+ {t("verified")} +
+ ) : ( +
+
+ {t("unverified")} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/contexts/domainContext.ts b/src/contexts/domainContext.ts new file mode 100644 index 00000000..d60c4ca4 --- /dev/null +++ b/src/contexts/domainContext.ts @@ -0,0 +1,11 @@ +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { createContext } from "react"; + +interface DomainContextType { + domain: GetDomainResponse; + updateDomain: (updatedDomain: Partial) => void; +} + +const DomainContext = createContext(undefined); + +export default DomainContext; \ No newline at end of file diff --git a/src/hooks/useDomainContext.ts b/src/hooks/useDomainContext.ts new file mode 100644 index 00000000..36d3840f --- /dev/null +++ b/src/hooks/useDomainContext.ts @@ -0,0 +1,10 @@ +import DomainContext from "@app/contexts/domainContext"; +import { useContext } from "react"; + +export function useDomainContext() { + const context = useContext(DomainContext); + if (context === undefined) { + throw new Error('useDomainContext must be used within a DomainProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/providers/DomainProvider.tsx b/src/providers/DomainProvider.tsx new file mode 100644 index 00000000..9b014449 --- /dev/null +++ b/src/providers/DomainProvider.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import DomainContext from "@app/contexts/domainContext"; + +interface DomainProviderProps { + children: React.ReactNode; + domain: GetDomainResponse; +} + +export function DomainProvider({ + children, + domain: serverDomain +}: DomainProviderProps) { + const [domain, setDomain] = useState(serverDomain); + + const t = useTranslations(); + + const updateDomain = (updatedDomain: Partial) => { + if (!domain) { + throw new Error(t('domainErrorNoUpdate')); + } + setDomain((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updatedDomain + }; + }); + }; + + return ( + + {children} + + ); +} + +export default DomainProvider; \ No newline at end of file From 43f907ebeccb998160256310040478142c880bc3 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Thu, 16 Oct 2025 21:32:25 +0530 Subject: [PATCH 17/69] remove import --- server/lib/traefik/getTraefikConfig.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index a5552c8c..da2f2001 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -15,7 +15,6 @@ import config from "@server/lib/config"; import { resources, sites, Target, targets } from "@server/db"; import createPathRewriteMiddleware from "./middleware"; import { sanitize, validatePathRewriteConfig } from "./utils"; -import { privateConfig } from "../../private/lib/config"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; From a9b9161c40ec2fb6b4188f531d499df540c267f3 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Thu, 16 Oct 2025 23:37:55 +0530 Subject: [PATCH 18/69] template for Domain Settings --- messages/en-US.json | 5 +- src/components/DomainInfoCard.tsx | 269 ++++++++++++++++++++++++++---- 2 files changed, 238 insertions(+), 36 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a60e432c..b2dbb302 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1898,6 +1898,7 @@ "enterCustomResolver": "Enter Custom Resolver", "preferWildcardCert": "Prefer Wildcard Certificate", "unverified": "Unverified", - "domainSetting": "DomainSetting", - "domainSettingDescription": "Configure settings for your domain" + "domainSetting": "Domain Settings", + "domainSettingDescription": "Configure settings for your domain", + "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver)." } diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index 951633f2..8564acfa 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -11,49 +11,250 @@ import { import { useTranslations } from "next-intl"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useDomainContext } from "@app/hooks/useDomainContext"; +import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; +import { Button } from "./ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@app/components/ui/form"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { Input } from "./ui/input"; +import { CheckboxWithLabel } from "./ui/checkbox"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { toASCII } from "punycode"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { Switch } from "./ui/switch"; type DomainInfoCardProps = {}; +// Helper functions for Unicode domain handling +function toPunycode(domain: string): string { + try { + const parts = toASCII(domain); + return parts; + } catch (error) { + return domain.toLowerCase(); + } +} + + +function isValidDomainFormat(domain: string): boolean { + const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; + + if (!unicodeRegex.test(domain)) { + return false; + } + + const parts = domain.split('.'); + for (const part of parts) { + if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) { + return false; + } + if (part.length > 63) { + return false; + } + } + + if (domain.length > 253) { + return false; + } + + return true; +} + +const formSchema = z.object({ + baseDomain: z + .string() + .min(1, "Domain is required") + .refine((val) => isValidDomainFormat(val), "Invalid domain format") + .transform((val) => toPunycode(val)), + type: z.enum(["ns", "cname", "wildcard"]), + certResolver: z.string().nullable().optional(), + preferWildcardCert: z.boolean().optional() +}); + +type FormValues = z.infer; + +const certResolverOptions = [ + { id: "default", title: "Default" }, + { id: "custom", title: "Custom Resolver" } +]; + + export default function DomainInfoCard({ }: DomainInfoCardProps) { const { domain, updateDomain } = useDomainContext(); const t = useTranslations(); const { env } = useEnvContext(); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + baseDomain: "", + type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", + certResolver: null, + preferWildcardCert: false + } + }); + return ( - - - - - - {t("type")} - - - - {domain.type} - - - - - - {t("status")} - - - {domain.verified ? ( -
-
- {t("verified")} -
- ) : ( -
-
- {t("unverified")} -
- )} -
-
-
-
-
+ <> + + + + + + {t("type")} + + + + {domain.type} + + + + + + {t("status")} + + + {domain.verified ? ( +
+
+ {t("verified")} +
+ ) : ( +
+
+ {t("unverified")} +
+ )} +
+
+
+
+
+ + + + + + {/* Domain Settings */} + {/* Add condition later to only show when domain is wildcard */} + + + + + {t("domainSetting")} + + + + + +
+ + ( + + {t("certResolver")} + + + + + {field.value !== null && field.value !== "default" && ( +
+ + field.onChange(e.target.value)} + /> + + ( + + +
+ + {t("preferWildcardCert")} +
+
+ + + {t("preferWildcardCertDescription")} + + +
+ )} + /> +
+ )} +
+ )} + /> + + +
+
+ + + + +
+
+ ); } From 8fdf120ec2f04127ed8dcfa1be2998dab737ea6e Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Fri, 17 Oct 2025 22:25:19 +0530 Subject: [PATCH 19/69] backend setup to store and get DNS Records --- server/auth/actions.ts | 1 + server/db/sqlite/schema/schema.ts | 13 ++++ server/routers/domain/createOrgDomain.ts | 43 +++++++++++- server/routers/domain/getDNSRecords.ts | 86 ++++++++++++++++++++++++ server/routers/domain/index.ts | 3 +- server/routers/external.ts | 7 ++ 6 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 server/routers/domain/getDNSRecords.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 4e2738e1..4c442d2c 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -82,6 +82,7 @@ export enum ActionsEnum { getClient = "getClient", listOrgDomains = "listOrgDomains", getDomain = "getDomain", + getDNSRecords = "getDNSRecords", createNewt = "createNewt", createIdp = "createIdp", updateIdp = "updateIdp", diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 30841a4b..bc1ce81b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -16,6 +16,18 @@ export const domains = sqliteTable("domains", { preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }) }); +export const dnsRecords = sqliteTable("dnsRecords", { + id: text("id").primaryKey(), + domainId: text("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + + recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: text("baseDomain"), + value: text("value").notNull(), +}); + + export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), @@ -748,6 +760,7 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type DnsRecord = InferSelectModel; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type RoleClient = InferSelectModel; diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index b35a3de8..1f1002af 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db"; +import { db, Domain, domains, OrgDomains, orgDomains, dnsRecords } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -276,9 +276,23 @@ export async function createOrgDomain( }) .returning(); + // Prepare DNS records to insert + const recordsToInsert = []; + // TODO: This needs to be cross region and not hardcoded if (type === "ns") { nsRecords = config.getRawConfig().dns.nameservers as string[]; + + // Save NS records to database + for (const nsValue of nsRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "NS", + baseDomain: baseDomain, + value: nsValue + }); + } } else if (type === "cname") { cnameRecords = [ { @@ -290,6 +304,17 @@ export async function createOrgDomain( baseDomain: `_acme-challenge.${baseDomain}` } ]; + + // Save CNAME records to database + for (const cnameRecord of cnameRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "CNAME", + baseDomain: cnameRecord.baseDomain, + value: cnameRecord.value + }); + } } else if (type === "wildcard") { aRecords = [ { @@ -301,6 +326,22 @@ export async function createOrgDomain( baseDomain: `${baseDomain}` } ]; + + // Save A records to database + for (const aRecord of aRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "A", + baseDomain: aRecord.baseDomain, + value: aRecord.value + }); + } + } + + // Insert all DNS records in batch + if (recordsToInsert.length > 0) { + await trx.insert(dnsRecords).values(recordsToInsert); } numOrgDomains = await trx diff --git a/server/routers/domain/getDNSRecords.ts b/server/routers/domain/getDNSRecords.ts new file mode 100644 index 00000000..ee349cdd --- /dev/null +++ b/server/routers/domain/getDNSRecords.ts @@ -0,0 +1,86 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, dnsRecords } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const getDNSRecordsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +async function query(domainId: string) { + const records = await db + .select() + .from(dnsRecords) + .where(eq(dnsRecords.domainId, domainId)); + + return records; +} + +export type GetDNSRecordsResponse = Awaited>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}/dns-records", + description: "Get all DNS records for a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDNSRecords( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDNSRecordsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domainId } = parsedParams.data; + + const records = await query(domainId); + + if (!records || records.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No DNS records found for this domain" + ) + ); + } + + return response(res, { + data: records, + success: true, + error: false, + message: "DNS records retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index e131e6a4..0bfedb41 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -2,4 +2,5 @@ export * from "./listDomains"; export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; export * from "./restartOrgDomain"; -export * from "./getDomain"; \ No newline at end of file +export * from "./getDomain"; +export * from "./getDNSRecords"; \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index cadbbad7..c00f1e9f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -309,6 +309,13 @@ authenticated.get( domain.getDomain ); +authenticated.get( + "/org/:orgId/domain/:domainId/dns-records", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDNSRecords), + domain.getDNSRecords +); + authenticated.get( "/org/:orgId/invitations", verifyOrgAccess, From c29ba9bb5ff04fbe1be24412c307a56e8ecfc187 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 18 Oct 2025 01:12:02 +0530 Subject: [PATCH 20/69] add DNS Records table --- messages/en-US.json | 5 +- server/routers/domain/createOrgDomain.ts | 9 +- .../settings/domains/[domainId]/layout.tsx | 2 +- src/components/DNSRecordTable.tsx | 138 ++++++++++++ src/components/DNSRecordsDataTable.tsx | 209 ++++++++++++++++++ src/components/DomainInfoCard.tsx | 80 ++++++- 6 files changed, 426 insertions(+), 17 deletions(-) create mode 100644 src/components/DNSRecordTable.tsx create mode 100644 src/components/DNSRecordsDataTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index b2dbb302..24e86a36 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1900,5 +1900,8 @@ "unverified": "Unverified", "domainSetting": "Domain Settings", "domainSettingDescription": "Configure settings for your domain", - "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver)." + "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).", + "recordName": "Record Name", + "auto": "Auto", + "TTL": "TTL" } diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 1f1002af..d40a0cb8 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -290,7 +290,8 @@ export async function createOrgDomain( domainId, recordType: "NS", baseDomain: baseDomain, - value: nsValue + value: nsValue, + verified: false }); } } else if (type === "cname") { @@ -312,7 +313,8 @@ export async function createOrgDomain( domainId, recordType: "CNAME", baseDomain: cnameRecord.baseDomain, - value: cnameRecord.value + value: cnameRecord.value, + verified: false }); } } else if (type === "wildcard") { @@ -334,7 +336,8 @@ export async function createOrgDomain( domainId, recordType: "A", baseDomain: aRecord.baseDomain, - value: aRecord.value + value: aRecord.value, + verified: true }); } } diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx index 1e1fec7b..319ccdd5 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -42,7 +42,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
- +
diff --git a/src/components/DNSRecordTable.tsx b/src/components/DNSRecordTable.tsx new file mode 100644 index 00000000..dd9bcad4 --- /dev/null +++ b/src/components/DNSRecordTable.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@app/components/ui/button"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { useToast } from "@app/hooks/useToast"; +import { Badge } from "@app/components/ui/badge"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { DNSRecordsDataTable } from "./DNSRecordsDataTable"; + +export type DNSRecordRow = { + id: string; + domainId: string; + recordType: string; // "NS" | "CNAME" | "A" | "TXT" + baseDomain: string | null; + value: string; + verified?: boolean; +}; + +type Props = { + records: DNSRecordRow[]; + domainId: string; + isRefreshing?: boolean; +}; + +export default function DNSRecordsTable({ records, domainId, isRefreshing }: Props) { + const t = useTranslations(); + + const columns: ColumnDef[] = [ + { + accessorKey: "baseDomain", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const baseDomain = row.original.baseDomain; + return ( +
+ {baseDomain || "-"} +
+ ); + } + }, + { + accessorKey: "recordType", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.recordType; + return ( +
+ {type} +
+ ); + } + }, + { + accessorKey: "ttl", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( +
+ {t("auto")} +
+ ); + } + }, + { + accessorKey: "value", + header: () => { + return
{t("value")}
; + }, + cell: ({ row }) => { + const value = row.original.value; + return ( +
+
+ {value} +
+
+ ); + } + }, + { + accessorKey: "verified", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const verified = row.original.verified; + return ( + verified ? ( + {t("verified")} + ) : ( + {t("unverified")} + ) + ); + } + } + ]; + + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx new file mode 100644 index 00000000..4e70a1b2 --- /dev/null +++ b/src/components/DNSRecordsDataTable.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + getSortedRowModel, + getFilteredRowModel +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useMemo, useState } from "react"; +import { Plus, RefreshCw } 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 { Badge } from "./ui/badge"; + + +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + +type DNSRecordsDataTableProps = { + columns: ColumnDef[]; + data: TData[]; + title?: string; + addButtonText?: string; + onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + persistPageSize?: boolean | string; + defaultPageSize?: number; +}; + +export function DNSRecordsDataTable({ + columns, + data, + title, + addButtonText, + onAdd, + onRefresh, + isRefreshing, + defaultSort, + tabs, + defaultTab, + +}: DNSRecordsDataTableProps) { + const t = useTranslations(); + + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + // Apply tab filter to data + const filteredData = useMemo(() => { + 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]); + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + + + + return ( +
+ + +
+
+

DNS Records

+ Required +
+ {tabs && tabs.length > 0 && ( + + + {tabs.map((tab) => ( + + {tab.label} ( + {data.filter(tab.filterFn).length}) + + ))} + + + )} +
+
+ {onRefresh && ( + + )} + {onAdd && addButtonText && ( + + )} +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results found. + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index 8564acfa..f768a5c2 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -1,7 +1,6 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { InfoIcon } from "lucide-react"; import { InfoSection, InfoSectionContent, @@ -24,15 +23,23 @@ import { } from "@app/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; import { Input } from "./ui/input"; -import { CheckboxWithLabel } from "./ui/checkbox"; import { useForm } from "react-hook-form"; import z from "zod"; import { toASCII } from "punycode"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { Switch } from "./ui/switch"; +import DNSRecordsTable, { DNSRecordRow } from "./DNSrecordTable"; +import { useEffect, useState } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { Badge } from "./ui/badge"; -type DomainInfoCardProps = {}; +type DomainInfoCardProps = { + orgId?: string; + domainId?: string; +}; // Helper functions for Unicode domain handling function toPunycode(domain: string): string { @@ -88,10 +95,16 @@ const certResolverOptions = [ ]; -export default function DomainInfoCard({ }: DomainInfoCardProps) { +export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) { const { domain, updateDomain } = useDomainContext(); const t = useTranslations(); const { env } = useEnvContext(); + const api = createApiClient(useEnvContext()); + const { toast } = useToast(); + + const [dnsRecords, setDnsRecords] = useState([]); + const [loadingRecords, setLoadingRecords] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); const form = useForm({ resolver: zodResolver(formSchema), @@ -103,6 +116,40 @@ export default function DomainInfoCard({ }: DomainInfoCardProps) { } }); + const fetchDNSRecords = async (showRefreshing = false) => { + if (showRefreshing) { + setIsRefreshing(true); + } else { + setLoadingRecords(true); + } + + try { + const response = await api.get<{ data: DNSRecordRow[] }>( + `/org/${orgId}/domain/${domainId}/dns-records` + ); + setDnsRecords(response.data.data); + } catch (error) { + // Only show error if records exist (not a 404) + const err = error as any; + if (err?.response?.status !== 404) { + toast({ + title: t("error"), + description: formatAxiosError(error), + variant: "destructive" + }); + } + } finally { + setLoadingRecords(false); + setIsRefreshing(false); + } + }; + + useEffect(() => { + if (domain.domainId) { + fetchDNSRecords(); + } + }, [domain.domainId]); + return ( <> @@ -126,8 +173,9 @@ export default function DomainInfoCard({ }: DomainInfoCardProps) { {domain.verified ? (
-
- {t("verified")} + + {t("verified")} +
) : (
@@ -141,12 +189,20 @@ export default function DomainInfoCard({ }: DomainInfoCardProps) { + {loadingRecords ? ( +
+ loading... +
+ ) : ( + + )} - - - - {/* Domain Settings */} - {/* Add condition later to only show when domain is wildcard */} + {/* Domain Settings */} + {/* Add condition later to only show when domain is wildcard */} @@ -257,4 +313,4 @@ export default function DomainInfoCard({ }: DomainInfoCardProps) { ); -} +} \ No newline at end of file From 2c01849f2ed52824f34e206c12617c80e716b02d Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 18 Oct 2025 01:17:25 +0530 Subject: [PATCH 21/69] fix import --- src/components/DomainInfoCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index f768a5c2..0f967f33 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -29,8 +29,8 @@ import { toASCII } from "punycode"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { Switch } from "./ui/switch"; -import DNSRecordsTable, { DNSRecordRow } from "./DNSrecordTable"; import { useEffect, useState } from "react"; +import DNSRecordsTable, {DNSRecordRow} from "./DNSRecordTable"; import { createApiClient } from "@app/lib/api"; import { useToast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; From d37e28215ecc2c7535c379c3b0f5dd538c14acfc Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 18 Oct 2025 16:15:21 +0530 Subject: [PATCH 22/69] add restart button --- .../[domainId]/DomainSettingsLayout.tsx | 112 ++++++++++++++++++ .../settings/domains/[domainId]/layout.tsx | 68 +++++------ 2 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx diff --git a/src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx b/src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx new file mode 100644 index 00000000..3b822dc0 --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useState } from "react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import DomainProvider from "@app/providers/DomainProvider"; +import { useTranslations } from "next-intl"; + + +interface DomainSettingsLayoutProps { + orgId: string; + domain: any, + children: React.ReactNode; +} + +export default function DomainSettingsLayout({ orgId, domain, children }: DomainSettingsLayoutProps) { + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isRefreshing, setIsRefreshing] = useState(false); + const [restartingDomains, setRestartingDomains] = useState>(new Set()); + const t = useTranslations(); + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive", + }); + } finally { + setIsRefreshing(false); + } + }; + + const restartDomain = async (domainId: string) => { + setRestartingDomains((prev) => new Set(prev).add(domainId)); + try { + await api.post(`/org/${orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { + fallback: "Domain verification restarted successfully", + }), + }); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive", + }); + } finally { + setRestartingDomains((prev) => { + const newSet = new Set(prev); + newSet.delete(domainId); + return newSet; + }); + } + }; + + const isRestarting = restartingDomains.has(domain.domainId); + + return ( + <> +
+ + + +
+ + +
+ +
+ {children} +
+ + ); +} diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx index 319ccdd5..c75ceaf4 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -1,50 +1,38 @@ -import { internal } from "@app/lib/api"; -import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { getTranslations } from "next-intl/server"; +import { internal } from "@app/lib/api"; import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import DomainProvider from "@app/providers/DomainProvider"; -import DomainInfoCard from "@app/components/DomainInfoCard"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import SettingsLayoutClient from "./DomainSettingsLayout"; interface SettingsLayoutProps { - children: React.ReactNode; - params: Promise<{ domainId: string; orgId: string }>; + children: React.ReactNode; + params: Promise<{ domainId: string; orgId: string }>; } -export default async function SettingsLayout(props: SettingsLayoutProps) { - const params = await props.params; +export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { + const { domainId, orgId } = await params; - const { children } = props; - - let domain = null; - try { - const res = await internal.get>( - `/org/${params.orgId}/domain/${params.domainId}`, - await authCookieHeader() - ); - domain = res.data.data; - console.log(JSON.stringify(domain)); - } catch { - redirect(`/${params.orgId}/settings/domains`); - } - - const t = await getTranslations(); - - - return ( - <> - - - -
- -
-
- + let domain = null; + try { + const res = await internal.get>( + `/org/${orgId}/domain/${domainId}`, + await authCookieHeader() ); + domain = res.data.data; + } catch { + redirect(`/${orgId}/settings/domains`); + } + + const t = await getTranslations(); + + return ( + + {children} + + ); } From 51af293d66a17458e0812b1d3ed826be7ec3fa1a Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 18 Oct 2025 17:53:50 +0530 Subject: [PATCH 23/69] add doc link button and fix continuous polling --- messages/en-US.json | 3 +- .../settings/domains/[domainId]/page.tsx | 9 ++-- src/components/DNSRecordTable.tsx | 30 +++++------ src/components/DNSRecordsDataTable.tsx | 53 ++++--------------- src/components/DomainInfoCard.tsx | 2 +- 5 files changed, 29 insertions(+), 68 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 24e86a36..4d382869 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1903,5 +1903,6 @@ "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).", "recordName": "Record Name", "auto": "Auto", - "TTL": "TTL" + "TTL": "TTL", + "howToAddRecords": "How to Add Records" } diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index e8187b95..791ba96d 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,8 +1,5 @@ -import { redirect } from "next/navigation"; -export default async function DomainPage(props: { - params: Promise<{ orgId: string; domainId: string }>; -}) { - const params = await props.params; - redirect(`/${params.orgId}/settings/domains/${params.domainId}`); + +export default function DomainPage({ children }: { children: React.ReactNode }) { + return <>{children}; } \ No newline at end of file diff --git a/src/components/DNSRecordTable.tsx b/src/components/DNSRecordTable.tsx index dd9bcad4..e0760977 100644 --- a/src/components/DNSRecordTable.tsx +++ b/src/components/DNSRecordTable.tsx @@ -15,7 +15,7 @@ export type DNSRecordRow = { recordType: string; // "NS" | "CNAME" | "A" | "TXT" baseDomain: string | null; value: string; - verified?: boolean; + verified?: boolean; }; type Props = { @@ -32,17 +32,16 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro accessorKey: "baseDomain", header: ({ column }) => { return ( - +
); }, cell: ({ row }) => { const baseDomain = row.original.baseDomain; return ( -
+
{baseDomain || "-"}
); @@ -52,11 +51,10 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro accessorKey: "recordType", header: ({ column }) => { return ( - +
); }, cell: ({ row }) => { @@ -72,11 +70,10 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro accessorKey: "ttl", header: ({ column }) => { return ( - + ); }, cell: ({ row }) => { @@ -95,10 +92,8 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro cell: ({ row }) => { const value = row.original.value; return ( -
-
- {value} -
+
+ {value}
); } @@ -107,11 +102,10 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro accessorKey: "verified", header: ({ column }) => { return ( - +
); }, cell: ({ row }) => { diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx index 4e70a1b2..422cf318 100644 --- a/src/components/DNSRecordsDataTable.tsx +++ b/src/components/DNSRecordsDataTable.tsx @@ -19,7 +19,7 @@ import { } from "@/components/ui/table"; import { Button } from "@app/components/ui/button"; import { useMemo, useState } from "react"; -import { Plus, RefreshCw } from "lucide-react"; +import { ExternalLink, Plus, RefreshCw } from "lucide-react"; import { Card, CardContent, @@ -107,56 +107,25 @@ export function DNSRecordsDataTable({
-
+
-

DNS Records

+

DNS Records

Required
- {tabs && tabs.length > 0 && ( - - - {tabs.map((tab) => ( - - {tab.label} ( - {data.filter(tab.filterFn).length}) - - ))} - - - )} -
-
- {onRefresh && ( - - )} - {onAdd && addButtonText && ( - - )} +
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( {header.isPlaceholder diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index 0f967f33..df63e1df 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -155,7 +155,7 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) <> - + {t("type")} From 7370448be99d85ab66664dd6ff5b06155c3de17a Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 19 Oct 2025 23:13:27 +0530 Subject: [PATCH 24/69] pg schema --- server/db/pg/schema/schema.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index ae5205bb..0b1192f7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -24,6 +24,17 @@ export const domains = pgTable("domains", { preferWildcardCert: boolean("preferWildcardCert") }); + +export const dnsRecords = pgTable("dnsRecords", { + id: varchar("id").primaryKey(), + domainId: varchar("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: varchar("baseDomain"), + value: varchar("value").notNull(), +}); + export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), name: varchar("name").notNull(), From edf64ae7b5090d1e4704d0c5396c55ff5ca667c6 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sun, 19 Oct 2025 23:23:55 +0530 Subject: [PATCH 25/69] fix invalid "default" --- src/app/[orgId]/settings/domains/[domainId]/layout.tsx | 4 ++-- src/app/[orgId]/settings/domains/[domainId]/page.tsx | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx index c75ceaf4..7c2c3ca5 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -8,11 +8,11 @@ import SettingsLayoutClient from "./DomainSettingsLayout"; interface SettingsLayoutProps { children: React.ReactNode; - params: Promise<{ domainId: string; orgId: string }>; + params: { domainId: string; orgId: string }; } export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { - const { domainId, orgId } = await params; + const { domainId, orgId } = params; let domain = null; try { diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 791ba96d..02efa517 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,5 +1,3 @@ - - -export default function DomainPage({ children }: { children: React.ReactNode }) { - return <>{children}; -} \ No newline at end of file +export default function DomainPage() { + return null; +} From 2b05bc1f5f228382c4c9fec74d19f81f71697a20 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 20 Oct 2025 01:39:39 +0530 Subject: [PATCH 26/69] ui and layout fix --- messages/en-US.json | 4 +- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 1 + .../[domainId]/DomainSettingsLayout.tsx | 112 ------------------ .../settings/domains/[domainId]/layout.tsx | 20 ++-- .../settings/domains/[domainId]/page.tsx | 108 ++++++++++++++++- src/components/DNSRecordTable.tsx | 8 +- src/components/DNSRecordsDataTable.tsx | 8 +- src/components/DomainInfoCard.tsx | 33 ++++-- src/contexts/domainContext.ts | 12 +- src/providers/DomainProvider.tsx | 6 +- 11 files changed, 159 insertions(+), 154 deletions(-) delete mode 100644 src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 4d382869..d53765cf 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1904,5 +1904,7 @@ "recordName": "Record Name", "auto": "Auto", "TTL": "TTL", - "howToAddRecords": "How to Add Records" + "howToAddRecords": "How to Add Records", + "dnsRecord": "DNS Records", + "required": "Required" } diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 0b1192f7..36130d36 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -33,6 +33,7 @@ export const dnsRecords = pgTable("dnsRecords", { recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" baseDomain: varchar("baseDomain"), value: varchar("value").notNull(), + verified: boolean("verified").notNull().default(false), }); export const orgs = pgTable("orgs", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index bc1ce81b..d3390c21 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -25,6 +25,7 @@ export const dnsRecords = sqliteTable("dnsRecords", { recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" baseDomain: text("baseDomain"), value: text("value").notNull(), + verified: integer("verified", { mode: "boolean" }).notNull().default(false), }); diff --git a/src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx b/src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx deleted file mode 100644 index 3b822dc0..00000000 --- a/src/app/[orgId]/settings/domains/[domainId]/DomainSettingsLayout.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { toast } from "@app/hooks/useToast"; -import { useRouter } from "next/navigation"; -import { RefreshCw } from "lucide-react"; -import { Button } from "@app/components/ui/button"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import DomainInfoCard from "@app/components/DomainInfoCard"; -import DomainProvider from "@app/providers/DomainProvider"; -import { useTranslations } from "next-intl"; - - -interface DomainSettingsLayoutProps { - orgId: string; - domain: any, - children: React.ReactNode; -} - -export default function DomainSettingsLayout({ orgId, domain, children }: DomainSettingsLayoutProps) { - const router = useRouter(); - const api = createApiClient(useEnvContext()); - const [isRefreshing, setIsRefreshing] = useState(false); - const [restartingDomains, setRestartingDomains] = useState>(new Set()); - const t = useTranslations(); - const refreshData = async () => { - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive", - }); - } finally { - setIsRefreshing(false); - } - }; - - const restartDomain = async (domainId: string) => { - setRestartingDomains((prev) => new Set(prev).add(domainId)); - try { - await api.post(`/org/${orgId}/domain/${domainId}/restart`); - toast({ - title: t("success"), - description: t("domainRestartedDescription", { - fallback: "Domain verification restarted successfully", - }), - }); - refreshData(); - } catch (e) { - toast({ - title: t("error"), - description: formatAxiosError(e), - variant: "destructive", - }); - } finally { - setRestartingDomains((prev) => { - const newSet = new Set(prev); - newSet.delete(domainId); - return newSet; - }); - } - }; - - const isRestarting = restartingDomains.has(domain.domainId); - - return ( - <> -
- - - -
- - -
- -
- {children} -
- - ); -} diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx index 7c2c3ca5..d33d666a 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -3,18 +3,17 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { internal } from "@app/lib/api"; import { GetDomainResponse } from "@server/routers/domain/getDomain"; import { AxiosResponse } from "axios"; -import { getTranslations } from "next-intl/server"; -import SettingsLayoutClient from "./DomainSettingsLayout"; +import DomainProvider from "@app/providers/DomainProvider"; interface SettingsLayoutProps { children: React.ReactNode; - params: { domainId: string; orgId: string }; + params: Promise<{ domainId: string; orgId: string }>; } export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { - const { domainId, orgId } = params; - + const { domainId, orgId } = await params; let domain = null; + try { const res = await internal.get>( `/org/${orgId}/domain/${domainId}`, @@ -25,14 +24,9 @@ export default async function SettingsLayout({ children, params }: SettingsLayou redirect(`/${orgId}/settings/domains`); } - const t = await getTranslations(); - return ( - + {children} - + ); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 02efa517..3051def0 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -1,3 +1,105 @@ -export default function DomainPage() { - return null; -} +"use client"; +import { useState } from "react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import { useDomain } from "@app/contexts/domainContext"; +import { useTranslations } from "next-intl"; + +export default function DomainSettingsPage() { + const { domain, orgId } = useDomain(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isRefreshing, setIsRefreshing] = useState(false); + const [restartingDomains, setRestartingDomains] = useState>(new Set()); + const t = useTranslations(); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive", + }); + } finally { + setIsRefreshing(false); + } + }; + + const restartDomain = async (domainId: string) => { + setRestartingDomains((prev) => new Set(prev).add(domainId)); + try { + await api.post(`/org/${orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { + fallback: "Domain verification restarted successfully", + }), + }); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive", + }); + } finally { + setRestartingDomains((prev) => { + const newSet = new Set(prev); + newSet.delete(domainId); + return newSet; + }); + } + }; + + if (!domain) { + return null; + } + + const isRestarting = restartingDomains.has(domain.domainId); + + return ( + <> +
+ + +
+
+ +
+ + ); +} \ No newline at end of file diff --git a/src/components/DNSRecordTable.tsx b/src/components/DNSRecordTable.tsx index e0760977..393f5c24 100644 --- a/src/components/DNSRecordTable.tsx +++ b/src/components/DNSRecordTable.tsx @@ -1,12 +1,8 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { Button } from "@app/components/ui/button"; -import { useState } from "react"; import { useTranslations } from "next-intl"; -import { useToast } from "@app/hooks/useToast"; import { Badge } from "@app/components/ui/badge"; -import CopyToClipboard from "@app/components/CopyToClipboard"; import { DNSRecordsDataTable } from "./DNSRecordsDataTable"; export type DNSRecordRow = { @@ -114,7 +110,9 @@ export default function DNSRecordsTable({ records, domainId, isRefreshing }: Pro verified ? ( {t("verified")} ) : ( - {t("unverified")} + + {t("failed", { fallback: "Failed" })} + ) ); } diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx index 422cf318..b29c67f8 100644 --- a/src/components/DNSRecordsDataTable.tsx +++ b/src/components/DNSRecordsDataTable.tsx @@ -108,9 +108,9 @@ export function DNSRecordsDataTable({
-
-

DNS Records

- Required +
+

{t("dnsRecord")}

+ {t("required")}
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( {header.isPlaceholder diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index df63e1df..a53a2420 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -30,7 +30,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { Switch } from "./ui/switch"; import { useEffect, useState } from "react"; -import DNSRecordsTable, {DNSRecordRow} from "./DNSRecordTable"; +import DNSRecordsTable, { DNSRecordRow } from "./DNSRecordTable"; import { createApiClient } from "@app/lib/api"; import { useToast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; @@ -150,19 +150,33 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) } }, [domain.domainId]); + const getTypeDisplay = (type: string) => { + switch (type) { + case "ns": + return t("selectDomainTypeNsName"); + case "cname": + return t("selectDomainTypeCnameName"); + case "wildcard": + return t("selectDomainTypeWildcardName"); + default: + return type; + } + }; + + return ( <> - + {t("type")} - {domain.type} + {getTypeDisplay(domain.type ? domain.type : "")} @@ -172,16 +186,11 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) {domain.verified ? ( -
- - {t("verified")} - -
+ {t("verified")} ) : ( -
-
- {t("unverified")} -
+ + {t("failed", { fallback: "Failed" })} + )}
diff --git a/src/contexts/domainContext.ts b/src/contexts/domainContext.ts index d60c4ca4..e38ddefb 100644 --- a/src/contexts/domainContext.ts +++ b/src/contexts/domainContext.ts @@ -1,11 +1,19 @@ import { GetDomainResponse } from "@server/routers/domain/getDomain"; -import { createContext } from "react"; - +import { createContext, useContext } from "react"; interface DomainContextType { domain: GetDomainResponse; updateDomain: (updatedDomain: Partial) => void; + orgId: string; } const DomainContext = createContext(undefined); +export function useDomain() { + const context = useContext(DomainContext); + if (!context) { + throw new Error("useDomain must be used within DomainProvider"); + } + return context; +} + export default DomainContext; \ No newline at end of file diff --git a/src/providers/DomainProvider.tsx b/src/providers/DomainProvider.tsx index 9b014449..845b369f 100644 --- a/src/providers/DomainProvider.tsx +++ b/src/providers/DomainProvider.tsx @@ -8,11 +8,13 @@ import DomainContext from "@app/contexts/domainContext"; interface DomainProviderProps { children: React.ReactNode; domain: GetDomainResponse; + orgId: string; } export function DomainProvider({ children, - domain: serverDomain + domain: serverDomain, + orgId }: DomainProviderProps) { const [domain, setDomain] = useState(serverDomain); @@ -34,7 +36,7 @@ export function DomainProvider({ }; return ( - + {children} ); From 07f5e8f21590922706975e2096a3ffb17281a2ac Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 20 Oct 2025 17:29:23 +0530 Subject: [PATCH 27/69] add update domain Settings for wildcard --- messages/en-US.json | 5 +- server/auth/actions.ts | 1 + server/routers/domain/index.ts | 3 +- server/routers/domain/updateDomain.ts | 161 ++++++++++++++ server/routers/external.ts | 7 + src/components/DNSRecordsDataTable.tsx | 4 +- src/components/DomainInfoCard.tsx | 291 +++++++++++++++---------- 7 files changed, 352 insertions(+), 120 deletions(-) create mode 100644 server/routers/domain/updateDomain.ts diff --git a/messages/en-US.json b/messages/en-US.json index d53765cf..c4990aae 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1906,5 +1906,8 @@ "TTL": "TTL", "howToAddRecords": "How to Add Records", "dnsRecord": "DNS Records", - "required": "Required" + "required": "Required", + "domainSettingsUpdated": "Domain settings updated successfully", + "orgOrDomainIdMissing": "Organization or Domain ID is missing", + "loadingDNSRecords": "Loading DNS records..." } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 4c442d2c..83582885 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -82,6 +82,7 @@ export enum ActionsEnum { getClient = "getClient", listOrgDomains = "listOrgDomains", getDomain = "getDomain", + updateOrgDomain = "updateOrgDomain", getDNSRecords = "getDNSRecords", createNewt = "createNewt", createIdp = "createIdp", diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index 0bfedb41..e7e0b555 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -3,4 +3,5 @@ export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; export * from "./restartOrgDomain"; export * from "./getDomain"; -export * from "./getDNSRecords"; \ No newline at end of file +export * from "./getDNSRecords"; +export * from "./updateDomain"; \ No newline at end of file diff --git a/server/routers/domain/updateDomain.ts b/server/routers/domain/updateDomain.ts new file mode 100644 index 00000000..c684466e --- /dev/null +++ b/server/routers/domain/updateDomain.ts @@ -0,0 +1,161 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + orgId: z.string(), + domainId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() + }) + .strict(); + +export type UpdateDomainResponse = { + domainId: string; + certResolver: string | null; + preferWildcardCert: boolean | null; +}; + + +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/domain/{domainId}", + description: "Update a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function updateOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + const { certResolver, preferWildcardCert } = parsedBody.data; + + const [orgDomain] = await db + .select() + .from(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + if (!orgDomain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Domain not found or does not belong to this organization" + ) + ); + } + + + const [existingDomain] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)); + + if (!existingDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Domain not found") + ); + } + + if (existingDomain.type !== "wildcard") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain settings can only be updated for wildcard domains" + ) + ); + } + + const updateData: Partial<{ + certResolver: string | null; + preferWildcardCert: boolean; + }> = {}; + + if (certResolver !== undefined) { + updateData.certResolver = certResolver; + } + + if (preferWildcardCert !== undefined && preferWildcardCert !== null) { + updateData.preferWildcardCert = preferWildcardCert; + } + + const [updatedDomain] = await db + .update(domains) + .set(updateData) + .where(eq(domains.domainId, domainId)) + .returning(); + + if (!updatedDomain) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update domain" + ) + ); + } + + return response(res, { + data: { + domainId: updatedDomain.domainId, + certResolver: updatedDomain.certResolver, + preferWildcardCert: updatedDomain.preferWildcardCert + }, + success: true, + error: false, + message: "Domain updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index c00f1e9f..fcc39ded 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -309,6 +309,13 @@ authenticated.get( domain.getDomain ); +authenticated.patch( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrgDomain), + domain.updateOrgDomain +) + authenticated.get( "/org/:orgId/domain/:domainId/dns-records", verifyOrgAccess, diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx index b29c67f8..672012ae 100644 --- a/src/components/DNSRecordsDataTable.tsx +++ b/src/components/DNSRecordsDataTable.tsx @@ -125,7 +125,7 @@ export function DNSRecordsDataTable({
{table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( {header.isPlaceholder @@ -165,7 +165,7 @@ export function DNSRecordsDataTable({ colSpan={columns.length} className="h-24 text-center" > - No results found. + {t("noResults")} )} diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx index a53a2420..2465c608 100644 --- a/src/components/DomainInfoCard.tsx +++ b/src/components/DomainInfoCard.tsx @@ -105,6 +105,7 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) const [dnsRecords, setDnsRecords] = useState([]); const [loadingRecords, setLoadingRecords] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); const form = useForm({ resolver: zodResolver(formSchema), @@ -116,6 +117,21 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) } }); + useEffect(() => { + if (domain.domainId) { + const certResolverValue = domain.certResolver && domain.certResolver.trim() !== "" + ? domain.certResolver + : null; + + form.reset({ + baseDomain: domain.baseDomain || "", + type: (domain.type as "ns" | "cname" | "wildcard") || "wildcard", + certResolver: certResolverValue, + preferWildcardCert: domain.preferWildcardCert || false + }); + } + }, [domain]); + const fetchDNSRecords = async (showRefreshing = false) => { if (showRefreshing) { setIsRefreshing(true); @@ -150,6 +166,49 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) } }, [domain.domainId]); + const onSubmit = async (values: FormValues) => { + if (!orgId || !domainId) { + toast({ + title: t("error"), + description: t("orgOrDomainIdMissing", { fallback: "Organization or Domain ID is missing" }), + variant: "destructive" + }); + return; + } + + setSaveLoading(true); + + try { + const response = await api.patch( + `/org/${orgId}/domain/${domainId}`, + { + certResolver: values.certResolver, + preferWildcardCert: values.preferWildcardCert + } + ); + + updateDomain({ + ...domain, + certResolver: values.certResolver || null, + preferWildcardCert: values.preferWildcardCert || false + }); + + toast({ + title: t("success"), + description: t("domainSettingsUpdated", { fallback: "Domain settings updated successfully" }), + variant: "default" + }); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError(error), + variant: "destructive" + }); + } finally { + setSaveLoading(false); + } + }; + const getTypeDisplay = (type: string) => { switch (type) { case "ns": @@ -198,128 +257,128 @@ export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) - {loadingRecords ? ( -
- loading... -
- ) : ( - + {domain.type !== "wildcard" && ( + loadingRecords ? ( +
+ {t("loadingDNSRecords", { fallback: "Loading DNS Records..." })} +
+ ) : ( + + ) )} - {/* Domain Settings */} - {/* Add condition later to only show when domain is wildcard */} - - - - - {t("domainSetting")} - - + {/* Domain Settings - Only show for wildcard domains */} + {domain.type === "wildcard" && ( + + + + + {t("domainSetting")} + + - - -
- - ( - - {t("certResolver")} - - - - - - - {certResolverOptions.map((opt) => ( - - {opt.title} - - ))} - - - - - {field.value !== null && field.value !== "default" && ( -
- - field.onChange(e.target.value)} + onValueChange={(val) => { + if (val === "default") { + field.onChange(null); + } else if (val === "custom") { + field.onChange(""); + } else { + field.onChange(val); + } + }} + > + + + + + {certResolverOptions.map((opt) => ( + + {opt.title} + + ))} + + + + + {field.value !== null && field.value !== "default" && ( +
+ + field.onChange(e.target.value)} + /> + + ( + + +
+ + {t("preferWildcardCert")} +
+
+ + + {t("preferWildcardCertDescription")} + + +
+ )} /> - - ( - - -
- - {t("preferWildcardCert")} -
-
+
+ )} + + )} + /> + + + + - - {t("preferWildcardCertDescription")} - - - - )} - /> -
- )} -
- )} - /> - - -
-
- - - - -
-
+ + + +
+
+ )} ); } \ No newline at end of file From 7a6838f5a50d29a676d06e18c3d56f614a1b8073 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 20 Oct 2025 20:29:35 +0530 Subject: [PATCH 28/69] fix lint --- server/routers/external.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/external.ts b/server/routers/external.ts index fcc39ded..f862e5cf 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -314,7 +314,7 @@ authenticated.patch( verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrgDomain), domain.updateOrgDomain -) +); authenticated.get( "/org/:orgId/domain/:domainId/dns-records", From 70aeaf7b5d425959fb351af21d64c885f9d7a1eb Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 20 Oct 2025 20:11:13 -0700 Subject: [PATCH 29/69] Change badges and button size --- src/app/[orgId]/settings/domains/[domainId]/page.tsx | 1 - src/components/DNSRecordTable.tsx | 4 ++-- src/components/DNSRecordsDataTable.tsx | 1 - src/components/DomainInfoCard.tsx | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 3051def0..c7e137f6 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -76,7 +76,6 @@ export default function DomainSettingsPage() { /> + ); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( + + ); + }, + // make the value capitalized + cell: ({ row }) => { + return ( + + {row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)} + + ); + }, + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return ( + + {row.original.actorType == "user" ? : } + {row.original.actor} + + ); + } + } + ]; + + return ( + <> + + + ); } diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 6b453811..80ef2e2e 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,7 +141,7 @@ export const orgNavSections = ( : []), { title: "sidebarLogs", - href: "/{orgId}/settings/logs", + href: "/{orgId}/settings/logs/access", icon: }, { diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx new file mode 100644 index 00000000..da6cb009 --- /dev/null +++ b/src/components/DateTimePicker.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { ChevronDownIcon, CalendarIcon } from "lucide-react"; + +import { Button } from "@app/components/ui/button"; +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(value?.date); + const [internalTime, setInternalTime] = useState(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 }; + setOpen(false); + onChange?.(newValue); + }; + + const handleTimeChange = (event: ChangeEvent) => { + 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; + + return `${dateStr} ${internalTime}`; + }; + + const hasValue = internalDate || (showTime && internalTime); + + return ( +
+
+ {label && ( + + )} +
+ + + + + +
+
+
+ + { + let dateValue = undefined; + if (e.target.value) { + // Create date in local timezone to avoid offset issues + const parts = e.target.value.split('-'); + dateValue = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); + } + handleDateChange(dateValue); + }} + className="mt-1" + /> +
+ {showTime && ( +
+ + +
+ )} +
+
+
+
+
+
+
+ ); +} + +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 ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx new file mode 100644 index 00000000..9fc3789b --- /dev/null +++ b/src/components/LogDataTable.tsx @@ -0,0 +1,367 @@ +"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 } 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 +}; + +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; +}; + +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 = { + columns: ColumnDef[]; + data: TData[]; + title?: string; + addButtonText?: string; + onRefresh?: () => void; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + persistPageSize?: boolean | string; + defaultPageSize?: number; + onDateRangeChange?: ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => void; + dateRange?: { + start: DateTimeValue; + end: DateTimeValue; + }; +}; + +export function LogDataTable({ + columns, + data, + title, + onRefresh, + isRefreshing, + searchPlaceholder = "Search...", + searchColumn = "name", + defaultSort, + tabs, + defaultTab, + persistPageSize = false, + defaultPageSize = 20, + onDateRangeChange, + dateRange +}: DataTableProps) { + const t = useTranslations(); + + // Determine table identifier for storage + const tableId = + typeof persistPageSize === "string" ? persistPageSize : undefined; + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + if (persistPageSize) { + return getStoredPageSize(tableId, defaultPageSize); + } + return defaultPageSize; + }); + + const [sorting, setSorting] = useState( + defaultSort ? [defaultSort] : [] + ); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState([]); + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + const [showDatePicker, setShowDatePicker] = useState(false); + const [startDate, setStartDate] = useState( + dateRange?.start || {} + ); + const [endDate, setEndDate] = useState(dateRange?.end || {}); + + // 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 (!tabs || activeTab === "") { + return data; + } + + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (!activeTabFilter) { + return data; + } + + return data.filter(activeTabFilter.filterFn); + }, [data, tabs, activeTab]); + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setGlobalFilter, + initialState: { + pagination: { + pageSize: pageSize, + pageIndex: 0 + } + }, + state: { + sorting, + columnFilters, + globalFilter + } + }); + + 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]); + + const handleTabChange = (value: string) => { + setActiveTab(value); + // Reset to first page when changing tabs + table.setPageIndex(0); + }; + + // Enhanced pagination component that updates our local state + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + table.setPageSize(newPageSize); + + // Persist immediately when changed + if (persistPageSize) { + setStoredPageSize(newPageSize, tableId); + } + }; + + const handleDateRangeChange = ( + start: DateTimeValue, + end: DateTimeValue + ) => { + setStartDate(start); + setEndDate(end); + onDateRangeChange?.(start, end); + }; + + const clearDateFilter = () => { + const emptyStart = {}; + const emptyEnd = {}; + setStartDate(emptyStart); + setEndDate(emptyEnd); + onDateRangeChange?.(emptyStart, emptyEnd); + setShowDatePicker(false); + }; + + const hasDateFilter = startDate?.date || endDate?.date; + + return ( +
+ + +
+
+ + table.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+ {tabs && tabs.length > 0 && ( + + + {tabs.map((tab) => ( + + {tab.label} ( + {data.filter(tab.filterFn).length}) + + ))} + + + )} + + +
+
+ {onRefresh && ( + + )} +
+
+ +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results found. + + + )} + +
+
+ +
+
+ +
+ ); +} From bdc3b2425b52305e724959aa38b80dd3f346f892 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Oct 2025 17:35:13 -0700 Subject: [PATCH 35/69] Basic table working --- messages/en-US.json | 3 +- .../routers/auditLogs/exportActionAuditLog.ts | 102 ++++++++++++++++++ server/private/routers/auditLogs/index.ts | 3 +- .../routers/auditLogs/queryActionAuditLog.ts | 29 ++--- server/private/routers/external.ts | 6 ++ .../settings/logs/{access => action}/page.tsx | 37 +++++++ src/app/navigation.tsx | 2 +- src/components/DateTimePicker.tsx | 1 - src/components/LogDataTable.tsx | 56 ++++------ 9 files changed, 186 insertions(+), 53 deletions(-) create mode 100644 server/private/routers/auditLogs/exportActionAuditLog.ts rename src/app/[orgId]/settings/logs/{access => action}/page.tsx (84%) diff --git a/messages/en-US.json b/messages/en-US.json index 4a44ab33..e6790e0d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1900,5 +1900,6 @@ "action": "Action", "actor": "Actor", "timestamp": "Timestamp", - "accessLogs": "Access Logs" + "accessLogs": "Access Logs", + "exportCsv": "Export CSV" } diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts new file mode 100644 index 00000000..de6cf298 --- /dev/null +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -0,0 +1,102 @@ +/* + * 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"; +import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, querySites } from "./queryActionAuditLog"; + +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"); +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/actionk/export", + description: "Export the action 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 { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { timeStart, timeEnd, limit, offset } = parsedQuery.data; + + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const baseQuery = querySites(timeStart, timeEnd, orgId); + + const log = await baseQuery.limit(limit).offset(offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="audit-logs-${orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index 1a1b1408..9b4a6e7f 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -11,4 +11,5 @@ * This file is not licensed under the AGPLv3. */ -export * from "./queryActionAuditLog"; \ No newline at end of file +export * from "./queryActionAuditLog"; +export * from "./exportActionAuditLog"; \ No newline at end of file diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 27b8b658..6139ce4e 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -59,7 +59,7 @@ export const queryAccessAuditLogsParams = z.object({ orgId: z.string() }); -function querySites(timeStart: number, timeEnd: number, orgId: string) { +export function querySites(timeStart: number, timeEnd: number, orgId: string) { return db .select({ orgId: actionAuditLog.orgId, @@ -79,6 +79,20 @@ function querySites(timeStart: number, timeEnd: number, orgId: string) { .orderBy(actionAuditLog.timestamp); } +export function countQuery(timeStart: number, timeEnd: number, orgId: string) { + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where( + and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ) + ); + return countQuery; +} + registry.registerPath({ method: "get", path: "/org/{orgId}/logs/action", @@ -123,18 +137,7 @@ export async function queryAccessAuditLogs( const log = await baseQuery.limit(limit).offset(offset); - const countQuery = db - .select({ count: count() }) - .from(actionAuditLog) - .where( - and( - gt(actionAuditLog.timestamp, timeStart), - lt(actionAuditLog.timestamp, timeEnd), - eq(actionAuditLog.orgId, orgId) - ) - ); - - const totalCountResult = await countQuery; + const totalCountResult = await countQuery(timeStart, timeEnd, orgId); const totalCount = totalCountResult[0].count; return response(res, { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 7e81336b..566c1c55 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -349,4 +349,10 @@ authenticated.post( authenticated.get( "/org/:orgId/logs/action", logs.queryAccessAuditLogs +) + + +authenticated.get( + "/org/:orgId/logs/action/export", + logs.exportAccessAuditLogs ) \ No newline at end of file diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx similarity index 84% rename from src/app/[orgId]/settings/logs/access/page.tsx rename to src/app/[orgId]/settings/logs/action/page.tsx index 58479166..2fe8acd7 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -21,6 +21,7 @@ export default function GeneralPage() { const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); // Set default date range to last 24 hours const getDefaultDateRange = () => { @@ -127,6 +128,40 @@ export default function GeneralPage() { } }; + const exportData = async () => { + try { + setIsExporting(true); + const response = await api.get(`/org/${orgId}/logs/action/export`, { + responseType: "blob", + params: { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + }, + }); + + // 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[] = [ { accessorKey: "timestamp", @@ -212,6 +247,8 @@ export default function GeneralPage() { searchColumn="action" onRefresh={refreshData} isRefreshing={isRefreshing} + onExport={exportData} + isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ start: dateRange.startDate, diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 80ef2e2e..4b8cfe58 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -141,7 +141,7 @@ export const orgNavSections = ( : []), { title: "sidebarLogs", - href: "/{orgId}/settings/logs/access", + href: "/{orgId}/settings/logs/action", icon: }, { diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index da6cb009..f4efce70 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -50,7 +50,6 @@ export function DateTimePicker({ const handleDateChange = (date: Date | undefined) => { setInternalDate(date); const newValue = { date, time: internalTime }; - setOpen(false); onChange?.(newValue); }; diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 9fc3789b..5b9e0514 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -23,7 +23,7 @@ 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 } from "lucide-react"; +import { Plus, Search, RefreshCw, Filter, X, Download } from "lucide-react"; import { Card, CardContent, @@ -82,6 +82,8 @@ type DataTableProps = { title?: string; addButtonText?: string; onRefresh?: () => void; + onExport?: () => void; + isExporting?: boolean; isRefreshing?: boolean; searchPlaceholder?: string; searchColumn?: string; @@ -109,6 +111,8 @@ export function LogDataTable({ title, onRefresh, isRefreshing, + onExport, + isExporting, searchPlaceholder = "Search...", searchColumn = "name", defaultSort, @@ -142,7 +146,6 @@ export function LogDataTable({ defaultTab || tabs?.[0]?.id || "" ); - const [showDatePicker, setShowDatePicker] = useState(false); const [startDate, setStartDate] = useState( dateRange?.start || {} ); @@ -233,22 +236,11 @@ export function LogDataTable({ onDateRangeChange?.(start, end); }; - const clearDateFilter = () => { - const emptyStart = {}; - const emptyEnd = {}; - setStartDate(emptyStart); - setEndDate(emptyEnd); - onDateRangeChange?.(emptyStart, emptyEnd); - setShowDatePicker(false); - }; - - const hasDateFilter = startDate?.date || endDate?.date; - return (
-
+
({ String(e.target.value) ) } - className="w-full pl-8" + className="w-full pl-8 m-0" />
- {tabs && tabs.length > 0 && ( - - - {tabs.map((tab) => ( - - {tab.label} ( - {data.filter(tab.filterFn).length}) - - ))} - - - )} - ({ className="flex-wrap gap-2" />
-
+
{onRefresh && ( )} + {onExport && ( + + )}
From 1142d6ac48ce8268e933ee4cb3589073cab79b00 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 21 Oct 2025 20:15:43 -0700 Subject: [PATCH 36/69] Date picker working --- messages/en-US.json | 3 +- package-lock.json | 77 ++++++- package.json | 2 + .../routers/auditLogs/queryActionAuditLog.ts | 1 + src/app/[orgId]/settings/logs/action/page.tsx | 150 +++++++----- src/app/[orgId]/settings/logs/layout.tsx | 4 +- src/components/DataTablePagination.tsx | 82 ++++++- src/components/DateTimePicker.tsx | 73 +++--- src/components/LogDataTable.tsx | 59 ++++- src/components/ui/calendar.tsx | 213 ++++++++++++++++++ 10 files changed, 555 insertions(+), 109 deletions(-) create mode 100644 src/components/ui/calendar.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e6790e0d..521c4a39 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1901,5 +1901,6 @@ "actor": "Actor", "timestamp": "Timestamp", "accessLogs": "Access Logs", - "exportCsv": "Export CSV" + "exportCsv": "Export CSV", + "actorId": "Actor ID" } diff --git a/package-lock.json b/package-lock.json index f03f5a87..3457a122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } diff --git a/package.json b/package.json index 44985a2f..ce222a85 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 6139ce4e..379266ae 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -65,6 +65,7 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) { orgId: actionAuditLog.orgId, action: actionAuditLog.action, actorType: actionAuditLog.actorType, + actorId: actionAuditLog.actorId, timestamp: actionAuditLog.timestamp, actor: actionAuditLog.actor }) diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 2fe8acd7..a681ec64 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -23,22 +23,31 @@ export default function GeneralPage() { const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, setIsExporting] = useState(false); + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [isLoading, setIsLoading] = useState(false); + // Set default date range to last 24 hours const getDefaultDateRange = () => { const now = new Date(); const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); - + return { startDate: { - date: yesterday, + date: yesterday }, endDate: { - date: now, + date: now } }; }; - const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue }>(getDefaultDateRange()); + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); // Trigger search with default values on component mount useEffect(() => { @@ -51,21 +60,42 @@ export default function GeneralPage() { endDate: DateTimeValue ) => { setDateRange({ startDate, endDate }); - queryDateTime(startDate, endDate); + setCurrentPage(0); // Reset to first page when filtering + 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); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; const queryDateTime = async ( startDate: DateTimeValue, - endDate: DateTimeValue + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize ) => { - console.log("Date range changed:", { startDate, endDate }); - setIsRefreshing(true); + console.log("Date range changed:", { startDate, endDate, page, size }); + setIsLoading(true); try { // Convert the date/time values to API parameters let params: any = { - limit: 20, - offset: 0 + limit: size, + offset: page * size }; if (startDate?.date) { @@ -89,14 +119,20 @@ export default function GeneralPage() { } else { // If no time is specified, set to NOW const now = new Date(); - endDateTime.setHours(now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds()); + 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); + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); console.log("Fetched logs:", res.data); } } catch (error) { @@ -106,17 +142,21 @@ export default function GeneralPage() { variant: "destructive" }); } finally { - setIsRefreshing(false); + setIsLoading(false); } }; - const refreshData = async () => { console.log("Data refreshed"); setIsRefreshing(true); try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); } catch (error) { toast({ title: t("error"), @@ -139,8 +179,8 @@ export default function GeneralPage() { : undefined, timeEnd: dateRange.endDate?.date ? new Date(dateRange.endDate.date).toISOString() - : undefined, - }, + : undefined + } }); // Create a URL for the blob and trigger a download @@ -148,7 +188,10 @@ export default function GeneralPage() { const link = document.createElement("a"); link.href = url; const epoch = Math.floor(Date.now() / 1000); - link.setAttribute("download", `access_audit_logs_${orgId}_${epoch}.csv`); + link.setAttribute( + "download", + `access_audit_logs_${orgId}_${epoch}.csv` + ); document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); @@ -166,21 +209,11 @@ export default function GeneralPage() { { accessorKey: "timestamp", header: ({ column }) => { - return ( - - ); + return t("timestamp"); }, cell: ({ row }) => { return ( -
+
{new Date( row.original.timestamp * 1000 ).toLocaleString()} @@ -191,48 +224,48 @@ export default function GeneralPage() { { accessorKey: "action", header: ({ column }) => { - return ( - - ); + return t("action"); }, // make the value capitalized cell: ({ row }) => { return ( - {row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)} + {row.original.action.charAt(0).toUpperCase() + + row.original.action.slice(1)} ); - }, + } }, { accessorKey: "actor", header: ({ column }) => { - return ( - - ); + return t("actor"); }, - cell: ({ row }) => { + cell: ({ row }) => { return ( - {row.original.actorType == "user" ? : } + {row.original.actorType == "user" ? ( + + ) : ( + + )} {row.original.actor} ); } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId} + + ); + } } ]; @@ -258,6 +291,13 @@ export default function GeneralPage() { id: "timestamp", desc: false }} + // Server-side pagination props + totalCount={totalCount} + currentPage={currentPage} + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} + isLoading={isLoading} + defaultPageSize={pageSize} /> ); diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx index 01e0d248..a5cee4a2 100644 --- a/src/app/[orgId]/settings/logs/layout.tsx +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -34,8 +34,8 @@ export default async function GeneralSettingsPage({ const navItems = [ { - title: t("access"), - href: `/{orgId}/settings/logs/access` + title: t("action"), + href: `/{orgId}/settings/logs/action` }, { title: t("request"), diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index af0a0fe6..a07354f7 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -19,11 +19,19 @@ import { useTranslations } from "next-intl"; interface DataTablePaginationProps { table: Table; onPageSizeChange?: (pageSize: number) => void; + onPageChange?: (pageIndex: number) => void; + totalCount?: number; + isServerPagination?: boolean; + isLoading?: boolean; } export function DataTablePagination({ table, - onPageSizeChange + onPageSizeChange, + onPageChange, + totalCount, + isServerPagination = false, + isLoading = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -37,6 +45,51 @@ export function DataTablePagination({ } }; + 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 (
@@ -61,14 +114,21 @@ export function DataTablePagination({
- {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()}) + )}
-
-
-
- - { - let dateValue = undefined; - if (e.target.value) { - // Create date in local timezone to avoid offset issues - const parts = e.target.value.split('-'); - dateValue = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); - } - handleDateChange(dateValue); - }} - className="mt-1" - /> -
- {showTime && ( -
+ {showTime ? ( +
+ { + handleDateChange(date); + if (!showTime) { + setOpen(false); + } + }} + className="flex-grow w-[250px]" + /> +
+
@@ -132,12 +129,22 @@ export function DateTimePicker({ step="1" value={internalTime} onChange={handleTimeChange} - className="mt-1 bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" />
- )} +
-
+ ) : ( + { + handleDateChange(date); + setOpen(false); + }} + /> + )}
diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 5b9e0514..3de4dc19 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -103,6 +103,12 @@ type DataTableProps = { start: DateTimeValue; end: DateTimeValue; }; + // Server-side pagination props + totalCount?: number; + currentPage?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + isLoading?: boolean; }; export function LogDataTable({ @@ -121,7 +127,12 @@ export function LogDataTable({ persistPageSize = false, defaultPageSize = 20, onDateRangeChange, - dateRange + dateRange, + totalCount, + currentPage = 0, + onPageChange, + onPageSizeChange: onPageSizeChangeProp, + isLoading = false }: DataTableProps) { const t = useTranslations(); @@ -175,26 +186,39 @@ export function LogDataTable({ return data.filter(activeTabFilter.filterFn); }, [data, tabs, activeTab]); + // Determine if using server-side pagination + const isServerPagination = totalCount !== undefined; + const table = useReactTable({ data: filteredData, columns, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), + // 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: 0 + pageIndex: currentPage } }, state: { sorting, columnFilters, - globalFilter + globalFilter, + pagination: { + pageSize: pageSize, + pageIndex: currentPage + } } }); @@ -210,6 +234,16 @@ export function LogDataTable({ } }, [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) => { setActiveTab(value); // Reset to first page when changing tabs @@ -225,6 +259,18 @@ export function LogDataTable({ 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 (isServerPagination && onPageChange) { + onPageChange(newPageIndex); + } }; const handleDateRangeChange = ( @@ -276,7 +322,6 @@ export function LogDataTable({ )} {onExport && (
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..a48a0f7c --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -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 & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + 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 ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, { accessorKey: "subnet", header: ({ column }) => { @@ -282,7 +325,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { {t("deleteClientQuestion")}

- {t("clientMessageRemove")} + {t("clientMessageRemove")}

} From f2c31d3ca697f1ad72b94f0b719ade83fd2808f4 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Oct 2025 14:27:21 -0700 Subject: [PATCH 43/69] Add actor data to request --- server/db/pg/schema/schema.ts | 1 - server/db/sqlite/schema/schema.ts | 1 - server/routers/auditLogs/types.ts | 2 +- server/routers/badger/logRequestAudit.ts | 18 ++-- server/routers/badger/verifySession.ts | 87 ++++++++++++++----- .../[orgId]/settings/logs/request/page.tsx | 14 ++- 6 files changed, 79 insertions(+), 44 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 00942de3..f204d5bc 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -678,7 +678,6 @@ export const requestAuditLog = pgTable( id: serial("id").primaryKey(), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId") - .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), action: boolean("action").notNull(), reason: integer("reason").notNull(), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index f7f1913a..28697318 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -723,7 +723,6 @@ export const requestAuditLog = sqliteTable( 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" }), action: integer("action", { mode: "boolean" }).notNull(), reason: integer("reason").notNull(), diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index f2f6139f..f0b1a224 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -17,9 +17,9 @@ export type QueryActionAuditLogResponse = { export type QueryRequestAuditLogResponse = { log: { timestamp: number; - orgId: string; action: boolean; reason: number; + orgId: string | null; actorType: string | null; actor: string | null; actorId: string | null; diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 2c10264c..b9adc161 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -27,9 +27,10 @@ export async function logRequestAudit( action: boolean; reason: number; resourceId?: number; + orgId?: string; location?: string; - user?: { username: string; userId: string; orgId: string }; - apiKey?: { name: string; apiKeyId: string; orgId: string }; + user?: { username: string; userId: string; }; + apiKey?: { name: string | null; apiKeyId: string; }; metadata?: any; // userAgent?: string; }, @@ -47,32 +48,23 @@ export async function logRequestAudit( } ) { try { - let orgId: string | undefined; let actorType: string | undefined; let actor: string | undefined; let actorId: string | undefined; const user = data.user; if (user) { - orgId = user.orgId; actorType = "user"; actor = user.username; actorId = user.userId; } const apiKey = data.apiKey; if (apiKey) { - orgId = apiKey.orgId; actorType = "apiKey"; - actor = apiKey.name; + actor = apiKey.name || apiKey.apiKeyId; actorId = apiKey.apiKeyId; } - if (!orgId) { - logger.warn("logRequestAudit: No organization context found"); - orgId = "org_7g93l5xu7p61q14"; - // return; - } - // if (!actorType || !actor || !actorId) { // logger.warn("logRequestAudit: Incomplete actor information"); // return; @@ -107,7 +99,7 @@ export async function logRequestAudit( await db.insert(requestAuditLog).values({ timestamp, - orgId, + orgId: data.orgId, actorType, actor, actorId, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index d5a859ab..db4bdf25 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,6 +1,4 @@ -import { - validateResourceSessionToken -} from "@server/auth/sessions/resource"; +import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, @@ -151,14 +149,17 @@ export async function verifyResourceSession( if (!result) { logger.debug(`Resource not found ${cleanHost}`); - logRequestAudit( - { - action: false, - reason: 201, //resource not found - location: ipCC - }, - parsedBody.data - ); + // 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); } @@ -172,14 +173,17 @@ export async function verifyResourceSession( if (!resource) { logger.debug(`Resource not found ${cleanHost}`); - logRequestAudit( - { - action: false, - reason: 201, //resource not found - location: ipCC - }, - parsedBody.data - ); + // 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); } @@ -193,6 +197,8 @@ export async function verifyResourceSession( { action: false, reason: 202, //resource blocked + resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -218,6 +224,7 @@ export async function verifyResourceSession( action: true, reason: 100, // allowed by rule resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -233,6 +240,7 @@ export async function verifyResourceSession( action: false, reason: 203, // dropped by rules resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -264,6 +272,7 @@ export async function verifyResourceSession( action: true, reason: 101, // allowed no auth resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -325,7 +334,12 @@ export async function verifyResourceSession( action: true, reason: 102, // valid access token resourceId: resource.resourceId, - location: ipCC + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId, + } }, parsedBody.data ); @@ -371,7 +385,12 @@ export async function verifyResourceSession( action: true, reason: 102, // valid access token resourceId: resource.resourceId, - location: ipCC + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId, + } }, parsedBody.data ); @@ -393,7 +412,8 @@ export async function verifyResourceSession( action: true, reason: 103, // valid header auth resourceId: resource.resourceId, - location: ipCC + orgId: resource.orgId, + location: ipCC, }, parsedBody.data ); @@ -413,6 +433,7 @@ export async function verifyResourceSession( action: true, reason: 103, // valid header auth resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -433,6 +454,7 @@ export async function verifyResourceSession( action: false, reason: 299, // no more auth methods resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -453,6 +475,7 @@ export async function verifyResourceSession( action: false, reason: 299, // no more auth methods resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -476,6 +499,7 @@ export async function verifyResourceSession( action: false, reason: 204, // no sessions resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -520,6 +544,7 @@ export async function verifyResourceSession( action: false, reason: 205, // temporary request token resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -539,6 +564,7 @@ export async function verifyResourceSession( action: true, reason: 104, // valid pincode resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -557,6 +583,7 @@ export async function verifyResourceSession( action: true, reason: 105, // valid password resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -578,6 +605,7 @@ export async function verifyResourceSession( action: true, reason: 106, // valid email resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data @@ -596,7 +624,12 @@ export async function verifyResourceSession( action: true, reason: 102, // valid access token resourceId: resource.resourceId, - location: ipCC + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: resourceSession.accessTokenTitle, + apiKeyId: resourceSession.accessTokenId, + } }, parsedBody.data ); @@ -634,7 +667,12 @@ export async function verifyResourceSession( action: true, reason: 107, // valid sso resourceId: resource.resourceId, - location: ipCC + orgId: resource.orgId, + location: ipCC, + user: { + username: allowedUserData.username, + userId: resourceSession.userId + } }, parsedBody.data ); @@ -662,6 +700,7 @@ export async function verifyResourceSession( action: false, reason: 299, // no more auth methods resourceId: resource.resourceId, + orgId: resource.orgId, location: ipCC }, parsedBody.data diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 3010806f..de1a2099 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -353,12 +353,18 @@ export default function GeneralPage() { cell: ({ row }) => { return ( - {row.original.actorType == "user" ? ( - + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + ) : ( - + <>- )} - {row.original.actor} ); } From 7f981f05fb6400aee0bc2f90494bd714a128ce93 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Oct 2025 14:58:18 -0700 Subject: [PATCH 44/69] Show resource link in table for requests --- .../routers/auditLogs/queryRequstAuditLog.ts | 7 +++++-- server/routers/auditLogs/types.ts | 2 ++ .../[orgId]/settings/logs/request/page.tsx | 20 +++++++++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts index 3f5d7f43..cadda1d7 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -1,4 +1,4 @@ -import { db, requestAuditLog } from "@server/db"; +import { db, requestAuditLog, resources } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -68,9 +68,12 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) { host: requestAuditLog.host, path: requestAuditLog.path, method: requestAuditLog.method, - tls: requestAuditLog.tls, + tls: requestAuditLog.tls, + resourceName: resources.name, + resourceNiceId: resources.niceId }) .from(requestAuditLog) + .leftJoin(resources, eq(requestAuditLog.resourceId, resources.resourceId)) // TODO: Is this efficient? .where( and( gt(requestAuditLog.timestamp, timeStart), diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index f0b1a224..38617f44 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -24,6 +24,8 @@ export type QueryRequestAuditLogResponse = { actor: string | null; actorId: string | null; resourceId: number | null; + resourceNiceId: string | null; + resourceName: string | null; ip: string | null; location: string | null; userAgent: string | null; diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index de1a2099..3dba67d0 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -9,7 +9,8 @@ import { useTranslations } from "next-intl"; import { LogDataTable } from "@app/components/LogDataTable"; import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; -import { Key, RouteOff, User, Lock, Unlock } from "lucide-react"; +import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react"; +import Link from "next/link"; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -294,7 +295,22 @@ export default function GeneralPage() { ); } }, - + { + accessorKey: "resourceName", + header: t("resource"), + cell: ({ row }) => { + return ( + + + + ); + } + }, { accessorKey: "host", header: ({ column }) => { From 0211f75cb65ea895283c0327cb75a00bee63fc8f Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Oct 2025 17:42:27 -0700 Subject: [PATCH 45/69] Access logs working --- messages/en-US.json | 2 +- server/db/pg/schema/privateSchema.ts | 17 +- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/privateSchema.ts | 20 +- server/db/sqlite/schema/schema.ts | 2 +- server/private/lib/logAccessAudit.ts | 102 +++++ .../routers/auditLogs/exportAccessAuditLog.ts | 81 ++++ .../routers/auditLogs/exportActionAuditLog.ts | 11 +- server/private/routers/auditLogs/index.ts | 4 +- .../routers/auditLogs/queryAccessAuditLog.ts | 173 ++++++++ .../routers/auditLogs/queryActionAuditLog.ts | 8 +- server/private/routers/external.ts | 11 +- .../routers/auditLogs/exportRequstAuditLog.ts | 4 +- .../routers/auditLogs/queryRequstAuditLog.ts | 8 +- server/routers/auditLogs/types.ts | 26 +- server/routers/auth/login.ts | 66 ++- .../routers/resource/authWithAccessToken.ts | 24 +- server/routers/resource/authWithPassword.ts | 20 + server/routers/resource/authWithPincode.ts | 20 + server/routers/resource/authWithWhitelist.ts | 36 +- server/routers/resource/getExchangeToken.ts | 24 +- src/actions/server.ts | 1 + src/app/[orgId]/settings/logs/access/page.tsx | 375 ++++++++++++++++++ src/app/[orgId]/settings/logs/action/page.tsx | 2 +- src/app/[orgId]/settings/logs/layout.tsx | 11 +- .../[orgId]/settings/logs/request/page.tsx | 21 +- src/components/LogDataTable.tsx | 4 +- src/components/LoginForm.tsx | 7 +- 28 files changed, 1003 insertions(+), 79 deletions(-) create mode 100644 server/private/lib/logAccessAudit.ts create mode 100644 server/private/routers/auditLogs/exportAccessAuditLog.ts create mode 100644 server/private/routers/auditLogs/queryAccessAuditLog.ts create mode 100644 src/app/[orgId]/settings/logs/access/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index d1041ea1..cb470439 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1915,7 +1915,7 @@ "droppedByRule": "Dropped by Rule", "noSessions": "No Sessions", "temporaryRequestToken": "Temporary Request Token", - "noMoreAuthMethods": "No More Auth Methods", + "noMoreAuthMethods": "No Valid Auth", "ip": "IP Address", "reason": "Reason", "requestLogs": "Request Logs", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index d2bb8841..266a8646 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -230,23 +230,22 @@ export const actionAuditLog = pgTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); -export const identityAuditLog = pgTable("identityAuditLog", { +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 }).notNull(), - actor: varchar("actor", { length: 255 }).notNull(), - actorId: varchar("actorId", { length: 255 }).notNull(), + actorType: varchar("actorType", { length: 50 }), + actor: varchar("actor", { length: 255 }), + actorId: varchar("actorId", { length: 255 }), resourceId: integer("resourceId"), - ip: varchar("ip", { length: 45 }).notNull(), + ip: varchar("ip", { length: 45 }), type: varchar("type", { length: 100 }).notNull(), - action: varchar("action", { length: 100 }).notNull(), + action: boolean("action").notNull(), location: text("location"), - path: text("path"), userAgent: text("userAgent"), - metadata: text("details") + metadata: text("metadata") }, (table) => ([ index("idx_identityAuditLog_timestamp").on(table.timestamp), index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) @@ -270,4 +269,4 @@ export type RemoteExitNodeSession = InferSelectModel< export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; export type ActionAuditLog = InferSelectModel; -export type IdentityAuditLog = InferSelectModel; \ No newline at end of file +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f204d5bc..f5322035 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -688,7 +688,7 @@ export const requestAuditLog = pgTable( ip: text("ip"), location: text("location"), userAgent: text("userAgent"), - metadata: text("details"), + metadata: text("metadata"), headers: text("headers"), // JSON blob query: text("query"), // JSON blob originalRequestURL: text("originalRequestURL"), diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 81c76799..89d11310 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -225,23 +225,22 @@ export const actionAuditLog = sqliteTable("actionAuditLog", { index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) ])); -export const identityAuditLog = sqliteTable("identityAuditLog", { +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").notNull(), - actor: text("actor").notNull(), - actorId: text("actorId").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), resourceId: integer("resourceId"), - ip: text("ip").notNull(), - type: text("type").notNull(), - action: text("action").notNull(), + ip: text("ip"), location: text("location"), - path: text("path"), + type: text("type").notNull(), + action: integer("action", { mode: "boolean" }).notNull(), userAgent: text("userAgent"), - metadata: text("details") + metadata: text("metadata") }, (table) => ([ index("idx_identityAuditLog_timestamp").on(table.timestamp), index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) @@ -264,4 +263,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; -export type ActionAuditLog = InferSelectModel; \ No newline at end of file +export type ActionAuditLog = InferSelectModel; +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 28697318..6980dab3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -733,7 +733,7 @@ export const requestAuditLog = sqliteTable( ip: text("ip"), location: text("location"), userAgent: text("userAgent"), - metadata: text("details"), + metadata: text("metadata"), headers: text("headers"), // JSON blob query: text("query"), // JSON blob originalRequestURL: text("originalRequestURL"), diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts new file mode 100644 index 00000000..9b3ae4d5 --- /dev/null +++ b/server/private/lib/logAccessAudit.ts @@ -0,0 +1,102 @@ +import { accessAuditLog, db } from "@server/db"; +import { getCountryCodeForIp } from "@server/lib/geoip"; +import logger from "@server/logger"; +import NodeCache from "node-cache"; + +const cache = new NodeCache({ + stdTTL: 5 // seconds +}); + +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 { + 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 { + 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; +} diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts new file mode 100644 index 00000000..8a6398fa --- /dev/null +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -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 { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { timeStart, timeEnd, limit, offset } = parsedQuery.data; + + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const baseQuery = queryAccess(timeStart, timeEnd, orgId); + + const log = await baseQuery.limit(limit).offset(offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts index 4d15dd55..21329879 100644 --- a/server/private/routers/auditLogs/exportActionAuditLog.ts +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -11,25 +11,20 @@ * 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"; -import { queryActionAuditLogsParams, queryActionAuditLogsQuery, querySites } from "./queryActionAuditLog"; +import { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction } from "./queryActionAuditLog"; import { generateCSV } from "@server/routers/auditLogs/generateCSV"; registry.registerPath({ method: "get", - path: "/org/{orgId}/logs/actionk/export", + path: "/org/{orgId}/logs/action/export", description: "Export the action audit log for an organization as CSV", tags: [OpenAPITags.Org], request: { @@ -67,7 +62,7 @@ export async function exportActionAuditLogs( } const { orgId } = parsedParams.data; - const baseQuery = querySites(timeStart, timeEnd, orgId); + const baseQuery = queryAction(timeStart, timeEnd, orgId); const log = await baseQuery.limit(limit).offset(offset); diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index 9b4a6e7f..ac623c4c 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -12,4 +12,6 @@ */ export * from "./queryActionAuditLog"; -export * from "./exportActionAuditLog"; \ No newline at end of file +export * from "./exportActionAuditLog"; +export * from "./queryAccessAuditLog"; +export * from "./exportAccessAuditLog"; \ No newline at end of file diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts new file mode 100644 index 00000000..cad2027f --- /dev/null +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -0,0 +1,173 @@ +/* + * 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()), + 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 function queryAccess(timeStart: number, timeEnd: number, orgId: string) { + 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( + and( + gt(accessAuditLog.timestamp, timeStart), + lt(accessAuditLog.timestamp, timeEnd), + eq(accessAuditLog.orgId, orgId) + ) + ) + .orderBy(accessAuditLog.timestamp); +} + +export function countAccessQuery(timeStart: number, timeEnd: number, orgId: string) { + const countQuery = db + .select({ count: count() }) + .from(accessAuditLog) + .where( + and( + gt(accessAuditLog.timestamp, timeStart), + lt(accessAuditLog.timestamp, timeEnd), + eq(accessAuditLog.orgId, orgId) + ) + ); + return countQuery; +} + +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 { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { timeStart, timeEnd, limit, offset } = parsedQuery.data; + + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const baseQuery = queryAccess(timeStart, timeEnd, orgId); + + const log = await baseQuery.limit(limit).offset(offset); + + const totalCountResult = await countAccessQuery(timeStart, timeEnd, orgId); + const totalCount = totalCountResult[0].count; + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit, + offset + } + }, + 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") + ); + } +} diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index dd82e21f..77f3bbd8 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -59,7 +59,7 @@ export const queryActionAuditLogsParams = z.object({ orgId: z.string() }); -export function querySites(timeStart: number, timeEnd: number, orgId: string) { +export function queryAction(timeStart: number, timeEnd: number, orgId: string) { return db .select({ orgId: actionAuditLog.orgId, @@ -80,7 +80,7 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) { .orderBy(actionAuditLog.timestamp); } -export function countQuery(timeStart: number, timeEnd: number, orgId: string) { +export function countActionQuery(timeStart: number, timeEnd: number, orgId: string) { const countQuery = db .select({ count: count() }) .from(actionAuditLog) @@ -134,11 +134,11 @@ export async function queryActionAuditLogs( } const { orgId } = parsedParams.data; - const baseQuery = querySites(timeStart, timeEnd, orgId); + const baseQuery = queryAction(timeStart, timeEnd, orgId); const log = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery(timeStart, timeEnd, orgId); + const totalCountResult = await countActionQuery(timeStart, timeEnd, orgId); const totalCount = totalCountResult[0].count; return response(res, { diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index d89b3294..445af82f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -351,8 +351,17 @@ authenticated.get( logs.queryActionAuditLogs ) - authenticated.get( "/org/:orgId/logs/action/export", logs.exportActionAuditLogs +) + +authenticated.get( + "/org/:orgId/logs/access", + logs.queryAccessAuditLogs +) + +authenticated.get( + "/org/:orgId/logs/access/export", + logs.exportAccessAuditLogs ) \ No newline at end of file diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequstAuditLog.ts index d8bf7916..759475c2 100644 --- a/server/routers/auditLogs/exportRequstAuditLog.ts +++ b/server/routers/auditLogs/exportRequstAuditLog.ts @@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error"; import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; -import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, querySites } from "./queryRequstAuditLog"; +import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog"; import { generateCSV } from "./generateCSV"; registry.registerPath({ @@ -54,7 +54,7 @@ export async function exportRequestAuditLogs( } const { orgId } = parsedParams.data; - const baseQuery = querySites(timeStart, timeEnd, orgId); + const baseQuery = queryRequest(timeStart, timeEnd, orgId); const log = await baseQuery.limit(limit).offset(offset); diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts index cadda1d7..b5af0e14 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -46,7 +46,7 @@ export const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); -export function querySites(timeStart: number, timeEnd: number, orgId: string) { +export function queryRequest(timeStart: number, timeEnd: number, orgId: string) { return db .select({ timestamp: requestAuditLog.timestamp, @@ -84,7 +84,7 @@ export function querySites(timeStart: number, timeEnd: number, orgId: string) { .orderBy(requestAuditLog.timestamp); } -export function countQuery(timeStart: number, timeEnd: number, orgId: string) { +export function countRequestQuery(timeStart: number, timeEnd: number, orgId: string) { const countQuery = db .select({ count: count() }) .from(requestAuditLog) @@ -138,11 +138,11 @@ export async function queryRequestAuditLogs( } const { orgId } = parsedParams.data; - const baseQuery = querySites(timeStart, timeEnd, orgId); + const baseQuery = queryRequest(timeStart, timeEnd, orgId); const log = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery(timeStart, timeEnd, orgId); + const totalCountResult = await countRequestQuery(timeStart, timeEnd, orgId); const totalCount = totalCountResult[0].count; return response(res, { diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 38617f44..893db5ef 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -44,4 +44,28 @@ export type QueryRequestAuditLogResponse = { limit: number; offset: number; }; -}; \ No newline at end of file +}; + +export type QueryAccessAuditLogResponse = { + log: { + orgId: string; + action: string; + type: string; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + actorType: string; + actorId: string; + timestamp: number; + actor: string; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 8dad5a42..a3fe1b54 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -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, diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 2d7fdf93..04317a73 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -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(res, { data: { session: token, diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 652c4e86..318c88d8 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -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(res, { data: { session: token diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index d8733c18..2508b84c 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -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(res, { data: { session: token diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 07662f7f..37410b7b 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -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(res, { data: { session: token diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 605e5ca6..aefd1885 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -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(res, { diff --git a/src/actions/server.ts b/src/actions/server.ts index b9dc6e55..5fbe1301 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -202,6 +202,7 @@ export type LoginRequest = { email: string; password: string; code?: string; + resourceGuid?: string; }; export type LoginResponse = { diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx new file mode 100644 index 00000000..af0bc8fc --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -0,0 +1,375 @@ +"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 } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { LogDataTable } 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"; + +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 [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [isLoading, setIsLoading] = useState(false); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + 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); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + 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); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + setIsLoading(true); + + try { + // Convert the date/time values to API parameters + let params: any = { + limit: size, + offset: page * size + }; + + 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); + 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); + const response = await api.get(`/org/${orgId}/logs/access/export`, { + responseType: "blob", + params: { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined + } + }); + + // 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[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return t("action"); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return t("location"); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + ({row.original.location}) + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: t("resource"), + cell: ({ row }) => { + return ( + + + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return t("type"); + }, + cell: ({ row }) => { + return ( + + {/* {row.original.type == "pincode" ? ( + + ) : ( + + )} */} + {row.original.type.charAt(0).toUpperCase() + + row.original.type.slice(1)} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return t("actor"); + }, + cell: ({ row }) => { + return ( + + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ) : ( + <>- + )} + + ); + } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId || "-"} + + ); + } + } + ]; + + return ( + <> + + + ); +} diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 180815d8..3b693515 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -190,7 +190,7 @@ export default function GeneralPage() { const epoch = Math.floor(Date.now() / 1000); link.setAttribute( "download", - `access-audit-logs-${orgId}-${epoch}.csv` + `action-audit-logs-${orgId}-${epoch}.csv` ); document.body.appendChild(link); link.click(); diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx index f917a5d0..fb1d2071 100644 --- a/src/app/[orgId]/settings/logs/layout.tsx +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -1,13 +1,6 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { verifySession } from "@app/lib/auth/verifySession"; -import OrgProvider from "@app/providers/OrgProvider"; -import OrgUserProvider from "@app/providers/OrgUserProvider"; -import { GetOrgResponse } from "@server/routers/org"; -import { GetOrgUserResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; import { getTranslations } from "next-intl/server"; @@ -37,6 +30,10 @@ export default async function GeneralSettingsPage({ title: t("request"), href: `/{orgId}/settings/logs/request` }, + { + title: t("access"), + href: `/{orgId}/settings/logs/access` + }, { title: t("action"), href: `/{orgId}/settings/logs/action` diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 3dba67d0..fe4bb9f2 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -266,14 +266,25 @@ export default function GeneralPage() { ); } }, - + { + accessorKey: "action", + header: ({ column }) => { + return t("action"); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, { accessorKey: "ip", header: ({ column }) => { return t("ip"); } }, - { accessorKey: "location", header: ({ column }) => { @@ -303,7 +314,11 @@ export default function GeneralPage() { - diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 2c1a33b1..b08d9856 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -373,8 +373,8 @@ export function LogDataTable({ typeof actionValue === "boolean" ) { className = actionValue - ? "bg-green-100 dark:bg-green-900" - : "bg-red-100 dark:bg-red-900"; + ? "bg-green-100 dark:bg-green-900/50" + : "bg-red-100 dark:bg-red-900/50"; } return ( diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index c55ad871..8149989e 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -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(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 { From eae2c37388fa5e3391e4686552baf277e6748c02 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Oct 2025 18:21:54 -0700 Subject: [PATCH 46/69] Add expandable columns --- .../routers/auditLogs/queryActionAuditLog.ts | 2 + .../routers/auditLogs/exportRequstAuditLog.ts | 10 +- .../routers/auditLogs/queryRequstAuditLog.ts | 106 +++++---- server/routers/auditLogs/types.ts | 11 +- src/app/[orgId]/settings/logs/access/page.tsx | 18 ++ src/app/[orgId]/settings/logs/action/page.tsx | 18 ++ .../[orgId]/settings/logs/request/page.tsx | 53 +++++ src/components/LogDataTable.tsx | 204 +++++++++++++----- 8 files changed, 316 insertions(+), 106 deletions(-) diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 77f3bbd8..9e357e8d 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error"; import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; +import { metadata } from "@app/app/[orgId]/settings/layout"; export const queryActionAuditLogsQuery = z.object({ // iso string just validate its a parseable date @@ -65,6 +66,7 @@ export function queryAction(timeStart: number, timeEnd: number, orgId: string) { orgId: actionAuditLog.orgId, action: actionAuditLog.action, actorType: actionAuditLog.actorType, + metadata: actionAuditLog.metadata, actorId: actionAuditLog.actorId, timestamp: actionAuditLog.timestamp, actor: actionAuditLog.actor diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequstAuditLog.ts index 759475c2..c1fb2872 100644 --- a/server/routers/auditLogs/exportRequstAuditLog.ts +++ b/server/routers/auditLogs/exportRequstAuditLog.ts @@ -41,7 +41,6 @@ export async function exportRequestAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { @@ -52,16 +51,17 @@ export async function exportRequestAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryRequest(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + 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-${orgId}-${Date.now()}.csv"`); + res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`); return res.send(csvData); } catch (error) { diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts index b5af0e14..f4e24649 100644 --- a/server/routers/auditLogs/queryRequstAuditLog.ts +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -28,6 +28,11 @@ export const queryAccessAuditLogsQuery = z.object({ .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .default(new Date().toISOString()), + action: z.boolean().optional(), + method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(), + reason: z.number().optional(), + resourceId: z.number().optional(), + actor: z.string().optional(), limit: z .string() .optional() @@ -46,55 +51,64 @@ export const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); -export function queryRequest(timeStart: number, timeEnd: number, orgId: string) { +export const queryRequestAuditLogsCombined = + queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams); +type Q = z.infer; + +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 + ); +} + +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, + 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( - and( - gt(requestAuditLog.timestamp, timeStart), - lt(requestAuditLog.timestamp, timeEnd), - eq(requestAuditLog.orgId, orgId) - ) - ) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) // TODO: Is this efficient? + .where(getWhere(data)) .orderBy(requestAuditLog.timestamp); } -export function countRequestQuery(timeStart: number, timeEnd: number, orgId: string) { - const countQuery = db - .select({ count: count() }) - .from(requestAuditLog) - .where( - and( - gt(requestAuditLog.timestamp, timeStart), - lt(requestAuditLog.timestamp, timeEnd), - eq(requestAuditLog.orgId, orgId) - ) - ); +export function countRequestQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(requestAuditLog) + .where(getWhere(data)); return countQuery; } @@ -125,7 +139,6 @@ export async function queryRequestAuditLogs( ) ); } - const { timeStart, timeEnd, limit, offset } = parsedQuery.data; const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { @@ -136,13 +149,14 @@ export async function queryRequestAuditLogs( ) ); } - const { orgId } = parsedParams.data; - const baseQuery = queryRequest(timeStart, timeEnd, orgId); + const data = { ...parsedQuery.data, ...parsedParams.data }; - const log = await baseQuery.limit(limit).offset(offset); + const baseQuery = queryRequest(data); - const totalCountResult = await countRequestQuery(timeStart, timeEnd, orgId); + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countRequestQuery(data); const totalCount = totalCountResult[0].count; return response(res, { @@ -150,8 +164,8 @@ export async function queryRequestAuditLogs( log: log, pagination: { total: totalCount, - limit, - offset + limit: data.limit, + offset: data.offset } }, success: true, diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 893db5ef..b35c2d9e 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -4,6 +4,7 @@ export type QueryActionAuditLogResponse = { action: string; actorType: string; actorId: string; + metadata: string | null; timestamp: number; actor: string; }[]; @@ -49,8 +50,9 @@ export type QueryRequestAuditLogResponse = { export type QueryAccessAuditLogResponse = { log: { orgId: string; - action: string; - type: string; + action: boolean; + actorType: string | null; + actorId: string | null; resourceId: number | null; resourceName: string | null; resourceNiceId: string | null; @@ -58,10 +60,9 @@ export type QueryAccessAuditLogResponse = { location: string | null; userAgent: string | null; metadata: string | null; - actorType: string; - actorId: string; + type: string; timestamp: number; - actor: string; + actor: string | null; }[]; pagination: { total: number; diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index af0bc8fc..e2b68ae0 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -340,6 +340,21 @@ export default function GeneralPage() { } ]; + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ Metadata: +
+                            {row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
+                        
+
+
+
+ ); + }; + return ( <> ); diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 3b693515..a332f0cb 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -269,6 +269,21 @@ export default function GeneralPage() { } ]; + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ Metadata: +
+                            {row.metadata ? JSON.stringify(JSON.parse(row.metadata), null, 2) : "N/A"}
+                        
+
+
+
+ ); + }; + return ( <> ); diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index fe4bb9f2..9c7f6bd4 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -313,6 +313,7 @@ export default function GeneralPage() { return ( e.stopPropagation()} > + ); + }, + size: 40 + }; + + return [expansionColumn, ...columns]; + }, [columns, expandable, expandedRows, toggleRowExpansion]); + const table = useReactTable({ data: filteredData, - columns, + columns: enhancedColumns, getCoreRowModel: getCoreRowModel(), // Only use client-side pagination if totalCount is not provided - ...(isServerPagination ? {} : { getPaginationRowModel: getPaginationRowModel() }), + ...(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, - } : {}), + ...(isServerPagination + ? { + manualPagination: true, + pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0 + } + : {}), initialState: { pagination: { pageSize: pageSize, @@ -321,10 +388,7 @@ export function LogDataTable({ )} {onExport && ( - + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value ? undefined : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} \ No newline at end of file diff --git a/src/components/DateTimePicker.tsx b/src/components/DateTimePicker.tsx index d079ff26..d0b6d40e 100644 --- a/src/components/DateTimePicker.tsx +++ b/src/components/DateTimePicker.tsx @@ -80,9 +80,9 @@ const getDisplayText = () => { return (
-
+
{label && ( -
} @@ -179,23 +217,24 @@ export default function GeneralPage() { string={org?.org.name || ""} title={t("orgDelete")} /> - - - - {t("orgGeneralSettings")} - - - {t("orgGeneralSettingsDescription")} - - - - -
- + + + + + + + {t("orgGeneralSettings")} + + + {t("orgGeneralSettingsDescription")} + + + + )} - - - - -
+
+
+
- {(build === "saas") && ( - - )} + + + + {t("logRetention")} + + + {t("logRetentionDescription")} + + + + {/* {build === "saas" && !subscription?.subscribed ? ( + + + {t("orgAuthPageDisabled")}{" "} + {t("subscriptionRequiredToUse")} + + + ) : null} */} + + + ( + + + {t("logRetentionRequestLabel")} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + (option) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionRequestDescription" + )} + + + + )} + /> + + {build != "oss" && ( + <> + ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + ( + option + ) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionAccessDescription" + )} + + + + )} + /> + ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + ( + option + ) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionActionDescription" + )} + + + + )} + /> + + )} + + + + + + + {build === "saas" && } {/* Save Button */}
diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 07e33824..4244222d 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -6,12 +6,21 @@ 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 { + 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); @@ -21,6 +30,8 @@ export default function GeneralPage() { const t = useTranslations(); const { env } = useEnvContext(); const { orgId } = useParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); @@ -202,6 +213,16 @@ export default function GeneralPage() { } ) => { 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 { @@ -583,6 +604,27 @@ export default function GeneralPage() { return ( <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + ); diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 14281646..d71cc3ff 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -6,11 +6,20 @@ 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 { + 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); @@ -20,6 +29,8 @@ export default function GeneralPage() { const { env } = useEnvContext(); const { orgId } = useParams(); const searchParams = useSearchParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); @@ -187,6 +198,15 @@ export default function GeneralPage() { } ) => { 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 { @@ -435,6 +455,27 @@ export default function GeneralPage() { return ( <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + ); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index ed95eba3..8776c788 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -123,16 +123,20 @@ export const orgNavSections = ( href: "/{orgId}/settings/logs/request", icon: }, - { - title: "sidebarLogsAccess", - href: "/{orgId}/settings/logs/access", - icon: - }, - { - title: "sidebarLogsAction", - href: "/{orgId}/settings/logs/action", - icon: - }, + ...(build != "oss" + ? [ + { + title: "sidebarLogsAccess", + href: "/{orgId}/settings/logs/access", + icon: + }, + { + title: "sidebarLogsAction", + href: "/{orgId}/settings/logs/action", + icon: + } + ] + : []) ] }, { diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index 5c7af9d4..70d64f0c 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -23,6 +23,7 @@ interface DataTablePaginationProps { totalCount?: number; isServerPagination?: boolean; isLoading?: boolean; + disabled?: boolean; } export function DataTablePagination({ @@ -31,7 +32,8 @@ export function DataTablePagination({ onPageChange, totalCount, isServerPagination = false, - isLoading = false + isLoading = false, + disabled = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -96,8 +98,9 @@ export function DataTablePagination({