mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-11 05:06:39 +00:00
Merge pull request #2121 from Fredkiss3/feat/device-approvals
feat: device approvals
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,4 +50,5 @@ dynamic/
|
|||||||
*.mmdb
|
*.mmdb
|
||||||
scratch/
|
scratch/
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
hydrateSaas.ts
|
hydrateSaas.ts
|
||||||
|
CLAUDE.md
|
||||||
@@ -257,6 +257,8 @@
|
|||||||
"accessRolesSearch": "Search roles...",
|
"accessRolesSearch": "Search roles...",
|
||||||
"accessRolesAdd": "Add Role",
|
"accessRolesAdd": "Add Role",
|
||||||
"accessRoleDelete": "Delete Role",
|
"accessRoleDelete": "Delete Role",
|
||||||
|
"accessApprovalsManage": "Manage Approvals",
|
||||||
|
"accessApprovalsDescription": "Manage approval requests in the organization",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"inviteTitle": "Open Invitations",
|
"inviteTitle": "Open Invitations",
|
||||||
"inviteDescription": "Manage invitations for other users to join the organization",
|
"inviteDescription": "Manage invitations for other users to join the organization",
|
||||||
@@ -450,6 +452,18 @@
|
|||||||
"selectDuration": "Select duration",
|
"selectDuration": "Select duration",
|
||||||
"selectResource": "Select Resource",
|
"selectResource": "Select Resource",
|
||||||
"filterByResource": "Filter By 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",
|
"resetFilters": "Reset Filters",
|
||||||
"totalBlocked": "Requests Blocked By Pangolin",
|
"totalBlocked": "Requests Blocked By Pangolin",
|
||||||
"totalRequests": "Total Requests",
|
"totalRequests": "Total Requests",
|
||||||
@@ -729,16 +743,28 @@
|
|||||||
"countries": "Countries",
|
"countries": "Countries",
|
||||||
"accessRoleCreate": "Create Role",
|
"accessRoleCreate": "Create Role",
|
||||||
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
||||||
|
"accessRoleEdit": "Edit Role",
|
||||||
|
"accessRoleEditDescription": "Edit role information.",
|
||||||
"accessRoleCreateSubmit": "Create Role",
|
"accessRoleCreateSubmit": "Create Role",
|
||||||
"accessRoleCreated": "Role created",
|
"accessRoleCreated": "Role created",
|
||||||
"accessRoleCreatedDescription": "The role has been successfully created.",
|
"accessRoleCreatedDescription": "The role has been successfully created.",
|
||||||
"accessRoleErrorCreate": "Failed to create role",
|
"accessRoleErrorCreate": "Failed to create role",
|
||||||
"accessRoleErrorCreateDescription": "An error occurred while creating the 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",
|
"accessRoleErrorNewRequired": "New role is required",
|
||||||
"accessRoleErrorRemove": "Failed to remove role",
|
"accessRoleErrorRemove": "Failed to remove role",
|
||||||
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
||||||
"accessRoleName": "Role Name",
|
"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",
|
"accessRoleRemove": "Remove Role",
|
||||||
"accessRoleRemoveDescription": "Remove a role from the organization",
|
"accessRoleRemoveDescription": "Remove a role from the organization",
|
||||||
"accessRoleRemoveSubmit": "Remove Role",
|
"accessRoleRemoveSubmit": "Remove Role",
|
||||||
@@ -1193,6 +1219,7 @@
|
|||||||
"sidebarOverview": "Overview",
|
"sidebarOverview": "Overview",
|
||||||
"sidebarHome": "Home",
|
"sidebarHome": "Home",
|
||||||
"sidebarSites": "Sites",
|
"sidebarSites": "Sites",
|
||||||
|
"sidebarApprovals": "Approval Requests",
|
||||||
"sidebarResources": "Resources",
|
"sidebarResources": "Resources",
|
||||||
"sidebarProxyResources": "Public",
|
"sidebarProxyResources": "Public",
|
||||||
"sidebarClientResources": "Private",
|
"sidebarClientResources": "Private",
|
||||||
@@ -1308,6 +1335,7 @@
|
|||||||
"refreshError": "Failed to refresh data",
|
"refreshError": "Failed to refresh data",
|
||||||
"verified": "Verified",
|
"verified": "Verified",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
|
"pendingApproval": "Pending Approval",
|
||||||
"sidebarBilling": "Billing",
|
"sidebarBilling": "Billing",
|
||||||
"billing": "Billing",
|
"billing": "Billing",
|
||||||
"orgBillingDescription": "Manage billing information and subscriptions",
|
"orgBillingDescription": "Manage billing information and subscriptions",
|
||||||
@@ -1551,6 +1579,8 @@
|
|||||||
"IntervalSeconds": "Healthy Interval",
|
"IntervalSeconds": "Healthy Interval",
|
||||||
"timeoutSeconds": "Timeout (sec)",
|
"timeoutSeconds": "Timeout (sec)",
|
||||||
"timeIsInSeconds": "Time is in seconds",
|
"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",
|
"retryAttempts": "Retry Attempts",
|
||||||
"expectedResponseCodes": "Expected Response Codes",
|
"expectedResponseCodes": "Expected Response Codes",
|
||||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
||||||
|
|||||||
@@ -129,7 +129,9 @@ export enum ActionsEnum {
|
|||||||
getBlueprint = "getBlueprint",
|
getBlueprint = "getBlueprint",
|
||||||
applyBlueprint = "applyBlueprint",
|
applyBlueprint = "applyBlueprint",
|
||||||
viewLogs = "viewLogs",
|
viewLogs = "viewLogs",
|
||||||
exportLogs = "exportLogs"
|
exportLogs = "exportLogs",
|
||||||
|
listApprovals = "listApprovals",
|
||||||
|
updateApprovals = "updateApprovals"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ import {
|
|||||||
index
|
index
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
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", {
|
export const certificates = pgTable("certificates", {
|
||||||
certId: serial("certId").primaryKey(),
|
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<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
export type Certificate = InferSelectModel<typeof certificates>;
|
export type Certificate = InferSelectModel<typeof certificates>;
|
||||||
|
|||||||
@@ -365,7 +365,8 @@ export const roles = pgTable("roles", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
isAdmin: boolean("isAdmin"),
|
isAdmin: boolean("isAdmin"),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
description: varchar("description")
|
description: varchar("description"),
|
||||||
|
requireDeviceApproval: boolean("requireDeviceApproval").default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roleActions = pgTable("roleActions", {
|
export const roleActions = pgTable("roleActions", {
|
||||||
@@ -691,7 +692,10 @@ export const clients = pgTable("clients", {
|
|||||||
lastHolePunch: integer("lastHolePunch"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
maxConnections: integer("maxConnections"),
|
maxConnections: integer("maxConnections"),
|
||||||
archived: boolean("archived").notNull().default(false),
|
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(
|
export const clientSitesAssociationsCache = pgTable(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
sqliteTable,
|
sqliteTable,
|
||||||
text
|
text
|
||||||
} from "drizzle-orm/sqlite-core";
|
} 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", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
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<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
export type Certificate = InferSelectModel<typeof certificates>;
|
export type Certificate = InferSelectModel<typeof certificates>;
|
||||||
|
|||||||
@@ -387,7 +387,10 @@ export const clients = sqliteTable("clients", {
|
|||||||
// endpoint: text("endpoint"),
|
// endpoint: text("endpoint"),
|
||||||
lastHolePunch: integer("lastHolePunch"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
|
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(
|
export const clientSitesAssociationsCache = sqliteTable(
|
||||||
@@ -604,7 +607,10 @@ export const roles = sqliteTable("roles", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description")
|
description: text("description"),
|
||||||
|
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||||
|
mode: "boolean"
|
||||||
|
}).default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roleActions = sqliteTable("roleActions", {
|
export const roleActions = sqliteTable("roleActions", {
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
|
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||||
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
|
approvals,
|
||||||
clients,
|
clients,
|
||||||
db,
|
db,
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
roles,
|
roles,
|
||||||
|
Transaction,
|
||||||
userClients,
|
userClients,
|
||||||
userOrgs,
|
userOrgs
|
||||||
Transaction
|
|
||||||
} from "@server/db";
|
} 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 { 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(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -38,13 +41,15 @@ export async function calculateUserClientsForOrgs(
|
|||||||
const allUserOrgs = await transaction
|
const allUserOrgs = await transaction
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
|
.innerJoin(roles, eq(roles.roleId, userOrgs.roleId))
|
||||||
.where(eq(userOrgs.userId, userId));
|
.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 each OLM, ensure there's a client in each org the user is in
|
||||||
for (const olm of userOlms) {
|
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 orgId = userOrg.orgId;
|
||||||
|
|
||||||
const [org] = await transaction
|
const [org] = await transaction
|
||||||
@@ -182,21 +187,46 @@ export async function calculateUserClientsForOrgs(
|
|||||||
|
|
||||||
const niceId = await getUniqueClientName(orgId);
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
|
userOrg.orgId
|
||||||
|
);
|
||||||
|
const requireApproval =
|
||||||
|
build !== "oss" &&
|
||||||
|
isOrgLicensed &&
|
||||||
|
role.requireDeviceApproval;
|
||||||
|
|
||||||
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
|
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
|
// Create the client
|
||||||
const [newClient] = await transaction
|
const [newClient] = await transaction
|
||||||
.insert(clients)
|
.insert(clients)
|
||||||
.values({
|
.values(newClientData)
|
||||||
userId,
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: olm.name || "User Client",
|
|
||||||
subnet: updatedSubnet,
|
|
||||||
olmId: olm.olmId,
|
|
||||||
type: "olm",
|
|
||||||
niceId
|
|
||||||
})
|
|
||||||
.returning();
|
.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(
|
await rebuildClientAssociationsFromClient(
|
||||||
newClient,
|
newClient,
|
||||||
transaction
|
transaction
|
||||||
|
|||||||
15
server/private/routers/approvals/index.ts
Normal file
15
server/private/routers/approvals/index.ts
Normal file
@@ -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";
|
||||||
188
server/private/routers/approvals/listApprovals.ts
Normal file
188
server/private/routers/approvals/listApprovals.ts
Normal file
@@ -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<typeof querySchema>["approvalState"]
|
||||||
|
) {
|
||||||
|
let state: Array<Approval["decision"]> = [];
|
||||||
|
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<Awaited<ReturnType<typeof queryApprovals>>>;
|
||||||
|
pagination: { total: number; limit: number; offset: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listApprovals(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedQuery = querySchema.safeParse(req.query);
|
||||||
|
if (!parsedQuery.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedQuery.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { limit, offset, 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<number>`count(*)` })
|
||||||
|
.from(approvals);
|
||||||
|
|
||||||
|
return response<ListApprovalsResponse>(res, {
|
||||||
|
data: {
|
||||||
|
approvals: approvalsList,
|
||||||
|
pagination: {
|
||||||
|
total: count,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Approvals retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
server/private/routers/approvals/processPendingApproval.ts
Normal file
142
server/private/routers/approvals/processPendingApproval.ts
Normal file
@@ -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<InferInsertModel<typeof clients>> = {
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import * as generateLicense from "./generatedLicense";
|
|||||||
import * as logs from "#private/routers/auditLogs";
|
import * as logs from "#private/routers/auditLogs";
|
||||||
import * as misc from "#private/routers/misc";
|
import * as misc from "#private/routers/misc";
|
||||||
import * as reKey from "#private/routers/re-key";
|
import * as reKey from "#private/routers/re-key";
|
||||||
|
import * as approval from "#private/routers/approvals";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -311,6 +312,24 @@ authenticated.get(
|
|||||||
loginPage.getLoginPage
|
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(
|
authenticated.get(
|
||||||
"/org/:orgId/login-page-branding",
|
"/org/:orgId/login-page-branding",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -29,11 +29,9 @@ import { getOrgTierData } from "#private/lib/billing";
|
|||||||
import { TierId } from "@server/lib/billing/tiers";
|
import { TierId } from "@server/lib/billing/tiers";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z.strictObject({
|
||||||
.object({
|
orgId: z.string()
|
||||||
orgId: z.string()
|
});
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export async function getLoginPageBranding(
|
export async function getLoginPageBranding(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export async function blockClient(
|
|||||||
// Block the client
|
// Block the client
|
||||||
await trx
|
await trx
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ blocked: true })
|
.set({ blocked: true, approvalState: "denied" })
|
||||||
.where(eq(clients.clientId, clientId));
|
.where(eq(clients.clientId, clientId));
|
||||||
|
|
||||||
// Send terminate signal if there's an associated OLM and it's connected
|
// Send terminate signal if there's an associated OLM and it's connected
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ function queryClients(
|
|||||||
userEmail: users.email,
|
userEmail: users.email,
|
||||||
niceId: clients.niceId,
|
niceId: clients.niceId,
|
||||||
agent: olms.agent,
|
agent: olms.agent,
|
||||||
|
approvalState: clients.approvalState,
|
||||||
olmArchived: olms.archived,
|
olmArchived: olms.archived,
|
||||||
archived: clients.archived,
|
archived: clients.archived,
|
||||||
blocked: clients.blocked,
|
blocked: clients.blocked,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export async function unblockClient(
|
|||||||
// Unblock the client
|
// Unblock the client
|
||||||
await db
|
await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({ blocked: false })
|
.set({ blocked: false, approvalState: null })
|
||||||
.where(eq(clients.clientId, clientId));
|
.where(eq(clients.clientId, clientId));
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -586,6 +586,14 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.listRoles),
|
verifyUserHasAction(ActionsEnum.listRoles),
|
||||||
role.listRoles
|
role.listRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/role/:roleId",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateRole),
|
||||||
|
logActionAudit(ActionsEnum.updateRole),
|
||||||
|
role.updateRole
|
||||||
|
);
|
||||||
// authenticated.get(
|
// authenticated.get(
|
||||||
// "/role/:roleId",
|
// "/role/:roleId",
|
||||||
// verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
|
|||||||
@@ -467,6 +467,14 @@ authenticated.put(
|
|||||||
role.createRole
|
role.createRole
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/role/:roleId",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.updateRole),
|
||||||
|
logActionAudit(ActionsEnum.updateRole),
|
||||||
|
role.updateRole
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/roles",
|
"/org/:orgId/roles",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { ActionsEnum } from "@server/auth/actions";
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||||
|
|
||||||
const createRoleParamsSchema = z.strictObject({
|
const createRoleParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -17,7 +19,8 @@ const createRoleParamsSchema = z.strictObject({
|
|||||||
|
|
||||||
const createRoleSchema = z.strictObject({
|
const createRoleSchema = z.strictObject({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
description: z.string().optional()
|
description: z.string().optional(),
|
||||||
|
requireDeviceApproval: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
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) => {
|
await db.transaction(async (trx) => {
|
||||||
const newRole = await trx
|
const newRole = await trx
|
||||||
.insert(roles)
|
.insert(roles)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { db, orgs, roles } from "@server/db";
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { roles, orgs } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
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 logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import stoi from "@server/lib/stoi";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
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({
|
const listRolesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -38,7 +36,8 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
|
|||||||
isAdmin: roles.isAdmin,
|
isAdmin: roles.isAdmin,
|
||||||
name: roles.name,
|
name: roles.name,
|
||||||
description: roles.description,
|
description: roles.description,
|
||||||
orgName: orgs.name
|
orgName: orgs.name,
|
||||||
|
requireDeviceApproval: roles.requireDeviceApproval
|
||||||
})
|
})
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db, orgs, type Role } from "@server/db";
|
||||||
import { roles } from "@server/db";
|
import { roles } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -8,20 +8,28 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||||
|
|
||||||
const updateRoleParamsSchema = z.strictObject({
|
const updateRoleParamsSchema = z.strictObject({
|
||||||
|
orgId: z.string(),
|
||||||
roleId: z.string().transform(Number).pipe(z.int().positive())
|
roleId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRoleBodySchema = z
|
const updateRoleBodySchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
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, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type UpdateRoleBody = z.infer<typeof updateRoleBodySchema>;
|
||||||
|
|
||||||
|
export type UpdateRoleResponse = Role;
|
||||||
|
|
||||||
export async function updateRole(
|
export async function updateRole(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
@@ -48,13 +56,14 @@ export async function updateRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { roleId } = parsedParams.data;
|
const { roleId, orgId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
const updateData = parsedBody.data;
|
||||||
|
|
||||||
const role = await db
|
const role = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(eq(roles.roleId, roleId))
|
.where(eq(roles.roleId, roleId))
|
||||||
|
.innerJoin(orgs, eq(roles.orgId, orgs.orgId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (role.length === 0) {
|
if (role.length === 0) {
|
||||||
@@ -66,7 +75,7 @@ export async function updateRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role[0].isAdmin) {
|
if (role[0].roles.isAdmin) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
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
|
const updatedRole = await db
|
||||||
.update(roles)
|
.update(roles)
|
||||||
.set(updateData)
|
.set(updateData)
|
||||||
|
|||||||
52
src/app/[orgId]/settings/(private)/access/approvals/page.tsx
Normal file
52
src/app/[orgId]/settings/(private)/access/approvals/page.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title={t("accessApprovalsManage")}
|
||||||
|
description={t("accessApprovalsDescription")}
|
||||||
|
/>
|
||||||
|
<OrgProvider org={org}>
|
||||||
|
<div className="container mx-auto max-w-12xl">
|
||||||
|
<ApprovalFeed orgId={params.orgId} />
|
||||||
|
</div>
|
||||||
|
</OrgProvider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Settings";
|
} 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 {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -19,29 +24,21 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} 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 { 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 { 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 { 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 { useTranslations } from "next-intl";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { AxiosResponse } from "axios";
|
import { useEffect, useState } from "react";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { GetOrgResponse } from "@server/routers/org";
|
import { GetOrgResponse } from "@server/routers/org";
|
||||||
import { cache } from "react";
|
|
||||||
import OrgProvider from "@app/providers/OrgProvider";
|
import OrgProvider from "@app/providers/OrgProvider";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
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 SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||||
|
|
||||||
type RolesPageProps = {
|
type RolesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
@@ -47,14 +47,7 @@ export default async function RolesPage(props: RolesPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let org: GetOrgResponse | null = null;
|
let org: GetOrgResponse | null = null;
|
||||||
const getOrg = cache(async () =>
|
const orgRes = await getCachedOrg(params.orgId);
|
||||||
internal
|
|
||||||
.get<
|
|
||||||
AxiosResponse<GetOrgResponse>
|
|
||||||
>(`/org/${params.orgId}`, await authCookieHeader())
|
|
||||||
.catch((e) => {})
|
|
||||||
);
|
|
||||||
const orgRes = await getOrg();
|
|
||||||
|
|
||||||
if (orgRes && orgRes.status === 200) {
|
if (orgRes && orgRes.status === 200) {
|
||||||
org = orgRes.data.data;
|
org = orgRes.data.data;
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
niceId: client.niceId,
|
niceId: client.niceId,
|
||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: client.archived || false,
|
||||||
blocked: client.blocked || false
|
blocked: client.blocked || false,
|
||||||
|
approvalState: client.approvalState ?? "approved"
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
|
|||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { ListClientsResponse } from "@server/routers/client";
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
import { getTranslations } from "next-intl/server";
|
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";
|
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
@@ -57,7 +57,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
niceId: client.niceId,
|
niceId: client.niceId,
|
||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: client.archived || false,
|
||||||
blocked: client.blocked || false
|
blocked: client.blocked || false,
|
||||||
|
approvalState: client.approvalState
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { internal } from "@app/lib/api";
|
|||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { ListSitesResponse } from "@server/routers/site";
|
import { ListSitesResponse } from "@server/routers/site";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import SitesTable, { SiteRow } from "../../../../components/SitesTable";
|
import SitesTable, { SiteRow } from "@app/components/SitesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import SitesBanner from "@app/components/SitesBanner";
|
import SitesBanner from "@app/components/SitesBanner";
|
||||||
import SitesSplashCard from "../../../../components/SitesSplashCard";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
type SitesPageProps = {
|
type SitesPageProps = {
|
||||||
|
|||||||
@@ -2,27 +2,27 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
|||||||
import { Env } from "@app/lib/types/env";
|
import { Env } from "@app/lib/types/env";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import {
|
import {
|
||||||
Settings,
|
ChartLine,
|
||||||
Users,
|
|
||||||
Link as LinkIcon,
|
|
||||||
Waypoints,
|
|
||||||
Combine,
|
Combine,
|
||||||
|
CreditCard,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
|
Globe,
|
||||||
|
GlobeLock,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Laptop,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Logs, // Added from 'dev' branch
|
||||||
|
MonitorUp,
|
||||||
|
ReceiptText,
|
||||||
|
ScanEye, // Added from 'dev' branch
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
SquareMousePointer,
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
User,
|
User,
|
||||||
Globe, // Added from 'dev' branch
|
UserCog,
|
||||||
MonitorUp, // Added from 'dev' branch
|
Users,
|
||||||
Server,
|
Waypoints
|
||||||
ReceiptText,
|
|
||||||
CreditCard,
|
|
||||||
Logs,
|
|
||||||
SquareMousePointer,
|
|
||||||
ScanEye,
|
|
||||||
GlobeLock,
|
|
||||||
Smartphone,
|
|
||||||
Laptop,
|
|
||||||
ChartLine
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export type SidebarNavSection = {
|
export type SidebarNavSection = {
|
||||||
@@ -123,7 +123,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
href: "/{orgId}/settings/access/roles",
|
href: "/{orgId}/settings/access/roles",
|
||||||
icon: <Users className="size-4 flex-none" />
|
icon: <Users className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build == "saas" || env?.flags.useOrgOnlyIdp
|
...(build === "saas" || env?.flags.useOrgOnlyIdp
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarIdentityProviders",
|
title: "sidebarIdentityProviders",
|
||||||
@@ -132,6 +132,15 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(build !== "oss"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: "sidebarApprovals",
|
||||||
|
href: "/{orgId}/settings/access/approvals",
|
||||||
|
icon: <UserCog className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
title: "sidebarShareableLinks",
|
title: "sidebarShareableLinks",
|
||||||
href: "/{orgId}/settings/share-links",
|
href: "/{orgId}/settings/share-links",
|
||||||
|
|||||||
243
src/components/ApprovalFeed.tsx
Normal file
243
src/components/ApprovalFeed.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<Card className="">
|
||||||
|
<CardHeader className="flex flex-col sm:flex-row sm:items-end lg:items-end gap-2 ">
|
||||||
|
<div className="flex flex-col items-start gap-2 w-48 mb-0">
|
||||||
|
<Label htmlFor="approvalState">
|
||||||
|
{t("filterByApprovalState")}
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(newValue) => {
|
||||||
|
const newSearch = new URLSearchParams(
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
newSearch.set("approvalState", newValue);
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`${path}?${newSearch.toString()}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={filters.approvalState ?? "all"}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="approvalState"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t("selectApprovalState")}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="w-full">
|
||||||
|
<SelectItem value="pending">
|
||||||
|
{t("pending")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="approved">
|
||||||
|
{t("approved")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="denied">
|
||||||
|
{t("denied")}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="all">{t("all")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="lg:static gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn(
|
||||||
|
"size-4",
|
||||||
|
isFetching && "animate-spin"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{t("refresh")}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<ul className="flex flex-col gap-4">
|
||||||
|
{approvals.map((approval, index) => (
|
||||||
|
<Fragment key={approval.approvalId}>
|
||||||
|
<li>
|
||||||
|
<ApprovalRequest
|
||||||
|
approval={approval}
|
||||||
|
orgId={orgId}
|
||||||
|
onSuccess={() => refetch()}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{index < approvals.length - 1 && <Separator />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{approvals.length === 0 && (
|
||||||
|
<li className="flex justify-center items-center p-4 text-muted-foreground">
|
||||||
|
{t("approvalListEmpty")}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="inline-flex items-start md:items-center gap-2">
|
||||||
|
<LaptopMinimal className="size-4 text-muted-foreground flex-none relative top-2 sm:top-0" />
|
||||||
|
<span>
|
||||||
|
<span className="text-primary">
|
||||||
|
{approval.user.username}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{approval.type === "user_device" && (
|
||||||
|
<span>{t("requestingNewDeviceApproval")}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{approval.decision === "pending" && (
|
||||||
|
<form action={formAction} className="inline-flex gap-2">
|
||||||
|
<Button
|
||||||
|
value="approved"
|
||||||
|
name="decision"
|
||||||
|
className="gap-2"
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
<Check className="size-4 flex-none" />
|
||||||
|
{t("approve")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
value="denied"
|
||||||
|
name="decision"
|
||||||
|
variant="destructive"
|
||||||
|
className="gap-2"
|
||||||
|
type="submit"
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
<Ban className="size-4 flex-none" />
|
||||||
|
{t("deny")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{approval.decision === "approved" && (
|
||||||
|
<Badge variant="green">{t("approved")}</Badge>
|
||||||
|
)}
|
||||||
|
{approval.decision === "denied" && (
|
||||||
|
<Badge variant="red">{t("denied")}</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {}}
|
||||||
|
className="gap-2"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={"#"}>
|
||||||
|
{t("viewDetails")}
|
||||||
|
<ArrowRight className="size-4 flex-none" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,21 +1,5 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaBody,
|
CredenzaBody,
|
||||||
@@ -26,17 +10,37 @@ import {
|
|||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
import {
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
Form,
|
||||||
import { createApiClient } from "@app/lib/api";
|
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 { 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 { 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 = {
|
type CreateRoleFormProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
|
afterCreate?: (res: CreateRoleResponse) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreateRoleForm({
|
export default function CreateRoleForm({
|
||||||
@@ -46,35 +50,35 @@ export default function CreateRoleForm({
|
|||||||
}: CreateRoleFormProps) {
|
}: CreateRoleFormProps) {
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string({ message: t("nameRequired") }).max(32),
|
name: z
|
||||||
description: z.string().max(255).optional()
|
.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 api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: ""
|
description: "",
|
||||||
|
requireDeviceApproval: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
const [loading, startTransition] = useTransition();
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
const res = await api
|
const res = await api
|
||||||
.put<AxiosResponse<CreateRoleResponse>>(
|
.put<
|
||||||
`/org/${org?.org.orgId}/role`,
|
AxiosResponse<CreateRoleResponse>
|
||||||
{
|
>(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
|
||||||
name: values.name,
|
|
||||||
description: values.description
|
|
||||||
} as CreateRoleBody
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -97,12 +101,8 @@ export default function CreateRoleForm({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (afterCreate) {
|
afterCreate?.(res.data.data);
|
||||||
afterCreate(res.data.data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,7 +111,6 @@ export default function CreateRoleForm({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(val) => {
|
onOpenChange={(val) => {
|
||||||
setOpen(val);
|
setOpen(val);
|
||||||
setLoading(false);
|
|
||||||
form.reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -125,7 +124,9 @@ export default function CreateRoleForm({
|
|||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit((values) =>
|
||||||
|
startTransition(() => onSubmit(values))
|
||||||
|
)}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-role-form"
|
id="create-role-form"
|
||||||
>
|
>
|
||||||
@@ -159,6 +160,56 @@ export default function CreateRoleForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{build !== "oss" && (
|
||||||
|
<div className="pt-3">
|
||||||
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requireDeviceApproval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="my-2">
|
||||||
|
<FormControl>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
!isPaidUser
|
||||||
|
}
|
||||||
|
value="on"
|
||||||
|
checked={form.watch(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
checked !==
|
||||||
|
"indeterminate"
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"requireDeviceApproval",
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={t(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"requireDeviceApprovalDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@@ -14,16 +12,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger
|
DialogTrigger
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import {
|
import { DrawerClose } from "@/components/ui/drawer";
|
||||||
Drawer,
|
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||||
DrawerClose,
|
import { cn } from "@app/lib/cn";
|
||||||
DrawerContent,
|
|
||||||
DrawerDescription,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerTrigger
|
|
||||||
} from "@/components/ui/drawer";
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -78,10 +69,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
|
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CredenzaClose
|
<CredenzaClose className={cn("", className)} {...props}>
|
||||||
className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
);
|
);
|
||||||
@@ -172,14 +160,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
|
|
||||||
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||||
const isDesktop = useMediaQuery(desktop);
|
const isDesktop = useMediaQuery(desktop);
|
||||||
// const isDesktop = true;
|
|
||||||
|
|
||||||
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CredenzaFooter
|
<CredenzaFooter
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-8 md:mt-0 -mx-6 px-6 pt-4 border-t border-border",
|
"mt-8 md:mt-0 -mx-6 px-6 py-4 border-t border-border",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -191,12 +178,12 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Credenza,
|
Credenza,
|
||||||
CredenzaTrigger,
|
CredenzaBody,
|
||||||
CredenzaClose,
|
CredenzaClose,
|
||||||
CredenzaContent,
|
CredenzaContent,
|
||||||
CredenzaDescription,
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
CredenzaHeader,
|
CredenzaHeader,
|
||||||
CredenzaTitle,
|
CredenzaTitle,
|
||||||
CredenzaBody,
|
CredenzaTrigger
|
||||||
CredenzaFooter
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ export const DismissableBanner = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="mb-6 relative border-primary/30 bg-gradient-to-br from-primary/10 via-background to-background overflow-hidden">
|
<Card className="mb-6 relative border-primary/30 bg-linear-to-br from-primary/10 via-background to-background overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors"
|
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors cursor-pointer"
|
||||||
aria-label={t("dismiss")}
|
aria-label={t("dismiss")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-muted-foreground" />
|
<X className="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -91,7 +91,7 @@ export const DismissableBanner = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="flex flex-wrap gap-3 lg:flex-shrink-0 lg:justify-end">
|
<div className="flex flex-wrap gap-3 lg:shrink-0 lg:justify-end">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
241
src/components/EditRoleForm.tsx
Normal file
241
src/components/EditRoleForm.tsx
Normal file
@@ -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<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
requireDeviceApproval: role.requireDeviceApproval ?? false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, startTransition] = useTransition();
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
const res = await api
|
||||||
|
.post<
|
||||||
|
AxiosResponse<UpdateRoleResponse>
|
||||||
|
>(`/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 (
|
||||||
|
<>
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("accessRoleEditDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((values) =>
|
||||||
|
startTransition(() => onSubmit(values))
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="create-role-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("accessRoleName")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("description")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{build !== "oss" && (
|
||||||
|
<div className="pt-3">
|
||||||
|
<PaidFeaturesAlert />
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requireDeviceApproval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="my-2">
|
||||||
|
<FormControl>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
!isPaidUser
|
||||||
|
}
|
||||||
|
value="on"
|
||||||
|
checked={form.watch(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
checked !==
|
||||||
|
"indeterminate"
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"requireDeviceApproval",
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={t(
|
||||||
|
"requireDeviceApproval"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"requireDeviceApprovalDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="create-role-form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("accessRoleUpdateSubmit")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
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 { Button } from "@app/components/ui/button";
|
||||||
|
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -16,7 +15,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ArrowUpRight,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
CircleSlash
|
CircleSlash
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -25,7 +23,6 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { InfoPopup } from "./ui/info-popup";
|
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -45,6 +42,7 @@ export type ClientRow = {
|
|||||||
agent: string | null;
|
agent: string | null;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
blocked?: boolean;
|
blocked?: boolean;
|
||||||
|
approvalState: "approved" | "pending" | "denied";
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
@@ -214,7 +212,10 @@ export default function MachineClientsTable({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{r.blocked && (
|
{r.blocked && (
|
||||||
<Badge variant="destructive" className="flex items-center gap-1">
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
<CircleSlash className="h-3 w-3" />
|
<CircleSlash className="h-3 w-3" />
|
||||||
{t("blocked")}
|
{t("blocked")}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -410,7 +411,9 @@ export default function MachineClientsTable({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{clientRow.archived ? "Unarchive" : "Archive"}
|
{clientRow.archived
|
||||||
|
? "Unarchive"
|
||||||
|
: "Archive"}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -424,7 +427,9 @@ export default function MachineClientsTable({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{clientRow.blocked ? "Unblock" : "Block"}
|
{clientRow.blocked
|
||||||
|
? "Unblock"
|
||||||
|
: "Block"}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -539,15 +544,27 @@ export default function MachineClientsTable({
|
|||||||
value: "blocked"
|
value: "blocked"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
|
filterFn: (
|
||||||
|
row: ClientRow,
|
||||||
|
selectedValues: (string | number | boolean)[]
|
||||||
|
) => {
|
||||||
if (selectedValues.length === 0) return true;
|
if (selectedValues.length === 0) return true;
|
||||||
const rowArchived = row.archived || false;
|
const rowArchived = row.archived || false;
|
||||||
const rowBlocked = row.blocked || false;
|
const rowBlocked = row.blocked || false;
|
||||||
const isActive = !rowArchived && !rowBlocked;
|
const isActive = !rowArchived && !rowBlocked;
|
||||||
|
|
||||||
if (selectedValues.includes("active") && isActive) return true;
|
if (selectedValues.includes("active") && isActive)
|
||||||
if (selectedValues.includes("archived") && rowArchived) return true;
|
return true;
|
||||||
if (selectedValues.includes("blocked") && rowBlocked) return true;
|
if (
|
||||||
|
selectedValues.includes("archived") &&
|
||||||
|
rowArchived
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
if (
|
||||||
|
selectedValues.includes("blocked") &&
|
||||||
|
rowBlocked
|
||||||
|
)
|
||||||
|
return true;
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
defaultValues: ["active"] // Default to showing active clients
|
defaultValues: ["active"] // Default to showing active clients
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
"use client";
|
"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 CreateRoleForm from "@app/components/CreateRoleForm";
|
||||||
import DeleteRoleForm from "@app/components/DeleteRoleForm";
|
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 { 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 { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
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;
|
export type RoleRow = Role;
|
||||||
|
|
||||||
@@ -29,27 +29,26 @@ type RolesTableProps = {
|
|||||||
roles: RoleRow[];
|
roles: RoleRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UsersTable({ roles: r }: RolesTableProps) {
|
export default function UsersTable({ roles }: RolesTableProps) {
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [editingRole, setEditingRole] = useState<RoleRow | null>(null);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [roles, setRoles] = useState<RoleRow[]>(r);
|
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
|
||||||
|
|
||||||
const [roleToRemove, setUserToRemove] = useState<RoleRow | null>(null);
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
const refreshData = async () => {
|
const refreshData = async () => {
|
||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@@ -57,8 +56,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
description: t("refreshError"),
|
description: t("refreshError"),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,26 +83,74 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
friendlyName: t("description"),
|
friendlyName: t("description"),
|
||||||
header: () => <span className="p-3">{t("description")}</span>
|
header: () => <span className="p-3">{t("description")}</span>
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// id: "actions",
|
||||||
|
// enableHiding: false,
|
||||||
|
// header: () => <span className="p-3"></span>,
|
||||||
|
// cell: ({ row }) => {
|
||||||
|
// const roleRow = row.original;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div className="flex items-center gap-2 justify-end">
|
||||||
|
// <Button
|
||||||
|
// variant={"outline"}
|
||||||
|
// disabled={roleRow.isAdmin || false}
|
||||||
|
// onClick={() => {
|
||||||
|
// setIsDeleteModalOpen(true);
|
||||||
|
// setUserToRemove(roleRow);
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// {t("accessRoleDelete")}
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
header: () => <span className="p-3"></span>,
|
header: () => <span className="p-3"></span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const roleRow = row.original;
|
const roleRow = row.original;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 justify-end">
|
!roleRow.isAdmin && (
|
||||||
<Button
|
<div className="flex items-center gap-2 justify-end">
|
||||||
variant={"outline"}
|
<DropdownMenu>
|
||||||
disabled={roleRow.isAdmin || false}
|
<DropdownMenuTrigger asChild>
|
||||||
onClick={() => {
|
<Button
|
||||||
setIsDeleteModalOpen(true);
|
variant="ghost"
|
||||||
setUserToRemove(roleRow);
|
className="h-8 w-8 p-0"
|
||||||
}}
|
>
|
||||||
>
|
<span className="sr-only">
|
||||||
{t("accessRoleDelete")}
|
{t("openMenu")}
|
||||||
</Button>
|
</span>
|
||||||
</div>
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setRoleToRemove(roleRow);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRole(roleRow);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,11 +158,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{editingRole && (
|
||||||
|
<EditRoleForm
|
||||||
|
role={editingRole}
|
||||||
|
open={isEditDialogOpen}
|
||||||
|
key={editingRole.roleId}
|
||||||
|
setOpen={setIsEditDialogOpen}
|
||||||
|
onSuccess={() => {
|
||||||
|
// Delay refresh to allow modal to close smoothly
|
||||||
|
setTimeout(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
await refreshData().then(() =>
|
||||||
|
setEditingRole(null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, 150);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<CreateRoleForm
|
<CreateRoleForm
|
||||||
open={isCreateModalOpen}
|
open={isCreateModalOpen}
|
||||||
setOpen={setIsCreateModalOpen}
|
setOpen={setIsCreateModalOpen}
|
||||||
afterCreate={async (role) => {
|
afterCreate={() => {
|
||||||
setRoles((prev) => [...prev, role]);
|
startTransition(refreshData);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -127,10 +190,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
setOpen={setIsDeleteModalOpen}
|
setOpen={setIsDeleteModalOpen}
|
||||||
roleToDelete={roleToRemove}
|
roleToDelete={roleToRemove}
|
||||||
afterDelete={() => {
|
afterDelete={() => {
|
||||||
setRoles((prev) =>
|
startTransition(async () => {
|
||||||
prev.filter((r) => r.roleId !== roleToRemove.roleId)
|
await refreshData().then(() =>
|
||||||
);
|
setRoleToRemove(null)
|
||||||
setUserToRemove(null);
|
);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -141,7 +205,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||||||
createRole={() => {
|
createRole={() => {
|
||||||
setIsCreateModalOpen(true);
|
setIsCreateModalOpen(true);
|
||||||
}}
|
}}
|
||||||
onRefresh={refreshData}
|
onRefresh={() => startTransition(refreshData)}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
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 { Button } from "@app/components/ui/button";
|
||||||
|
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -24,9 +23,11 @@ import { useTranslations } from "next-intl";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Badge } from "./ui/badge";
|
|
||||||
import { InfoPopup } from "./ui/info-popup";
|
|
||||||
import ClientDownloadBanner from "./ClientDownloadBanner";
|
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 = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -44,6 +45,7 @@ export type ClientRow = {
|
|||||||
userEmail: string | null;
|
userEmail: string | null;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
agent: string | null;
|
agent: string | null;
|
||||||
|
approvalState: "approved" | "pending" | "denied" | null;
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
blocked?: boolean;
|
blocked?: boolean;
|
||||||
};
|
};
|
||||||
@@ -210,11 +212,22 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{r.blocked && (
|
{r.blocked && (
|
||||||
<Badge variant="destructive" className="flex items-center gap-1">
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
<CircleSlash className="h-3 w-3" />
|
<CircleSlash className="h-3 w-3" />
|
||||||
{t("blocked")}
|
{t("blocked")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{r.approvalState === "pending" && (
|
||||||
|
<Badge
|
||||||
|
variant="outlinePrimary"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t("pendingApproval")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,33 +285,6 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// accessorKey: "siteName",
|
|
||||||
// header: ({ column }) => {
|
|
||||||
// return (
|
|
||||||
// <Button
|
|
||||||
// variant="ghost"
|
|
||||||
// onClick={() =>
|
|
||||||
// column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
// }
|
|
||||||
// >
|
|
||||||
// Site
|
|
||||||
// <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
// </Button>
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// cell: ({ row }) => {
|
|
||||||
// const r = row.original;
|
|
||||||
// return (
|
|
||||||
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
|
|
||||||
// <Button variant="outline">
|
|
||||||
// {r.siteName}
|
|
||||||
// <ArrowUpRight className="ml-2 h-4 w-4" />
|
|
||||||
// </Button>
|
|
||||||
// </Link>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: "Connectivity",
|
friendlyName: "Connectivity",
|
||||||
@@ -460,7 +446,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{clientRow.archived ? "Unarchive" : "Archive"}</span>
|
<span>
|
||||||
|
{clientRow.archived
|
||||||
|
? "Unarchive"
|
||||||
|
: "Archive"}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -472,7 +462,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{clientRow.blocked ? "Unblock" : "Block"}</span>
|
<span>
|
||||||
|
{clientRow.blocked
|
||||||
|
? "Unblock"
|
||||||
|
: "Block"}
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!clientRow.userId && (
|
{!clientRow.userId && (
|
||||||
// Machine client - also show delete option
|
// Machine client - also show delete option
|
||||||
@@ -482,7 +476,9 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-red-500">Delete</span>
|
<span className="text-red-500">
|
||||||
|
Delete
|
||||||
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -570,32 +566,65 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
|||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
id: "active",
|
id: "active",
|
||||||
label: t("active") || "Active",
|
label: t("active"),
|
||||||
value: "active"
|
value: "active"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "pending",
|
||||||
|
label: t("pendingApproval"),
|
||||||
|
value: "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "denied",
|
||||||
|
label: t("deniedApproval"),
|
||||||
|
value: "denied"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "archived",
|
id: "archived",
|
||||||
label: t("archived") || "Archived",
|
label: t("archived"),
|
||||||
value: "archived"
|
value: "archived"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "blocked",
|
id: "blocked",
|
||||||
label: t("blocked") || "Blocked",
|
label: t("blocked"),
|
||||||
value: "blocked"
|
value: "blocked"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
|
filterFn: (
|
||||||
|
row: ClientRow,
|
||||||
|
selectedValues: (string | number | boolean)[]
|
||||||
|
) => {
|
||||||
if (selectedValues.length === 0) return true;
|
if (selectedValues.length === 0) return true;
|
||||||
const rowArchived = row.archived || false;
|
const rowArchived = row.archived;
|
||||||
const rowBlocked = row.blocked || false;
|
const rowBlocked = row.blocked;
|
||||||
|
const approvalState = row.approvalState;
|
||||||
const isActive = !rowArchived && !rowBlocked;
|
const isActive = !rowArchived && !rowBlocked;
|
||||||
|
|
||||||
if (selectedValues.includes("active") && isActive) return true;
|
if (selectedValues.includes("active") && isActive)
|
||||||
if (selectedValues.includes("archived") && rowArchived) return true;
|
return true;
|
||||||
if (selectedValues.includes("blocked") && rowBlocked) 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;
|
return false;
|
||||||
},
|
},
|
||||||
defaultValues: ["active"] // Default to showing active clients
|
defaultValues: ["active", "pending"] // Default to showing active clients
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const checkboxVariants = cva(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface CheckboxProps
|
interface CheckboxProps
|
||||||
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
extends
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||||
VariantProps<typeof checkboxVariants> {}
|
VariantProps<typeof checkboxVariants> {}
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
const Checkbox = React.forwardRef<
|
||||||
@@ -49,17 +50,18 @@ const Checkbox = React.forwardRef<
|
|||||||
));
|
));
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||||
|
|
||||||
interface CheckboxWithLabelProps
|
interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<
|
||||||
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
|
typeof Checkbox
|
||||||
|
> {
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CheckboxWithLabel = React.forwardRef<
|
const CheckboxWithLabel = React.forwardRef<
|
||||||
React.ElementRef<typeof Checkbox>,
|
React.ComponentRef<typeof Checkbox>,
|
||||||
CheckboxWithLabelProps
|
CheckboxWithLabelProps
|
||||||
>(({ className, label, id, ...props }, ref) => {
|
>(({ className, label, id, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center space-x-2", className)}>
|
<div className={cn("flex items-center gap-x-2", className)}>
|
||||||
<Checkbox id={id} ref={ref} {...props} />
|
<Checkbox id={id} ref={ref} {...props} />
|
||||||
<label
|
<label
|
||||||
htmlFor={id}
|
htmlFor={id}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const DialogPortal = DialogPrimitive.Portal;
|
|||||||
const DialogClose = DialogPrimitive.Close;
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
@@ -30,7 +30,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||||
import type { ListClientsResponse } from "@server/routers/client";
|
import type { ListClientsResponse } from "@server/routers/client";
|
||||||
|
import type { ListDomainsResponse } from "@server/routers/domain";
|
||||||
|
import type {
|
||||||
|
GetResourceWhitelistResponse,
|
||||||
|
ListResourceNamesResponse
|
||||||
|
} from "@server/routers/resource";
|
||||||
import type { ListRolesResponse } from "@server/routers/role";
|
import type { ListRolesResponse } from "@server/routers/role";
|
||||||
import type { ListSitesResponse } from "@server/routers/site";
|
import type { ListSitesResponse } from "@server/routers/site";
|
||||||
import type {
|
import type {
|
||||||
@@ -7,20 +13,14 @@ import type {
|
|||||||
ListSiteResourceRolesResponse,
|
ListSiteResourceRolesResponse,
|
||||||
ListSiteResourceUsersResponse
|
ListSiteResourceUsersResponse
|
||||||
} from "@server/routers/siteResource";
|
} from "@server/routers/siteResource";
|
||||||
|
import type { ListTargetsResponse } from "@server/routers/target";
|
||||||
import type { ListUsersResponse } from "@server/routers/user";
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||||
import type { AxiosInstance, AxiosResponse } from "axios";
|
import type { AxiosResponse } from "axios";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { remote } from "./api";
|
import { remote } from "./api";
|
||||||
import { durationToMs } from "./durationToMs";
|
import { durationToMs } from "./durationToMs";
|
||||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
|
||||||
import type {
|
|
||||||
GetResourceWhitelistResponse,
|
|
||||||
ListResourceNamesResponse
|
|
||||||
} from "@server/routers/resource";
|
|
||||||
import type { ListTargetsResponse } from "@server/routers/target";
|
|
||||||
import type { ListDomainsResponse } from "@server/routers/domain";
|
|
||||||
|
|
||||||
export type ProductUpdate = {
|
export type ProductUpdate = {
|
||||||
link: string | null;
|
link: string | null;
|
||||||
@@ -322,3 +322,47 @@ export const resourceQueries = {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const approvalFiltersSchema = z.object({
|
||||||
|
approvalState: z
|
||||||
|
.enum(["pending", "approved", "denied", "all"])
|
||||||
|
.optional()
|
||||||
|
.catch("all")
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ApprovalItem = {
|
||||||
|
approvalId: number;
|
||||||
|
orgId: string;
|
||||||
|
clientId: number | null;
|
||||||
|
decision: "pending" | "approved" | "denied";
|
||||||
|
type: "user_device";
|
||||||
|
user: {
|
||||||
|
name: string | null;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const approvalQueries = {
|
||||||
|
listApprovals: (
|
||||||
|
orgId: string,
|
||||||
|
filters: z.infer<typeof approvalFiltersSchema>
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["APPROVALS", orgId, filters] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.approvalState) {
|
||||||
|
sp.set("approvalState", filters.approvalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<{ approvals: ApprovalItem[] }>
|
||||||
|
>(`/org/${orgId}/approvals?${sp.toString()}`, {
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user