Merge pull request #2121 from Fredkiss3/feat/device-approvals

feat: device approvals
This commit is contained in:
Milo Schwartz
2026-01-15 21:33:31 -08:00
committed by GitHub
39 changed files with 1549 additions and 286 deletions

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ dynamic/
scratch/ scratch/
tsconfig.json tsconfig.json
hydrateSaas.ts hydrateSaas.ts
CLAUDE.md

View File

@@ -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.",

View File

@@ -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(

View File

@@ -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>;

View File

@@ -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(

View File

@@ -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>;

View File

@@ -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", {

View File

@@ -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

View 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";

View 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")
);
}
}

View 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")
);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View 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>
</>
);
}

View File

@@ -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();

View File

@@ -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;

View File

@@ -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"
}; };
}; };

View File

@@ -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
}; };
}; };

View File

@@ -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 = {

View File

@@ -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",

View 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>
&nbsp;
{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>
);
}

View File

@@ -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>

View File

@@ -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
}; };

View File

@@ -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>
)} )}

View 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>
</>
);
}

View File

@@ -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

View File

@@ -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}
/> />
</> </>

View File

@@ -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
} }
]} ]}
/> />

View File

@@ -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}

View File

@@ -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>

View File

@@ -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;
}
})
};