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
diff --git a/messages/en-US.json b/messages/en-US.json
index 82b419bb..c799a0cb 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -257,6 +257,8 @@
"accessRolesSearch": "Search roles...",
"accessRolesAdd": "Add Role",
"accessRoleDelete": "Delete Role",
+ "accessApprovalsManage": "Manage Approvals",
+ "accessApprovalsDescription": "Manage approval requests in the organization",
"description": "Description",
"inviteTitle": "Open Invitations",
"inviteDescription": "Manage invitations for other users to join the organization",
@@ -450,6 +452,18 @@
"selectDuration": "Select duration",
"selectResource": "Select Resource",
"filterByResource": "Filter By Resource",
+ "selectApprovalState": "Select Approval State",
+ "filterByApprovalState": "Filter By Approval State",
+ "approvalListEmpty": "No approvals",
+ "approvalState": "Approval State",
+ "approve": "Approve",
+ "approved": "Approved",
+ "denied": "Denied",
+ "deniedApproval": "Denied Approval",
+ "all": "All",
+ "deny": "Deny",
+ "viewDetails": "View Details",
+ "requestingNewDeviceApproval": "requested a new device",
"resetFilters": "Reset Filters",
"totalBlocked": "Requests Blocked By Pangolin",
"totalRequests": "Total Requests",
@@ -729,16 +743,28 @@
"countries": "Countries",
"accessRoleCreate": "Create Role",
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
+ "accessRoleEdit": "Edit Role",
+ "accessRoleEditDescription": "Edit role information.",
"accessRoleCreateSubmit": "Create Role",
"accessRoleCreated": "Role created",
"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.",
+ "accessApprovalUpdated": "Approval processed",
+ "accessApprovalApprovedDescription": "Set Approval Request decision to approved.",
+ "accessApprovalDeniedDescription": "Set Approval Request decision to denied.",
+ "accessRoleErrorUpdate": "Failed to update role",
+ "accessRoleErrorUpdateDescription": "An error occurred while updating the role.",
+ "accessApprovalErrorUpdate": "Failed to process approval",
+ "accessApprovalErrorUpdateDescription": "An error occurred while processing the approval.",
"accessRoleErrorNewRequired": "New role is required",
"accessRoleErrorRemove": "Failed to remove role",
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
"accessRoleName": "Role Name",
- "accessRoleQuestionRemove": "You're about to delete the {name} role. You cannot undo this action.",
+ "accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.",
"accessRoleRemove": "Remove Role",
"accessRoleRemoveDescription": "Remove a role from the organization",
"accessRoleRemoveSubmit": "Remove Role",
@@ -1193,6 +1219,7 @@
"sidebarOverview": "Overview",
"sidebarHome": "Home",
"sidebarSites": "Sites",
+ "sidebarApprovals": "Approval Requests",
"sidebarResources": "Resources",
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",
@@ -1308,6 +1335,7 @@
"refreshError": "Failed to refresh data",
"verified": "Verified",
"pending": "Pending",
+ "pendingApproval": "Pending Approval",
"sidebarBilling": "Billing",
"billing": "Billing",
"orgBillingDescription": "Manage billing information and subscriptions",
@@ -1551,6 +1579,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/auth/actions.ts b/server/auth/actions.ts
index ea3ab6d1..094437f4 100644
--- a/server/auth/actions.ts
+++ b/server/auth/actions.ts
@@ -129,7 +129,9 @@ export enum ActionsEnum {
getBlueprint = "getBlueprint",
applyBlueprint = "applyBlueprint",
viewLogs = "viewLogs",
- exportLogs = "exportLogs"
+ exportLogs = "exportLogs",
+ listApprovals = "listApprovals",
+ updateApprovals = "updateApprovals"
}
export async function checkUserActionPermission(
diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts
index 1f30dbf5..3900f46a 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,
+ clients
+} from "./schema";
export const certificates = pgTable("certificates", {
certId: serial("certId").primaryKey(),
@@ -289,6 +297,33 @@ export const accessAuditLog = pgTable(
]
);
+export const approvals = pgTable("approvals", {
+ approvalId: serial("approvalId").primaryKey(),
+ timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
+ orgId: varchar("orgId")
+ .references(() => orgs.orgId, {
+ onDelete: "cascade"
+ })
+ .notNull(),
+ clientId: integer("clientId").references(() => clients.clientId, {
+ onDelete: "cascade"
+ }), // clients reference user devices (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"
+ })
+ .notNull(),
+ decision: varchar("decision")
+ .$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/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 95619887..9fb1932c 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -365,7 +365,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", {
@@ -691,7 +692,10 @@ export const clients = pgTable("clients", {
lastHolePunch: integer("lastHolePunch"),
maxConnections: integer("maxConnections"),
archived: boolean("archived").notNull().default(false),
- blocked: boolean("blocked").notNull().default(false)
+ blocked: boolean("blocked").notNull().default(false),
+ approvalState: varchar("approvalState").$type<
+ "pending" | "approved" | "denied"
+ >()
});
export const clientSitesAssociationsCache = pgTable(
diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts
index af7d021d..32aa543e 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 { clients, domains, exitNodes, orgs, sessions, users } from "./schema";
export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -289,6 +289,31 @@ export const accessAuditLog = sqliteTable(
]
);
+export const approvals = sqliteTable("approvals", {
+ approvalId: integer("approvalId").primaryKey({ autoIncrement: true }),
+ timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
+ orgId: text("orgId")
+ .references(() => orgs.orgId, {
+ onDelete: "cascade"
+ })
+ .notNull(),
+ clientId: integer("clientId").references(() => clients.clientId, {
+ onDelete: "cascade"
+ }), // olms reference user devices clients
+ userId: text("userId").references(() => users.userId, {
+ // optionally tied to a user and in this case delete when the user deletes
+ onDelete: "cascade"
+ }),
+ decision: text("decision")
+ .$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;
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 3aabc623..8fe0152a 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -387,7 +387,10 @@ export const clients = sqliteTable("clients", {
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
- blocked: integer("blocked", { mode: "boolean" }).notNull().default(false)
+ blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
+ approvalState: text("approvalState").$type<
+ "pending" | "approved" | "denied"
+ >()
});
export const clientSitesAssociationsCache = sqliteTable(
@@ -604,7 +607,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/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts
index ac3d719f..0b4a131a 100644
--- a/server/lib/calculateUserClientsForOrgs.ts
+++ b/server/lib/calculateUserClientsForOrgs.ts
@@ -1,21 +1,24 @@
+import { listExitNodes } from "#dynamic/lib/exitNodes";
+import { build } from "@server/build";
import {
+ approvals,
clients,
db,
olms,
orgs,
roleClients,
roles,
+ Transaction,
userClients,
- userOrgs,
- Transaction
+ userOrgs
} from "@server/db";
-import { eq, and, notInArray } from "drizzle-orm";
-import { listExitNodes } from "#dynamic/lib/exitNodes";
-import { getNextAvailableClientSubnet } from "@server/lib/ip";
-import logger from "@server/logger";
-import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
-import { sendTerminateClient } from "@server/routers/client/terminate";
import { getUniqueClientName } from "@server/db/names";
+import { getNextAvailableClientSubnet } from "@server/lib/ip";
+import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
+import logger from "@server/logger";
+import { sendTerminateClient } from "@server/routers/client/terminate";
+import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
+import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
export async function calculateUserClientsForOrgs(
userId: string,
@@ -38,13 +41,15 @@ export async function calculateUserClientsForOrgs(
const allUserOrgs = await transaction
.select()
.from(userOrgs)
+ .innerJoin(roles, eq(roles.roleId, userOrgs.roleId))
.where(eq(userOrgs.userId, userId));
- const userOrgIds = allUserOrgs.map((uo) => uo.orgId);
+ const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId);
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
- for (const userOrg of allUserOrgs) {
+ for (const userRoleOrg of allUserOrgs) {
+ const { userOrgs: userOrg, roles: role } = userRoleOrg;
const orgId = userOrg.orgId;
const [org] = await transaction
@@ -182,21 +187,46 @@ export async function calculateUserClientsForOrgs(
const niceId = await getUniqueClientName(orgId);
+ const isOrgLicensed = await isLicensedOrSubscribed(
+ userOrg.orgId
+ );
+ const requireApproval =
+ build !== "oss" &&
+ isOrgLicensed &&
+ role.requireDeviceApproval;
+
+ const newClientData: InferInsertModel = {
+ userId,
+ orgId: userOrg.orgId,
+ exitNodeId: randomExitNode.exitNodeId,
+ name: olm.name || "User Client",
+ subnet: updatedSubnet,
+ olmId: olm.olmId,
+ type: "olm",
+ niceId,
+ approvalState: requireApproval ? "pending" : null
+ };
+
// Create the client
const [newClient] = await transaction
.insert(clients)
- .values({
- userId,
- orgId: userOrg.orgId,
- exitNodeId: randomExitNode.exitNodeId,
- name: olm.name || "User Client",
- subnet: updatedSubnet,
- olmId: olm.olmId,
- type: "olm",
- niceId
- })
+ .values(newClientData)
.returning();
+ // create approval request
+ if (requireApproval) {
+ await transaction
+ .insert(approvals)
+ .values({
+ timestamp: Math.floor(new Date().getTime() / 1000),
+ orgId: userOrg.orgId,
+ clientId: newClient.clientId,
+ userId,
+ type: "user_device"
+ })
+ .returning();
+ }
+
await rebuildClientAssociationsFromClient(
newClient,
transaction
diff --git a/server/private/routers/approvals/index.ts b/server/private/routers/approvals/index.ts
new file mode 100644
index 00000000..40e59cc9
--- /dev/null
+++ b/server/private/routers/approvals/index.ts
@@ -0,0 +1,15 @@
+/*
+ * 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";
+export * from "./processPendingApproval";
diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts
new file mode 100644
index 00000000..6006e48b
--- /dev/null
+++ b/server/private/routers/approvals/listApprovals.ts
@@ -0,0 +1,188 @@
+/*
+ * 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, clients, db, users, type Approval } from "@server/db";
+import { eq, isNull, sql, not, and, desc } 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()),
+ approvalState: z
+ .enum(["pending", "approved", "denied", "all"])
+ .optional()
+ .default("all")
+ .catch("all")
+});
+
+async function queryApprovals(
+ orgId: string,
+ limit: number,
+ offset: number,
+ approvalState: z.infer["approvalState"]
+) {
+ let state: Array = [];
+ switch (approvalState) {
+ case "pending":
+ state = ["pending"];
+ break;
+ case "approved":
+ state = ["approved"];
+ break;
+ case "denied":
+ state = ["denied"];
+ break;
+ default:
+ state = ["approved", "denied", "pending"];
+ }
+
+ const res = await db
+ .select({
+ approvalId: approvals.approvalId,
+ orgId: approvals.orgId,
+ clientId: approvals.clientId,
+ decision: approvals.decision,
+ type: approvals.type,
+ user: {
+ name: users.name,
+ userId: users.userId,
+ username: users.username
+ }
+ })
+ .from(approvals)
+ .innerJoin(users, and(eq(approvals.userId, users.userId)))
+ .leftJoin(
+ clients,
+ and(
+ eq(approvals.clientId, clients.clientId),
+ not(isNull(clients.userId)) // only user devices
+ )
+ )
+ .where(
+ and(
+ eq(approvals.orgId, orgId),
+ sql`${approvals.decision} in ${state}`
+ )
+ )
+ .orderBy(
+ sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
+ desc(approvals.timestamp)
+ )
+ .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, approvalState } = 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,
+ approvalState
+ );
+
+ 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/approvals/processPendingApproval.ts b/server/private/routers/approvals/processPendingApproval.ts
new file mode 100644
index 00000000..43639dd7
--- /dev/null
+++ b/server/private/routers/approvals/processPendingApproval.ts
@@ -0,0 +1,142 @@
+/*
+ * 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 { build } from "@server/build";
+import { approvals, clients, db, orgs, type Approval } from "@server/db";
+import { getOrgTierData } from "@server/lib/billing";
+import { TierId } from "@server/lib/billing/tiers";
+import response from "@server/lib/response";
+import { and, eq, type InferInsertModel } from "drizzle-orm";
+import type { NextFunction, Request, Response } from "express";
+
+const paramsSchema = z.strictObject({
+ orgId: z.string(),
+ approvalId: z.string().transform(Number).pipe(z.int().positive())
+});
+
+const bodySchema = z.strictObject({
+ decision: z.enum(["approved", "denied"])
+});
+
+export type ProcessApprovalResponse = Approval;
+
+export async function processPendingApproval(
+ 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 parsedBody = bodySchema.safeParse(req.body);
+
+ if (!parsedBody.success) {
+ return next(
+ createHttpError(
+ HttpCode.BAD_REQUEST,
+ fromError(parsedBody.error).toString()
+ )
+ );
+ }
+
+ const { orgId, approvalId } = 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 updateData = parsedBody.data;
+
+ const approval = await db
+ .select()
+ .from(approvals)
+ .where(
+ and(
+ eq(approvals.approvalId, approvalId),
+ eq(approvals.decision, "pending")
+ )
+ )
+ .innerJoin(orgs, eq(approvals.orgId, approvals.orgId))
+ .limit(1);
+
+ if (approval.length === 0) {
+ return next(
+ createHttpError(
+ HttpCode.NOT_FOUND,
+ `Pending Approval with ID ${approvalId} not found`
+ )
+ );
+ }
+
+ const [updatedApproval] = await db
+ .update(approvals)
+ .set(updateData)
+ .where(eq(approvals.approvalId, approvalId))
+ .returning();
+
+ // Update user device approval state too
+ if (
+ updatedApproval.type === "user_device" &&
+ updatedApproval.clientId
+ ) {
+ const updateDataBody: Partial> = {
+ approvalState: updateData.decision
+ };
+
+ if (updateData.decision === "denied") {
+ updateDataBody.blocked = true;
+ }
+
+ await db
+ .update(clients)
+ .set(updateDataBody)
+ .where(eq(clients.clientId, updatedApproval.clientId));
+ }
+
+ return response(res, {
+ data: updatedApproval,
+ success: true,
+ error: false,
+ message: "Approval updated 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 97c6db9f..44af3fe9 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,24 @@ authenticated.get(
loginPage.getLoginPage
);
+authenticated.get(
+ "/org/:orgId/approvals",
+ verifyValidLicense,
+ verifyOrgAccess,
+ verifyUserHasAction(ActionsEnum.listApprovals),
+ logActionAudit(ActionsEnum.listApprovals),
+ approval.listApprovals
+);
+
+authenticated.put(
+ "/org/:orgId/approvals/:approvalId",
+ verifyValidLicense,
+ verifyOrgAccess,
+ verifyUserHasAction(ActionsEnum.updateApprovals),
+ logActionAudit(ActionsEnum.updateApprovals),
+ approval.processPendingApproval
+);
+
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/client/blockClient.ts b/server/routers/client/blockClient.ts
index e1a00ff6..68ae64f8 100644
--- a/server/routers/client/blockClient.ts
+++ b/server/routers/client/blockClient.ts
@@ -73,7 +73,7 @@ export async function blockClient(
// Block the client
await trx
.update(clients)
- .set({ blocked: true })
+ .set({ blocked: true, approvalState: "denied" })
.where(eq(clients.clientId, clientId));
// Send terminate signal if there's an associated OLM and it's connected
diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts
index a157c674..99857261 100644
--- a/server/routers/client/listClients.ts
+++ b/server/routers/client/listClients.ts
@@ -139,6 +139,7 @@ function queryClients(
userEmail: users.email,
niceId: clients.niceId,
agent: olms.agent,
+ approvalState: clients.approvalState,
olmArchived: olms.archived,
archived: clients.archived,
blocked: clients.blocked,
diff --git a/server/routers/client/unblockClient.ts b/server/routers/client/unblockClient.ts
index 82b608a2..a16a1030 100644
--- a/server/routers/client/unblockClient.ts
+++ b/server/routers/client/unblockClient.ts
@@ -71,7 +71,7 @@ export async function unblockClient(
// Unblock the client
await db
.update(clients)
- .set({ blocked: false })
+ .set({ blocked: false, approvalState: null })
.where(eq(clients.clientId, clientId));
return response(res, {
diff --git a/server/routers/external.ts b/server/routers/external.ts
index 2930331c..3ea60983 100644
--- a/server/routers/external.ts
+++ b/server/routers/external.ts
@@ -586,6 +586,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 3373285b..7a5a3efe 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/listRoles.ts b/server/routers/role/listRoles.ts
index 288a540d..ec7f3b4b 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()
@@ -38,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/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/(private)/access/approvals/page.tsx b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx
new file mode 100644
index 00000000..0fef0d78
--- /dev/null
+++ b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx
@@ -0,0 +1,52 @@
+import { ApprovalFeed } from "@app/components/ApprovalFeed";
+import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import { internal } from "@app/lib/api";
+import { authCookieHeader } from "@app/lib/api/cookies";
+import { getCachedOrg } from "@app/lib/api/getCachedOrg";
+import type { ApprovalItem } from "@app/lib/queries";
+import OrgProvider from "@app/providers/OrgProvider";
+import type { GetOrgResponse } from "@server/routers/org";
+import type { AxiosResponse } from "axios";
+import { getTranslations } from "next-intl/server";
+
+export interface ApprovalFeedPageProps {
+ params: Promise<{ orgId: string }>;
+}
+
+export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
+ const params = await props.params;
+
+ let approvals: ApprovalItem[] = [];
+ const res = await internal
+ .get<
+ AxiosResponse<{ approvals: ApprovalItem[] }>
+ >(`/org/${params.orgId}/approvals`, await authCookieHeader())
+ .catch((e) => {});
+
+ if (res && res.status === 200) {
+ approvals = res.data.data.approvals;
+ }
+
+ let org: GetOrgResponse | null = null;
+ const orgRes = await getCachedOrg(params.orgId);
+
+ if (orgRes && orgRes.status === 200) {
+ org = orgRes.data.data;
+ }
+
+ const t = await getTranslations();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
index f6260073..5ae4f237 100644
--- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx
+++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
import {
SettingsContainer,
SettingsSection,
@@ -10,6 +11,10 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
+import HeaderTitle from "@app/components/SettingsSectionTitle";
+import { StrategySelect } from "@app/components/StrategySelect";
+import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
+import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
@@ -19,29 +24,21 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
-import HeaderTitle from "@app/components/SettingsSectionTitle";
-import { z } from "zod";
-import { createElement, useEffect, useState } from "react";
-import { useForm } from "react-hook-form";
-import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
-import { Button } from "@app/components/ui/button";
-import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
-import { toast } from "@app/hooks/useToast";
-import { useParams, useRouter } from "next/navigation";
-import { Checkbox } from "@app/components/ui/checkbox";
-import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
-import { InfoIcon, ExternalLink } from "lucide-react";
-import { StrategySelect } from "@app/components/StrategySelect";
-import { SwitchInput } from "@app/components/SwitchInput";
-import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { ListRolesResponse } from "@server/routers/role";
+import { AxiosResponse } from "axios";
+import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import Image from "next/image";
-import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
-import { AxiosResponse } from "axios";
-import { ListRolesResponse } from "@server/routers/role";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
export default function Page() {
const { env } = useEnvContext();
diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx
index c4818abe..7165d9e6 100644
--- a/src/app/[orgId]/settings/access/roles/page.tsx
+++ b/src/app/[orgId]/settings/access/roles/page.tsx
@@ -2,12 +2,12 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
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";
+import { getCachedOrg } from "@app/lib/api/getCachedOrg";
type RolesPageProps = {
params: Promise<{ orgId: string }>;
@@ -47,14 +47,7 @@ export default async function RolesPage(props: RolesPageProps) {
}
let org: GetOrgResponse | null = null;
- const getOrg = cache(async () =>
- internal
- .get<
- AxiosResponse
- >(`/org/${params.orgId}`, await authCookieHeader())
- .catch((e) => {})
- );
- const orgRes = await getOrg();
+ const orgRes = await getCachedOrg(params.orgId);
if (orgRes && orgRes.status === 200) {
org = orgRes.data.data;
diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx
index 6c39041c..b3e731e8 100644
--- a/src/app/[orgId]/settings/clients/machine/page.tsx
+++ b/src/app/[orgId]/settings/clients/machine/page.tsx
@@ -61,7 +61,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
niceId: client.niceId,
agent: client.agent,
archived: client.archived || false,
- blocked: client.blocked || false
+ blocked: client.blocked || false,
+ approvalState: client.approvalState ?? "approved"
};
};
diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx
index 79f9d800..dee24532 100644
--- a/src/app/[orgId]/settings/clients/user/page.tsx
+++ b/src/app/[orgId]/settings/clients/user/page.tsx
@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import { getTranslations } from "next-intl/server";
-import type { ClientRow } from "@app/components/MachineClientsTable";
+import type { ClientRow } from "@app/components/UserDevicesTable";
import UserDevicesTable from "@app/components/UserDevicesTable";
type ClientsPageProps = {
@@ -57,7 +57,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
niceId: client.niceId,
agent: client.agent,
archived: client.archived || false,
- blocked: client.blocked || false
+ blocked: client.blocked || false,
+ approvalState: client.approvalState
};
};
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx
index 132f0c05..85f0e2b1 100644
--- a/src/app/[orgId]/settings/sites/page.tsx
+++ b/src/app/[orgId]/settings/sites/page.tsx
@@ -2,10 +2,9 @@ import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
-import SitesTable, { SiteRow } from "../../../../components/SitesTable";
+import SitesTable, { SiteRow } from "@app/components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesBanner from "@app/components/SitesBanner";
-import SitesSplashCard from "../../../../components/SitesSplashCard";
import { getTranslations } from "next-intl/server";
type SitesPageProps = {
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx
index 345b5a4c..4fb5430c 100644
--- a/src/app/navigation.tsx
+++ b/src/app/navigation.tsx
@@ -2,27 +2,27 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
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 = (env?: Env): SidebarNavSection[] => [
href: "/{orgId}/settings/access/roles",
icon:
},
- ...(build == "saas" || env?.flags.useOrgOnlyIdp
+ ...(build === "saas" || env?.flags.useOrgOnlyIdp
? [
{
title: "sidebarIdentityProviders",
@@ -132,6 +132,15 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
}
]
: []),
+ ...(build !== "oss"
+ ? [
+ {
+ title: "sidebarApprovals",
+ href: "/{orgId}/settings/access/approvals",
+ icon:
+ }
+ ]
+ : []),
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx
new file mode 100644
index 00000000..b3d446b1
--- /dev/null
+++ b/src/components/ApprovalFeed.tsx
@@ -0,0 +1,243 @@
+"use client";
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { toast } from "@app/hooks/useToast";
+import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { cn } from "@app/lib/cn";
+import {
+ approvalFiltersSchema,
+ approvalQueries,
+ type ApprovalItem
+} from "@app/lib/queries";
+import { useQuery } from "@tanstack/react-query";
+import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { Fragment, useActionState } from "react";
+import { Badge } from "./ui/badge";
+import { Button } from "./ui/button";
+import { Card, CardHeader } from "./ui/card";
+import { Label } from "./ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from "./ui/select";
+import { Separator } from "./ui/separator";
+
+export type ApprovalFeedProps = {
+ orgId: string;
+};
+
+export function ApprovalFeed({ orgId }: ApprovalFeedProps) {
+ const searchParams = useSearchParams();
+ const path = usePathname();
+ const t = useTranslations();
+
+ const router = useRouter();
+
+ const filters = approvalFiltersSchema.parse(
+ Object.fromEntries(searchParams.entries())
+ );
+
+ const { data, isFetching, refetch } = useQuery(
+ approvalQueries.listApprovals(orgId, filters)
+ );
+
+ const approvals = data?.approvals ?? [];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {approvals.map((approval, index) => (
+
+ -
+ refetch()}
+ />
+
+ {index < approvals.length - 1 && }
+
+ ))}
+
+ {approvals.length === 0 && (
+ -
+ {t("approvalListEmpty")}
+
+ )}
+
+
+
+
+ );
+}
+
+type ApprovalRequestProps = {
+ approval: ApprovalItem;
+ orgId: string;
+ onSuccess?: () => void;
+};
+
+function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
+ const t = useTranslations();
+
+ const [_, formAction, isSubmitting] = useActionState(onSubmit, null);
+ const api = createApiClient(useEnvContext());
+
+ async function onSubmit(_previousState: any, formData: FormData) {
+ const decision = formData.get("decision");
+ const res = await api
+ .put(`/org/${orgId}/approvals/${approval.approvalId}`, { decision })
+ .catch((e) => {
+ toast({
+ variant: "destructive",
+ title: t("accessApprovalErrorUpdate"),
+ description: formatAxiosError(
+ e,
+ t("accessApprovalErrorUpdateDescription")
+ )
+ });
+ });
+ if (res && res.status === 200) {
+ const result = res.data.data;
+ toast({
+ variant: "default",
+ title: t("accessApprovalUpdated"),
+ description:
+ result.decision === "approved"
+ ? t("accessApprovalApprovedDescription")
+ : t("accessApprovalDeniedDescription")
+ });
+
+ onSuccess?.();
+ }
+ }
+
+ return (
+
+
+
+
+
+ {approval.user.username}
+
+
+ {approval.type === "user_device" && (
+ {t("requestingNewDeviceApproval")}
+ )}
+
+
+
+ {approval.decision === "pending" && (
+
+ )}
+ {approval.decision === "approved" && (
+
{t("approved")}
+ )}
+ {approval.decision === "denied" && (
+
{t("denied")}
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx
index b8df1f78..8108461d 100644
--- a/src/components/CreateRoleForm.tsx
+++ b/src/components/CreateRoleForm.tsx
@@ -1,21 +1,5 @@
"use client";
-import { Button } from "@app/components/ui/button";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage
-} from "@app/components/ui/form";
-import { Input } from "@app/components/ui/input";
-import { toast } from "@app/hooks/useToast";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { AxiosResponse } from "axios";
-import { useState } from "react";
-import { useForm } from "react-hook-form";
-import { z } from "zod";
import {
Credenza,
CredenzaBody,
@@ -26,17 +10,37 @@ import {
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
-import { useOrgContext } from "@app/hooks/useOrgContext";
-import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
-import { formatAxiosError } from "@app/lib/api";
-import { createApiClient } from "@app/lib/api";
+import { Button } from "@app/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ 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 { useTranslations } from "next-intl";
+import { useTransition } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+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({
@@ -46,35 +50,35 @@ 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()
+ 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>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
- description: ""
+ description: "",
+ requireDeviceApproval: false
}
});
- 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",
@@ -97,12 +101,8 @@ export default function CreateRoleForm({
setOpen(false);
}
- if (afterCreate) {
- afterCreate(res.data.data);
- }
+ afterCreate?.(res.data.data);
}
-
- setLoading(false);
}
return (
@@ -111,7 +111,6 @@ export default function CreateRoleForm({
open={open}
onOpenChange={(val) => {
setOpen(val);
- setLoading(false);
form.reset();
}}
>
@@ -125,7 +124,9 @@ export default function CreateRoleForm({
diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx
index 0446500c..26e84e5d 100644
--- a/src/components/Credenza.tsx
+++ b/src/components/Credenza.tsx
@@ -2,8 +2,6 @@
import * as React from "react";
-import { cn } from "@app/lib/cn";
-import { useMediaQuery } from "@app/hooks/useMediaQuery";
import {
Dialog,
DialogClose,
@@ -14,16 +12,9 @@ import {
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
-import {
- Drawer,
- DrawerClose,
- DrawerContent,
- DrawerDescription,
- DrawerFooter,
- DrawerHeader,
- DrawerTitle,
- DrawerTrigger
-} from "@/components/ui/drawer";
+import { DrawerClose } from "@/components/ui/drawer";
+import { useMediaQuery } from "@app/hooks/useMediaQuery";
+import { cn } from "@app/lib/cn";
import {
Sheet,
SheetContent,
@@ -78,10 +69,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return (
-
+
{children}
);
@@ -172,14 +160,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 (
{
export {
Credenza,
- CredenzaTrigger,
+ CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
+ CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
- CredenzaBody,
- CredenzaFooter
+ CredenzaTrigger
};
diff --git a/src/components/DismissableBanner.tsx b/src/components/DismissableBanner.tsx
index c4230c54..289c4ec2 100644
--- a/src/components/DismissableBanner.tsx
+++ b/src/components/DismissableBanner.tsx
@@ -71,10 +71,10 @@ export const DismissableBanner = ({
}
return (
-
+
{children && (
-
+
{children}
)}
diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx
new file mode 100644
index 00000000..7990ab92
--- /dev/null
+++ b/src/components/EditRoleForm.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import {
+ Credenza,
+ CredenzaBody,
+ CredenzaClose,
+ CredenzaContent,
+ CredenzaDescription,
+ CredenzaFooter,
+ CredenzaHeader,
+ CredenzaTitle
+} from "@app/components/Credenza";
+import { Button } from "@app/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ 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 { Role } from "@server/db";
+import type {
+ CreateRoleBody,
+ CreateRoleResponse,
+ UpdateRoleBody,
+ UpdateRoleResponse
+} from "@server/routers/role";
+import { AxiosResponse } from "axios";
+import { useTranslations } from "next-intl";
+import { useTransition } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
+import { CheckboxWithLabel } from "./ui/checkbox";
+
+type CreateRoleFormProps = {
+ role: Role;
+ open: boolean;
+ setOpen: (open: boolean) => 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("accessRoleEdit")}
+
+ {t("accessRoleEditDescription")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx
index 85d09664..71117be6 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,
@@ -16,7 +15,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ArrowRight,
ArrowUpDown,
- ArrowUpRight,
MoreHorizontal,
CircleSlash
} from "lucide-react";
@@ -25,7 +23,6 @@ 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;
@@ -45,6 +42,7 @@ export type ClientRow = {
agent: string | null;
archived?: boolean;
blocked?: boolean;
+ approvalState: "approved" | "pending" | "denied";
};
type ClientTableProps = {
@@ -214,7 +212,10 @@ export default function MachineClientsTable({
)}
{r.blocked && (
-
+
{t("blocked")}
@@ -410,7 +411,9 @@ export default function MachineClientsTable({
}}
>
- {clientRow.archived ? "Unarchive" : "Archive"}
+ {clientRow.archived
+ ? "Unarchive"
+ : "Archive"}
- {clientRow.blocked ? "Unblock" : "Block"}
+ {clientRow.blocked
+ ? "Unblock"
+ : "Block"}
{
+ filterFn: (
+ row: ClientRow,
+ selectedValues: (string | number | boolean)[]
+ ) => {
if (selectedValues.length === 0) return true;
const rowArchived = row.archived || false;
const rowBlocked = row.blocked || false;
const isActive = !rowArchived && !rowBlocked;
-
- if (selectedValues.includes("active") && isActive) return true;
- if (selectedValues.includes("archived") && rowArchived) return true;
- if (selectedValues.includes("blocked") && rowBlocked) return true;
+
+ if (selectedValues.includes("active") && isActive)
+ return true;
+ if (
+ selectedValues.includes("archived") &&
+ rowArchived
+ )
+ return true;
+ if (
+ selectedValues.includes("blocked") &&
+ rowBlocked
+ )
+ return true;
return false;
},
defaultValues: ["active"] // Default to showing active clients
diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx
index 6ecfbbac..210eee3c 100644
--- a/src/components/RolesTable.tsx
+++ b/src/components/RolesTable.tsx
@@ -1,27 +1,27 @@
"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 { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
+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;
@@ -29,27 +29,26 @@ 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 [roleToRemove, setRoleToRemove] = useState(null);
const api = createApiClient(useEnvContext());
const { org } = useOrgContext();
+ 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({
@@ -57,8 +56,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
description: t("refreshError"),
variant: "destructive"
});
- } finally {
- setIsRefreshing(false);
}
};
@@ -86,26 +83,74 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
friendlyName: t("description"),
header: () => {t("description")}
},
+ // {
+ // id: "actions",
+ // enableHiding: false,
+ // header: () => ,
+ // cell: ({ row }) => {
+ // const roleRow = row.original;
+
+ // return (
+ //
+ //
+ //
+ // );
+ // }
+ // },
{
id: "actions",
enableHiding: false,
header: () => ,
cell: ({ row }) => {
const roleRow = row.original;
-
return (
-
-
-
+ !roleRow.isAdmin && (
+
+
+
+
+
+
+ {
+ setRoleToRemove(roleRow);
+ setIsDeleteModalOpen(true);
+ }}
+ >
+
+ {t("delete")}
+
+
+
+
+
+
+ )
);
}
}
@@ -113,11 +158,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
return (
<>
+ {editingRole && (
+ {
+ // Delay refresh to allow modal to close smoothly
+ setTimeout(() => {
+ startTransition(async () => {
+ await refreshData().then(() =>
+ setEditingRole(null)
+ );
+ });
+ }, 150);
+ }}
+ />
+ )}
{
- setRoles((prev) => [...prev, role]);
+ afterCreate={() => {
+ startTransition(refreshData);
}}
/>
@@ -127,10 +190,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
setOpen={setIsDeleteModalOpen}
roleToDelete={roleToRemove}
afterDelete={() => {
- setRoles((prev) =>
- prev.filter((r) => r.roleId !== roleToRemove.roleId)
- );
- setUserToRemove(null);
+ startTransition(async () => {
+ await refreshData().then(() =>
+ setRoleToRemove(null)
+ );
+ });
}}
/>
)}
@@ -141,7 +205,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
createRole={() => {
setIsCreateModalOpen(true);
}}
- onRefresh={refreshData}
+ onRefresh={() => startTransition(refreshData)}
isRefreshing={isRefreshing}
/>
>
diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx
index 14d12373..b2a5fd8b 100644
--- a/src/components/UserDevicesTable.tsx
+++ b/src/components/UserDevicesTable.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,
@@ -24,9 +23,11 @@ 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";
import ClientDownloadBanner from "./ClientDownloadBanner";
+import { Badge } from "./ui/badge";
+import { build } from "@server/build";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
+import { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
export type ClientRow = {
id: number;
@@ -44,6 +45,7 @@ export type ClientRow = {
userEmail: string | null;
niceId: string;
agent: string | null;
+ approvalState: "approved" | "pending" | "denied" | null;
archived?: boolean;
blocked?: boolean;
};
@@ -210,11 +212,22 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)}
{r.blocked && (
-
+
{t("blocked")}
)}
+ {r.approvalState === "pending" && (
+
+ {t("pendingApproval")}
+
+ )}
);
}
@@ -272,33 +285,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
);
}
},
- // {
- // accessorKey: "siteName",
- // header: ({ column }) => {
- // return (
- //
- // );
- // },
- // cell: ({ row }) => {
- // const r = row.original;
- // return (
- //
- //
- //
- // );
- // }
- // },
{
accessorKey: "online",
friendlyName: "Connectivity",
@@ -460,7 +446,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}
}}
>
- {clientRow.archived ? "Unarchive" : "Archive"}
+
+ {clientRow.archived
+ ? "Unarchive"
+ : "Archive"}
+
{
@@ -472,7 +462,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}
}}
>
- {clientRow.blocked ? "Unblock" : "Block"}
+
+ {clientRow.blocked
+ ? "Unblock"
+ : "Block"}
+
{!clientRow.userId && (
// Machine client - also show delete option
@@ -482,7 +476,9 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
setIsDeleteModalOpen(true);
}}
>
- Delete
+
+ Delete
+
)}
@@ -570,32 +566,65 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
options: [
{
id: "active",
- label: t("active") || "Active",
+ label: t("active"),
value: "active"
},
+ {
+ id: "pending",
+ label: t("pendingApproval"),
+ value: "pending"
+ },
+ {
+ id: "denied",
+ label: t("deniedApproval"),
+ value: "denied"
+ },
{
id: "archived",
- label: t("archived") || "Archived",
+ label: t("archived"),
value: "archived"
},
{
id: "blocked",
- label: t("blocked") || "Blocked",
+ label: t("blocked"),
value: "blocked"
}
],
- filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
+ filterFn: (
+ row: ClientRow,
+ selectedValues: (string | number | boolean)[]
+ ) => {
if (selectedValues.length === 0) return true;
- const rowArchived = row.archived || false;
- const rowBlocked = row.blocked || false;
+ const rowArchived = row.archived;
+ const rowBlocked = row.blocked;
+ const approvalState = row.approvalState;
const isActive = !rowArchived && !rowBlocked;
-
- if (selectedValues.includes("active") && isActive) return true;
- if (selectedValues.includes("archived") && rowArchived) return true;
- if (selectedValues.includes("blocked") && rowBlocked) return true;
+
+ if (selectedValues.includes("active") && isActive)
+ return true;
+ if (
+ selectedValues.includes("pending") &&
+ approvalState === "pending"
+ )
+ return true;
+ if (
+ selectedValues.includes("denied") &&
+ approvalState === "denied"
+ )
+ return true;
+ if (
+ selectedValues.includes("archived") &&
+ rowArchived
+ )
+ return true;
+ if (
+ selectedValues.includes("blocked") &&
+ rowBlocked
+ )
+ return true;
return false;
},
- defaultValues: ["active"] // Default to showing active clients
+ defaultValues: ["active", "pending"] // Default to showing active clients
}
]}
/>
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 (
-
+