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({
+ startTransition(() => onSubmit(values)) + )} className="space-y-4" id="create-role-form" > @@ -159,6 +160,56 @@ export default function CreateRoleForm({ )} /> + {build !== "oss" && ( +
+ + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + + + + {t( + "requireDeviceApprovalDescription" + )} + + + + + )} + /> +
+ )}
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 ( - + + + + + + + + ); +} 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 ( -
+