ssh settings on a role

This commit is contained in:
miloschwartz
2026-02-19 17:53:11 -08:00
parent 874794c996
commit 7a01a4e090
16 changed files with 982 additions and 484 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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].replace(/[^a-zA-Z0-9_-]/g, ""); 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
} }
} }
}); });

View File

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

View File

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

View File

@@ -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);
.update(roles) if (!isLicensedSshPam) {
.set(updateData) delete updateData.sshSudoMode;
.where(eq(roles.roleId, roleId)) delete updateData.sshSudoCommands;
.returning(); 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( 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",

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

View File

@@ -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,143 +90,42 @@ 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 <CredenzaContent>
open={open} <CredenzaHeader>
onOpenChange={(val) => { <CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
setOpen(val); <CredenzaDescription>
form.reset(); {t("accessRoleCreateDescription")}
}} </CredenzaDescription>
> </CredenzaHeader>
<CredenzaContent> <CredenzaBody>
<CredenzaHeader> <RoleForm
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle> variant="create"
<CredenzaDescription> onSubmit={(values) =>
{t("accessRoleCreateDescription")} startTransition(() => onSubmit(values))
</CredenzaDescription> }
</CredenzaHeader> />
<CredenzaBody> </CredenzaBody>
<Form {...form}> <CredenzaFooter>
<form <CredenzaClose asChild>
onSubmit={form.handleSubmit((values) => <Button variant="outline">{t("close")}</Button>
startTransition(() => onSubmit(values)) </CredenzaClose>
)} <Button
className="space-y-4" type="submit"
id="create-role-form" form="create-role-form"
> loading={loading}
<FormField disabled={loading}
control={form.control} >
name="name" {t("accessRoleCreateSubmit")}
render={({ field }) => ( </Button>
<FormItem> </CredenzaFooter>
<FormLabel> </CredenzaContent>
{t("accessRoleName")} </Credenza>
</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>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleCreateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
); );
} }

View File

@@ -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,143 +91,43 @@ 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 <CredenzaContent>
open={open} <CredenzaHeader>
onOpenChange={(val) => { <CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
setOpen(val); <CredenzaDescription>
form.reset(); {t("accessRoleEditDescription")}
}} </CredenzaDescription>
> </CredenzaHeader>
<CredenzaContent> <CredenzaBody>
<CredenzaHeader> <RoleForm
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle> variant="edit"
<CredenzaDescription> role={role}
{t("accessRoleEditDescription")} onSubmit={(values) =>
</CredenzaDescription> startTransition(() => onSubmit(values))
</CredenzaHeader> }
<CredenzaBody> />
<Form {...form}> </CredenzaBody>
<form <CredenzaFooter>
onSubmit={form.handleSubmit((values) => <CredenzaClose asChild>
startTransition(() => onSubmit(values)) <Button variant="outline">{t("close")}</Button>
)} </CredenzaClose>
className="space-y-4" <Button
id="create-role-form" type="submit"
> form="create-role-form"
<FormField loading={loading}
control={form.control} disabled={loading}
name="name" >
render={({ field }) => ( {t("accessRoleUpdateSubmit")}
<FormItem> </Button>
<FormLabel> </CredenzaFooter>
{t("accessRoleName")} </CredenzaContent>
</FormLabel> </Credenza>
<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>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleUpdateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
); );
} }

View File

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

441
src/components/RoleForm.tsx Normal file
View 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>
);
}

View File

@@ -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} setPlatform(os);
variant={ const architectures = getArchitectures(os);
platform === os setArchitecture(architectures[0]);
? "squareOutlinePrimary" }}
: "squareOutline" cols={5}
} />
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
>
{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"> options={getArchitectures(platform).map((arch) => ({
{getArchitectures(platform).map((arch) => ( value: arch,
<Button label: arch
key={arch} }))}
variant={ value={architecture}
architecture === arch onChange={setArchitecture}
? "squareOutlinePrimary" cols={5}
: "squareOutline" className="mt-4"
} />
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() => setArchitecture(arch)}
>
{arch}
</Button>
))}
</div>
<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>
); );

View File

@@ -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,54 +108,35 @@ 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} setPlatform(os);
variant={ const architectures = getArchitectures(os);
platform === os setArchitecture(architectures[0]);
? "squareOutlinePrimary" }}
: "squareOutline" cols={5}
} />
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
>
{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"> options={getArchitectures(platform).map((arch) => ({
{getArchitectures(platform).map((arch) => ( value: arch,
<Button label: arch
key={arch} }))}
variant={ value={architecture}
architecture === arch onChange={setArchitecture}
? "squareOutlinePrimary" cols={5}
: "squareOutline" className="mt-4"
} />
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() => setArchitecture(arch)} <div className="pt-4">
>
{arch}
</Button>
))}
</div>
<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">
{commands.map((item, index) => { {commands.map((item, index) => {
@@ -174,7 +164,6 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
); );
})} })}
</div> </div>
</div>
</div> </div>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>