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

3
.gitignore vendored
View File

@@ -50,4 +50,5 @@ dynamic/
*.mmdb
scratch/
tsconfig.json
hydrateSaas.ts
hydrateSaas.ts
CLAUDE.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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