mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-08 05:56:38 +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
|
||||
scratch/
|
||||
tsconfig.json
|
||||
hydrateSaas.ts
|
||||
hydrateSaas.ts
|
||||
CLAUDE.md
|
||||
@@ -257,6 +257,8 @@
|
||||
"accessRolesSearch": "Search roles...",
|
||||
"accessRolesAdd": "Add Role",
|
||||
"accessRoleDelete": "Delete Role",
|
||||
"accessApprovalsManage": "Manage Approvals",
|
||||
"accessApprovalsDescription": "Manage approval requests in the organization",
|
||||
"description": "Description",
|
||||
"inviteTitle": "Open Invitations",
|
||||
"inviteDescription": "Manage invitations for other users to join the organization",
|
||||
@@ -450,6 +452,18 @@
|
||||
"selectDuration": "Select duration",
|
||||
"selectResource": "Select Resource",
|
||||
"filterByResource": "Filter By Resource",
|
||||
"selectApprovalState": "Select Approval State",
|
||||
"filterByApprovalState": "Filter By Approval State",
|
||||
"approvalListEmpty": "No approvals",
|
||||
"approvalState": "Approval State",
|
||||
"approve": "Approve",
|
||||
"approved": "Approved",
|
||||
"denied": "Denied",
|
||||
"deniedApproval": "Denied Approval",
|
||||
"all": "All",
|
||||
"deny": "Deny",
|
||||
"viewDetails": "View Details",
|
||||
"requestingNewDeviceApproval": "requested a new device",
|
||||
"resetFilters": "Reset Filters",
|
||||
"totalBlocked": "Requests Blocked By Pangolin",
|
||||
"totalRequests": "Total Requests",
|
||||
@@ -729,16 +743,28 @@
|
||||
"countries": "Countries",
|
||||
"accessRoleCreate": "Create Role",
|
||||
"accessRoleCreateDescription": "Create a new role to group users and manage their permissions.",
|
||||
"accessRoleEdit": "Edit Role",
|
||||
"accessRoleEditDescription": "Edit role information.",
|
||||
"accessRoleCreateSubmit": "Create Role",
|
||||
"accessRoleCreated": "Role created",
|
||||
"accessRoleCreatedDescription": "The role has been successfully created.",
|
||||
"accessRoleErrorCreate": "Failed to create role",
|
||||
"accessRoleErrorCreateDescription": "An error occurred while creating the role.",
|
||||
"accessRoleUpdateSubmit": "Update Role",
|
||||
"accessRoleUpdated": "Role updated",
|
||||
"accessRoleUpdatedDescription": "The role has been successfully updated.",
|
||||
"accessApprovalUpdated": "Approval processed",
|
||||
"accessApprovalApprovedDescription": "Set Approval Request decision to approved.",
|
||||
"accessApprovalDeniedDescription": "Set Approval Request decision to denied.",
|
||||
"accessRoleErrorUpdate": "Failed to update role",
|
||||
"accessRoleErrorUpdateDescription": "An error occurred while updating the role.",
|
||||
"accessApprovalErrorUpdate": "Failed to process approval",
|
||||
"accessApprovalErrorUpdateDescription": "An error occurred while processing the approval.",
|
||||
"accessRoleErrorNewRequired": "New role is required",
|
||||
"accessRoleErrorRemove": "Failed to remove role",
|
||||
"accessRoleErrorRemoveDescription": "An error occurred while removing the role.",
|
||||
"accessRoleName": "Role Name",
|
||||
"accessRoleQuestionRemove": "You're about to delete the {name} role. You cannot undo this action.",
|
||||
"accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.",
|
||||
"accessRoleRemove": "Remove Role",
|
||||
"accessRoleRemoveDescription": "Remove a role from the organization",
|
||||
"accessRoleRemoveSubmit": "Remove Role",
|
||||
@@ -1193,6 +1219,7 @@
|
||||
"sidebarOverview": "Overview",
|
||||
"sidebarHome": "Home",
|
||||
"sidebarSites": "Sites",
|
||||
"sidebarApprovals": "Approval Requests",
|
||||
"sidebarResources": "Resources",
|
||||
"sidebarProxyResources": "Public",
|
||||
"sidebarClientResources": "Private",
|
||||
@@ -1308,6 +1335,7 @@
|
||||
"refreshError": "Failed to refresh data",
|
||||
"verified": "Verified",
|
||||
"pending": "Pending",
|
||||
"pendingApproval": "Pending Approval",
|
||||
"sidebarBilling": "Billing",
|
||||
"billing": "Billing",
|
||||
"orgBillingDescription": "Manage billing information and subscriptions",
|
||||
@@ -1551,6 +1579,8 @@
|
||||
"IntervalSeconds": "Healthy Interval",
|
||||
"timeoutSeconds": "Timeout (sec)",
|
||||
"timeIsInSeconds": "Time is in seconds",
|
||||
"requireDeviceApproval": "Require Device Approvals",
|
||||
"requireDeviceApprovalDescription": "Users with this role need their devices approved by an admin before they can access resources",
|
||||
"retryAttempts": "Retry Attempts",
|
||||
"expectedResponseCodes": "Expected Response Codes",
|
||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
||||
|
||||
@@ -129,7 +129,9 @@ export enum ActionsEnum {
|
||||
getBlueprint = "getBlueprint",
|
||||
applyBlueprint = "applyBlueprint",
|
||||
viewLogs = "viewLogs",
|
||||
exportLogs = "exportLogs"
|
||||
exportLogs = "exportLogs",
|
||||
listApprovals = "listApprovals",
|
||||
updateApprovals = "updateApprovals"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
index
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { domains, orgs, targets, users, exitNodes, sessions } from "./schema";
|
||||
import {
|
||||
domains,
|
||||
orgs,
|
||||
targets,
|
||||
users,
|
||||
exitNodes,
|
||||
sessions,
|
||||
clients
|
||||
} from "./schema";
|
||||
|
||||
export const certificates = pgTable("certificates", {
|
||||
certId: serial("certId").primaryKey(),
|
||||
@@ -289,6 +297,33 @@ export const accessAuditLog = pgTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const approvals = pgTable("approvals", {
|
||||
approvalId: serial("approvalId").primaryKey(),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}), // clients reference user devices (in this case)
|
||||
userId: varchar("userId")
|
||||
.references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
decision: varchar("decision")
|
||||
.$type<"approved" | "denied" | "pending">()
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
type: varchar("type")
|
||||
.$type<"user_device" /*| 'proxy' // for later */>()
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
|
||||
@@ -365,7 +365,8 @@ export const roles = pgTable("roles", {
|
||||
.notNull(),
|
||||
isAdmin: boolean("isAdmin"),
|
||||
name: varchar("name").notNull(),
|
||||
description: varchar("description")
|
||||
description: varchar("description"),
|
||||
requireDeviceApproval: boolean("requireDeviceApproval").default(false)
|
||||
});
|
||||
|
||||
export const roleActions = pgTable("roleActions", {
|
||||
@@ -691,7 +692,10 @@ export const clients = pgTable("clients", {
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
maxConnections: integer("maxConnections"),
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
blocked: boolean("blocked").notNull().default(false)
|
||||
blocked: boolean("blocked").notNull().default(false),
|
||||
approvalState: varchar("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
});
|
||||
|
||||
export const clientSitesAssociationsCache = pgTable(
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
sqliteTable,
|
||||
text
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { domains, exitNodes, orgs, sessions, users } from "./schema";
|
||||
import { clients, domains, exitNodes, orgs, sessions, users } from "./schema";
|
||||
|
||||
export const certificates = sqliteTable("certificates", {
|
||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||
@@ -289,6 +289,31 @@ export const accessAuditLog = sqliteTable(
|
||||
]
|
||||
);
|
||||
|
||||
export const approvals = sqliteTable("approvals", {
|
||||
approvalId: integer("approvalId").primaryKey({ autoIncrement: true }),
|
||||
timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}), // olms reference user devices clients
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
// optionally tied to a user and in this case delete when the user deletes
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
decision: text("decision")
|
||||
.$type<"approved" | "denied" | "pending">()
|
||||
.default("pending")
|
||||
.notNull(),
|
||||
type: text("type")
|
||||
.$type<"user_device" /*| 'proxy' // for later */>()
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type Certificate = InferSelectModel<typeof certificates>;
|
||||
|
||||
@@ -387,7 +387,10 @@ export const clients = sqliteTable("clients", {
|
||||
// endpoint: text("endpoint"),
|
||||
lastHolePunch: integer("lastHolePunch"),
|
||||
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
|
||||
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false)
|
||||
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
|
||||
approvalState: text("approvalState").$type<
|
||||
"pending" | "approved" | "denied"
|
||||
>()
|
||||
});
|
||||
|
||||
export const clientSitesAssociationsCache = sqliteTable(
|
||||
@@ -604,7 +607,10 @@ export const roles = sqliteTable("roles", {
|
||||
.notNull(),
|
||||
isAdmin: integer("isAdmin", { mode: "boolean" }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description")
|
||||
description: text("description"),
|
||||
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||
mode: "boolean"
|
||||
}).default(false)
|
||||
});
|
||||
|
||||
export const roleActions = sqliteTable("roleActions", {
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
approvals,
|
||||
clients,
|
||||
db,
|
||||
olms,
|
||||
orgs,
|
||||
roleClients,
|
||||
roles,
|
||||
Transaction,
|
||||
userClients,
|
||||
userOrgs,
|
||||
Transaction
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { eq, and, notInArray } from "drizzle-orm";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||
import logger from "@server/logger";
|
||||
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
|
||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||
import { getUniqueClientName } from "@server/db/names";
|
||||
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
import logger from "@server/logger";
|
||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
|
||||
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
|
||||
|
||||
export async function calculateUserClientsForOrgs(
|
||||
userId: string,
|
||||
@@ -38,13 +41,15 @@ export async function calculateUserClientsForOrgs(
|
||||
const allUserOrgs = await transaction
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.innerJoin(roles, eq(roles.roleId, userOrgs.roleId))
|
||||
.where(eq(userOrgs.userId, userId));
|
||||
|
||||
const userOrgIds = allUserOrgs.map((uo) => uo.orgId);
|
||||
const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId);
|
||||
|
||||
// For each OLM, ensure there's a client in each org the user is in
|
||||
for (const olm of userOlms) {
|
||||
for (const userOrg of allUserOrgs) {
|
||||
for (const userRoleOrg of allUserOrgs) {
|
||||
const { userOrgs: userOrg, roles: role } = userRoleOrg;
|
||||
const orgId = userOrg.orgId;
|
||||
|
||||
const [org] = await transaction
|
||||
@@ -182,21 +187,46 @@ export async function calculateUserClientsForOrgs(
|
||||
|
||||
const niceId = await getUniqueClientName(orgId);
|
||||
|
||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||
userOrg.orgId
|
||||
);
|
||||
const requireApproval =
|
||||
build !== "oss" &&
|
||||
isOrgLicensed &&
|
||||
role.requireDeviceApproval;
|
||||
|
||||
const newClientData: InferInsertModel<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
|
||||
const [newClient] = await transaction
|
||||
.insert(clients)
|
||||
.values({
|
||||
userId,
|
||||
orgId: userOrg.orgId,
|
||||
exitNodeId: randomExitNode.exitNodeId,
|
||||
name: olm.name || "User Client",
|
||||
subnet: updatedSubnet,
|
||||
olmId: olm.olmId,
|
||||
type: "olm",
|
||||
niceId
|
||||
})
|
||||
.values(newClientData)
|
||||
.returning();
|
||||
|
||||
// create approval request
|
||||
if (requireApproval) {
|
||||
await transaction
|
||||
.insert(approvals)
|
||||
.values({
|
||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||
orgId: userOrg.orgId,
|
||||
clientId: newClient.clientId,
|
||||
userId,
|
||||
type: "user_device"
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromClient(
|
||||
newClient,
|
||||
transaction
|
||||
|
||||
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 misc from "#private/routers/misc";
|
||||
import * as reKey from "#private/routers/re-key";
|
||||
import * as approval from "#private/routers/approvals";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -311,6 +312,24 @@ authenticated.get(
|
||||
loginPage.getLoginPage
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/approvals",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listApprovals),
|
||||
logActionAudit(ActionsEnum.listApprovals),
|
||||
approval.listApprovals
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/approvals/:approvalId",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateApprovals),
|
||||
logActionAudit(ActionsEnum.updateApprovals),
|
||||
approval.processPendingApproval
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/login-page-branding",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -29,11 +29,9 @@ import { getOrgTierData } from "#private/lib/billing";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string()
|
||||
})
|
||||
.strict();
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
export async function getLoginPageBranding(
|
||||
req: Request,
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function blockClient(
|
||||
// Block the client
|
||||
await trx
|
||||
.update(clients)
|
||||
.set({ blocked: true })
|
||||
.set({ blocked: true, approvalState: "denied" })
|
||||
.where(eq(clients.clientId, clientId));
|
||||
|
||||
// Send terminate signal if there's an associated OLM and it's connected
|
||||
|
||||
@@ -139,6 +139,7 @@ function queryClients(
|
||||
userEmail: users.email,
|
||||
niceId: clients.niceId,
|
||||
agent: olms.agent,
|
||||
approvalState: clients.approvalState,
|
||||
olmArchived: olms.archived,
|
||||
archived: clients.archived,
|
||||
blocked: clients.blocked,
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function unblockClient(
|
||||
// Unblock the client
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ blocked: false })
|
||||
.set({ blocked: false, approvalState: null })
|
||||
.where(eq(clients.clientId, clientId));
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -586,6 +586,14 @@ authenticated.get(
|
||||
verifyUserHasAction(ActionsEnum.listRoles),
|
||||
role.listRoles
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/role/:roleId",
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateRole),
|
||||
logActionAudit(ActionsEnum.updateRole),
|
||||
role.updateRole
|
||||
);
|
||||
// authenticated.get(
|
||||
// "/role/:roleId",
|
||||
// verifyRoleAccess,
|
||||
|
||||
@@ -467,6 +467,14 @@ authenticated.put(
|
||||
role.createRole
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/role/:roleId",
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateRole),
|
||||
logActionAudit(ActionsEnum.updateRole),
|
||||
role.updateRole
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/roles",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
||||
@@ -10,6 +10,8 @@ import { fromError } from "zod-validation-error";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
|
||||
const createRoleParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -17,7 +19,8 @@ const createRoleParamsSchema = z.strictObject({
|
||||
|
||||
const createRoleSchema = z.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional()
|
||||
description: z.string().optional(),
|
||||
requireDeviceApproval: z.boolean().optional()
|
||||
});
|
||||
|
||||
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
||||
@@ -97,6 +100,11 @@ export async function createRole(
|
||||
);
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (build === "oss" || !isLicensed) {
|
||||
roleData.requireDeviceApproval = undefined;
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newRole = await trx
|
||||
.insert(roles)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { roles, orgs } from "@server/db";
|
||||
import { db, orgs, roles } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const listRolesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -38,7 +36,8 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
|
||||
isAdmin: roles.isAdmin,
|
||||
name: roles.name,
|
||||
description: roles.description,
|
||||
orgName: orgs.name
|
||||
orgName: orgs.name,
|
||||
requireDeviceApproval: roles.requireDeviceApproval
|
||||
})
|
||||
.from(roles)
|
||||
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, orgs, type Role } from "@server/db";
|
||||
import { roles } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -8,20 +8,28 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
|
||||
|
||||
const updateRoleParamsSchema = z.strictObject({
|
||||
orgId: z.string(),
|
||||
roleId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const updateRoleBodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional()
|
||||
description: z.string().optional(),
|
||||
requireDeviceApproval: z.boolean().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
});
|
||||
|
||||
export type UpdateRoleBody = z.infer<typeof updateRoleBodySchema>;
|
||||
|
||||
export type UpdateRoleResponse = Role;
|
||||
|
||||
export async function updateRole(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -48,13 +56,14 @@ export async function updateRole(
|
||||
);
|
||||
}
|
||||
|
||||
const { roleId } = parsedParams.data;
|
||||
const { roleId, orgId } = parsedParams.data;
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
const role = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.roleId, roleId))
|
||||
.innerJoin(orgs, eq(roles.orgId, orgs.orgId))
|
||||
.limit(1);
|
||||
|
||||
if (role.length === 0) {
|
||||
@@ -66,7 +75,7 @@ export async function updateRole(
|
||||
);
|
||||
}
|
||||
|
||||
if (role[0].isAdmin) {
|
||||
if (role[0].roles.isAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
@@ -75,6 +84,11 @@ export async function updateRole(
|
||||
);
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(orgId);
|
||||
if (build === "oss" || !isLicensed) {
|
||||
updateData.requireDeviceApproval = undefined;
|
||||
}
|
||||
|
||||
const updatedRole = await db
|
||||
.update(roles)
|
||||
.set(updateData)
|
||||
|
||||
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";
|
||||
|
||||
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -19,29 +24,21 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, ExternalLink } from "lucide-react";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Image from "next/image";
|
||||
import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
|
||||
@@ -2,12 +2,12 @@ import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import { cache } from "react";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import RolesTable, { RoleRow } from "../../../../../components/RolesTable";
|
||||
import RolesTable, { type RoleRow } from "@app/components/RolesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
|
||||
|
||||
type RolesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -47,14 +47,7 @@ export default async function RolesPage(props: RolesPageProps) {
|
||||
}
|
||||
|
||||
let org: GetOrgResponse | null = null;
|
||||
const getOrg = cache(async () =>
|
||||
internal
|
||||
.get<
|
||||
AxiosResponse<GetOrgResponse>
|
||||
>(`/org/${params.orgId}`, await authCookieHeader())
|
||||
.catch((e) => {})
|
||||
);
|
||||
const orgRes = await getOrg();
|
||||
const orgRes = await getCachedOrg(params.orgId);
|
||||
|
||||
if (orgRes && orgRes.status === 200) {
|
||||
org = orgRes.data.data;
|
||||
|
||||
@@ -61,7 +61,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
niceId: client.niceId,
|
||||
agent: client.agent,
|
||||
archived: client.archived || false,
|
||||
blocked: client.blocked || false
|
||||
blocked: client.blocked || false,
|
||||
approvalState: client.approvalState ?? "approved"
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListClientsResponse } from "@server/routers/client";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { ClientRow } from "@app/components/MachineClientsTable";
|
||||
import type { ClientRow } from "@app/components/UserDevicesTable";
|
||||
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||
|
||||
type ClientsPageProps = {
|
||||
@@ -57,7 +57,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
||||
niceId: client.niceId,
|
||||
agent: client.agent,
|
||||
archived: client.archived || false,
|
||||
blocked: client.blocked || false
|
||||
blocked: client.blocked || false,
|
||||
approvalState: client.approvalState
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@ import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SitesTable, { SiteRow } from "../../../../components/SitesTable";
|
||||
import SitesTable, { SiteRow } from "@app/components/SitesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import SitesBanner from "@app/components/SitesBanner";
|
||||
import SitesSplashCard from "../../../../components/SitesSplashCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type SitesPageProps = {
|
||||
|
||||
@@ -2,27 +2,27 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
|
||||
import { Env } from "@app/lib/types/env";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
Settings,
|
||||
Users,
|
||||
Link as LinkIcon,
|
||||
Waypoints,
|
||||
ChartLine,
|
||||
Combine,
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
GlobeLock,
|
||||
KeyRound,
|
||||
Laptop,
|
||||
Link as LinkIcon,
|
||||
Logs, // Added from 'dev' branch
|
||||
MonitorUp,
|
||||
ReceiptText,
|
||||
ScanEye, // Added from 'dev' branch
|
||||
Server,
|
||||
Settings,
|
||||
SquareMousePointer,
|
||||
TicketCheck,
|
||||
User,
|
||||
Globe, // Added from 'dev' branch
|
||||
MonitorUp, // Added from 'dev' branch
|
||||
Server,
|
||||
ReceiptText,
|
||||
CreditCard,
|
||||
Logs,
|
||||
SquareMousePointer,
|
||||
ScanEye,
|
||||
GlobeLock,
|
||||
Smartphone,
|
||||
Laptop,
|
||||
ChartLine
|
||||
UserCog,
|
||||
Users,
|
||||
Waypoints
|
||||
} from "lucide-react";
|
||||
|
||||
export type SidebarNavSection = {
|
||||
@@ -123,7 +123,7 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
href: "/{orgId}/settings/access/roles",
|
||||
icon: <Users className="size-4 flex-none" />
|
||||
},
|
||||
...(build == "saas" || env?.flags.useOrgOnlyIdp
|
||||
...(build === "saas" || env?.flags.useOrgOnlyIdp
|
||||
? [
|
||||
{
|
||||
title: "sidebarIdentityProviders",
|
||||
@@ -132,6 +132,15 @@ export const orgNavSections = (env?: Env): SidebarNavSection[] => [
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(build !== "oss"
|
||||
? [
|
||||
{
|
||||
title: "sidebarApprovals",
|
||||
href: "/{orgId}/settings/access/approvals",
|
||||
icon: <UserCog className="size-4 flex-none" />
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "sidebarShareableLinks",
|
||||
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";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
@@ -26,17 +10,37 @@ import {
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { build } from "@server/build";
|
||||
import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
|
||||
afterCreate?: (res: CreateRoleResponse) => void;
|
||||
};
|
||||
|
||||
export default function CreateRoleForm({
|
||||
@@ -46,35 +50,35 @@ export default function CreateRoleForm({
|
||||
}: CreateRoleFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string({ message: t("nameRequired") }).max(32),
|
||||
description: z.string().max(255).optional()
|
||||
name: z
|
||||
.string({ message: t("nameRequired") })
|
||||
.min(1)
|
||||
.max(32),
|
||||
description: z.string().max(255).optional(),
|
||||
requireDeviceApproval: z.boolean().optional()
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: ""
|
||||
description: "",
|
||||
requireDeviceApproval: false
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const res = await api
|
||||
.put<AxiosResponse<CreateRoleResponse>>(
|
||||
`/org/${org?.org.orgId}/role`,
|
||||
{
|
||||
name: values.name,
|
||||
description: values.description
|
||||
} as CreateRoleBody
|
||||
)
|
||||
.put<
|
||||
AxiosResponse<CreateRoleResponse>
|
||||
>(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
@@ -97,12 +101,8 @@ export default function CreateRoleForm({
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
if (afterCreate) {
|
||||
afterCreate(res.data.data);
|
||||
}
|
||||
afterCreate?.(res.data.data);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -111,7 +111,6 @@ export default function CreateRoleForm({
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
setLoading(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
@@ -125,7 +124,9 @@ export default function CreateRoleForm({
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-role-form"
|
||||
>
|
||||
@@ -159,6 +160,56 @@ export default function CreateRoleForm({
|
||||
</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>
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -14,16 +12,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger
|
||||
} from "@/components/ui/drawer";
|
||||
import { DrawerClose } from "@/components/ui/drawer";
|
||||
import { useMediaQuery } from "@app/hooks/useMediaQuery";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -78,10 +69,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
|
||||
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
|
||||
|
||||
return (
|
||||
<CredenzaClose
|
||||
className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)}
|
||||
{...props}
|
||||
>
|
||||
<CredenzaClose className={cn("", className)} {...props}>
|
||||
{children}
|
||||
</CredenzaClose>
|
||||
);
|
||||
@@ -172,14 +160,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
||||
|
||||
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||
const isDesktop = useMediaQuery(desktop);
|
||||
// const isDesktop = true;
|
||||
|
||||
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
||||
|
||||
return (
|
||||
<CredenzaFooter
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -191,12 +178,12 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||
|
||||
export {
|
||||
Credenza,
|
||||
CredenzaTrigger,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle,
|
||||
CredenzaBody,
|
||||
CredenzaFooter
|
||||
CredenzaTrigger
|
||||
};
|
||||
|
||||
@@ -71,10 +71,10 @@ export const DismissableBanner = ({
|
||||
}
|
||||
|
||||
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
|
||||
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")}
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground" />
|
||||
@@ -91,7 +91,7 @@ export const DismissableBanner = ({
|
||||
</p>
|
||||
</div>
|
||||
{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}
|
||||
</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";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -16,7 +15,6 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
MoreHorizontal,
|
||||
CircleSlash
|
||||
} from "lucide-react";
|
||||
@@ -25,7 +23,6 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -45,6 +42,7 @@ export type ClientRow = {
|
||||
agent: string | null;
|
||||
archived?: boolean;
|
||||
blocked?: boolean;
|
||||
approvalState: "approved" | "pending" | "denied";
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
@@ -214,7 +212,10 @@ export default function MachineClientsTable({
|
||||
</Badge>
|
||||
)}
|
||||
{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" />
|
||||
{t("blocked")}
|
||||
</Badge>
|
||||
@@ -410,7 +411,9 @@ export default function MachineClientsTable({
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{clientRow.archived ? "Unarchive" : "Archive"}
|
||||
{clientRow.archived
|
||||
? "Unarchive"
|
||||
: "Archive"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -424,7 +427,9 @@ export default function MachineClientsTable({
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{clientRow.blocked ? "Unblock" : "Block"}
|
||||
{clientRow.blocked
|
||||
? "Unblock"
|
||||
: "Block"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -539,15 +544,27 @@ export default function MachineClientsTable({
|
||||
value: "blocked"
|
||||
}
|
||||
],
|
||||
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
|
||||
filterFn: (
|
||||
row: ClientRow,
|
||||
selectedValues: (string | number | boolean)[]
|
||||
) => {
|
||||
if (selectedValues.length === 0) return true;
|
||||
const rowArchived = row.archived || false;
|
||||
const rowBlocked = row.blocked || false;
|
||||
const isActive = !rowArchived && !rowBlocked;
|
||||
|
||||
if (selectedValues.includes("active") && isActive) return true;
|
||||
if (selectedValues.includes("archived") && rowArchived) return true;
|
||||
if (selectedValues.includes("blocked") && rowBlocked) return true;
|
||||
|
||||
if (selectedValues.includes("active") && isActive)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("archived") &&
|
||||
rowArchived
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("blocked") &&
|
||||
rowBlocked
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
defaultValues: ["active"] // Default to showing active clients
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { RolesDataTable } from "@app/components/RolesDataTable";
|
||||
import { Role } from "@server/db";
|
||||
import CreateRoleForm from "@app/components/CreateRoleForm";
|
||||
import DeleteRoleForm from "@app/components/DeleteRoleForm";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { RolesDataTable } from "@app/components/RolesDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Role } from "@server/db";
|
||||
import { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem
|
||||
} from "./ui/dropdown-menu";
|
||||
import EditRoleForm from "./EditRoleForm";
|
||||
|
||||
export type RoleRow = Role;
|
||||
|
||||
@@ -29,27 +29,26 @@ type RolesTableProps = {
|
||||
roles: RoleRow[];
|
||||
};
|
||||
|
||||
export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
export default function UsersTable({ roles }: RolesTableProps) {
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<RoleRow | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const [roles, setRoles] = useState<RoleRow[]>(r);
|
||||
|
||||
const [roleToRemove, setUserToRemove] = useState<RoleRow | null>(null);
|
||||
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const t = useTranslations();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log("Data refreshed");
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -57,8 +56,6 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
description: t("refreshError"),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,26 +83,74 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
friendlyName: t("description"),
|
||||
header: () => <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",
|
||||
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>
|
||||
!roleRow.isAdmin && (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<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 (
|
||||
<>
|
||||
{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
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
afterCreate={async (role) => {
|
||||
setRoles((prev) => [...prev, role]);
|
||||
afterCreate={() => {
|
||||
startTransition(refreshData);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -127,10 +190,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
setOpen={setIsDeleteModalOpen}
|
||||
roleToDelete={roleToRemove}
|
||||
afterDelete={() => {
|
||||
setRoles((prev) =>
|
||||
prev.filter((r) => r.roleId !== roleToRemove.roleId)
|
||||
);
|
||||
setUserToRemove(null);
|
||||
startTransition(async () => {
|
||||
await refreshData().then(() =>
|
||||
setRoleToRemove(null)
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -141,7 +205,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
createRole={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
}}
|
||||
onRefresh={refreshData}
|
||||
onRefresh={() => startTransition(refreshData)}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -24,9 +23,11 @@ import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
import ClientDownloadBanner from "./ClientDownloadBanner";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { t } from "@faker-js/faker/dist/airline-DF6RqYmq";
|
||||
|
||||
export type ClientRow = {
|
||||
id: number;
|
||||
@@ -44,6 +45,7 @@ export type ClientRow = {
|
||||
userEmail: string | null;
|
||||
niceId: string;
|
||||
agent: string | null;
|
||||
approvalState: "approved" | "pending" | "denied" | null;
|
||||
archived?: boolean;
|
||||
blocked?: boolean;
|
||||
};
|
||||
@@ -210,11 +212,22 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
</Badge>
|
||||
)}
|
||||
{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" />
|
||||
{t("blocked")}
|
||||
</Badge>
|
||||
)}
|
||||
{r.approvalState === "pending" && (
|
||||
<Badge
|
||||
variant="outlinePrimary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{t("pendingApproval")}
|
||||
</Badge>
|
||||
)}
|
||||
</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",
|
||||
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
|
||||
onClick={() => {
|
||||
@@ -472,7 +462,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{clientRow.blocked ? "Unblock" : "Block"}</span>
|
||||
<span>
|
||||
{clientRow.blocked
|
||||
? "Unblock"
|
||||
: "Block"}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{!clientRow.userId && (
|
||||
// Machine client - also show delete option
|
||||
@@ -482,7 +476,9 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">
|
||||
Delete
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
@@ -570,32 +566,65 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
options: [
|
||||
{
|
||||
id: "active",
|
||||
label: t("active") || "Active",
|
||||
label: t("active"),
|
||||
value: "active"
|
||||
},
|
||||
{
|
||||
id: "pending",
|
||||
label: t("pendingApproval"),
|
||||
value: "pending"
|
||||
},
|
||||
{
|
||||
id: "denied",
|
||||
label: t("deniedApproval"),
|
||||
value: "denied"
|
||||
},
|
||||
{
|
||||
id: "archived",
|
||||
label: t("archived") || "Archived",
|
||||
label: t("archived"),
|
||||
value: "archived"
|
||||
},
|
||||
{
|
||||
id: "blocked",
|
||||
label: t("blocked") || "Blocked",
|
||||
label: t("blocked"),
|
||||
value: "blocked"
|
||||
}
|
||||
],
|
||||
filterFn: (row: ClientRow, selectedValues: (string | number | boolean)[]) => {
|
||||
filterFn: (
|
||||
row: ClientRow,
|
||||
selectedValues: (string | number | boolean)[]
|
||||
) => {
|
||||
if (selectedValues.length === 0) return true;
|
||||
const rowArchived = row.archived || false;
|
||||
const rowBlocked = row.blocked || false;
|
||||
const rowArchived = row.archived;
|
||||
const rowBlocked = row.blocked;
|
||||
const approvalState = row.approvalState;
|
||||
const isActive = !rowArchived && !rowBlocked;
|
||||
|
||||
if (selectedValues.includes("active") && isActive) return true;
|
||||
if (selectedValues.includes("archived") && rowArchived) return true;
|
||||
if (selectedValues.includes("blocked") && rowBlocked) return true;
|
||||
|
||||
if (selectedValues.includes("active") && isActive)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("pending") &&
|
||||
approvalState === "pending"
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("denied") &&
|
||||
approvalState === "denied"
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("archived") &&
|
||||
rowArchived
|
||||
)
|
||||
return true;
|
||||
if (
|
||||
selectedValues.includes("blocked") &&
|
||||
rowBlocked
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
defaultValues: ["active"] // Default to showing active clients
|
||||
defaultValues: ["active", "pending"] // Default to showing active clients
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -30,7 +30,8 @@ const checkboxVariants = cva(
|
||||
);
|
||||
|
||||
interface CheckboxProps
|
||||
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||
VariantProps<typeof checkboxVariants> {}
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
@@ -49,17 +50,18 @@ const Checkbox = React.forwardRef<
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
interface CheckboxWithLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
|
||||
interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof Checkbox
|
||||
> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const CheckboxWithLabel = React.forwardRef<
|
||||
React.ElementRef<typeof Checkbox>,
|
||||
React.ComponentRef<typeof Checkbox>,
|
||||
CheckboxWithLabelProps
|
||||
>(({ className, label, id, ...props }, ref) => {
|
||||
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} />
|
||||
<label
|
||||
htmlFor={id}
|
||||
|
||||
@@ -15,7 +15,7 @@ const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
@@ -30,7 +30,7 @@ const DialogOverlay = React.forwardRef<
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { build } from "@server/build";
|
||||
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
|
||||
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 { ListSitesResponse } from "@server/routers/site";
|
||||
import type {
|
||||
@@ -7,20 +13,14 @@ import type {
|
||||
ListSiteResourceRolesResponse,
|
||||
ListSiteResourceUsersResponse
|
||||
} from "@server/routers/siteResource";
|
||||
import type { ListTargetsResponse } from "@server/routers/target";
|
||||
import type { ListUsersResponse } from "@server/routers/user";
|
||||
import type ResponseT from "@server/types/Response";
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import type { AxiosInstance, AxiosResponse } from "axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import z from "zod";
|
||||
import { remote } from "./api";
|
||||
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 = {
|
||||
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