mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-21 04:16:38 +00:00
ssh settings on a role
This commit is contained in:
@@ -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<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
requireDeviceApproval: false
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
.put<
|
||||
AxiosResponse<CreateRoleResponse>
|
||||
>(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
|
||||
.put<AxiosResponse<CreateRoleResponse>>(
|
||||
`/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 (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleCreateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("accessRoleName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!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>
|
||||
</>
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleCreateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<RoleForm
|
||||
variant="create"
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: role.name,
|
||||
description: role.description ?? "",
|
||||
requireDeviceApproval: role.requireDeviceApproval ?? false
|
||||
}
|
||||
});
|
||||
|
||||
const [loading, startTransition] = useTransition();
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
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<UpdateRoleResponse>
|
||||
>(`/role/${role.roleId}`, values satisfies UpdateRoleBody)
|
||||
.post<AxiosResponse<UpdateRoleResponse>>(
|
||||
`/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 (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleEditDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("accessRoleName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!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>
|
||||
</>
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("accessRoleEditDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<RoleForm
|
||||
variant="edit"
|
||||
role={role}
|
||||
onSubmit={(values) =>
|
||||
startTransition(() => onSubmit(values))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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<Platform>[] = PLATFORMS.map(
|
||||
(os) => ({
|
||||
value: os,
|
||||
label: getPlatformName(os),
|
||||
icon: getPlatformIcon(os)
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -149,53 +157,33 @@ WantedBy=default.target`
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">{t("operatingSystem")}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{PLATFORMS.map((os) => (
|
||||
<Button
|
||||
key={os}
|
||||
variant={
|
||||
platform === os
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
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>
|
||||
<OptionSelect<Platform>
|
||||
label={t("operatingSystem")}
|
||||
options={platformOptions}
|
||||
value={platform}
|
||||
onChange={(os) => {
|
||||
setPlatform(os);
|
||||
const architectures = getArchitectures(os);
|
||||
setArchitecture(architectures[0]);
|
||||
}}
|
||||
cols={5}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{["docker", "podman"].includes(platform)
|
||||
<OptionSelect<string>
|
||||
label={
|
||||
["docker", "podman"].includes(platform)
|
||||
? t("method")
|
||||
: 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`}
|
||||
onClick={() => setArchitecture(arch)}
|
||||
>
|
||||
{arch}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
: t("architecture")
|
||||
}
|
||||
options={getArchitectures(platform).map((arch) => ({
|
||||
value: arch,
|
||||
label: arch
|
||||
}))}
|
||||
value={architecture}
|
||||
onChange={setArchitecture}
|
||||
cols={5}
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
@@ -250,7 +238,6 @@ WantedBy=default.target`
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -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<Platform>[] = PLATFORMS.map(
|
||||
(os) => ({
|
||||
value: os,
|
||||
label: getPlatformName(os),
|
||||
icon: getPlatformIcon(os)
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -99,54 +108,35 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">{t("operatingSystem")}</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{PLATFORMS.map((os) => (
|
||||
<Button
|
||||
key={os}
|
||||
variant={
|
||||
platform === os
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
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>
|
||||
<OptionSelect<Platform>
|
||||
label={t("operatingSystem")}
|
||||
options={platformOptions}
|
||||
value={platform}
|
||||
onChange={(os) => {
|
||||
setPlatform(os);
|
||||
const architectures = getArchitectures(os);
|
||||
setArchitecture(architectures[0]);
|
||||
}}
|
||||
cols={5}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{["docker", "podman"].includes(platform)
|
||||
<OptionSelect<string>
|
||||
label={
|
||||
platform === "docker"
|
||||
? t("method")
|
||||
: 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`}
|
||||
onClick={() => setArchitecture(arch)}
|
||||
>
|
||||
{arch}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
: t("architecture")
|
||||
}
|
||||
options={getArchitectures(platform).map((arch) => ({
|
||||
value: arch,
|
||||
label: arch
|
||||
}))}
|
||||
value={architecture}
|
||||
onChange={setArchitecture}
|
||||
cols={5}
|
||||
className="mt-4"
|
||||
/>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">{t("commands")}</p>
|
||||
<div className="mt-2 space-y-3">
|
||||
{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>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
Reference in New Issue
Block a user