From 7a01a4e090934b5ec7f14d10f9a09eb372512602 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 19 Feb 2026 17:53:11 -0800 Subject: [PATCH] ssh settings on a role --- messages/en-US.json | 18 + server/db/pg/schema/schema.ts | 6 +- server/db/sqlite/schema/schema.ts | 8 +- server/lib/billing/tierMatrix.ts | 2 +- .../routers/billing/featureLifecycle.ts | 18 + server/private/routers/ssh/signSshKey.ts | 62 ++- server/routers/role/createRole.ts | 39 +- server/routers/role/listRoles.ts | 32 +- server/routers/role/updateRole.ts | 88 +++- server/setup/scriptsSqlite/1.16.0.ts | 28 ++ src/components/CreateRoleForm.tsx | 241 +++------- src/components/EditRoleForm.tsx | 247 +++------- src/components/OptionSelect.tsx | 70 +++ src/components/RoleForm.tsx | 441 ++++++++++++++++++ src/components/newt-install-commands.tsx | 81 ++-- src/components/olm-install-commands.tsx | 85 ++-- 16 files changed, 982 insertions(+), 484 deletions(-) create mode 100644 server/setup/scriptsSqlite/1.16.0.ts create mode 100644 src/components/OptionSelect.tsx create mode 100644 src/components/RoleForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 44d980c5..3d6b5d77 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1643,6 +1643,24 @@ "timeIsInSeconds": "Time is in seconds", "requireDeviceApproval": "Require Device Approvals", "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", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 7c252b8b..4b628675 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -372,7 +372,11 @@ export const roles = pgTable("roles", { isAdmin: boolean("isAdmin"), name: varchar("name").notNull(), 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", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 04d4338a..1bef04b3 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -679,7 +679,13 @@ export const roles = sqliteTable("roles", { description: text("description"), requireDeviceApproval: integer("requireDeviceApproval", { 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", { diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 20f8001d..c08bcea7 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -48,5 +48,5 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["enterprise"] + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] }; diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 3e4b8a4a..af7114a2 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -286,6 +286,10 @@ async function disableFeature( await disableAutoProvisioning(orgId); break; + case TierFeature.SshPam: + await disableSshPam(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -315,6 +319,20 @@ async function disableDeviceApprovals(orgId: string): Promise { logger.info(`Disabled device approvals on all roles for org ${orgId}`); } +async function disableSshPam(orgId: string): Promise { + 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 { const [existingBranding] = await db .select() diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 4967b600..41593e9f 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -13,7 +13,17 @@ import { Request, Response, NextFunction } from "express"; 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -25,6 +35,8 @@ import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResourc import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA"; import config from "@server/lib/config"; import { sendToClient } from "#private/routers/ws"; +import { groups } from "d3"; +import { homedir } from "os"; const paramsSchema = z.strictObject({ 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; if (!userOrg.pamUsername) { if (req.user?.email) { // Extract username from email (first part before @) - usernameToUse = req.user?.email.split("@")[0].replace(/[^a-zA-Z0-9_-]/g, ""); + usernameToUse = req.user?.email + .split("@")[0] + .replace(/[^a-zA-Z0-9_-]/g, ""); if (!usernameToUse) { return next( 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 const [newt] = await db .select() @@ -334,7 +384,7 @@ export async function signSshKey( .values({ wsClientId: newt.newtId, messageType: `newt/pam/connection`, - sentAt: Math.floor(Date.now() / 1000), + sentAt: Math.floor(Date.now() / 1000) }) .returning(); @@ -358,8 +408,10 @@ export async function signSshKey( username: usernameToUse, niceId: resource.niceId, metadata: { - sudo: true, // we are hardcoding these for now but should make configurable from the role or something - homedir: true + sudoMode: sudoMode, + sudoCommands: parsedSudoCommands, + homedir: homedir, + groups: parsedGroups } } }); diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index edb8f1bd..e732b405 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -18,10 +18,17 @@ const createRoleParamsSchema = z.strictObject({ orgId: z.string() }); +const sshSudoModeSchema = z.enum(["none", "full", "commands"]); + const createRoleSchema = z.strictObject({ name: z.string().min(1).max(255), 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[] = [ @@ -101,24 +108,40 @@ export async function createRole( ); } - const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); - if (!isLicensed) { + const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + if (!isLicensedDeviceApprovals) { roleData.requireDeviceApproval = undefined; } + const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); + const roleInsertValues: Record = { + 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) => { const newRole = await trx .insert(roles) - .values({ - ...roleData, - orgId - }) + .values(roleInsertValues as typeof roles.$inferInsert) .returning(); + const actionsToInsert = [...defaultRoleAllowedActions]; + if (roleData.allowSsh) { + actionsToInsert.push(ActionsEnum.signSshKey); + } + await trx .insert(roleActions) .values( - defaultRoleAllowedActions.map((action) => ({ + actionsToInsert.map((action) => ({ roleId: newRole[0].roleId, actionId: action, orgId diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index ec7f3b4b..d4cb580f 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -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 logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; 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 createHttpError from "http-errors"; import { z } from "zod"; @@ -37,7 +38,11 @@ async function queryRoles(orgId: string, limit: number, offset: number) { name: roles.name, description: roles.description, orgName: orgs.name, - requireDeviceApproval: roles.requireDeviceApproval + requireDeviceApproval: roles.requireDeviceApproval, + sshSudoMode: roles.sshSudoMode, + sshSudoCommands: roles.sshSudoCommands, + sshCreateHomeDir: roles.sshCreateHomeDir, + sshUnixGroups: roles.sshUnixGroups }) .from(roles) .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) @@ -106,9 +111,28 @@ export async function listRoles( const totalCountResult = await countQuery; 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, { data: { - roles: rolesList, + roles: rolesWithAllowSsh, pagination: { total: totalCount, limit, diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index 51a33e32..66332bf2 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -1,8 +1,9 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, type Role } from "@server/db"; -import { roles } from "@server/db"; -import { eq } from "drizzle-orm"; +import { roleActions, roles } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { ActionsEnum } from "@server/auth/actions"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -16,11 +17,18 @@ const updateRoleParamsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); +const sshSudoModeSchema = z.enum(["none", "full", "commands"]); + const updateRoleBodySchema = z .strictObject({ name: z.string().min(1).max(255).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, { error: "At least one field must be provided for update" @@ -75,7 +83,9 @@ export async function updateRole( } const { roleId } = parsedParams.data; - const updateData = parsedBody.data; + const body = parsedBody.data; + const { allowSsh, ...restBody } = body; + const updateData: Record = { ...restBody }; const role = await db .select() @@ -111,18 +121,70 @@ export async function updateRole( ); } - const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); - if (!isLicensed) { + const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + if (!isLicensedDeviceApprovals) { updateData.requireDeviceApproval = undefined; } - const updatedRole = await db - .update(roles) - .set(updateData) - .where(eq(roles.roleId, roleId)) - .returning(); + 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); + } + } - if (updatedRole.length === 0) { + const updatedRole = await db.transaction(async (trx) => { + const result = await trx + .update(roles) + .set(updateData as typeof roles.$inferInsert) + .where(eq(roles.roleId, roleId)) + .returning(); + + 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( createHttpError( HttpCode.NOT_FOUND, @@ -132,7 +194,7 @@ export async function updateRole( } return response(res, { - data: updatedRole[0], + data: updatedRole, success: true, error: false, message: "Role updated successfully", diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts new file mode 100644 index 00000000..969053bf --- /dev/null +++ b/server/setup/scriptsSqlite/1.16.0.ts @@ -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`); +} diff --git a/src/components/CreateRoleForm.tsx b/src/components/CreateRoleForm.tsx index 735993c7..537618ec 100644 --- a/src/components/CreateRoleForm.tsx +++ b/src/components/CreateRoleForm.tsx @@ -11,31 +11,19 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; -import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role"; +import type { + CreateRoleBody, + CreateRoleResponse +} from "@server/routers/role"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useTransition } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { CheckboxWithLabel } from "./ui/checkbox"; +import { RoleForm, type RoleFormValues } from "./RoleForm"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type CreateRoleFormProps = { @@ -52,35 +40,39 @@ export default function CreateRoleForm({ const { org } = useOrgContext(); 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() - }); - const api = createApiClient(useEnvContext()); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: "", - description: "", - requireDeviceApproval: false - } - }); - const [loading, startTransition] = useTransition(); - async function onSubmit(values: z.infer) { + 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 - .put< - AxiosResponse - >(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody) + .put>( + `/org/${org?.org.orgId}/role`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -98,143 +90,42 @@ export default function CreateRoleForm({ title: t("accessRoleCreated"), description: t("accessRoleCreatedDescription") }); - - if (open) { - setOpen(false); - } - + if (open) setOpen(false); afterCreate?.(res.data.data); } } return ( - <> - { - setOpen(val); - form.reset(); - }} - > - - - {t("accessRoleCreate")} - - {t("accessRoleCreateDescription")} - - - -
- - startTransition(() => onSubmit(values)) - )} - className="space-y-4" - id="create-role-form" - > - ( - - - {t("accessRoleName")} - - - - - - - )} - /> - ( - - - {t("description")} - - - - - - - )} - /> - - {!env.flags.disableEnterpriseFeatures && ( - <> - - - ( - - - { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", - checked - ); - } - }} - label={t( - "requireDeviceApproval" - )} - /> - - - - {t( - "requireDeviceApprovalDescription" - )} - - - - - )} - /> - - )} - - -
- - - - - - -
-
- + + + + {t("accessRoleCreate")} + + {t("accessRoleCreateDescription")} + + + + + startTransition(() => onSubmit(values)) + } + /> + + + + + + + + + ); } diff --git a/src/components/EditRoleForm.tsx b/src/components/EditRoleForm.tsx index 3d81f928..80555660 100644 --- a/src/components/EditRoleForm.tsx +++ b/src/components/EditRoleForm.tsx @@ -11,44 +11,26 @@ import { CredenzaTitle } from "@app/components/Credenza"; import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { build } from "@server/build"; import type { Role } from "@server/db"; import type { - CreateRoleBody, - CreateRoleResponse, UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useTransition } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; -import { CheckboxWithLabel } from "./ui/checkbox"; +import { RoleForm, type RoleFormValues } from "./RoleForm"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -type CreateRoleFormProps = { +type EditRoleFormProps = { role: Role; open: boolean; setOpen: (open: boolean) => void; - onSuccess?: (res: CreateRoleResponse) => void; + onSuccess?: (res: UpdateRoleResponse) => void; }; export default function EditRoleForm({ @@ -56,39 +38,42 @@ export default function EditRoleForm({ role, setOpen, onSuccess -}: CreateRoleFormProps) { - const { org } = useOrgContext(); +}: EditRoleFormProps) { 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() - }); - const api = createApiClient(useEnvContext()); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - name: role.name, - description: role.description ?? "", - requireDeviceApproval: role.requireDeviceApproval ?? false - } - }); - const [loading, startTransition] = useTransition(); - async function onSubmit(values: z.infer) { + 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 - .post< - AxiosResponse - >(`/role/${role.roleId}`, values satisfies UpdateRoleBody) + .post>( + `/role/${role.roleId}`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -106,143 +91,43 @@ export default function EditRoleForm({ title: t("accessRoleUpdated"), description: t("accessRoleUpdatedDescription") }); - - if (open) { - setOpen(false); - } - + if (open) setOpen(false); onSuccess?.(res.data.data); } } return ( - <> - { - setOpen(val); - form.reset(); - }} - > - - - {t("accessRoleEdit")} - - {t("accessRoleEditDescription")} - - - -
- - startTransition(() => onSubmit(values)) - )} - className="space-y-4" - id="create-role-form" - > - ( - - - {t("accessRoleName")} - - - - - - - )} - /> - ( - - - {t("description")} - - - - - - - )} - /> - - {!env.flags.disableEnterpriseFeatures && ( - <> - - - ( - - - { - if ( - checked !== - "indeterminate" - ) { - form.setValue( - "requireDeviceApproval", - checked - ); - } - }} - label={t( - "requireDeviceApproval" - )} - /> - - - - {t( - "requireDeviceApprovalDescription" - )} - - - - - )} - /> - - )} - - -
- - - - - - -
-
- + + + + {t("accessRoleEdit")} + + {t("accessRoleEditDescription")} + + + + + startTransition(() => onSubmit(values)) + } + /> + + + + + + + + + ); } diff --git a/src/components/OptionSelect.tsx b/src/components/OptionSelect.tsx new file mode 100644 index 00000000..2f891394 --- /dev/null +++ b/src/components/OptionSelect.tsx @@ -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 = { + value: TValue; + label: string; + icon?: ReactNode; +}; + +type OptionSelectProps = { + options: ReadonlyArray>; + 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({ + options, + value, + onChange, + label, + cols = 5, + className, + disabled = false +}: OptionSelectProps) { + return ( +
+ {label && ( +

{label}

+ )} +
+ {options.map((option) => { + const isSelected = value === option.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx new file mode 100644 index 00000000..10d74e5f --- /dev/null +++ b/src/components/RoleForm.tsx @@ -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; + 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>({ + 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 ( +
+ onSubmit(values))} + className="space-y-4" + id={formId} + > + {env.flags.disableEnterpriseFeatures ? ( +
+ ( + + {t("accessRoleName")} + + + + + + )} + /> + ( + + {t("description")} + + + + + + )} + /> +
+ ) : ( + + {/* General tab */} +
+ ( + + + {t("accessRoleName")} + + + + + + + )} + /> + ( + + + {t("description")} + + + + + + + )} + /> + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "requireDeviceApproval", + checked + ); + } + }} + label={t( + "requireDeviceApproval" + )} + /> + + + {t( + "requireDeviceApprovalDescription" + )} + + + + )} + /> +
+ + {/* SSH tab - hidden when enterprise features are disabled */} + {!env.flags.disableEnterpriseFeatures && ( +
+ + { + const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [ + { + value: "allow", + label: t("roleAllowSshAllow") + }, + { + value: "disallow", + label: t("roleAllowSshDisallow") + } + ]; + return ( + + + {t("roleAllowSsh")} + + + options={allowSshOptions} + value={ + field.value + ? "allow" + : "disallow" + } + onChange={(v) => + field.onChange(v === "allow") + } + cols={2} + /> + + {t( + "roleAllowSshDescription" + )} + + + + ); + }} + /> + { + const sudoOptions: OptionSelectOption[] = + [ + { + value: "none", + label: t("sshSudoModeNone") + }, + { + value: "full", + label: t("sshSudoModeFull") + }, + { + value: "commands", + label: t( + "sshSudoModeCommands" + ) + } + ]; + return ( + + + {t("sshSudoMode")} + + + options={sudoOptions} + value={field.value} + onChange={field.onChange} + cols={3} + disabled={sshDisabled} + /> + + + ); + }} + /> + {sshSudoMode === "commands" && ( + ( + + + {t("sshSudoCommands")} + + + + + + {t( + "sshSudoCommandsDescription" + )} + + + + )} + /> + )} + + ( + + + {t("sshUnixGroups")} + + + + + + {t("sshUnixGroupsDescription")} + + + + )} + /> + + ( + + + { + if ( + checked !== + "indeterminate" + ) { + form.setValue( + "sshCreateHomeDir", + checked + ); + } + }} + label={t( + "sshCreateHomeDir" + )} + disabled={sshDisabled} + /> + + + + )} + /> +
+ )} +
+ )} +
+ + ); +} diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index 3561cd6b..5a252f0d 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -8,7 +8,7 @@ import { SettingsSectionTitle } from "./Settings"; import { CheckboxWithLabel } from "./ui/checkbox"; -import { Button } from "./ui/button"; +import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; import { useState } from "react"; import { FaCubes, FaDocker, FaWindows } from "react-icons/fa"; import { Terminal } from "lucide-react"; @@ -138,6 +138,14 @@ WantedBy=default.target` const commands = commandList[platform][architecture]; + const platformOptions: OptionSelectOption[] = PLATFORMS.map( + (os) => ({ + value: os, + label: getPlatformName(os), + icon: getPlatformIcon(os) + }) + ); + return ( @@ -149,53 +157,33 @@ WantedBy=default.target` -
-

{t("operatingSystem")}

-
- {PLATFORMS.map((os) => ( - - ))} -
-
+ + label={t("operatingSystem")} + options={platformOptions} + value={platform} + onChange={(os) => { + setPlatform(os); + const architectures = getArchitectures(os); + setArchitecture(architectures[0]); + }} + cols={5} + /> -
-

- {["docker", "podman"].includes(platform) + + label={ + ["docker", "podman"].includes(platform) ? t("method") - : t("architecture")} -

-
- {getArchitectures(platform).map((arch) => ( - - ))} -
+ : t("architecture") + } + options={getArchitectures(platform).map((arch) => ({ + value: arch, + label: arch + }))} + value={architecture} + onChange={setArchitecture} + cols={5} + className="mt-4" + />

@@ -250,7 +238,6 @@ WantedBy=default.target` })}

-
); diff --git a/src/components/olm-install-commands.tsx b/src/components/olm-install-commands.tsx index c613d698..38bd8e97 100644 --- a/src/components/olm-install-commands.tsx +++ b/src/components/olm-install-commands.tsx @@ -10,7 +10,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; -import { Button } from "./ui/button"; +import { OptionSelect, type OptionSelectOption } from "./OptionSelect"; 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 platformOptions: OptionSelectOption[] = PLATFORMS.map( + (os) => ({ + value: os, + label: getPlatformName(os), + icon: getPlatformIcon(os) + }) + ); + return ( @@ -99,54 +108,35 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol -
-

{t("operatingSystem")}

-
- {PLATFORMS.map((os) => ( - - ))} -
-
+ + label={t("operatingSystem")} + options={platformOptions} + value={platform} + onChange={(os) => { + setPlatform(os); + const architectures = getArchitectures(os); + setArchitecture(architectures[0]); + }} + cols={5} + /> -
-

- {["docker", "podman"].includes(platform) + + label={ + platform === "docker" ? t("method") - : t("architecture")} -

-
- {getArchitectures(platform).map((arch) => ( - - ))} -
-
+ : t("architecture") + } + options={getArchitectures(platform).map((arch) => ({ + value: arch, + label: arch + }))} + value={architecture} + onChange={setArchitecture} + cols={5} + className="mt-4" + /> + +

{t("commands")}

{commands.map((item, index) => { @@ -174,7 +164,6 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol ); })}
-