Merge pull request #2511 from fosrl/dev

1.15.4-s.4
This commit is contained in:
Owen Schwartz
2026-02-20 20:45:23 -08:00
committed by GitHub
64 changed files with 3777 additions and 3576 deletions

View File

@@ -47,7 +47,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<ClientProvider client={client}>
<div className="space-y-6">
<div className="space-y-4">
<ClientInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>

View File

@@ -78,7 +78,7 @@ export default async function GeneralSettingsPage({
description={t("orgSettingsDescription")}
/>
<div className="space-y-6">
<div className="space-y-4">
<OrgInfoCard />
<HorizontalTabs items={navItems}>
{children}

View File

@@ -74,7 +74,9 @@ export default async function ClientResourcesPage(
niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false
disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null
};
}
);

View File

@@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
@@ -187,21 +187,22 @@ export default function GeneralPage() {
</FormControl>
<FormMessage />
<FormDescription>
{t(
"enableDockerSocketDescription"
)}{" "}
<Link
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
<span>
{t(
"enableDockerSocketLink"
)}
</span>
</Link>
{t.rich(
"enableDockerSocketDescription",
{
docsLink: (chunks) => (
<a
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
}
)}
</FormDescription>
</FormItem>
)}

View File

@@ -56,7 +56,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<SiteProvider site={site}>
<div className="space-y-6">
<div className="space-y-4">
<SiteInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>

View File

@@ -125,9 +125,9 @@ export default async function RootLayout({
</ThemeProvider>
</NextIntlClientProvider>
{process.env.NODE_ENV === "development" && (
{/*process.env.NODE_ENV === "development" && (
<TailwindIndicator />
)}
)*/}
</body>
</html>
);

View File

@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
Building2,
ChartLine,
Combine,
CreditCard,
@@ -11,10 +12,11 @@ import {
KeyRound,
Laptop,
Link as LinkIcon,
Logs, // Added from 'dev' branch
Logs,
MonitorUp,
Plug,
ReceiptText,
ScanEye, // Added from 'dev' branch
ScanEye,
Server,
Settings,
SquareMousePointer,
@@ -49,12 +51,12 @@ export const orgNavSections = (
options?: OrgNavSectionsOptions
): SidebarNavSection[] => [
{
heading: "sidebarGeneral",
heading: "network",
items: [
{
title: "sidebarSites",
href: "/{orgId}/settings/sites",
icon: <Combine className="size-4 flex-none" />
icon: <Plug className="size-4 flex-none" />
},
{
title: "sidebarResources",
@@ -108,14 +110,19 @@ export const orgNavSections = (
heading: "access",
items: [
{
title: "sidebarUsers",
icon: <User className="size-4 flex-none" />,
title: "sidebarTeam",
icon: <Users className="size-4 flex-none" />,
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="size-4 flex-none" />
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
@@ -123,11 +130,6 @@ export const orgNavSections = (
}
]
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
// PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" ||
@@ -157,92 +159,88 @@ export const orgNavSections = (
}
]
},
{
heading: "sidebarLogsAndAnalytics",
items: (() => {
const logItems: SidebarNavItem[] = [
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
];
const analytics = {
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="h-4 w-4" />
};
// If only one log item, return it directly without grouping
if (logItems.length === 1) {
return [analytics, ...logItems];
}
// If multiple log items, create a group
return [
analytics,
{
title: "sidebarLogs",
icon: <Logs className="size-4 flex-none" />,
items: logItems
}
];
})()
},
{
heading: "sidebarOrganization",
items: [
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
title: "sidebarLogsAndAnalytics",
icon: <ChartLine className="size-4 flex-none" />,
items: [
{
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="size-4 flex-none" />
},
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: (
<SquareMousePointer className="size-4 flex-none" />
)
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
]
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
title: "sidebarManagement",
icon: <Building2 className="size-4 flex-none" />,
items: [
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
}
]
},
...(build == "saas" && options?.isPrimaryOrg
? [
{
title: "sidebarBillingAndLicenses",
icon: <CreditCard className="size-4 flex-none" />,
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: (
<CreditCard className="size-4 flex-none" />
)
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: (
<TicketCheck className="size-4 flex-none" />
)
}
]
}
]
: []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="size-4 flex-none" />
}
]
},
...(build == "saas" && options?.isPrimaryOrg
? [
{
heading: "sidebarBillingAndLicenses",
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <CreditCard className="size-4 flex-none" />
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: <TicketCheck className="size-4 flex-none" />
}
]
}
]
: [])
}
];
export const adminNavSections = (env?: Env): SidebarNavSection[] => [

View File

@@ -51,6 +51,8 @@ export type InternalResourceRow = {
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null;
};
type ClientResourcesTableProps = {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
className={cn(
"overflow-y-auto max-h-[100dvh] md:max-h-screen",
"overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
className
)}
{...props}

File diff suppressed because it is too large Load Diff

View File

@@ -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,44 @@ 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 = {
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (!role.isAdmin) {
payload.name = values.name;
payload.description = values.description || undefined;
}
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 +93,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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,7 @@ export async function Layout({
<div
className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
)}
>
{children}

View File

@@ -73,14 +73,14 @@ export function LayoutMobileMenu({
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
<div className="px-1">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="w-full border-b border-border" />
<div className="px-3">
<div className="px-3 pt-3">
{!isAdminPage &&
user.serverAdmin && (
<div className="py-2">

View File

@@ -18,7 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { useQuery } from "@tanstack/react-query";
import { ListUserOrgsResponse } from "@server/routers/org";
import { ExternalLink, Server } from "lucide-react";
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -145,37 +145,7 @@ export function LayoutSidebar({
)}
/>
<div className="flex-1 overflow-y-auto relative">
<div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="py-2">
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("serverAdmin")}</span>
)}
</Link>
</div>
)}
<div className="px-2 pt-3">
<SidebarNav
sections={navItems}
isCollapsed={isSidebarCollapsed}
@@ -186,31 +156,89 @@ export function LayoutSidebar({
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
<div className="w-full border-t border-border" />
{!isAdminPage && user.serverAdmin && (
<div className="shrink-0 px-2 pb-2">
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed ? t("serverAdmin") : undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</>
)}
</Link>
</div>
)}
<div className="p-4 pt-1 flex flex-col shrink-0">
{canShowProductUpdates ? (
<div className="mb-3">
{isSidebarCollapsed && (
<div className="shrink-0 flex justify-center py-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
setIsSidebarCollapsed(false);
setHasManualToggle(true);
setSidebarStateCookie(false);
}}
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
aria-label={t("sidebarExpand")}
>
<PanelRightOpen className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t("sidebarExpand")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<div className="w-full border-t border-border mb-3" />
<div className="p-4 pt-0 mt-0 flex flex-col shrink-0">
{canShowProductUpdates && (
<div className="mb-3 empty:mb-0">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
) : (
<div className="mb-3"></div>
)}
{build === "enterprise" && (
<div className="mb-3">
<div className="mb-3 empty:mb-0">
<SidebarLicenseButton
isCollapsed={isSidebarCollapsed}
/>
</div>
)}
{build === "oss" && (
<div className="mb-3">
<div className="mb-3 empty:mb-0">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{build === "saas" && (
<div className="mb-3">
<div className="mb-3 empty:mb-0">
<SidebarSupportButton
isCollapsed={isSidebarCollapsed}
/>
@@ -226,19 +254,19 @@ export function LayoutSidebar({
className="whitespace-nowrap"
>
{link.href ? (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
<Link
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
className="flex items-center justify-start gap-1"
>
{link.text}
<ExternalLink size={12} />
</Link>
</div>
) : (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
{link.text}
</div>
)}
@@ -247,12 +275,12 @@ export function LayoutSidebar({
</>
) : (
<>
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
className="flex items-center justify-start gap-1"
>
{build === "oss"
? t("communityEdition")
@@ -265,22 +293,22 @@ export function LayoutSidebar({
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
{t("personalUseOnly")}
</div>
) : null}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
{t("unlicensed")}
</div>
) : null}
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
className="flex items-center justify-start gap-1"
>
v{env.app.version}
<ExternalLink size={12} />

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

View File

@@ -6,8 +6,7 @@ import {
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
CommandList
} from "@app/components/ui/command";
import {
Popover,
@@ -25,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -71,7 +71,7 @@ export function OrgSelector({
"cursor-pointer transition-colors",
isCollapsed
? "w-full h-16 flex items-center justify-center hover:bg-muted"
: "w-full px-4 py-4 hover:bg-muted"
: "w-full px-5 py-4 hover:bg-muted"
)}
>
{isCollapsed ? (
@@ -93,68 +93,45 @@ export function OrgSelector({
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg">
<PopoverContent
className="w-[320px] p-0 ml-4 flex flex-col relative overflow-visible"
align="start"
sideOffset={12}
>
<Command className="rounded-lg border-0 flex-1 min-h-0">
<CommandInput
placeholder={t("searchPlaceholder")}
className="border-0 focus:ring-0"
className="border-0 focus:ring-0 h-9 rounded-b-none"
/>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{t("orgNotFound2")}
</div>
</CommandEmpty>
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<>
<CommandGroup
heading={t("create")}
className="py-2"
>
<CommandList>
<CommandItem
onSelect={() => {
setOpen(false);
router.push("/setup");
}}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Plus className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col">
<span className="font-medium">
{t("setupNewOrg")}
</span>
<span className="text-xs text-muted-foreground">
{t("createNewOrgDescription")}
</span>
</div>
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator className="my-2" />
</>
)}
<CommandGroup heading={t("orgs")} className="py-2">
<CommandList>
<CommandList className="max-h-[280px]">
<CommandEmpty className="py-4 text-center">
<div className="text-muted-foreground text-sm">
{t("orgNotFound2")}
</div>
</CommandEmpty>
<CommandGroup className="p-1" heading={t("orgs")}>
{sortedOrgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={() => {
setOpen(false);
const newPath = pathname.replace(
/^\/[^/]+/,
`/${org.orgId}`
);
const newPath = pathname.includes(
"/settings/"
)
? pathname.replace(
/^\/[^/]+/,
`/${org.orgId}`
)
: `/${org.orgId}`;
router.push(newPath);
}}
className="mx-2 rounded-md"
className="mx-1 rounded-md py-1.5 h-auto min-h-0"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-muted mr-2.5 flex-shrink-0">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<span className="font-medium truncate text-sm">
{org.name}
</span>
<div className="flex items-center gap-2 min-w-0">
@@ -173,7 +150,7 @@ export function OrgSelector({
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
"h-4 w-4 text-primary flex-shrink-0",
orgId === org.orgId
? "opacity-100"
: "opacity-0"
@@ -181,9 +158,25 @@ export function OrgSelector({
/>
</CommandItem>
))}
</CommandList>
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<div className="p-2 border-t border-border">
<Button
variant="ghost"
size="sm"
className="w-full justify-start h-8 font-normal text-muted-foreground hover:text-foreground"
onClick={() => {
setOpen(false);
router.push("/setup");
}}
>
<Plus className="h-3.5 w-3.5 mr-2" />
{t("setupNewOrg")}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);

View File

@@ -12,34 +12,42 @@ import { useParams } from "next/navigation";
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
const TIER_TRANSLATION_KEYS: Record<Tier, "subscriptionTierTier1" | "subscriptionTierTier2" | "subscriptionTierTier3" | "subscriptionTierEnterprise"> = {
const TIER_TRANSLATION_KEYS: Record<
Tier,
| "subscriptionTierTier1"
| "subscriptionTierTier2"
| "subscriptionTierTier3"
| "subscriptionTierEnterprise"
> = {
tier1: "subscriptionTierTier1",
tier2: "subscriptionTierTier2",
tier3: "subscriptionTierTier3",
enterprise: "subscriptionTierEnterprise"
};
function getRequiredTier(tiers: Tier[]): Tier | null {
function formatRequiredTiersList(
tiers: Tier[],
t: (key: (typeof TIER_TRANSLATION_KEYS)[Tier]) => string
): string | null {
if (tiers.length === 0) return null;
let min: Tier | null = null;
for (const tier of tiers) {
const idx = TIER_ORDER.indexOf(tier);
if (idx === -1) continue;
if (min === null || TIER_ORDER.indexOf(min) > idx) {
min = tier;
}
}
return min;
const sorted = [...tiers]
.filter((tier) => TIER_ORDER.includes(tier))
.sort((a, b) => TIER_ORDER.indexOf(a) - TIER_ORDER.indexOf(b));
if (sorted.length === 0) return null;
const names = sorted.map((tier) => t(TIER_TRANSLATION_KEYS[tier]));
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} or ${names[1]}`;
return `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
}
const bannerClassName =
"mb-6 border-purple-500/30 bg-linear-to-br from-purple-500/10 via-background to-background overflow-hidden";
"mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
const bannerContentClassName = "py-3 px-4";
const bannerRowClassName =
"flex items-center gap-2.5 text-sm text-muted-foreground";
const bannerIconClassName = "size-4 shrink-0 text-purple-500";
const bannerIconClassName = "size-4 shrink-0 text-black-500";
const docsLinkClassName =
"inline-flex items-center gap-1 font-medium text-purple-600 underline";
"inline-flex items-center gap-1 font-medium text-black-600 underline";
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
@@ -94,11 +102,17 @@ export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations();
const params = useParams();
const orgId = params?.orgId as string | undefined;
const { hasSaasSubscription, hasEnterpriseLicense, isActive, subscriptionTier } = usePaidStatus();
const {
hasSaasSubscription,
hasEnterpriseLicense,
isActive,
subscriptionTier
} = usePaidStatus();
const { env } = useEnvContext();
const requiredTier = getRequiredTier(tiers);
const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null;
const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing";
const requiredTiersLabel = formatRequiredTiersList(tiers, t);
const billingHref = orgId
? `/${orgId}/settings/billing`
: "https://pangolin.net/pricing";
const tierLinkRenderer = getTierLinkRenderer(billingHref);
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
@@ -115,16 +129,16 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} />
<span>
{requiredTierName
{requiredTiersLabel
? isActive
? t.rich("upgradeToTierToUse", {
tier: requiredTierName,
tierLink: tierLinkRenderer
})
: t.rich("subscriptionRequiredTierToUse", {
tier: requiredTierName,
tierLink: tierLinkRenderer
})
tier: requiredTiersLabel,
tierLink: tierLinkRenderer
})
: t.rich("upgradeToTierToUse", {
tier: requiredTiersLabel,
tierLink: tierLinkRenderer
})
: isActive
? t("mustUpgradeToUse")
: t("subscriptionRequiredToUse")}
@@ -141,7 +155,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("licenseRequiredToUse", {
enterpriseLicenseLink: enterpriseDocsLinkRenderer,
enterpriseLicenseLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>
@@ -157,7 +172,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: enterpriseDocsLinkRenderer,
enterpriseEditionLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>

View File

@@ -105,7 +105,7 @@ export default function ProductUpdates({
<div className="flex flex-col gap-1">
<small
className={cn(
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
"text-xs text-muted-foreground flex items-center gap-1 mt-2 empty:mt-0",
showMoreUpdatesText
? "animate-in fade-in duration-300"
: "opacity-0"

468
src/components/RoleForm.tsx Normal file
View File

@@ -0,0 +1,468 @@
"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");
const isAdminRole = variant === "edit" && role?.isAdmin === true;
useEffect(() => {
if (sshDisabled) {
form.setValue("allowSsh", false);
}
}, [sshDisabled, form]);
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}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t("description")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</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}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</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={
sshDisabled
? "disallow"
: field.value
? "allow"
: "disallow"
}
onChange={(v) => {
if (sshDisabled) return;
field.onChange(v === "allow");
}}
cols={2}
disabled={sshDisabled}
/>
<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

@@ -103,45 +103,46 @@ export default function UsersTable({ roles }: RolesTableProps) {
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const roleRow = row.original;
const isAdmin = roleRow.isAdmin;
return (
!roleRow.isAdmin && (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setRoleToRemove(roleRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingRole(roleRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
)
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled={isAdmin || false}
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={isAdmin || false}
onClick={() => {
setRoleToRemove(roleRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingRole(roleRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}

View File

@@ -119,7 +119,7 @@ function CollapsibleNavItem({
<button
className={cn(
"flex items-center w-full rounded-md transition-colors",
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
"px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
@@ -128,7 +128,7 @@ function CollapsibleNavItem({
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
@@ -167,22 +167,192 @@ function CollapsibleNavItem({
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<CollapsibleContent forceMount>
<div
className={cn(
"border-l ml-3 pl-3 mt-0 space-y-0",
"border-border"
"grid overflow-hidden transition-[grid-template-rows] duration-200 ease-in-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
{item.items!.map((childItem) =>
renderNavItem(childItem, level + 1)
)}
<div className="min-h-0">
<div
className={cn(
"border-l ml-[22px] pl-[9px] mt-0 space-y-0",
"border-border"
)}
>
{item.items!.map((childItem) =>
renderNavItem(childItem, level + 1)
)}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}
type CollapsedNavItemWithPopoverProps = {
item: SidebarNavItem;
tooltipText: string;
isActive: boolean;
isChildActive: boolean;
isDisabled: boolean;
hydrateHref: (val?: string) => string | undefined;
pathname: string;
build: string;
isUnlocked: () => boolean;
disabled: boolean;
t: (key: string) => string;
onItemClick?: () => void;
};
const TOOLTIP_SUPPRESS_MS = 400;
function CollapsedNavItemWithPopover({
item,
tooltipText,
isActive,
isChildActive,
isDisabled,
hydrateHref,
pathname,
build,
isUnlocked,
disabled,
t,
onItemClick
}: CollapsedNavItemWithPopoverProps) {
const [popoverOpen, setPopoverOpen] = React.useState(false);
const [tooltipOpen, setTooltipOpen] = React.useState(false);
const suppressTooltipRef = React.useRef(false);
const handlePopoverOpenChange = React.useCallback((open: boolean) => {
setPopoverOpen(open);
if (!open) {
setTooltipOpen(false);
suppressTooltipRef.current = true;
window.setTimeout(() => {
suppressTooltipRef.current = false;
}, TOOLTIP_SUPPRESS_MS);
}
}, []);
const handleTooltipOpenChange = React.useCallback((open: boolean) => {
if (open && suppressTooltipRef.current) return;
setTooltipOpen(open);
}, []);
return (
<TooltipProvider>
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<Popover
open={popoverOpen}
onOpenChange={handlePopoverOpenChange}
>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref = hydrateHref(
childItem.href
);
const childIsActive = childHydratedHref
? pathname.startsWith(childHydratedHref)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled = disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else {
handlePopoverOpenChange(false);
onItemClick?.();
}
}}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">
{t(childItem.title)}
</span>
{childItem.isBeta && (
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
{t("beta")}
</span>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0 ml-2"
>
{t("licenseBadge")}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
);
}
export function SidebarNav({
className,
sections,
@@ -278,11 +448,7 @@ export function SidebarNav({
href={isDisabled ? "#" : hydratedHref}
className={cn(
"flex items-center rounded-md transition-colors relative",
isCollapsed
? "px-2 py-2 justify-center"
: level === 0
? "px-3 py-1.5"
: "px-3 py-1",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
@@ -298,10 +464,13 @@ export function SidebarNav({
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
{item.icon && (
{item.icon && level === 0 && (
<span
className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center",
isCollapsed
? "text-muted-foreground"
: "text-muted-foreground",
!isCollapsed && "mr-3"
)}
>
@@ -355,13 +524,13 @@ export function SidebarNav({
<div
className={cn(
"flex items-center rounded-md transition-colors",
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
"px-3 py-1.5",
"text-muted-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
>
{item.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{item.icon && level === 0 && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
@@ -401,120 +570,21 @@ export function SidebarNav({
// If item has nested items, show both tooltip and popover
if (hasNestedItems) {
return (
<TooltipProvider key={item.title}>
<Tooltip>
<Popover>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref =
hydrateHref(childItem.href);
const childIsActive =
childHydratedHref
? pathname.startsWith(
childHydratedHref
)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled =
disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else if (
onItemClick
) {
onItemClick();
}
}}
>
{childItem.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{childItem.icon}
</span>
)}
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">
{t(childItem.title)}
</span>
{childItem.isBeta && (
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
{t("beta")}
</span>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0 ml-2"
>
{t(
"licenseBadge"
)}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
<CollapsedNavItemWithPopover
key={item.title}
item={item}
tooltipText={tooltipText}
isActive={isActive}
isChildActive={isChildActive}
isDisabled={!!isDisabled}
hydrateHref={hydrateHref}
pathname={pathname}
build={build}
isUnlocked={isUnlocked}
disabled={disabled ?? false}
t={t}
onItemClick={onItemClick}
/>
);
}
@@ -549,7 +619,7 @@ export function SidebarNav({
className={cn(sectionIndex > 0 && "mt-4")}
>
{!isCollapsed && (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider">
<div className="px-3 py-2 text-xs font-medium text-foreground uppercase tracking-wider">
{t(`${section.heading}`)}
</div>
)}

View File

@@ -14,6 +14,7 @@ export interface StrategyOption<TValue extends string> {
interface StrategySelectProps<TValue extends string> {
options: ReadonlyArray<StrategyOption<TValue>>;
value?: TValue | null;
defaultValue?: TValue;
onChange?: (value: TValue) => void;
cols?: number;
@@ -21,18 +22,21 @@ interface StrategySelectProps<TValue extends string> {
export function StrategySelect<TValue extends string>({
options,
value: controlledValue,
defaultValue,
onChange,
cols
}: StrategySelectProps<TValue>) {
const [selected, setSelected] = useState<TValue | undefined>(defaultValue);
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
const isControlled = controlledValue !== undefined;
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
return (
<RadioGroup
defaultValue={defaultValue}
value={selected ?? ""}
onValueChange={(value: string) => {
const typedValue = value as TValue;
setSelected(typedValue);
if (!isControlled) setUncontrolledSelected(typedValue);
onChange?.(typedValue);
}}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}

View File

@@ -155,62 +155,72 @@ export default function UsersTable({ users: u }: UsersTableProps) {
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const userRow = row.original;
const isCurrentUser =
`${userRow.username}-${userRow.idpId}` ===
`${user?.username}-${user?.idpId}`;
const isDisabled = userRow.isOwner || isCurrentUser;
return (
<div className="flex items-center justify-end">
<div>
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${user?.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="text-red-500">
{t("accessUserRemove")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled={isDisabled}
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
aria-disabled={isDisabled}
onClick={(e) =>
isDisabled && e.preventDefault()
}
>
<DropdownMenuItem
disabled={isDisabled}
>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{!isDisabled && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedUser(userRow);
}}
>
<span className="text-red-500">
{t("accessUserRemove")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{!userRow.isOwner && (
{isDisabled ? (
<Button
variant={"outline"}
className="ml-2"
disabled
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
) : (
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button
variant={"outline"}
className="ml-2"
disabled={userRow.isOwner}
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

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

View File

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