🚧 wip: approval tables in DB

This commit is contained in:
Fred KISSIE
2025-12-20 00:05:33 +01:00
parent 009b86c33b
commit e983e1166a
13 changed files with 220 additions and 40 deletions

View File

@@ -1184,6 +1184,7 @@
"sidebarOverview": "Overview", "sidebarOverview": "Overview",
"sidebarHome": "Home", "sidebarHome": "Home",
"sidebarSites": "Sites", "sidebarSites": "Sites",
"sidebarApprovals": "Approval Requests",
"sidebarResources": "Resources", "sidebarResources": "Resources",
"sidebarProxyResources": "Public", "sidebarProxyResources": "Public",
"sidebarClientResources": "Private", "sidebarClientResources": "Private",

View File

@@ -125,7 +125,8 @@ export enum ActionsEnum {
getBlueprint = "getBlueprint", getBlueprint = "getBlueprint",
applyBlueprint = "applyBlueprint", applyBlueprint = "applyBlueprint",
viewLogs = "viewLogs", viewLogs = "viewLogs",
exportLogs = "exportLogs" exportLogs = "exportLogs",
listApprovals = "listApprovals"
} }
export async function checkUserActionPermission( export async function checkUserActionPermission(

View File

@@ -307,7 +307,11 @@ export const approvals = pgTable("approvals", {
.notNull(), .notNull(),
olmId: varchar("olmId").references(() => olms.olmId, { olmId: varchar("olmId").references(() => olms.olmId, {
onDelete: "cascade" 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") decision: varchar("type")
.$type<"approved" | "denied" | "pending">() .$type<"approved" | "denied" | "pending">()
.default("pending") .default("pending")

View File

@@ -355,7 +355,8 @@ export const roles = pgTable("roles", {
.notNull(), .notNull(),
isAdmin: boolean("isAdmin"), isAdmin: boolean("isAdmin"),
name: varchar("name").notNull(), name: varchar("name").notNull(),
description: varchar("description") description: varchar("description"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false)
}); });
export const roleActions = pgTable("roleActions", { export const roleActions = pgTable("roleActions", {
@@ -699,7 +700,10 @@ export const olms = pgTable("olms", {
userId: text("userId").references(() => users.userId, { userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes // optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade" onDelete: "cascade"
}) }),
authorizationState: varchar("authorizationState")
.$type<"pending" | "authorized" | "denied">()
.default("authorized")
}); });
export const olmSessions = pgTable("clientSession", { export const olmSessions = pgTable("clientSession", {

View File

@@ -503,7 +503,10 @@ export const roles = sqliteTable("roles", {
.notNull(), .notNull(),
isAdmin: integer("isAdmin", { mode: "boolean" }), isAdmin: integer("isAdmin", { mode: "boolean" }),
name: text("name").notNull(), name: text("name").notNull(),
description: text("description") description: text("description"),
requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean"
}).default(false)
}); });
export const roleActions = sqliteTable("roleActions", { export const roleActions = sqliteTable("roleActions", {

View File

@@ -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";

View File

@@ -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<Awaited<ReturnType<typeof queryApprovals>>>;
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<number>`count(*)` })
.from(approvals);
return response<ListApprovalsResponse>(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")
);
}
}

View File

@@ -24,6 +24,7 @@ import * as generateLicense from "./generatedLicense";
import * as logs from "#private/routers/auditLogs"; import * as logs from "#private/routers/auditLogs";
import * as misc from "#private/routers/misc"; import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key"; import * as reKey from "#private/routers/re-key";
import * as approval from "#private/routers/approvals";
import { import {
verifyOrgAccess, verifyOrgAccess,
@@ -311,6 +312,15 @@ authenticated.get(
loginPage.getLoginPage loginPage.getLoginPage
); );
authenticated.get(
"/org/:orgId/approvals",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listApprovals),
logActionAudit(ActionsEnum.listApprovals),
approval.listApprovals
);
authenticated.get( authenticated.get(
"/org/:orgId/login-page-branding", "/org/:orgId/login-page-branding",
verifyValidLicense, verifyValidLicense,

View File

@@ -29,11 +29,9 @@ import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers"; import { TierId } from "@server/lib/billing/tiers";
import { build } from "@server/build"; import { build } from "@server/build";
const paramsSchema = z const paramsSchema = z.strictObject({
.object({ orgId: z.string()
orgId: z.string() });
})
.strict();
export async function getLoginPageBranding( export async function getLoginPageBranding(
req: Request, req: Request,

View File

@@ -1,15 +1,13 @@
import { Request, Response, NextFunction } from "express"; import { db, orgs, roles } from "@server/db";
import { z } from "zod";
import { db } from "@server/db";
import { roles, orgs } from "@server/db";
import response from "@server/lib/response"; 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 logger from "@server/logger";
import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi"; 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({ const listRolesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()

View File

@@ -0,0 +1,5 @@
export interface ApprovalFeedPageProps {}
export default function ApprovalFeedPage(props: ApprovalFeedPageProps) {
return <></>;
}

View File

@@ -1,27 +1,27 @@
import { SidebarNavItem } from "@app/components/SidebarNav"; import { SidebarNavItem } from "@app/components/SidebarNav";
import { build } from "@server/build"; import { build } from "@server/build";
import { import {
Settings, ChartLine,
Users,
Link as LinkIcon,
Waypoints,
Combine, Combine,
CreditCard,
Fingerprint, Fingerprint,
Globe,
GlobeLock,
KeyRound, KeyRound,
Laptop,
Link as LinkIcon,
Logs, // Added from 'dev' branch
MonitorUp,
ReceiptText,
ScanEye, // Added from 'dev' branch
Server,
Settings,
SquareMousePointer,
TicketCheck, TicketCheck,
User, User,
Globe, // Added from 'dev' branch UserCog,
MonitorUp, // Added from 'dev' branch Users,
Server, Waypoints
ReceiptText,
CreditCard,
Logs,
SquareMousePointer,
ScanEye,
GlobeLock,
Smartphone,
Laptop,
ChartLine
} from "lucide-react"; } from "lucide-react";
export type SidebarNavSection = { export type SidebarNavSection = {
@@ -123,7 +123,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
href: "/{orgId}/settings/access/roles", href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" /> icon: <Users className="size-4 flex-none" />
}, },
...(build == "saas" ...(build === "saas"
? [ ? [
{ {
title: "sidebarIdentityProviders", title: "sidebarIdentityProviders",
@@ -133,6 +133,15 @@ export const orgNavSections = (): SidebarNavSection[] => [
} }
] ]
: []), : []),
...(build !== "oss"
? [
{
title: "sidebarApprovals",
href: "/{orgId}/settings/access/approvals",
icon: <UserCog className="size-4 flex-none" />
}
]
: []),
{ {
title: "sidebarShareableLinks", title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links", href: "/{orgId}/settings/share-links",

View File

@@ -64,10 +64,10 @@ export const DismissableBanner = ({
} }
return ( return (
<Card className="mb-6 relative border-primary/30 bg-gradient-to-br from-primary/10 via-background to-background overflow-hidden"> <Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
<button <button
onClick={handleDismiss} onClick={handleDismiss}
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors" className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
aria-label={t("dismiss")} aria-label={t("dismiss")}
> >
<X className="w-4 h-4 text-muted-foreground" /> <X className="w-4 h-4 text-muted-foreground" />
@@ -84,7 +84,7 @@ export const DismissableBanner = ({
</p> </p>
</div> </div>
{children && ( {children && (
<div className="flex flex-wrap gap-3 lg:flex-shrink-0 lg:justify-end"> <div className="flex flex-wrap gap-3 lg:shrink-0 lg:justify-end">
{children} {children}
</div> </div>
)} )}
@@ -95,4 +95,3 @@ export const DismissableBanner = ({
}; };
export default DismissableBanner; export default DismissableBanner;