🚧 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",
"sidebarHome": "Home",
"sidebarSites": "Sites",
"sidebarApprovals": "Approval Requests",
"sidebarResources": "Resources",
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",

View File

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

View File

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

View File

@@ -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", {

View File

@@ -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", {

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 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,

View File

@@ -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,

View File

@@ -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()

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 { 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: <Users className="size-4 flex-none" />
},
...(build == "saas"
...(build === "saas"
? [
{
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",
href: "/{orgId}/settings/share-links",

View File

@@ -64,10 +64,10 @@ export const DismissableBanner = ({
}
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
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")}
>
<X className="w-4 h-4 text-muted-foreground" />
@@ -84,7 +84,7 @@ export const DismissableBanner = ({
</p>
</div>
{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}
</div>
)}
@@ -95,4 +95,3 @@ export const DismissableBanner = ({
};
export default DismissableBanner;