mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-20 20:06:39 +00:00
Compare commits
9 Commits
1.15.4-s.3
...
ssh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6442eb12fb | ||
|
|
01c15afa74 | ||
|
|
4e88f1f38a | ||
|
|
13ab505f4d | ||
|
|
7d112aab27 | ||
|
|
7a01a4e090 | ||
|
|
874794c996 | ||
|
|
5e37c4e85f | ||
|
|
4e7eac368f |
@@ -649,7 +649,7 @@
|
|||||||
"resourcesUsersRolesAccess": "User and role-based access control",
|
"resourcesUsersRolesAccess": "User and role-based access control",
|
||||||
"resourcesErrorUpdate": "Failed to toggle resource",
|
"resourcesErrorUpdate": "Failed to toggle resource",
|
||||||
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
||||||
"access": "Access",
|
"access": "Access Control",
|
||||||
"shareLink": "{resource} Share Link",
|
"shareLink": "{resource} Share Link",
|
||||||
"resourceSelect": "Select resource",
|
"resourceSelect": "Select resource",
|
||||||
"shareLinks": "Share Links",
|
"shareLinks": "Share Links",
|
||||||
@@ -790,6 +790,7 @@
|
|||||||
"accessRoleRemoved": "Role removed",
|
"accessRoleRemoved": "Role removed",
|
||||||
"accessRoleRemovedDescription": "The role has been successfully removed.",
|
"accessRoleRemovedDescription": "The role has been successfully removed.",
|
||||||
"accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.",
|
"accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.",
|
||||||
|
"network": "Network",
|
||||||
"manage": "Manage",
|
"manage": "Manage",
|
||||||
"sitesNotFound": "No sites found.",
|
"sitesNotFound": "No sites found.",
|
||||||
"pangolinServerAdmin": "Server Admin - Pangolin",
|
"pangolinServerAdmin": "Server Admin - Pangolin",
|
||||||
@@ -1267,6 +1268,7 @@
|
|||||||
"sidebarLogAndAnalytics": "Log & Analytics",
|
"sidebarLogAndAnalytics": "Log & Analytics",
|
||||||
"sidebarBluePrints": "Blueprints",
|
"sidebarBluePrints": "Blueprints",
|
||||||
"sidebarOrganization": "Organization",
|
"sidebarOrganization": "Organization",
|
||||||
|
"sidebarManagement": "Management",
|
||||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||||
"sidebarLogsAnalytics": "Analytics",
|
"sidebarLogsAnalytics": "Analytics",
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
@@ -1643,6 +1645,24 @@
|
|||||||
"timeIsInSeconds": "Time is in seconds",
|
"timeIsInSeconds": "Time is in seconds",
|
||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
|
"sshAccess": "SSH Access",
|
||||||
|
"roleAllowSsh": "Allow SSH",
|
||||||
|
"roleAllowSshAllow": "Allow",
|
||||||
|
"roleAllowSshDisallow": "Disallow",
|
||||||
|
"roleAllowSshDescription": "Allow users with this role to connect to resources via SSH. When disabled, the role cannot use SSH access.",
|
||||||
|
"sshSudoMode": "Sudo Access",
|
||||||
|
"sshSudoModeNone": "None",
|
||||||
|
"sshSudoModeNoneDescription": "User cannot run commands with sudo.",
|
||||||
|
"sshSudoModeFull": "Full Sudo",
|
||||||
|
"sshSudoModeFullDescription": "User can run any command with sudo.",
|
||||||
|
"sshSudoModeCommands": "Commands",
|
||||||
|
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
||||||
|
"sshSudo": "Allow sudo",
|
||||||
|
"sshSudoCommands": "Sudo Commands",
|
||||||
|
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.",
|
||||||
|
"sshCreateHomeDir": "Create Home Directory",
|
||||||
|
"sshUnixGroups": "Unix Groups",
|
||||||
|
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host.",
|
||||||
"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.",
|
||||||
@@ -2079,7 +2099,7 @@
|
|||||||
"manageMachineClients": "Manage Machine Clients",
|
"manageMachineClients": "Manage Machine Clients",
|
||||||
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
|
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
|
||||||
"machineClientsBannerTitle": "Servers & Automated Systems",
|
"machineClientsBannerTitle": "Servers & Automated Systems",
|
||||||
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
|
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can be deployed as a CLI or a container.",
|
||||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||||
"machineClientsBannerOlmCLI": "Olm CLI",
|
"machineClientsBannerOlmCLI": "Olm CLI",
|
||||||
"machineClientsBannerOlmContainer": "Container",
|
"machineClientsBannerOlmContainer": "Container",
|
||||||
|
|||||||
@@ -372,7 +372,11 @@ export const roles = pgTable("roles", {
|
|||||||
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)
|
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
|
||||||
|
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands"
|
||||||
|
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||||
|
sshCreateHomeDir: boolean("sshCreateHomeDir").default(false),
|
||||||
|
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roleActions = pgTable("roleActions", {
|
export const roleActions = pgTable("roleActions", {
|
||||||
|
|||||||
@@ -679,7 +679,13 @@ export const roles = sqliteTable("roles", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
requireDeviceApproval: integer("requireDeviceApproval", {
|
requireDeviceApproval: integer("requireDeviceApproval", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).default(false)
|
}).default(false),
|
||||||
|
sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands"
|
||||||
|
sshSudoCommands: text("sshSudoCommands").default("[]"),
|
||||||
|
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
|
||||||
|
false
|
||||||
|
),
|
||||||
|
sshUnixGroups: text("sshUnixGroups").default("[]")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const roleActions = sqliteTable("roleActions", {
|
export const roleActions = sqliteTable("roleActions", {
|
||||||
|
|||||||
@@ -48,5 +48,5 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
"enterprise"
|
"enterprise"
|
||||||
],
|
],
|
||||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||||
[TierFeature.SshPam]: ["enterprise"]
|
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ export async function verifyApiKeyRoleAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { roleIds } = req.body;
|
let allRoleIds: number[] = [];
|
||||||
const allRoleIds =
|
if (!isNaN(singleRoleId)) {
|
||||||
roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
// If roleId is provided in URL params, query params, or body (single), use it exclusively
|
||||||
|
allRoleIds = [singleRoleId];
|
||||||
|
} else if (req.body?.roleIds) {
|
||||||
|
// Only use body.roleIds if no single roleId was provided
|
||||||
|
allRoleIds = req.body.roleIds;
|
||||||
|
}
|
||||||
|
|
||||||
if (allRoleIds.length === 0) {
|
if (allRoleIds.length === 0) {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -23,8 +23,14 @@ export async function verifyRoleAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = req.body?.roleIds;
|
let allRoleIds: number[] = [];
|
||||||
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
|
if (!isNaN(singleRoleId)) {
|
||||||
|
// If roleId is provided in URL params, query params, or body (single), use it exclusively
|
||||||
|
allRoleIds = [singleRoleId];
|
||||||
|
} else if (req.body?.roleIds) {
|
||||||
|
// Only use body.roleIds if no single roleId was provided
|
||||||
|
allRoleIds = req.body.roleIds;
|
||||||
|
}
|
||||||
|
|
||||||
if (allRoleIds.length === 0) {
|
if (allRoleIds.length === 0) {
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ async function disableFeature(
|
|||||||
await disableAutoProvisioning(orgId);
|
await disableAutoProvisioning(orgId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case TierFeature.SshPam:
|
||||||
|
await disableSshPam(orgId);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Unknown feature ${feature} for org ${orgId}, skipping`
|
`Unknown feature ${feature} for org ${orgId}, skipping`
|
||||||
@@ -315,6 +319,20 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
|
|||||||
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
|
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function disableSshPam(orgId: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(roles)
|
||||||
|
.set({
|
||||||
|
sshSudoMode: "none",
|
||||||
|
sshSudoCommands: "[]",
|
||||||
|
sshCreateHomeDir: false,
|
||||||
|
sshUnixGroups: "[]"
|
||||||
|
})
|
||||||
|
.where(eq(roles.orgId, orgId));
|
||||||
|
|
||||||
|
logger.info(`Disabled SSH PAM options on all roles for org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function disableLoginPageBranding(orgId: string): Promise<void> {
|
async function disableLoginPageBranding(orgId: string): Promise<void> {
|
||||||
const [existingBranding] = await db
|
const [existingBranding] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -13,7 +13,17 @@
|
|||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
newts,
|
||||||
|
roles,
|
||||||
|
roundTripMessageTracker,
|
||||||
|
siteResources,
|
||||||
|
sites,
|
||||||
|
userOrgs
|
||||||
|
} from "@server/db";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -25,6 +35,8 @@ import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResourc
|
|||||||
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { sendToClient } from "#private/routers/ws";
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
import { groups } from "d3";
|
||||||
|
import { homedir } from "os";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -135,11 +147,26 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.sshPam
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"SSH key signing requires a paid plan"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let usernameToUse;
|
let usernameToUse;
|
||||||
if (!userOrg.pamUsername) {
|
if (!userOrg.pamUsername) {
|
||||||
if (req.user?.email) {
|
if (req.user?.email) {
|
||||||
// Extract username from email (first part before @)
|
// Extract username from email (first part before @)
|
||||||
usernameToUse = req.user?.email.split("@")[0];
|
usernameToUse = req.user?.email
|
||||||
|
.split("@")[0]
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
if (!usernameToUse) {
|
if (!usernameToUse) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -301,6 +328,29 @@ export async function signSshKey(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [roleRow] = await db
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.roleId, roleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let parsedSudoCommands: string[] = [];
|
||||||
|
let parsedGroups: string[] = [];
|
||||||
|
try {
|
||||||
|
parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
|
||||||
|
if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = [];
|
||||||
|
} catch {
|
||||||
|
parsedSudoCommands = [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
|
||||||
|
if (!Array.isArray(parsedGroups)) parsedGroups = [];
|
||||||
|
} catch {
|
||||||
|
parsedGroups = [];
|
||||||
|
}
|
||||||
|
const homedir = roleRow?.sshCreateHomeDir ?? null;
|
||||||
|
const sudoMode = roleRow?.sshSudoMode ?? "none";
|
||||||
|
|
||||||
// get the site
|
// get the site
|
||||||
const [newt] = await db
|
const [newt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -334,7 +384,7 @@ export async function signSshKey(
|
|||||||
.values({
|
.values({
|
||||||
wsClientId: newt.newtId,
|
wsClientId: newt.newtId,
|
||||||
messageType: `newt/pam/connection`,
|
messageType: `newt/pam/connection`,
|
||||||
sentAt: Math.floor(Date.now() / 1000),
|
sentAt: Math.floor(Date.now() / 1000)
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -358,8 +408,10 @@ export async function signSshKey(
|
|||||||
username: usernameToUse,
|
username: usernameToUse,
|
||||||
niceId: resource.niceId,
|
niceId: resource.niceId,
|
||||||
metadata: {
|
metadata: {
|
||||||
sudo: true, // we are hardcoding these for now but should make configurable from the role or something
|
sudoMode: sudoMode,
|
||||||
homedir: true
|
sudoCommands: parsedSudoCommands,
|
||||||
|
homedir: homedir,
|
||||||
|
groups: parsedGroups
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import response from "@server/lib/response";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
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 { eq, and } from "drizzle-orm";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
@@ -93,7 +93,8 @@ export async function updateClient(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(clients.niceId, niceId),
|
eq(clients.niceId, niceId),
|
||||||
eq(clients.orgId, clients.orgId)
|
eq(clients.orgId, clients.orgId),
|
||||||
|
ne(clients.clientId, clientId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Resource,
|
Resource,
|
||||||
resources
|
resources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -33,7 +33,15 @@ const updateResourceParamsSchema = z.strictObject({
|
|||||||
const updateHttpResourceBodySchema = z
|
const updateHttpResourceBodySchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
niceId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(255)
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9-]+$/,
|
||||||
|
"niceId can only contain letters, numbers, and dashes"
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
subdomain: subdomainSchema.nullable().optional(),
|
subdomain: subdomainSchema.nullable().optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
@@ -248,14 +256,13 @@ async function updateHttpResource(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(resources.niceId, updateData.niceId),
|
eq(resources.niceId, updateData.niceId),
|
||||||
eq(resources.orgId, resource.orgId)
|
eq(resources.orgId, resource.orgId),
|
||||||
|
ne(resources.resourceId, resource.resourceId) // exclude the current resource from the search
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
if (
|
if (existingResource) {
|
||||||
existingResource &&
|
|
||||||
existingResource.resourceId !== resource.resourceId
|
|
||||||
) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.CONFLICT,
|
HttpCode.CONFLICT,
|
||||||
@@ -343,7 +350,10 @@ async function updateHttpResource(
|
|||||||
headers = null;
|
headers = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage);
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
resource.orgId,
|
||||||
|
tierMatrix.maintencePage
|
||||||
|
);
|
||||||
if (!isLicensed) {
|
if (!isLicensed) {
|
||||||
updateData.maintenanceModeEnabled = undefined;
|
updateData.maintenanceModeEnabled = undefined;
|
||||||
updateData.maintenanceModeType = undefined;
|
updateData.maintenanceModeType = undefined;
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ const createRoleParamsSchema = z.strictObject({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
|
||||||
|
|
||||||
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()
|
requireDeviceApproval: z.boolean().optional(),
|
||||||
|
allowSsh: z.boolean().optional(),
|
||||||
|
sshSudoMode: sshSudoModeSchema.optional(),
|
||||||
|
sshSudoCommands: z.array(z.string()).optional(),
|
||||||
|
sshCreateHomeDir: z.boolean().optional(),
|
||||||
|
sshUnixGroups: z.array(z.string()).optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
export const defaultRoleAllowedActions: ActionsEnum[] = [
|
||||||
@@ -101,24 +108,40 @@ export async function createRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
|
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
|
||||||
if (!isLicensed) {
|
if (!isLicensedDeviceApprovals) {
|
||||||
roleData.requireDeviceApproval = undefined;
|
roleData.requireDeviceApproval = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam);
|
||||||
|
const roleInsertValues: Record<string, unknown> = {
|
||||||
|
name: roleData.name,
|
||||||
|
orgId
|
||||||
|
};
|
||||||
|
if (roleData.description !== undefined) roleInsertValues.description = roleData.description;
|
||||||
|
if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval;
|
||||||
|
if (isLicensedSshPam) {
|
||||||
|
if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode;
|
||||||
|
if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands);
|
||||||
|
if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir;
|
||||||
|
if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups);
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
const newRole = await trx
|
const newRole = await trx
|
||||||
.insert(roles)
|
.insert(roles)
|
||||||
.values({
|
.values(roleInsertValues as typeof roles.$inferInsert)
|
||||||
...roleData,
|
|
||||||
orgId
|
|
||||||
})
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
const actionsToInsert = [...defaultRoleAllowedActions];
|
||||||
|
if (roleData.allowSsh) {
|
||||||
|
actionsToInsert.push(ActionsEnum.signSshKey);
|
||||||
|
}
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleActions)
|
.insert(roleActions)
|
||||||
.values(
|
.values(
|
||||||
defaultRoleAllowedActions.map((action) => ({
|
actionsToInsert.map((action) => ({
|
||||||
roleId: newRole[0].roleId,
|
roleId: newRole[0].roleId,
|
||||||
actionId: action,
|
actionId: action,
|
||||||
orgId
|
orgId
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { db, orgs, roles } from "@server/db";
|
import { db, orgs, roleActions, roles } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -37,7 +38,11 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
|
|||||||
name: roles.name,
|
name: roles.name,
|
||||||
description: roles.description,
|
description: roles.description,
|
||||||
orgName: orgs.name,
|
orgName: orgs.name,
|
||||||
requireDeviceApproval: roles.requireDeviceApproval
|
requireDeviceApproval: roles.requireDeviceApproval,
|
||||||
|
sshSudoMode: roles.sshSudoMode,
|
||||||
|
sshSudoCommands: roles.sshSudoCommands,
|
||||||
|
sshCreateHomeDir: roles.sshCreateHomeDir,
|
||||||
|
sshUnixGroups: roles.sshUnixGroups
|
||||||
})
|
})
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
|
||||||
@@ -106,9 +111,28 @@ export async function listRoles(
|
|||||||
const totalCountResult = await countQuery;
|
const totalCountResult = await countQuery;
|
||||||
const totalCount = totalCountResult[0].count;
|
const totalCount = totalCountResult[0].count;
|
||||||
|
|
||||||
|
let rolesWithAllowSsh = rolesList;
|
||||||
|
if (rolesList.length > 0) {
|
||||||
|
const roleIds = rolesList.map((r) => r.roleId);
|
||||||
|
const signSshKeyRows = await db
|
||||||
|
.select({ roleId: roleActions.roleId })
|
||||||
|
.from(roleActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(roleActions.roleId, roleIds),
|
||||||
|
eq(roleActions.actionId, ActionsEnum.signSshKey)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const roleIdsWithSsh = new Set(signSshKeyRows.map((r) => r.roleId));
|
||||||
|
rolesWithAllowSsh = rolesList.map((r) => ({
|
||||||
|
...r,
|
||||||
|
allowSsh: roleIdsWithSsh.has(r.roleId)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {
|
data: {
|
||||||
roles: rolesList,
|
roles: rolesWithAllowSsh,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, type Role } from "@server/db";
|
import { db, type Role } from "@server/db";
|
||||||
import { roles } from "@server/db";
|
import { roleActions, roles } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { ActionsEnum } from "@server/auth/actions";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -16,11 +17,18 @@ const updateRoleParamsSchema = z.strictObject({
|
|||||||
roleId: z.string().transform(Number).pipe(z.int().positive())
|
roleId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
|
||||||
|
|
||||||
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()
|
requireDeviceApproval: z.boolean().optional(),
|
||||||
|
allowSsh: z.boolean().optional(),
|
||||||
|
sshSudoMode: sshSudoModeSchema.optional(),
|
||||||
|
sshSudoCommands: z.array(z.string()).optional(),
|
||||||
|
sshCreateHomeDir: z.boolean().optional(),
|
||||||
|
sshUnixGroups: z.array(z.string()).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"
|
||||||
@@ -75,7 +83,9 @@ export async function updateRole(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { roleId } = parsedParams.data;
|
const { roleId } = parsedParams.data;
|
||||||
const updateData = parsedBody.data;
|
const body = parsedBody.data;
|
||||||
|
const { allowSsh, ...restBody } = body;
|
||||||
|
const updateData: Record<string, unknown> = { ...restBody };
|
||||||
|
|
||||||
const role = await db
|
const role = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -111,18 +121,70 @@ export async function updateRole(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
|
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
|
||||||
if (!isLicensed) {
|
if (!isLicensedDeviceApprovals) {
|
||||||
updateData.requireDeviceApproval = undefined;
|
updateData.requireDeviceApproval = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedRole = await db
|
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam);
|
||||||
|
if (!isLicensedSshPam) {
|
||||||
|
delete updateData.sshSudoMode;
|
||||||
|
delete updateData.sshSudoCommands;
|
||||||
|
delete updateData.sshCreateHomeDir;
|
||||||
|
delete updateData.sshUnixGroups;
|
||||||
|
} else {
|
||||||
|
if (Array.isArray(updateData.sshSudoCommands)) {
|
||||||
|
updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands);
|
||||||
|
}
|
||||||
|
if (Array.isArray(updateData.sshUnixGroups)) {
|
||||||
|
updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRole = await db.transaction(async (trx) => {
|
||||||
|
const result = await trx
|
||||||
.update(roles)
|
.update(roles)
|
||||||
.set(updateData)
|
.set(updateData as typeof roles.$inferInsert)
|
||||||
.where(eq(roles.roleId, roleId))
|
.where(eq(roles.roleId, roleId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (updatedRole.length === 0) {
|
if (result.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowSsh === true) {
|
||||||
|
const existing = await trx
|
||||||
|
.select()
|
||||||
|
.from(roleActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleActions.roleId, roleId),
|
||||||
|
eq(roleActions.actionId, ActionsEnum.signSshKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
await trx.insert(roleActions).values({
|
||||||
|
roleId,
|
||||||
|
actionId: ActionsEnum.signSshKey,
|
||||||
|
orgId: orgId!
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (allowSsh === false) {
|
||||||
|
await trx
|
||||||
|
.delete(roleActions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleActions.roleId, roleId),
|
||||||
|
eq(roleActions.actionId, ActionsEnum.signSshKey)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedRole) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -132,7 +194,7 @@ export async function updateRole(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: updatedRole[0],
|
data: updatedRole,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Role updated successfully",
|
message: "Role updated successfully",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, ne } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -19,8 +19,8 @@ const updateSiteBodySchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
niceId: z.string().min(1).max(255).optional(),
|
niceId: z.string().min(1).max(255).optional(),
|
||||||
dockerSocketEnabled: z.boolean().optional(),
|
dockerSocketEnabled: z.boolean().optional()
|
||||||
remoteSubnets: z.string().optional()
|
// remoteSubnets: z.string().optional()
|
||||||
// subdomain: z
|
// subdomain: z
|
||||||
// .string()
|
// .string()
|
||||||
// .min(1)
|
// .min(1)
|
||||||
@@ -86,18 +86,19 @@ export async function updateSite(
|
|||||||
|
|
||||||
// if niceId is provided, check if it's already in use by another site
|
// if niceId is provided, check if it's already in use by another site
|
||||||
if (updateData.niceId) {
|
if (updateData.niceId) {
|
||||||
const existingSite = await db
|
const [existingSite] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(sites.niceId, updateData.niceId),
|
eq(sites.niceId, updateData.niceId),
|
||||||
eq(sites.orgId, sites.orgId)
|
eq(sites.orgId, sites.orgId),
|
||||||
|
ne(sites.siteId, siteId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingSite.length > 0 && existingSite[0].siteId !== siteId) {
|
if (existingSite) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.CONFLICT,
|
HttpCode.CONFLICT,
|
||||||
@@ -107,22 +108,22 @@ export async function updateSite(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
|
// // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
|
||||||
if (updateData.remoteSubnets) {
|
// if (updateData.remoteSubnets) {
|
||||||
const subnets = updateData.remoteSubnets
|
// const subnets = updateData.remoteSubnets
|
||||||
.split(",")
|
// .split(",")
|
||||||
.map((s) => s.trim());
|
// .map((s) => s.trim());
|
||||||
for (const subnet of subnets) {
|
// for (const subnet of subnets) {
|
||||||
if (!isValidCIDR(subnet)) {
|
// if (!isValidCIDR(subnet)) {
|
||||||
return next(
|
// return next(
|
||||||
createHttpError(
|
// createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
// HttpCode.BAD_REQUEST,
|
||||||
`Invalid CIDR format: ${subnet}`
|
// `Invalid CIDR format: ${subnet}`
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
const updatedSite = await db
|
const updatedSite = await db
|
||||||
.update(sites)
|
.update(sites)
|
||||||
|
|||||||
28
server/setup/scriptsSqlite/1.16.0.ts
Normal file
28
server/setup/scriptsSqlite/1.16.0.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const version = "1.16.0";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
|
const db = new Database(location);
|
||||||
|
|
||||||
|
// set all admin role sudo to "full"; all other roles to "none"
|
||||||
|
// all roles set hoemdir to true
|
||||||
|
|
||||||
|
// generate ca certs for all orgs?
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction(() => {})();
|
||||||
|
|
||||||
|
console.log(`Migrated database`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Failed to migrate db:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ClientProvider client={client}>
|
<ClientProvider client={client}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<ClientInfoCard />
|
<ClientInfoCard />
|
||||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default async function GeneralSettingsPage({
|
|||||||
description={t("orgSettingsDescription")}
|
description={t("orgSettingsDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<OrgInfoCard />
|
<OrgInfoCard />
|
||||||
<HorizontalTabs items={navItems}>
|
<HorizontalTabs items={navItems}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SiteProvider site={site}>
|
<SiteProvider site={site}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<SiteInfoCard />
|
<SiteInfoCard />
|
||||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 {
|
||||||
|
Building2,
|
||||||
ChartLine,
|
ChartLine,
|
||||||
Combine,
|
Combine,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
@@ -11,10 +12,11 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Laptop,
|
Laptop,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Logs, // Added from 'dev' branch
|
Logs,
|
||||||
MonitorUp,
|
MonitorUp,
|
||||||
|
Plug,
|
||||||
ReceiptText,
|
ReceiptText,
|
||||||
ScanEye, // Added from 'dev' branch
|
ScanEye,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
@@ -49,12 +51,12 @@ export const orgNavSections = (
|
|||||||
options?: OrgNavSectionsOptions
|
options?: OrgNavSectionsOptions
|
||||||
): SidebarNavSection[] => [
|
): SidebarNavSection[] => [
|
||||||
{
|
{
|
||||||
heading: "sidebarGeneral",
|
heading: "network",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarSites",
|
title: "sidebarSites",
|
||||||
href: "/{orgId}/settings/sites",
|
href: "/{orgId}/settings/sites",
|
||||||
icon: <Combine className="size-4 flex-none" />
|
icon: <Plug className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarResources",
|
title: "sidebarResources",
|
||||||
@@ -158,13 +160,23 @@ export const orgNavSections = (
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "sidebarLogsAndAnalytics",
|
heading: "sidebarOrganization",
|
||||||
items: (() => {
|
items: [
|
||||||
const logItems: SidebarNavItem[] = [
|
{
|
||||||
|
title: "sidebarLogsAndAnalytics",
|
||||||
|
icon: <ChartLine className="size-4 flex-none" />,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "sidebarLogsAnalytics",
|
||||||
|
href: "/{orgId}/settings/logs/analytics",
|
||||||
|
icon: <ChartLine className="size-4 flex-none" />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarLogsRequest",
|
title: "sidebarLogsRequest",
|
||||||
href: "/{orgId}/settings/logs/request",
|
href: "/{orgId}/settings/logs/request",
|
||||||
icon: <SquareMousePointer className="size-4 flex-none" />
|
icon: (
|
||||||
|
<SquareMousePointer className="size-4 flex-none" />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
...(!env?.flags.disableEnterpriseFeatures
|
...(!env?.flags.disableEnterpriseFeatures
|
||||||
? [
|
? [
|
||||||
@@ -180,32 +192,11 @@ export const orgNavSections = (
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [])
|
: [])
|
||||||
];
|
]
|
||||||
|
|
||||||
const analytics = {
|
|
||||||
title: "sidebarLogsAnalytics",
|
|
||||||
href: "/{orgId}/settings/logs/analytics",
|
|
||||||
icon: <ChartLine className="h-4 w-4" />
|
|
||||||
};
|
|
||||||
|
|
||||||
// If only one log item, return it directly without grouping
|
|
||||||
if (logItems.length === 1) {
|
|
||||||
return [analytics, ...logItems];
|
|
||||||
}
|
|
||||||
|
|
||||||
// If multiple log items, create a group
|
|
||||||
return [
|
|
||||||
analytics,
|
|
||||||
{
|
|
||||||
title: "sidebarLogs",
|
|
||||||
icon: <Logs className="size-4 flex-none" />,
|
|
||||||
items: logItems
|
|
||||||
}
|
|
||||||
];
|
|
||||||
})()
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
heading: "sidebarOrganization",
|
title: "sidebarManagement",
|
||||||
|
icon: <Building2 className="size-4 flex-none" />,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
@@ -216,33 +207,40 @@ export const orgNavSections = (
|
|||||||
title: "sidebarBluePrints",
|
title: "sidebarBluePrints",
|
||||||
href: "/{orgId}/settings/blueprints",
|
href: "/{orgId}/settings/blueprints",
|
||||||
icon: <ReceiptText className="size-4 flex-none" />
|
icon: <ReceiptText className="size-4 flex-none" />
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "sidebarSettings",
|
|
||||||
href: "/{orgId}/settings/general",
|
|
||||||
icon: <Settings className="size-4 flex-none" />
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...(build == "saas" && options?.isPrimaryOrg
|
...(build == "saas" && options?.isPrimaryOrg
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
heading: "sidebarBillingAndLicenses",
|
title: "sidebarBillingAndLicenses",
|
||||||
|
icon: <CreditCard className="size-4 flex-none" />,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "sidebarBilling",
|
title: "sidebarBilling",
|
||||||
href: "/{orgId}/settings/billing",
|
href: "/{orgId}/settings/billing",
|
||||||
icon: <CreditCard className="size-4 flex-none" />
|
icon: (
|
||||||
|
<CreditCard className="size-4 flex-none" />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "sidebarEnterpriseLicenses",
|
title: "sidebarEnterpriseLicenses",
|
||||||
href: "/{orgId}/settings/license",
|
href: "/{orgId}/settings/license",
|
||||||
icon: <TicketCheck className="size-4 flex-none" />
|
icon: (
|
||||||
|
<TicketCheck className="size-4 flex-none" />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
: [])
|
: []),
|
||||||
|
{
|
||||||
|
title: "sidebarSettings",
|
||||||
|
href: "/{orgId}/settings/general",
|
||||||
|
icon: <Settings className="size-4 flex-none" />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
||||||
|
|||||||
@@ -11,31 +11,19 @@ import {
|
|||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { Button } from "@app/components/ui/button";
|
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 { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import type {
|
||||||
import { build } from "@server/build";
|
CreateRoleBody,
|
||||||
import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
CreateRoleResponse
|
||||||
|
} from "@server/routers/role";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { RoleForm, type RoleFormValues } from "./RoleForm";
|
||||||
import { z } from "zod";
|
|
||||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
|
||||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
type CreateRoleFormProps = {
|
type CreateRoleFormProps = {
|
||||||
@@ -52,35 +40,39 @@ export default function CreateRoleForm({
|
|||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const { env } = useEnvContext();
|
|
||||||
|
|
||||||
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 api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
requireDeviceApproval: false
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const [loading, startTransition] = useTransition();
|
const [loading, startTransition] = useTransition();
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: RoleFormValues) {
|
||||||
|
const payload: CreateRoleBody = {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description || undefined,
|
||||||
|
requireDeviceApproval: values.requireDeviceApproval,
|
||||||
|
allowSsh: values.allowSsh
|
||||||
|
};
|
||||||
|
if (isPaidUser(tierMatrix.sshPam)) {
|
||||||
|
payload.sshSudoMode = values.sshSudoMode;
|
||||||
|
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||||
|
payload.sshSudoCommands =
|
||||||
|
values.sshSudoMode === "commands" &&
|
||||||
|
values.sshSudoCommands?.trim()
|
||||||
|
? values.sshSudoCommands
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (values.sshUnixGroups?.trim()) {
|
||||||
|
payload.sshUnixGroups = values.sshUnixGroups
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
const res = await api
|
const res = await api
|
||||||
.put<
|
.put<AxiosResponse<CreateRoleResponse>>(
|
||||||
AxiosResponse<CreateRoleResponse>
|
`/org/${org?.org.orgId}/role`,
|
||||||
>(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
|
payload
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -98,24 +90,13 @@ export default function CreateRoleForm({
|
|||||||
title: t("accessRoleCreated"),
|
title: t("accessRoleCreated"),
|
||||||
description: t("accessRoleCreatedDescription")
|
description: t("accessRoleCreatedDescription")
|
||||||
});
|
});
|
||||||
|
if (open) setOpen(false);
|
||||||
if (open) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterCreate?.(res.data.data);
|
afterCreate?.(res.data.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
<Credenza
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setOpen(val);
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
|
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
|
||||||
@@ -124,101 +105,12 @@ export default function CreateRoleForm({
|
|||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<Form {...form}>
|
<RoleForm
|
||||||
<form
|
variant="create"
|
||||||
onSubmit={form.handleSubmit((values) =>
|
onSubmit={(values) =>
|
||||||
startTransition(() => onSubmit(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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!env.flags.disableEnterpriseFeatures && (
|
|
||||||
<>
|
|
||||||
<PaidFeaturesAlert
|
|
||||||
tiers={tierMatrix.deviceApprovals}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="requireDeviceApproval"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="my-2">
|
|
||||||
<FormControl>
|
|
||||||
<CheckboxWithLabel
|
|
||||||
{...field}
|
|
||||||
disabled={
|
|
||||||
!isPaidUser(
|
|
||||||
tierMatrix.deviceApprovals
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
@@ -235,6 +127,5 @@ export default function CreateRoleForm({
|
|||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaContent
|
<CredenzaContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"overflow-y-auto max-h-[100dvh] md:max-h-screen",
|
"overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[200px] md:translate-y-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -11,44 +11,26 @@ import {
|
|||||||
CredenzaTitle
|
CredenzaTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import { Button } from "@app/components/ui/button";
|
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 { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
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 { Role } from "@server/db";
|
||||||
import type {
|
import type {
|
||||||
CreateRoleBody,
|
|
||||||
CreateRoleResponse,
|
|
||||||
UpdateRoleBody,
|
UpdateRoleBody,
|
||||||
UpdateRoleResponse
|
UpdateRoleResponse
|
||||||
} from "@server/routers/role";
|
} from "@server/routers/role";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { RoleForm, type RoleFormValues } from "./RoleForm";
|
||||||
import { z } from "zod";
|
|
||||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
|
||||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
type CreateRoleFormProps = {
|
type EditRoleFormProps = {
|
||||||
role: Role;
|
role: Role;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
onSuccess?: (res: CreateRoleResponse) => void;
|
onSuccess?: (res: UpdateRoleResponse) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EditRoleForm({
|
export default function EditRoleForm({
|
||||||
@@ -56,39 +38,42 @@ export default function EditRoleForm({
|
|||||||
role,
|
role,
|
||||||
setOpen,
|
setOpen,
|
||||||
onSuccess
|
onSuccess
|
||||||
}: CreateRoleFormProps) {
|
}: EditRoleFormProps) {
|
||||||
const { org } = useOrgContext();
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const { env } = useEnvContext();
|
|
||||||
|
|
||||||
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 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();
|
const [loading, startTransition] = useTransition();
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: RoleFormValues) {
|
||||||
|
const payload: UpdateRoleBody = {
|
||||||
|
name: values.name,
|
||||||
|
description: values.description || undefined,
|
||||||
|
requireDeviceApproval: values.requireDeviceApproval,
|
||||||
|
allowSsh: values.allowSsh
|
||||||
|
};
|
||||||
|
if (isPaidUser(tierMatrix.sshPam)) {
|
||||||
|
payload.sshSudoMode = values.sshSudoMode;
|
||||||
|
payload.sshCreateHomeDir = values.sshCreateHomeDir;
|
||||||
|
payload.sshSudoCommands =
|
||||||
|
values.sshSudoMode === "commands" &&
|
||||||
|
values.sshSudoCommands?.trim()
|
||||||
|
? values.sshSudoCommands
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (values.sshUnixGroups !== undefined) {
|
||||||
|
payload.sshUnixGroups = values.sshUnixGroups
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
const res = await api
|
const res = await api
|
||||||
.post<
|
.post<AxiosResponse<UpdateRoleResponse>>(
|
||||||
AxiosResponse<UpdateRoleResponse>
|
`/role/${role.roleId}`,
|
||||||
>(`/role/${role.roleId}`, values satisfies UpdateRoleBody)
|
payload
|
||||||
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -106,24 +91,13 @@ export default function EditRoleForm({
|
|||||||
title: t("accessRoleUpdated"),
|
title: t("accessRoleUpdated"),
|
||||||
description: t("accessRoleUpdatedDescription")
|
description: t("accessRoleUpdatedDescription")
|
||||||
});
|
});
|
||||||
|
if (open) setOpen(false);
|
||||||
if (open) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess?.(res.data.data);
|
onSuccess?.(res.data.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
<Credenza
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(val) => {
|
|
||||||
setOpen(val);
|
|
||||||
form.reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
||||||
@@ -132,101 +106,13 @@ export default function EditRoleForm({
|
|||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<Form {...form}>
|
<RoleForm
|
||||||
<form
|
variant="edit"
|
||||||
onSubmit={form.handleSubmit((values) =>
|
role={role}
|
||||||
|
onSubmit={(values) =>
|
||||||
startTransition(() => onSubmit(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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!env.flags.disableEnterpriseFeatures && (
|
|
||||||
<>
|
|
||||||
<PaidFeaturesAlert
|
|
||||||
tiers={tierMatrix.deviceApprovals}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="requireDeviceApproval"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="my-2">
|
|
||||||
<FormControl>
|
|
||||||
<CheckboxWithLabel
|
|
||||||
{...field}
|
|
||||||
disabled={
|
|
||||||
!isPaidUser(
|
|
||||||
tierMatrix.deviceApprovals
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
@@ -243,6 +129,5 @@ export default function EditRoleForm({
|
|||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export async function Layout({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"container mx-auto max-w-12xl mb-12",
|
"container mx-auto max-w-12xl mb-12",
|
||||||
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
|
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
|
|||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block">
|
<div className="absolute top-0 left-0 right-0 z-50 hidden md:block border-b border-border">
|
||||||
<div className="absolute inset-0 bg-background/83 backdrop-blur-sm" />
|
<div className="absolute inset-0 bg-card" />
|
||||||
<div className="relative z-10 px-6 py-2">
|
<div className="relative z-10 px-6 py-2">
|
||||||
<div className="container mx-auto max-w-12xl">
|
<div className="container mx-auto max-w-12xl">
|
||||||
<div className="h-16 flex items-center justify-between">
|
<div className="h-16 flex items-center justify-between">
|
||||||
|
|||||||
@@ -73,14 +73,14 @@ export function LayoutMobileMenu({
|
|||||||
{t("navbarDescription")}
|
{t("navbarDescription")}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
<div className="flex-1 overflow-y-auto relative">
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
<div className="px-3">
|
<div className="px-1">
|
||||||
<OrgSelector
|
<OrgSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full border-b border-border" />
|
<div className="w-full border-b border-border" />
|
||||||
<div className="px-3">
|
<div className="px-3 pt-3">
|
||||||
{!isAdminPage &&
|
{!isAdminPage &&
|
||||||
user.serverAdmin && (
|
user.serverAdmin && (
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { ExternalLink, Server } from "lucide-react";
|
import { ArrowRight, ExternalLink, Server } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -145,9 +145,19 @@ export function LayoutSidebar({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto relative">
|
<div className="flex-1 overflow-y-auto relative">
|
||||||
<div className="px-2 pt-1">
|
<div className="px-2 pt-3">
|
||||||
|
<SidebarNav
|
||||||
|
sections={navItems}
|
||||||
|
isCollapsed={isSidebarCollapsed}
|
||||||
|
notificationCounts={notificationCounts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Fade gradient at bottom to indicate scrollable content */}
|
||||||
|
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{!isAdminPage && user.serverAdmin && (
|
{!isAdminPage && user.serverAdmin && (
|
||||||
<div className="py-2">
|
<div className="shrink-0 px-2 pb-2">
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -157,9 +167,7 @@ export function LayoutSidebar({
|
|||||||
: "px-3 py-1.5"
|
: "px-3 py-1.5"
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
isSidebarCollapsed
|
isSidebarCollapsed ? t("serverAdmin") : undefined
|
||||||
? t("serverAdmin")
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -171,20 +179,16 @@ export function LayoutSidebar({
|
|||||||
<Server className="h-4 w-4" />
|
<Server className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
{!isSidebarCollapsed && (
|
{!isSidebarCollapsed && (
|
||||||
<span>{t("serverAdmin")}</span>
|
<>
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("serverAdmin")}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarNav
|
|
||||||
sections={navItems}
|
|
||||||
isCollapsed={isSidebarCollapsed}
|
|
||||||
notificationCounts={notificationCounts}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Fade gradient at bottom to indicate scrollable content */}
|
|
||||||
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full border-t border-border" />
|
<div className="w-full border-t border-border" />
|
||||||
|
|
||||||
|
|||||||
70
src/components/OptionSelect.tsx
Normal file
70
src/components/OptionSelect.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type OptionSelectOption<TValue extends string> = {
|
||||||
|
value: TValue;
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OptionSelectProps<TValue extends string> = {
|
||||||
|
options: ReadonlyArray<OptionSelectOption<TValue>>;
|
||||||
|
value: TValue;
|
||||||
|
onChange: (value: TValue) => void;
|
||||||
|
label?: string;
|
||||||
|
/** Grid columns: 2, 3, 4, 5, etc. Default 5 on md+. */
|
||||||
|
cols?: number;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OptionSelect<TValue extends string>({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
cols = 5,
|
||||||
|
className,
|
||||||
|
disabled = false
|
||||||
|
}: OptionSelectProps<TValue>) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{label && (
|
||||||
|
<p className="font-bold mb-3">{label}</p>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-2",
|
||||||
|
cols === 2 && "grid-cols-2",
|
||||||
|
cols === 3 && "grid-cols-2 md:grid-cols-3",
|
||||||
|
cols === 4 && "grid-cols-2 md:grid-cols-4",
|
||||||
|
cols === 5 && "grid-cols-2 md:grid-cols-5",
|
||||||
|
cols === 6 && "grid-cols-2 md:grid-cols-3 lg:grid-cols-6"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value === option.value;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
variant={isSelected ? "squareOutlinePrimary" : "squareOutline"}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 min-w-30 shadow-none",
|
||||||
|
isSelected && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{option.icon}
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList
|
||||||
CommandSeparator
|
|
||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -25,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
|
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
@@ -71,7 +71,7 @@ export function OrgSelector({
|
|||||||
"cursor-pointer transition-colors",
|
"cursor-pointer transition-colors",
|
||||||
isCollapsed
|
isCollapsed
|
||||||
? "w-full h-16 flex items-center justify-center hover:bg-muted"
|
? "w-full h-16 flex items-center justify-center hover:bg-muted"
|
||||||
: "w-full px-4 py-4 hover:bg-muted"
|
: "w-full px-5 py-4 hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
@@ -93,50 +93,32 @@ export function OrgSelector({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[320px] p-0" align="start">
|
<PopoverContent
|
||||||
<Command className="rounded-lg">
|
className="w-[320px] p-0 ml-4 flex flex-col relative overflow-visible"
|
||||||
|
align="start"
|
||||||
|
sideOffset={12}
|
||||||
|
>
|
||||||
|
{/* Peak pointing up to the trigger */}
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-full w-0 h-0 border-[7px] border-transparent border-b-border"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-full w-0 h-0 border-[6px] border-transparent border-b-[var(--color-popover)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Command className="rounded-lg border-0 flex-1 min-h-0">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={t("searchPlaceholder")}
|
placeholder={t("searchPlaceholder")}
|
||||||
className="border-0 focus:ring-0"
|
className="border-0 focus:ring-0 h-9 rounded-b-none"
|
||||||
/>
|
/>
|
||||||
<CommandEmpty className="py-6 text-center">
|
<CommandList className="max-h-[280px]">
|
||||||
|
<CommandEmpty className="py-4 text-center">
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
{t("orgNotFound2")}
|
{t("orgNotFound2")}
|
||||||
</div>
|
</div>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
|
<CommandGroup className="p-1" heading={t("orgs")}>
|
||||||
<>
|
|
||||||
<CommandGroup
|
|
||||||
heading={t("create")}
|
|
||||||
className="py-2"
|
|
||||||
>
|
|
||||||
<CommandList>
|
|
||||||
<CommandItem
|
|
||||||
onSelect={() => {
|
|
||||||
setOpen(false);
|
|
||||||
router.push("/setup");
|
|
||||||
}}
|
|
||||||
className="mx-2 rounded-md"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
|
|
||||||
<Plus className="h-4 w-4 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">
|
|
||||||
{t("setupNewOrg")}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t("createNewOrgDescription")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
</CommandList>
|
|
||||||
</CommandGroup>
|
|
||||||
<CommandSeparator className="my-2" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<CommandGroup heading={t("orgs")} className="py-2">
|
|
||||||
<CommandList>
|
|
||||||
{sortedOrgs.map((org) => (
|
{sortedOrgs.map((org) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={org.orgId}
|
key={org.orgId}
|
||||||
@@ -148,13 +130,13 @@ export function OrgSelector({
|
|||||||
);
|
);
|
||||||
router.push(newPath);
|
router.push(newPath);
|
||||||
}}
|
}}
|
||||||
className="mx-2 rounded-md"
|
className="mx-1 rounded-md py-1.5 h-auto min-h-0"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
|
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-muted mr-2.5 flex-shrink-0">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||||
<span className="font-medium truncate">
|
<span className="font-medium truncate text-sm">
|
||||||
{org.name}
|
{org.name}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
@@ -173,7 +155,7 @@ export function OrgSelector({
|
|||||||
</div>
|
</div>
|
||||||
<Check
|
<Check
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 text-primary",
|
"h-4 w-4 text-primary flex-shrink-0",
|
||||||
orgId === org.orgId
|
orgId === org.orgId
|
||||||
? "opacity-100"
|
? "opacity-100"
|
||||||
: "opacity-0"
|
: "opacity-0"
|
||||||
@@ -181,9 +163,26 @@ export function OrgSelector({
|
|||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandList>
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
{(!env.flags.disableUserCreateOrg ||
|
||||||
|
user.serverAdmin) && (
|
||||||
|
<div className="p-2 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start h-8 font-normal text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
router.push("/setup");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-2" />
|
||||||
|
{t("setupNewOrg")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
441
src/components/RoleForm.tsx
Normal file
441
src/components/RoleForm.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
OptionSelect,
|
||||||
|
type OptionSelectOption
|
||||||
|
} from "@app/components/OptionSelect";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||||
|
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import type { Role } from "@server/db";
|
||||||
|
|
||||||
|
export const SSH_SUDO_MODE_VALUES = ["none", "full", "commands"] as const;
|
||||||
|
export type SshSudoMode = (typeof SSH_SUDO_MODE_VALUES)[number];
|
||||||
|
|
||||||
|
function parseRoleJsonArray(value: string | null | undefined): string[] {
|
||||||
|
if (value == null || value === "") return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSshSudoMode(value: string | null | undefined): SshSudoMode {
|
||||||
|
if (value === "none" || value === "full" || value === "commands")
|
||||||
|
return value;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleFormValues = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
requireDeviceApproval?: boolean;
|
||||||
|
allowSsh?: boolean;
|
||||||
|
sshSudoMode: SshSudoMode;
|
||||||
|
sshSudoCommands?: string;
|
||||||
|
sshCreateHomeDir?: boolean;
|
||||||
|
sshUnixGroups?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoleFormProps = {
|
||||||
|
variant: "create" | "edit";
|
||||||
|
role?: Role;
|
||||||
|
onSubmit: (values: RoleFormValues) => void | Promise<void>;
|
||||||
|
formId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleForm({
|
||||||
|
variant,
|
||||||
|
role,
|
||||||
|
onSubmit,
|
||||||
|
formId = "create-role-form"
|
||||||
|
}: RoleFormProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string({ message: t("nameRequired") })
|
||||||
|
.min(1)
|
||||||
|
.max(32),
|
||||||
|
description: z.string().max(255).optional(),
|
||||||
|
requireDeviceApproval: z.boolean().optional(),
|
||||||
|
allowSsh: z.boolean().optional(),
|
||||||
|
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
|
||||||
|
sshSudoCommands: z.string().optional(),
|
||||||
|
sshCreateHomeDir: z.boolean().optional(),
|
||||||
|
sshUnixGroups: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultValues: RoleFormValues = role
|
||||||
|
? {
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
requireDeviceApproval: role.requireDeviceApproval ?? false,
|
||||||
|
allowSsh:
|
||||||
|
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
|
||||||
|
sshSudoMode: toSshSudoMode(role.sshSudoMode),
|
||||||
|
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
|
||||||
|
", "
|
||||||
|
),
|
||||||
|
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
|
||||||
|
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
requireDeviceApproval: false,
|
||||||
|
allowSsh: false,
|
||||||
|
sshSudoMode: "none",
|
||||||
|
sshSudoCommands: "",
|
||||||
|
sshCreateHomeDir: true,
|
||||||
|
sshUnixGroups: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (variant === "edit" && role) {
|
||||||
|
form.reset({
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
requireDeviceApproval: role.requireDeviceApproval ?? false,
|
||||||
|
allowSsh:
|
||||||
|
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
|
||||||
|
sshSudoMode: toSshSudoMode(role.sshSudoMode),
|
||||||
|
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
|
||||||
|
", "
|
||||||
|
),
|
||||||
|
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
|
||||||
|
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [variant, role, form]);
|
||||||
|
|
||||||
|
const sshDisabled = !isPaidUser(tierMatrix.sshPam);
|
||||||
|
const sshSudoMode = form.watch("sshSudoMode");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((values) => onSubmit(values))}
|
||||||
|
className="space-y-4"
|
||||||
|
id={formId}
|
||||||
|
>
|
||||||
|
{env.flags.disableEnterpriseFeatures ? (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<HorizontalTabs
|
||||||
|
clientSide={true}
|
||||||
|
defaultTab={0}
|
||||||
|
items={[
|
||||||
|
{ title: t("general"), href: "#" },
|
||||||
|
...(env.flags.disableEnterpriseFeatures
|
||||||
|
? []
|
||||||
|
: [{ title: t("sshAccess"), href: "#" }])
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* General tab */}
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PaidFeaturesAlert
|
||||||
|
tiers={tierMatrix.deviceApprovals}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="requireDeviceApproval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="my-2">
|
||||||
|
<FormControl>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
{...field}
|
||||||
|
disabled={
|
||||||
|
!isPaidUser(
|
||||||
|
tierMatrix.deviceApprovals
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* SSH tab - hidden when enterprise features are disabled */}
|
||||||
|
{!env.flags.disableEnterpriseFeatures && (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowSsh"
|
||||||
|
render={({ field }) => {
|
||||||
|
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
|
||||||
|
{
|
||||||
|
value: "allow",
|
||||||
|
label: t("roleAllowSshAllow")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "disallow",
|
||||||
|
label: t("roleAllowSshDisallow")
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("roleAllowSsh")}
|
||||||
|
</FormLabel>
|
||||||
|
<OptionSelect<"allow" | "disallow">
|
||||||
|
options={allowSshOptions}
|
||||||
|
value={
|
||||||
|
field.value
|
||||||
|
? "allow"
|
||||||
|
: "disallow"
|
||||||
|
}
|
||||||
|
onChange={(v) =>
|
||||||
|
field.onChange(v === "allow")
|
||||||
|
}
|
||||||
|
cols={2}
|
||||||
|
/>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"roleAllowSshDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshSudoMode"
|
||||||
|
render={({ field }) => {
|
||||||
|
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: "none",
|
||||||
|
label: t("sshSudoModeNone")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "full",
|
||||||
|
label: t("sshSudoModeFull")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "commands",
|
||||||
|
label: t(
|
||||||
|
"sshSudoModeCommands"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("sshSudoMode")}
|
||||||
|
</FormLabel>
|
||||||
|
<OptionSelect<SshSudoMode>
|
||||||
|
options={sudoOptions}
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
cols={3}
|
||||||
|
disabled={sshDisabled}
|
||||||
|
/>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{sshSudoMode === "commands" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshSudoCommands"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("sshSudoCommands")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={sshDisabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"sshSudoCommandsDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshUnixGroups"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("sshUnixGroups")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
disabled={sshDisabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t("sshUnixGroupsDescription")}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="sshCreateHomeDir"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="my-2">
|
||||||
|
<FormControl>
|
||||||
|
<CheckboxWithLabel
|
||||||
|
{...field}
|
||||||
|
value="on"
|
||||||
|
checked={form.watch(
|
||||||
|
"sshCreateHomeDir"
|
||||||
|
)}
|
||||||
|
onCheckedChange={(
|
||||||
|
checked
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
checked !==
|
||||||
|
"indeterminate"
|
||||||
|
) {
|
||||||
|
form.setValue(
|
||||||
|
"sshCreateHomeDir",
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
label={t(
|
||||||
|
"sshCreateHomeDir"
|
||||||
|
)}
|
||||||
|
disabled={sshDisabled}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</HorizontalTabs>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -103,14 +103,15 @@ export default function UsersTable({ roles }: RolesTableProps) {
|
|||||||
header: () => <span className="p-3"></span>,
|
header: () => <span className="p-3"></span>,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const roleRow = row.original;
|
const roleRow = row.original;
|
||||||
|
const isAdmin = roleRow.isAdmin;
|
||||||
return (
|
return (
|
||||||
!roleRow.isAdmin && (
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
|
disabled={isAdmin}
|
||||||
>
|
>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{t("openMenu")}
|
{t("openMenu")}
|
||||||
@@ -120,6 +121,7 @@ export default function UsersTable({ roles }: RolesTableProps) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
disabled={isAdmin}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRoleToRemove(roleRow);
|
setRoleToRemove(roleRow);
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
@@ -133,6 +135,7 @@ export default function UsersTable({ roles }: RolesTableProps) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button
|
<Button
|
||||||
variant={"outline"}
|
variant={"outline"}
|
||||||
|
disabled={isAdmin}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingRole(roleRow);
|
setEditingRole(roleRow);
|
||||||
setIsEditDialogOpen(true);
|
setIsEditDialogOpen(true);
|
||||||
@@ -141,7 +144,6 @@ export default function UsersTable({ roles }: RolesTableProps) {
|
|||||||
{t("edit")}
|
{t("edit")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,16 +119,16 @@ function CollapsibleNavItem({
|
|||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center w-full rounded-md transition-colors",
|
"flex items-center w-full rounded-md transition-colors",
|
||||||
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
|
"px-3 py-1.5",
|
||||||
isActive
|
isActive
|
||||||
? "bg-secondary font-medium"
|
? "bg-secondary font-medium"
|
||||||
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
|
: "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
|
||||||
isDisabled && "cursor-not-allowed opacity-60"
|
isDisabled && "cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
|
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center opacity-50">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -167,10 +167,17 @@ function CollapsibleNavItem({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent forceMount>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-l ml-3 pl-3 mt-0 space-y-0",
|
"grid overflow-hidden transition-[grid-template-rows] duration-200 ease-in-out",
|
||||||
|
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-h-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-l ml-[22px] pl-[9px] mt-0 space-y-0",
|
||||||
"border-border"
|
"border-border"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -178,6 +185,8 @@ function CollapsibleNavItem({
|
|||||||
renderNavItem(childItem, level + 1)
|
renderNavItem(childItem, level + 1)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
@@ -278,14 +287,10 @@ export function SidebarNav({
|
|||||||
href={isDisabled ? "#" : hydratedHref}
|
href={isDisabled ? "#" : hydratedHref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-md transition-colors relative",
|
"flex items-center rounded-md transition-colors relative",
|
||||||
isCollapsed
|
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
|
||||||
? "px-2 py-2 justify-center"
|
|
||||||
: level === 0
|
|
||||||
? "px-3 py-1.5"
|
|
||||||
: "px-3 py-1",
|
|
||||||
isActive
|
isActive
|
||||||
? "bg-secondary font-medium"
|
? "bg-secondary font-medium"
|
||||||
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
|
: "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
|
||||||
isDisabled && "cursor-not-allowed opacity-60"
|
isDisabled && "cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -298,10 +303,10 @@ export function SidebarNav({
|
|||||||
tabIndex={isDisabled ? -1 : undefined}
|
tabIndex={isDisabled ? -1 : undefined}
|
||||||
aria-disabled={isDisabled}
|
aria-disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && level === 0 && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center",
|
"flex-shrink-0 w-5 h-5 flex items-center justify-center opacity-50",
|
||||||
!isCollapsed && "mr-3"
|
!isCollapsed && "mr-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -355,13 +360,13 @@ export function SidebarNav({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center rounded-md transition-colors",
|
"flex items-center rounded-md transition-colors",
|
||||||
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
|
"px-3 py-1.5",
|
||||||
"text-muted-foreground",
|
"text-foreground/80",
|
||||||
isDisabled && "cursor-not-allowed opacity-60"
|
isDisabled && "cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && level === 0 && (
|
||||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
|
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center opacity-50">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -411,14 +416,14 @@ export function SidebarNav({
|
|||||||
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
|
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
|
||||||
isActive || isChildActive
|
isActive || isChildActive
|
||||||
? "bg-secondary font-medium"
|
? "bg-secondary font-medium"
|
||||||
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
|
: "text-foreground/80 hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
|
||||||
isDisabled &&
|
isDisabled &&
|
||||||
"cursor-not-allowed opacity-60"
|
"cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
|
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center opacity-50">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -466,7 +471,7 @@ export function SidebarNav({
|
|||||||
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
|
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
|
||||||
childIsActive
|
childIsActive
|
||||||
? "bg-secondary font-medium"
|
? "bg-secondary font-medium"
|
||||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
|
: "text-foreground/80 hover:bg-secondary/50 hover:text-foreground",
|
||||||
childIsDisabled &&
|
childIsDisabled &&
|
||||||
"cursor-not-allowed opacity-60"
|
"cursor-not-allowed opacity-60"
|
||||||
)}
|
)}
|
||||||
@@ -480,11 +485,6 @@ export function SidebarNav({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{childItem.icon && (
|
|
||||||
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
|
|
||||||
{childItem.icon}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{t(childItem.title)}
|
{t(childItem.title)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "./Settings";
|
} from "./Settings";
|
||||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||||
import { Button } from "./ui/button";
|
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
|
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
|
||||||
import { Terminal } from "lucide-react";
|
import { Terminal } from "lucide-react";
|
||||||
@@ -138,6 +138,14 @@ WantedBy=default.target`
|
|||||||
|
|
||||||
const commands = commandList[platform][architecture];
|
const commands = commandList[platform][architecture];
|
||||||
|
|
||||||
|
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
|
||||||
|
(os) => ({
|
||||||
|
value: os,
|
||||||
|
label: getPlatformName(os),
|
||||||
|
icon: getPlatformIcon(os)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
@@ -149,53 +157,33 @@ WantedBy=default.target`
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<div>
|
<OptionSelect<Platform>
|
||||||
<p className="font-bold mb-3">{t("operatingSystem")}</p>
|
label={t("operatingSystem")}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
options={platformOptions}
|
||||||
{PLATFORMS.map((os) => (
|
value={platform}
|
||||||
<Button
|
onChange={(os) => {
|
||||||
key={os}
|
|
||||||
variant={
|
|
||||||
platform === os
|
|
||||||
? "squareOutlinePrimary"
|
|
||||||
: "squareOutline"
|
|
||||||
}
|
|
||||||
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
|
|
||||||
onClick={() => {
|
|
||||||
setPlatform(os);
|
setPlatform(os);
|
||||||
const architectures = getArchitectures(os);
|
const architectures = getArchitectures(os);
|
||||||
setArchitecture(architectures[0]);
|
setArchitecture(architectures[0]);
|
||||||
}}
|
}}
|
||||||
>
|
cols={5}
|
||||||
{getPlatformIcon(os)}
|
/>
|
||||||
{getPlatformName(os)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<OptionSelect<string>
|
||||||
<p className="font-bold mb-3">
|
label={
|
||||||
{["docker", "podman"].includes(platform)
|
["docker", "podman"].includes(platform)
|
||||||
? t("method")
|
? t("method")
|
||||||
: t("architecture")}
|
: t("architecture")
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
||||||
{getArchitectures(platform).map((arch) => (
|
|
||||||
<Button
|
|
||||||
key={arch}
|
|
||||||
variant={
|
|
||||||
architecture === arch
|
|
||||||
? "squareOutlinePrimary"
|
|
||||||
: "squareOutline"
|
|
||||||
}
|
}
|
||||||
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
|
options={getArchitectures(platform).map((arch) => ({
|
||||||
onClick={() => setArchitecture(arch)}
|
value: arch,
|
||||||
>
|
label: arch
|
||||||
{arch}
|
}))}
|
||||||
</Button>
|
value={architecture}
|
||||||
))}
|
onChange={setArchitecture}
|
||||||
</div>
|
cols={5}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<p className="font-bold mb-3">
|
<p className="font-bold mb-3">
|
||||||
@@ -250,7 +238,6 @@ WantedBy=default.target`
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "./Settings";
|
} from "./Settings";
|
||||||
import { Button } from "./ui/button";
|
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||||
|
|
||||||
export type CommandItem = string | { title: string; command: string };
|
export type CommandItem = string | { title: string; command: string };
|
||||||
|
|
||||||
@@ -88,6 +88,15 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
|||||||
};
|
};
|
||||||
|
|
||||||
const commands = commandList[platform][architecture];
|
const commands = commandList[platform][architecture];
|
||||||
|
|
||||||
|
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
|
||||||
|
(os) => ({
|
||||||
|
value: os,
|
||||||
|
label: getPlatformName(os),
|
||||||
|
icon: getPlatformIcon(os)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
@@ -99,53 +108,34 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<div>
|
<OptionSelect<Platform>
|
||||||
<p className="font-bold mb-3">{t("operatingSystem")}</p>
|
label={t("operatingSystem")}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
options={platformOptions}
|
||||||
{PLATFORMS.map((os) => (
|
value={platform}
|
||||||
<Button
|
onChange={(os) => {
|
||||||
key={os}
|
|
||||||
variant={
|
|
||||||
platform === os
|
|
||||||
? "squareOutlinePrimary"
|
|
||||||
: "squareOutline"
|
|
||||||
}
|
|
||||||
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
|
|
||||||
onClick={() => {
|
|
||||||
setPlatform(os);
|
setPlatform(os);
|
||||||
const architectures = getArchitectures(os);
|
const architectures = getArchitectures(os);
|
||||||
setArchitecture(architectures[0]);
|
setArchitecture(architectures[0]);
|
||||||
}}
|
}}
|
||||||
>
|
cols={5}
|
||||||
{getPlatformIcon(os)}
|
/>
|
||||||
{getPlatformName(os)}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<OptionSelect<string>
|
||||||
<p className="font-bold mb-3">
|
label={
|
||||||
{["docker", "podman"].includes(platform)
|
platform === "docker"
|
||||||
? t("method")
|
? t("method")
|
||||||
: t("architecture")}
|
: t("architecture")
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
||||||
{getArchitectures(platform).map((arch) => (
|
|
||||||
<Button
|
|
||||||
key={arch}
|
|
||||||
variant={
|
|
||||||
architecture === arch
|
|
||||||
? "squareOutlinePrimary"
|
|
||||||
: "squareOutline"
|
|
||||||
}
|
}
|
||||||
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
|
options={getArchitectures(platform).map((arch) => ({
|
||||||
onClick={() => setArchitecture(arch)}
|
value: arch,
|
||||||
>
|
label: arch
|
||||||
{arch}
|
}))}
|
||||||
</Button>
|
value={architecture}
|
||||||
))}
|
onChange={setArchitecture}
|
||||||
</div>
|
cols={5}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<p className="font-bold mb-3">{t("commands")}</p>
|
<p className="font-bold mb-3">{t("commands")}</p>
|
||||||
<div className="mt-2 space-y-3">
|
<div className="mt-2 space-y-3">
|
||||||
@@ -175,7 +165,6 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user