From a5775a0f4ffa486d8a1262987da7d941ee8e1e80 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Fri, 19 Dec 2025 00:00:10 +0100 Subject: [PATCH 01/46] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20create=20`approva?= =?UTF-8?q?ls`=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/pg/schema/privateSchema.ts | 31 +++++++++++++++++++++++- server/db/sqlite/schema/privateSchema.ts | 23 +++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 1f30dbf5..1f32f328 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -10,7 +10,15 @@ import { index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; -import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; +import { + domains, + orgs, + targets, + users, + exitNodes, + sessions, + olms +} from "./schema"; export const certificates = pgTable("certificates", { certId: serial("certId").primaryKey(), @@ -289,6 +297,27 @@ export const accessAuditLog = pgTable( ] ); +export const approvals = pgTable("approvals", { + id: serial("id").primaryKey(), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + olmId: varchar("olmId").references(() => olms.olmId, { + onDelete: "cascade" + }), // olms reference user devices clients + decision: varchar("type") + .$type<"approved" | "denied" | "pending">() + .default("pending") + .notNull(), + type: varchar("type") + .$type<"user_device" /*| 'proxy' // for later */>() + .notNull() +}); + +export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index af7d021d..311bcf4b 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -6,7 +6,7 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core"; -import { domains, exitNodes, orgs, sessions, users } from "./schema"; +import { domains, exitNodes, olms, orgs, sessions, users } from "./schema"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -289,6 +289,27 @@ export const accessAuditLog = sqliteTable( ] ); +export const approvals = sqliteTable("approvals", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + olmId: text("olmId").references(() => olms.olmId, { + onDelete: "cascade" + }), // olms reference user devices clients + decision: text("type") + .$type<"approved" | "denied" | "pending">() + .default("pending") + .notNull(), + type: text("type") + .$type<"user_device" /*| 'proxy' // for later */>() + .notNull() +}); + +export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; From e983e1166afc5da7e992dce22a3aea0d28206ce5 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 20 Dec 2025 00:05:33 +0100 Subject: [PATCH 02/46] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20approval=20tables?= =?UTF-8?q?=20in=20DB?= 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 ( - + + + + + + + + ); +} diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 67ed2e08..89f20b80 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -1,9 +1,8 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { DataTable } from "@app/components/ui/data-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { Button } from "@app/components/ui/button"; +import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -13,18 +12,12 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { - ArrowRight, - ArrowUpDown, - ArrowUpRight, - MoreHorizontal -} from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { Badge } from "./ui/badge"; -import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index 2fd3353d..1ef103f3 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -10,12 +10,18 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { Role } from "@server/db"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useState } from "react"; -import { Switch } from "./ui/switch"; +import { useState, useTransition } from "react"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem +} from "./ui/dropdown-menu"; +import EditRoleForm from "./EditRoleForm"; export type RoleRow = Role; @@ -23,13 +29,13 @@ type RolesTableProps = { roles: RoleRow[]; }; -export default function UsersTable({ roles: r }: RolesTableProps) { +export default function UsersTable({ roles }: RolesTableProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const router = useRouter(); - const [roles, setRoles] = useState(r); - const [roleToRemove, setUserToRemove] = useState(null); const api = createApiClient(useEnvContext()); @@ -38,13 +44,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) { const { isPaidUser } = usePaidStatus(); const t = useTranslations(); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshing, startTransition] = useTransition(); const refreshData = async () => { console.log("Data refreshed"); - setIsRefreshing(true); try { - await new Promise((resolve) => setTimeout(resolve, 200)); router.refresh(); } catch (error) { toast({ @@ -52,8 +56,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) { description: t("refreshError"), variant: "destructive" }); - } finally { - setIsRefreshing(false); } }; @@ -81,52 +83,76 @@ export default function UsersTable({ roles: r }: RolesTableProps) { friendlyName: t("description"), header: () => {t("description")} }, + // { + // id: "actions", + // enableHiding: false, + // header: () => , + // cell: ({ row }) => { + // const roleRow = row.original; - ...(isPaidUser - ? ([ - { - accessorKey: "requireDeviceApproval", - friendlyName: t("requireDeviceApproval"), - header: () => ( - - {t("requireDeviceApproval")} - - ), - cell: ({ row }) => ( - { - // ... - }} - /> - ) - } - ] as ExtendedColumnDef[]) - : []), - + // return ( + //
+ // + //
+ // ); + // } + // }, { id: "actions", enableHiding: false, header: () => , cell: ({ row }) => { const roleRow = row.original; - return ( -
- -
+ !roleRow.isAdmin && ( +
+ + + + + + { + // setSelectedInternalResource( + // resourceRow + // ); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + +
+ ) ); } } @@ -134,11 +160,26 @@ export default function UsersTable({ roles: r }: RolesTableProps) { return ( <> + {editingRole && ( + { + // Delay refresh to allow modal to close smoothly + setTimeout(() => { + router.refresh(); + setEditingRole(null); + }, 150); + }} + /> + )} { - setRoles((prev) => [...prev, role]); + afterCreate={() => { + startTransition(refreshData); }} /> @@ -148,9 +189,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) { setOpen={setIsDeleteModalOpen} roleToDelete={roleToRemove} afterDelete={() => { - setRoles((prev) => - prev.filter((r) => r.roleId !== roleToRemove.roleId) - ); + startTransition(refreshData); setUserToRemove(null); }} /> @@ -162,7 +201,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) { createRole={() => { setIsCreateModalOpen(true); }} - onRefresh={refreshData} + onRefresh={() => startTransition(refreshData)} isRefreshing={isRefreshing} /> diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 85825dc1..261655bb 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -30,7 +30,8 @@ const checkboxVariants = cva( ); interface CheckboxProps - extends React.ComponentPropsWithoutRef, + extends + React.ComponentPropsWithoutRef, VariantProps {} const Checkbox = React.forwardRef< @@ -49,17 +50,18 @@ const Checkbox = React.forwardRef< )); Checkbox.displayName = CheckboxPrimitive.Root.displayName; -interface CheckboxWithLabelProps - extends React.ComponentPropsWithoutRef { +interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef< + typeof Checkbox +> { label: string; } const CheckboxWithLabel = React.forwardRef< - React.ElementRef, + React.ComponentRef, CheckboxWithLabelProps >(({ className, label, id, ...props }, ref) => { return ( -
+