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 (
-
+
{children && (
-
+
{children}
)}
@@ -95,4 +95,3 @@ export const DismissableBanner = ({
};
export default DismissableBanner;
-