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 (
-
+
{children && (
-
+
{children}
)}
@@ -95,4 +95,3 @@ export const DismissableBanner = ({
};
export default DismissableBanner;
-
From 1f80845a7a2f0a87c015be64a50a311671a15ba9 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Mon, 5 Jan 2026 22:49:42 +0100
Subject: [PATCH 03/46] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F=20move=20approval?=
=?UTF-8?q?=20state=20to=20client=20directly=20where=20it=20makes=20more?=
=?UTF-8?q?=20sense?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/db/pg/schema/schema.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index df51d23f..f4c593ae 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -689,7 +689,10 @@ export const clients = pgTable("clients", {
online: boolean("online").notNull().default(false),
// endpoint: varchar("endpoint"),
lastHolePunch: integer("lastHolePunch"),
- maxConnections: integer("maxConnections")
+ maxConnections: integer("maxConnections"),
+ approvalState: varchar("approvalState")
+ .$type<"pending" | "approved" | "denied">()
+ .default("approved")
});
export const clientSitesAssociationsCache = pgTable(
@@ -727,10 +730,7 @@ 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", {
From cb21cab117c2272931285f8b71a49143676851b1 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Tue, 6 Jan 2026 01:51:33 +0100
Subject: [PATCH 04/46] =?UTF-8?q?=F0=9F=9A=A7=20add=20device=20approval=20?=
=?UTF-8?q?in=20the=20roles=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en-US.json | 2 ++
server/db/sqlite/schema/schema.ts | 9 +++--
server/routers/role/listRoles.ts | 3 +-
src/components/CreateRoleForm.tsx | 42 +++++++++++++++++++++--
src/components/RolesTable.tsx | 55 +++++++++++++++++++++----------
5 files changed, 88 insertions(+), 23 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index 075bc307..a805c2f0 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1548,6 +1548,8 @@
"IntervalSeconds": "Healthy Interval",
"timeoutSeconds": "Timeout (sec)",
"timeIsInSeconds": "Time is in seconds",
+ "requireDeviceApproval": "Require Device Approvals",
+ "requireDeviceApprovalDescription": "Users with this role need their devices approved by an admin before they can access resources",
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index cb524480..ef6fff6d 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -255,7 +255,9 @@ export const siteResources = sqliteTable("siteResources", {
aliasAddress: text("aliasAddress"),
tcpPortRangeString: text("tcpPortRangeString").notNull().default("*"),
udpPortRangeString: text("udpPortRangeString").notNull().default("*"),
- disableIcmp: integer("disableIcmp", { mode: "boolean" }).notNull().default(false)
+ disableIcmp: integer("disableIcmp", { mode: "boolean" })
+ .notNull()
+ .default(false)
});
export const clientSiteResources = sqliteTable("clientSiteResources", {
@@ -796,7 +798,10 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
identifierPath: text("identifierPath").notNull(),
emailPath: text("emailPath"),
namePath: text("namePath"),
- scopes: text("scopes").notNull()
+ scopes: text("scopes").notNull(),
+ approvalState: text("approvalState")
+ .$type<"pending" | "approved" | "denied">()
+ .default("approved")
});
export const licenseKey = sqliteTable("licenseKey", {
diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts
index cf6b90df..ec7f3b4b 100644
--- a/server/routers/role/listRoles.ts
+++ b/server/routers/role/listRoles.ts
@@ -36,7 +36,8 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
isAdmin: roles.isAdmin,
name: roles.name,
description: roles.description,
- orgName: orgs.name
+ orgName: orgs.name,
+ requireDeviceApproval: roles.requireDeviceApproval
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx
index b8df1f78..30c37693 100644
--- a/src/components/CreateRoleForm.tsx
+++ b/src/components/CreateRoleForm.tsx
@@ -4,6 +4,7 @@ import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
@@ -27,11 +28,13 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
-import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
+import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
+import { CheckboxWithLabel } from "./ui/checkbox";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
type CreateRoleFormProps = {
open: boolean;
@@ -46,10 +49,12 @@ export default function CreateRoleForm({
}: CreateRoleFormProps) {
const { org } = useOrgContext();
const t = useTranslations();
+ const { isPaidUser } = usePaidStatus();
const formSchema = z.object({
name: z.string({ message: t("nameRequired") }).max(32),
- description: z.string().max(255).optional()
+ description: z.string().max(255).optional(),
+ requireDeviceApproval: z.boolean().optional()
});
const [loading, setLoading] = useState(false);
@@ -60,7 +65,8 @@ export default function CreateRoleForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
- description: ""
+ description: "",
+ requireDeviceApproval: false
}
});
@@ -159,6 +165,36 @@ export default function CreateRoleForm({
)}
/>
+ {isPaidUser && (
+ (
+
+
+
+
+
+
+ {t(
+ "requireDeviceApprovalDescription"
+ )}
+
+
+
+
+ )}
+ />
+ )}
diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx
index 6ecfbbac..2fd3353d 100644
--- a/src/components/RolesTable.tsx
+++ b/src/components/RolesTable.tsx
@@ -1,27 +1,21 @@
"use client";
-import { ColumnDef } from "@tanstack/react-table";
-import { ExtendedColumnDef } from "@app/components/ui/data-table";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger
-} from "@app/components/ui/dropdown-menu";
-import { Button } from "@app/components/ui/button";
-import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
-import { useState } from "react";
-import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
-import { useOrgContext } from "@app/hooks/useOrgContext";
-import { toast } from "@app/hooks/useToast";
-import { RolesDataTable } from "@app/components/RolesDataTable";
-import { Role } from "@server/db";
import CreateRoleForm from "@app/components/CreateRoleForm";
import DeleteRoleForm from "@app/components/DeleteRoleForm";
-import { createApiClient } from "@app/lib/api";
+import { RolesDataTable } from "@app/components/RolesDataTable";
+import { Button } from "@app/components/ui/button";
+import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { useEnvContext } from "@app/hooks/useEnvContext";
+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 { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
+import { useState } from "react";
+import { Switch } from "./ui/switch";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
export type RoleRow = Role;
@@ -41,6 +35,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
+ const { isPaidUser } = usePaidStatus();
const t = useTranslations();
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -86,6 +81,32 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
friendlyName: t("description"),
header: () => {t("description")}
},
+
+ ...(isPaidUser
+ ? ([
+ {
+ accessorKey: "requireDeviceApproval",
+ friendlyName: t("requireDeviceApproval"),
+ header: () => (
+
+ {t("requireDeviceApproval")}
+
+ ),
+ cell: ({ row }) => (
+ {
+ // ...
+ }}
+ />
+ )
+ }
+ ] as ExtendedColumnDef[])
+ : []),
+
{
id: "actions",
enableHiding: false,
From bbca200ceb0003113b1e2b52f1917745615cefa9 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Tue, 6 Jan 2026 01:51:54 +0100
Subject: [PATCH 05/46] =?UTF-8?q?=F0=9F=99=88=20do=20not=20include=20claud?=
=?UTF-8?q?e.md=20in=20gitignore?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 700963cc..df9179a4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,4 +50,5 @@ dynamic/
*.mmdb
scratch/
tsconfig.json
-hydrateSaas.ts
\ No newline at end of file
+hydrateSaas.ts
+CLAUDE.md
\ No newline at end of file
From abfe476cb9f7710f41d0a6be139d6b0a03590d0e Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Tue, 6 Jan 2026 02:02:09 +0100
Subject: [PATCH 06/46] =?UTF-8?q?=F0=9F=9A=A7=20wip?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/CreateRoleForm.tsx | 62 +++++++++++++++++--------------
1 file changed, 35 insertions(+), 27 deletions(-)
diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx
index 30c37693..1f6914fe 100644
--- a/src/components/CreateRoleForm.tsx
+++ b/src/components/CreateRoleForm.tsx
@@ -35,6 +35,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { CheckboxWithLabel } from "./ui/checkbox";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import { build } from "@server/build";
+import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
type CreateRoleFormProps = {
open: boolean;
@@ -165,35 +167,41 @@ export default function CreateRoleForm({
)}
/>
- {isPaidUser && (
- (
-
-
-
+
+ (
+
+
+
+
+
+
+ {t(
+ "requireDeviceApprovalDescription"
)}
- />
-
+
-
- {t(
- "requireDeviceApprovalDescription"
- )}
-
-
-
-
- )}
- />
+
+
+ )}
+ />
+ >
)}
From 39bebea5f77e01f5a7e3d2af2b0d7f9aa014a71c Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Thu, 8 Jan 2026 03:33:03 +0100
Subject: [PATCH 07/46] =?UTF-8?q?=E2=9C=A8=20create=20&=20update=20role=20?=
=?UTF-8?q?with=20device=20approval?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en-US.json | 5 +
server/routers/external.ts | 8 +
server/routers/integration.ts | 8 +
server/routers/role/createRole.ts | 10 +-
server/routers/role/updateRole.ts | 22 +-
.../[orgId]/settings/access/roles/page.tsx | 2 +-
src/components/CreateRoleForm.tsx | 101 ++++----
src/components/Credenza.tsx | 8 +-
src/components/EditRoleForm.tsx | 241 ++++++++++++++++++
src/components/MachineClientsTable.tsx | 11 +-
src/components/RolesTable.tsx | 149 +++++++----
src/components/ui/checkbox.tsx | 12 +-
12 files changed, 449 insertions(+), 128 deletions(-)
create mode 100644 src/components/EditRoleForm.tsx
diff --git a/messages/en-US.json b/messages/en-US.json
index a805c2f0..dbccd6d6 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -734,6 +734,11 @@
"accessRoleCreatedDescription": "The role has been successfully created.",
"accessRoleErrorCreate": "Failed to create role",
"accessRoleErrorCreateDescription": "An error occurred while creating the role.",
+ "accessRoleUpdateSubmit": "Update Role",
+ "accessRoleUpdated": "Role updated",
+ "accessRoleUpdatedDescription": "The role has been successfully updated.",
+ "accessRoleErrorUpdate": "Failed to update role",
+ "accessRoleErrorUpdateDescription": "An error occurred while updating the role.",
"accessRoleErrorNewRequired": "New role is required",
"accessRoleErrorRemove": "Failed to remove role",
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
diff --git a/server/routers/external.ts b/server/routers/external.ts
index cb5328ab..bdea5ae9 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -554,6 +554,14 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.listRoles),
role.listRoles
);
+
+authenticated.post(
+ "/org/:orgId/role/:roleId",
+ verifyOrgAccess,
+ verifyUserHasAction(ActionsEnum.updateRole),
+ logActionAudit(ActionsEnum.updateRole),
+ role.updateRole
+);
// authenticated.get(
// "/role/:roleId",
// verifyRoleAccess,
diff --git a/server/routers/integration.ts b/server/routers/integration.ts
index 4250458a..7d5a43dd 100644
--- a/server/routers/integration.ts
+++ b/server/routers/integration.ts
@@ -467,6 +467,14 @@ authenticated.put(
role.createRole
);
+authenticated.post(
+ "/org/:orgId/role/:roleId",
+ verifyApiKeyOrgAccess,
+ verifyApiKeyHasAction(ActionsEnum.updateRole),
+ logActionAudit(ActionsEnum.updateRole),
+ role.updateRole
+);
+
authenticated.get(
"/org/:orgId/roles",
verifyApiKeyOrgAccess,
diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts
index 16696af4..a1e21d7a 100644
--- a/server/routers/role/createRole.ts
+++ b/server/routers/role/createRole.ts
@@ -10,6 +10,8 @@ import { fromError } from "zod-validation-error";
import { ActionsEnum } from "@server/auth/actions";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
+import { build } from "@server/build";
+import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
const createRoleParamsSchema = z.strictObject({
orgId: z.string()
@@ -17,7 +19,8 @@ const createRoleParamsSchema = z.strictObject({
const createRoleSchema = z.strictObject({
name: z.string().min(1).max(255),
- description: z.string().optional()
+ description: z.string().optional(),
+ requireDeviceApproval: z.boolean().optional()
});
export const defaultRoleAllowedActions: ActionsEnum[] = [
@@ -97,6 +100,11 @@ export async function createRole(
);
}
+ const isLicensed = await isLicensedOrSubscribed(orgId);
+ if (build === "oss" || !isLicensed) {
+ roleData.requireDeviceApproval = undefined;
+ }
+
await db.transaction(async (trx) => {
const newRole = await trx
.insert(roles)
diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts
index c9f63a7b..0eeef100 100644
--- a/server/routers/role/updateRole.ts
+++ b/server/routers/role/updateRole.ts
@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
-import { db } from "@server/db";
+import { db, orgs, type Role } from "@server/db";
import { roles } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -8,20 +8,28 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
+import { build } from "@server/build";
+import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
const updateRoleParamsSchema = z.strictObject({
+ orgId: z.string(),
roleId: z.string().transform(Number).pipe(z.int().positive())
});
const updateRoleBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
- description: z.string().optional()
+ description: z.string().optional(),
+ requireDeviceApproval: z.boolean().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
});
+export type UpdateRoleBody = z.infer;
+
+export type UpdateRoleResponse = Role;
+
export async function updateRole(
req: Request,
res: Response,
@@ -48,13 +56,14 @@ export async function updateRole(
);
}
- const { roleId } = parsedParams.data;
+ const { roleId, orgId } = parsedParams.data;
const updateData = parsedBody.data;
const role = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
+ .innerJoin(orgs, eq(roles.orgId, orgs.orgId))
.limit(1);
if (role.length === 0) {
@@ -66,7 +75,7 @@ export async function updateRole(
);
}
- if (role[0].isAdmin) {
+ if (role[0].roles.isAdmin) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -75,6 +84,11 @@ export async function updateRole(
);
}
+ const isLicensed = await isLicensedOrSubscribed(orgId);
+ if (build === "oss" || !isLicensed) {
+ updateData.requireDeviceApproval = undefined;
+ }
+
const updatedRole = await db
.update(roles)
.set(updateData)
diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx
index c4818abe..93649816 100644
--- a/src/app/[orgId]/settings/access/roles/page.tsx
+++ b/src/app/[orgId]/settings/access/roles/page.tsx
@@ -5,7 +5,7 @@ import { GetOrgResponse } from "@server/routers/org";
import { cache } from "react";
import OrgProvider from "@app/providers/OrgProvider";
import { ListRolesResponse } from "@server/routers/role";
-import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
+import RolesTable, { type RoleRow } from "@app/components/RolesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx
index 1f6914fe..8108461d 100644
--- a/src/components/CreateRoleForm.tsx
+++ b/src/components/CreateRoleForm.tsx
@@ -1,5 +1,15 @@
"use client";
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import {
Form,
@@ -11,37 +21,26 @@ import {
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useOrgContext } from "@app/hooks/useOrgContext";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
+import { build } from "@server/build";
+import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
-import { useState } from "react";
+import { useTranslations } from "next-intl";
+import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
-import {
- Credenza,
- CredenzaBody,
- CredenzaClose,
- CredenzaContent,
- CredenzaDescription,
- CredenzaFooter,
- CredenzaHeader,
- CredenzaTitle
-} from "@app/components/Credenza";
-import { useOrgContext } from "@app/hooks/useOrgContext";
-import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
-import { formatAxiosError } from "@app/lib/api";
-import { createApiClient } from "@app/lib/api";
-import { useEnvContext } from "@app/hooks/useEnvContext";
-import { useTranslations } from "next-intl";
-import { CheckboxWithLabel } from "./ui/checkbox";
-import { usePaidStatus } from "@app/hooks/usePaidStatus";
-import { build } from "@server/build";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
+import { CheckboxWithLabel } from "./ui/checkbox";
type CreateRoleFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
- afterCreate?: (res: CreateRoleResponse) => Promise;
+ afterCreate?: (res: CreateRoleResponse) => void;
};
export default function CreateRoleForm({
@@ -54,13 +53,14 @@ export default function CreateRoleForm({
const { isPaidUser } = usePaidStatus();
const formSchema = z.object({
- name: z.string({ message: t("nameRequired") }).max(32),
+ name: z
+ .string({ message: t("nameRequired") })
+ .min(1)
+ .max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional()
});
- const [loading, setLoading] = useState(false);
-
const api = createApiClient(useEnvContext());
const form = useForm>({
@@ -72,17 +72,13 @@ export default function CreateRoleForm({
}
});
- async function onSubmit(values: z.infer) {
- setLoading(true);
+ const [loading, startTransition] = useTransition();
+ async function onSubmit(values: z.infer) {
const res = await api
- .put>(
- `/org/${org?.org.orgId}/role`,
- {
- name: values.name,
- description: values.description
- } as CreateRoleBody
- )
+ .put<
+ AxiosResponse
+ >(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
.catch((e) => {
toast({
variant: "destructive",
@@ -105,12 +101,8 @@ export default function CreateRoleForm({
setOpen(false);
}
- if (afterCreate) {
- afterCreate(res.data.data);
- }
+ afterCreate?.(res.data.data);
}
-
- setLoading(false);
}
return (
@@ -119,7 +111,6 @@ export default function CreateRoleForm({
open={open}
onOpenChange={(val) => {
setOpen(val);
- setLoading(false);
form.reset();
}}
>
@@ -133,7 +124,9 @@ export default function CreateRoleForm({
diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx
index 0446500c..bda0e7b4 100644
--- a/src/components/Credenza.tsx
+++ b/src/components/Credenza.tsx
@@ -78,10 +78,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return (
-
+
{children}
);
@@ -172,14 +169,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const isDesktop = useMediaQuery(desktop);
- // const isDesktop = true;
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return (
void;
+ onSuccess?: (res: CreateRoleResponse) => void;
+};
+
+export default function EditRoleForm({
+ open,
+ role,
+ setOpen,
+ onSuccess
+}: CreateRoleFormProps) {
+ const { org } = useOrgContext();
+ const t = useTranslations();
+ const { isPaidUser } = usePaidStatus();
+
+ const formSchema = z.object({
+ name: z
+ .string({ message: t("nameRequired") })
+ .min(1)
+ .max(32),
+ description: z.string().max(255).optional(),
+ requireDeviceApproval: z.boolean().optional()
+ });
+
+ const api = createApiClient(useEnvContext());
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ defaultValues: {
+ name: role.name,
+ description: role.description ?? "",
+ requireDeviceApproval: role.requireDeviceApproval ?? false
+ }
+ });
+
+ const [loading, startTransition] = useTransition();
+
+ async function onSubmit(values: z.infer) {
+ const res = await api
+ .post<
+ AxiosResponse
+ >(`/org/${org?.org.orgId}/role/${role.roleId}`, values satisfies UpdateRoleBody)
+ .catch((e) => {
+ toast({
+ variant: "destructive",
+ title: t("accessRoleErrorUpdate"),
+ description: formatAxiosError(
+ e,
+ t("accessRoleErrorUpdateDescription")
+ )
+ });
+ });
+
+ if (res && res.status === 200) {
+ toast({
+ variant: "default",
+ title: t("accessRoleUpdated"),
+ description: t("accessRoleUpdatedDescription")
+ });
+
+ if (open) {
+ setOpen(false);
+ }
+
+ onSuccess?.(res.data.data);
+ }
+ }
+
+ return (
+ <>
+ {
+ setOpen(val);
+ form.reset();
+ }}
+ >
+
+
+ {t("accessRoleCreate")}
+
+ {t("accessRoleCreateDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
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 (
-
+