From e983e1166afc5da7e992dce22a3aea0d28206ce5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 20 Dec 2025 00:05:33 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20approval=20tables=20in=20?= =?UTF-8?q?DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + server/auth/actions.ts | 3 +- server/db/pg/schema/privateSchema.ts | 6 +- server/db/pg/schema/schema.ts | 8 +- server/db/sqlite/schema/schema.ts | 5 +- server/private/routers/approvals/index.ts | 14 ++ .../routers/approvals/listApprovals.ts | 134 ++++++++++++++++++ server/private/routers/external.ts | 10 ++ .../routers/loginPage/getLoginPageBranding.ts | 8 +- server/routers/role/listRoles.ts | 16 +-- .../settings/access/approvals/page.tsx | 5 + src/app/navigation.tsx | 43 +++--- src/components/DismissableBanner.tsx | 7 +- 13 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 server/private/routers/approvals/index.ts create mode 100644 server/private/routers/approvals/listApprovals.ts create mode 100644 src/app/[orgId]/settings/access/approvals/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e0728c94..ca5b53b3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1184,6 +1184,7 @@ "sidebarOverview": "Overview", "sidebarHome": "Home", "sidebarSites": "Sites", + "sidebarApprovals": "Approval Requests", "sidebarResources": "Resources", "sidebarProxyResources": "Public", "sidebarClientResources": "Private", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71017f8d..17ad7cda 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -125,7 +125,8 @@ export enum ActionsEnum { getBlueprint = "getBlueprint", applyBlueprint = "applyBlueprint", viewLogs = "viewLogs", - exportLogs = "exportLogs" + exportLogs = "exportLogs", + listApprovals = "listApprovals" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 1f32f328..7332ec73 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -307,7 +307,11 @@ export const approvals = pgTable("approvals", { .notNull(), olmId: varchar("olmId").references(() => olms.olmId, { onDelete: "cascade" - }), // olms reference user devices clients + }), // olms reference user devices clients (in this case) + userId: varchar("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes + onDelete: "cascade" + }), decision: varchar("type") .$type<"approved" | "denied" | "pending">() .default("pending") diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index c689a35a..c9348371 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -355,7 +355,8 @@ export const roles = pgTable("roles", { .notNull(), isAdmin: boolean("isAdmin"), name: varchar("name").notNull(), - description: varchar("description") + description: varchar("description"), + requireDeviceApproval: boolean("requireDeviceApproval").default(false) }); export const roleActions = pgTable("roleActions", { @@ -699,7 +700,10 @@ export const olms = pgTable("olms", { userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) + }), + authorizationState: varchar("authorizationState") + .$type<"pending" | "authorized" | "denied">() + .default("authorized") }); export const olmSessions = pgTable("clientSession", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 848289ee..c8d8c114 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -503,7 +503,10 @@ export const roles = sqliteTable("roles", { .notNull(), isAdmin: integer("isAdmin", { mode: "boolean" }), name: text("name").notNull(), - description: text("description") + description: text("description"), + requireDeviceApproval: integer("requireDeviceApproval", { + mode: "boolean" + }).default(false) }); export const roleActions = sqliteTable("roleActions", { diff --git a/server/private/routers/approvals/index.ts b/server/private/routers/approvals/index.ts new file mode 100644 index 00000000..d115a054 --- /dev/null +++ b/server/private/routers/approvals/index.ts @@ -0,0 +1,14 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./listApprovals"; diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts new file mode 100644 index 00000000..05e3a238 --- /dev/null +++ b/server/private/routers/approvals/listApprovals.ts @@ -0,0 +1,134 @@ +/* + * 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 logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +import type { Request, Response, NextFunction } from "express"; +import { build } from "@server/build"; +import { getOrgTierData } from "@server/lib/billing"; +import { TierId } from "@server/lib/billing/tiers"; +import { approvals, db } from "@server/db"; +import { eq, sql } from "drizzle-orm"; +import response from "@server/lib/response"; + +const paramsSchema = z.strictObject({ + orgId: z.string() +}); + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +async function queryApprovals(orgId: string, limit: number, offset: number) { + const res = await db + .select() + .from(approvals) + .where(eq(approvals.orgId, orgId)) + .limit(limit) + .offset(offset); + return res; +} + +export type ListApprovalsResponse = { + approvals: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listApprovals( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const { orgId } = parsedParams.data; + + if (build === "saas") { + const { tier } = await getOrgTierData(orgId); + const subscribed = tier === TierId.STANDARD; + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } + } + + const approvalsList = await queryApprovals( + orgId.toString(), + limit, + offset + ); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(approvals); + + return response(res, { + data: { + approvals: approvalsList, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Approvals 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 d9608e21..9ecf57f3 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -24,6 +24,7 @@ import * as generateLicense from "./generatedLicense"; import * as logs from "#private/routers/auditLogs"; import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; +import * as approval from "#private/routers/approvals"; import { verifyOrgAccess, @@ -311,6 +312,15 @@ authenticated.get( loginPage.getLoginPage ); +authenticated.get( + "/org/:orgId/approvals", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listApprovals), + logActionAudit(ActionsEnum.listApprovals), + approval.listApprovals +); + authenticated.get( "/org/:orgId/login-page-branding", verifyValidLicense, diff --git a/server/private/routers/loginPage/getLoginPageBranding.ts b/server/private/routers/loginPage/getLoginPageBranding.ts index 262e9ce8..8fd0772d 100644 --- a/server/private/routers/loginPage/getLoginPageBranding.ts +++ b/server/private/routers/loginPage/getLoginPageBranding.ts @@ -29,11 +29,9 @@ import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { build } from "@server/build"; -const paramsSchema = z - .object({ - orgId: z.string() - }) - .strict(); +const paramsSchema = z.strictObject({ + orgId: z.string() +}); export async function getLoginPageBranding( req: Request, diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index 288a540d..cf6b90df 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -1,15 +1,13 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { roles, orgs } from "@server/db"; +import { db, orgs, roles } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { sql, eq } from "drizzle-orm"; import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { eq, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const listRolesParamsSchema = z.strictObject({ orgId: z.string() diff --git a/src/app/[orgId]/settings/access/approvals/page.tsx b/src/app/[orgId]/settings/access/approvals/page.tsx new file mode 100644 index 00000000..5674a707 --- /dev/null +++ b/src/app/[orgId]/settings/access/approvals/page.tsx @@ -0,0 +1,5 @@ +export interface ApprovalFeedPageProps {} + +export default function ApprovalFeedPage(props: ApprovalFeedPageProps) { + return <>; +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 54576c0c..98bbe307 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -1,27 +1,27 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; import { build } from "@server/build"; import { - Settings, - Users, - Link as LinkIcon, - Waypoints, + ChartLine, Combine, + CreditCard, Fingerprint, + Globe, + GlobeLock, KeyRound, + Laptop, + Link as LinkIcon, + Logs, // Added from 'dev' branch + MonitorUp, + ReceiptText, + ScanEye, // Added from 'dev' branch + Server, + Settings, + SquareMousePointer, TicketCheck, User, - Globe, // Added from 'dev' branch - MonitorUp, // Added from 'dev' branch - Server, - ReceiptText, - CreditCard, - Logs, - SquareMousePointer, - ScanEye, - GlobeLock, - Smartphone, - Laptop, - ChartLine + UserCog, + Users, + Waypoints } from "lucide-react"; export type SidebarNavSection = { @@ -123,7 +123,7 @@ export const orgNavSections = (): SidebarNavSection[] => [ href: "/{orgId}/settings/access/roles", icon: }, - ...(build == "saas" + ...(build === "saas" ? [ { title: "sidebarIdentityProviders", @@ -133,6 +133,15 @@ export const orgNavSections = (): SidebarNavSection[] => [ } ] : []), + ...(build !== "oss" + ? [ + { + title: "sidebarApprovals", + href: "/{orgId}/settings/access/approvals", + icon: + } + ] + : []), { title: "sidebarShareableLinks", href: "/{orgId}/settings/share-links", diff --git a/src/components/DismissableBanner.tsx b/src/components/DismissableBanner.tsx index 6f49e036..555fdaa4 100644 --- a/src/components/DismissableBanner.tsx +++ b/src/components/DismissableBanner.tsx @@ -64,10 +64,10 @@ export const DismissableBanner = ({ } return ( - +