mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-11 14:54:21 +00:00
Merge branch 'dev' into feat/resource-policies
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
||||
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type ApplyInternalRedirectProps = {
|
||||
orgId: string;
|
||||
@@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const path = consumeInternalRedirectPath();
|
||||
if (path) {
|
||||
router.replace(`/${orgId}${path}`);
|
||||
const target = getInternalRedirectTarget(orgId);
|
||||
if (target) {
|
||||
router.replace(target);
|
||||
}
|
||||
}, [orgId, router]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -27,6 +28,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { build } from "@server/build";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||
import { XIcon } from "lucide-react";
|
||||
@@ -43,13 +45,36 @@ export type AuthPageCustomizationProps = {
|
||||
const AuthPageFormSchema = z.object({
|
||||
logoUrl: z.union([
|
||||
z.literal(""),
|
||||
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
|
||||
z.string().superRefine(async (urlOrPath, ctx) => {
|
||||
const parseResult = z.url().safeParse(urlOrPath);
|
||||
if (!parseResult.success) {
|
||||
if (build !== "enterprise") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Must be a valid URL"
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
validateLocalPath(urlOrPath);
|
||||
} catch (error) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message:
|
||||
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||
});
|
||||
} finally {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const response = await fetch(urlOrPath, {
|
||||
method: "HEAD"
|
||||
}).catch(() => {
|
||||
// If HEAD fails (CORS or method not allowed), try GET
|
||||
return fetch(url, { method: "GET" });
|
||||
return fetch(urlOrPath, { method: "GET" });
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
@@ -269,12 +294,25 @@ export default function AuthPageBrandingForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-3">
|
||||
<FormLabel>
|
||||
{t("brandingLogoURL")}
|
||||
{build === "enterprise"
|
||||
? t(
|
||||
"brandingLogoURLOrPath"
|
||||
)
|
||||
: t("brandingLogoURL")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{build === "enterprise"
|
||||
? t(
|
||||
"brandingLogoPathDescription"
|
||||
)
|
||||
: t(
|
||||
"brandingLogoURLDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||
@@ -55,12 +55,6 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.subnet.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -15,7 +15,15 @@ import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -51,6 +59,8 @@ export type InternalResourceRow = {
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
authDaemonMode?: "site" | "remote" | null;
|
||||
authDaemonPort?: number | null;
|
||||
};
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
@@ -131,7 +141,26 @@ export default function ClientResourcesTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="p-3">{t("name")}</span>
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
@@ -327,6 +356,14 @@ export default function ClientResourcesTable({
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
|
||||
@@ -31,6 +31,18 @@ const CopyToClipboard = ({
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 min-w-0 max-w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{!copied ? (
|
||||
<Copy className="h-4 w-4" />
|
||||
) : (
|
||||
<Check className="text-green-500 h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
{isLink ? (
|
||||
<Link
|
||||
href={text}
|
||||
@@ -54,18 +66,6 @@ const CopyToClipboard = ({
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{!copied ? (
|
||||
<Copy className="h-4 w-4" />
|
||||
) : (
|
||||
<Check className="text-green-500 h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
414
src/components/DeleteAccountConfirmDialog.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import type {
|
||||
DeleteMyAccountPreviewResponse,
|
||||
DeleteMyAccountCodeRequestedResponse,
|
||||
DeleteMyAccountSuccessResponse
|
||||
} from "@server/routers/auth/deleteMyAccount";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
type DeleteAccountConfirmDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export default function DeleteAccountConfirmDialog({
|
||||
open,
|
||||
setOpen
|
||||
}: DeleteAccountConfirmDialogProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const passwordSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
password: z.string().min(1, { message: t("passwordRequired") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const codeSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<0 | 1 | 2>(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [preview, setPreview] =
|
||||
useState<DeleteMyAccountPreviewResponse | null>(null);
|
||||
const [passwordValue, setPasswordValue] = useState("");
|
||||
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: { password: "" }
|
||||
});
|
||||
|
||||
const codeForm = useForm<z.infer<typeof codeSchema>>({
|
||||
resolver: zodResolver(codeSchema),
|
||||
defaultValues: { code: "" }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open && step === 0 && !preview) {
|
||||
setLoadingPreview(true);
|
||||
api.post<AxiosResponse<DeleteMyAccountPreviewResponse>>(
|
||||
"/auth/delete-my-account",
|
||||
{}
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.data?.data?.preview) {
|
||||
setPreview(res.data.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
t("deleteAccountError")
|
||||
)
|
||||
});
|
||||
setOpen(false);
|
||||
})
|
||||
.finally(() => setLoadingPreview(false));
|
||||
}
|
||||
}, [open, step, preview, api, setOpen, t]);
|
||||
|
||||
function reset() {
|
||||
setStep(0);
|
||||
setPreview(null);
|
||||
setPasswordValue("");
|
||||
passwordForm.reset();
|
||||
codeForm.reset();
|
||||
}
|
||||
|
||||
async function handleContinueToPassword() {
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(
|
||||
values: z.infer<typeof passwordSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
setPasswordValue(values.password);
|
||||
try {
|
||||
const res = await api.post<
|
||||
| AxiosResponse<DeleteMyAccountCodeRequestedResponse>
|
||||
| AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", { password: values.password });
|
||||
|
||||
const data = res.data?.data;
|
||||
|
||||
if (data && "codeRequested" in data && data.codeRequested) {
|
||||
setStep(2);
|
||||
} else if (data && "success" in data && data.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCodeSubmit(values: z.infer<typeof codeSchema>) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||
>("/auth/delete-my-account", {
|
||||
password: passwordValue,
|
||||
code: values.code
|
||||
});
|
||||
|
||||
if (res.data?.data?.success) {
|
||||
toast({
|
||||
title: t("deleteAccountSuccess"),
|
||||
description: t("deleteAccountSuccessMessage")
|
||||
});
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("deleteAccountError"),
|
||||
description: formatAxiosError(err, t("deleteAccountError"))
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) reset();
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("deleteAccountConfirmTitle")}
|
||||
</CredenzaTitle>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{step === 0 && (
|
||||
<>
|
||||
{loadingPreview ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("loading")}...
|
||||
</p>
|
||||
) : preview ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("deleteAccountConfirmMessage")}
|
||||
</p>
|
||||
<div className="rounded-md bg-muted p-3 space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t(
|
||||
"deleteAccountPreviewAccount"
|
||||
)}
|
||||
</p>
|
||||
{preview.orgs.length > 0 && (
|
||||
<>
|
||||
<p className="text-sm font-medium mt-2">
|
||||
{t(
|
||||
"deleteAccountPreviewOrgs"
|
||||
)}
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
{preview.orgs.map(
|
||||
(org) => (
|
||||
<li
|
||||
key={
|
||||
org.orgId
|
||||
}
|
||||
>
|
||||
{org.name ||
|
||||
org.orgId}
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-destructive">
|
||||
{t("cannotbeUndone")}
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
id="delete-account-password-form"
|
||||
onSubmit={passwordForm.handleSubmit(
|
||||
handlePasswordSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...codeForm}>
|
||||
<form
|
||||
id="delete-account-code-form"
|
||||
onSubmit={codeForm.handleSubmit(
|
||||
handleCodeSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={codeForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
pattern={
|
||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||
}
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
0
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
1
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
2
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
3
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
4
|
||||
}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
5
|
||||
}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{step === 0 && preview && !loadingPreview && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleContinueToPassword}
|
||||
>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-password-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
form="delete-account-code-form"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("deleteAccountButton")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
1328
src/components/InternalResourceForm.tsx
Normal file
1328
src/components/InternalResourceForm.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExternalLink, Menu, Server } from "lucide-react";
|
||||
import { ArrowRight, Menu, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import ProfileIcon from "@app/components/ProfileIcon";
|
||||
import ThemeSwitcher from "@app/components/ThemeSwitcher";
|
||||
@@ -44,7 +42,6 @@ export function LayoutMobileMenu({
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -73,17 +70,17 @@ 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">
|
||||
<div className="mb-1">
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
@@ -98,11 +95,12 @@ export function LayoutMobileMenu({
|
||||
<span className="flex-shrink-0 mr-2">
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="flex-1">
|
||||
{t(
|
||||
"serverAdmin"
|
||||
)}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@@ -115,22 +113,6 @@ export function LayoutMobileMenu({
|
||||
</div>
|
||||
<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="px-3 pt-3 pb-3 space-y-4 border-t shrink-0">
|
||||
<SupporterStatus />
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<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"
|
||||
>
|
||||
v{env.app.version}
|
||||
<ExternalLink size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
@@ -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,9 +145,14 @@ export function LayoutSidebar({
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
<div className="px-2 pt-1">
|
||||
<div className="px-2 pt-3">
|
||||
{!isAdminPage && user.serverAdmin && (
|
||||
<div className="py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/admin"
|
||||
className={cn(
|
||||
@@ -171,7 +176,12 @@ export function LayoutSidebar({
|
||||
<Server className="h-4 w-4" />
|
||||
</span>
|
||||
{!isSidebarCollapsed && (
|
||||
<span>{t("serverAdmin")}</span>
|
||||
<>
|
||||
<span className="flex-1">
|
||||
{t("serverAdmin")}
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -186,29 +196,55 @@ 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" />
|
||||
{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-1 flex flex-col shrink-0">
|
||||
{canShowProductUpdates && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-3 empty:mb-0">
|
||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||
</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}
|
||||
/>
|
||||
@@ -224,19 +260,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>
|
||||
)}
|
||||
@@ -245,12 +281,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")
|
||||
@@ -263,22 +299,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} />
|
||||
|
||||
@@ -204,7 +204,26 @@ export default function MachineClientsTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="px-3">{t("name")}</span>,
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => toggleSort("name")}
|
||||
className="px-3"
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
|
||||
@@ -58,6 +58,18 @@ type Resource = {
|
||||
siteName?: string | null;
|
||||
};
|
||||
|
||||
type SiteResource = {
|
||||
siteResourceId: number;
|
||||
name: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
enabled: boolean;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
type: 'site';
|
||||
};
|
||||
|
||||
type MemberResourcesPortalProps = {
|
||||
orgId: string;
|
||||
};
|
||||
@@ -334,7 +346,9 @@ export default function MemberResourcesPortal({
|
||||
const { toast } = useToast();
|
||||
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [siteResources, setSiteResources] = useState<SiteResource[]>([]);
|
||||
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||
const [filteredSiteResources, setFilteredSiteResources] = useState<SiteResource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -360,7 +374,9 @@ export default function MemberResourcesPortal({
|
||||
|
||||
if (response.data.success) {
|
||||
setResources(response.data.data.resources);
|
||||
setSiteResources(response.data.data.siteResources || []);
|
||||
setFilteredResources(response.data.data.resources);
|
||||
setFilteredSiteResources(response.data.data.siteResources || []);
|
||||
} else {
|
||||
setError("Failed to load resources");
|
||||
}
|
||||
@@ -417,17 +433,61 @@ export default function MemberResourcesPortal({
|
||||
|
||||
setFilteredResources(filtered);
|
||||
|
||||
// Filter and sort site resources
|
||||
const filteredSites = siteResources.filter(
|
||||
(resource) =>
|
||||
resource.name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
resource.destination
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort site resources
|
||||
filteredSites.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case "name-asc":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "name-desc":
|
||||
return b.name.localeCompare(a.name);
|
||||
case "domain-asc":
|
||||
case "domain-desc":
|
||||
// Sort by destination for site resources
|
||||
const destCompare = sortBy === "domain-asc"
|
||||
? a.destination.localeCompare(b.destination)
|
||||
: b.destination.localeCompare(a.destination);
|
||||
return destCompare;
|
||||
case "status-enabled":
|
||||
return b.enabled ? 1 : -1;
|
||||
case "status-disabled":
|
||||
return a.enabled ? 1 : -1;
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
|
||||
setFilteredSiteResources(filteredSites);
|
||||
|
||||
// Reset to first page when search/sort changes
|
||||
setCurrentPage(1);
|
||||
}, [resources, searchQuery, sortBy]);
|
||||
}, [resources, siteResources, searchQuery, sortBy]);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(filteredResources.length / itemsPerPage);
|
||||
const totalItems = filteredResources.length + filteredSiteResources.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const paginatedResources = filteredResources.slice(
|
||||
startIndex,
|
||||
startIndex + itemsPerPage
|
||||
);
|
||||
const remainingSlots = itemsPerPage - paginatedResources.length;
|
||||
const paginatedSiteResources = remainingSlots > 0
|
||||
? filteredSiteResources.slice(
|
||||
Math.max(0, startIndex - filteredResources.length),
|
||||
Math.max(0, startIndex - filteredResources.length) + remainingSlots
|
||||
)
|
||||
: [];
|
||||
|
||||
const handleOpenResource = (resource: Resource) => {
|
||||
// Open the resource in a new tab
|
||||
@@ -575,7 +635,7 @@ export default function MemberResourcesPortal({
|
||||
</div>
|
||||
|
||||
{/* Resources Content */}
|
||||
{filteredResources.length === 0 ? (
|
||||
{filteredResources.length === 0 && filteredSiteResources.length === 0 ? (
|
||||
/* Enhanced Empty State */
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
@@ -623,9 +683,20 @@ export default function MemberResourcesPortal({
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Resources Grid */}
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr">
|
||||
{paginatedResources.map((resource) => (
|
||||
{/* Public Resources Section */}
|
||||
{paginatedResources.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Globe className="h-5 w-5" />
|
||||
Public Resources
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Web applications and services accessible via browser
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||
{paginatedResources.map((resource) => (
|
||||
<Card key={resource.resourceId}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -702,13 +773,167 @@ export default function MemberResourcesPortal({
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Private Resources (Site Resources) Section */}
|
||||
{paginatedSiteResources.length > 0 && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Combine className="h-5 w-5" />
|
||||
Private Resources
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Internal network resources accessible via client
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 auto-cols-fr mb-8">
|
||||
{paginatedSiteResources.map((siteResource) => (
|
||||
<Card key={siteResource.siteResourceId}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center min-w-0 flex-1 gap-3 overflow-hidden">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="min-w-0 max-w-full">
|
||||
<CardTitle className="text-lg font-bold text-foreground truncate group-hover:text-primary transition-colors">
|
||||
{siteResource.name}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs break-words">
|
||||
{siteResource.name}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<InfoPopup>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="text-xs font-medium mb-1.5">Resource Details</div>
|
||||
<div>
|
||||
<span className="font-medium">Mode:</span>
|
||||
<span className="ml-2 text-muted-foreground capitalize">
|
||||
{siteResource.mode}
|
||||
</span>
|
||||
</div>
|
||||
{siteResource.protocol && (
|
||||
<div>
|
||||
<span className="font-medium">Protocol:</span>
|
||||
<span className="ml-2 text-muted-foreground uppercase">
|
||||
{siteResource.protocol}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{siteResource.alias && (
|
||||
<div>
|
||||
<span className="font-medium">Alias:</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{siteResource.alias}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{siteResource.aliasAddress && (
|
||||
<div>
|
||||
<span className="font-medium">Alias Address:</span>
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{siteResource.aliasAddress}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Status:</span>
|
||||
<span className={`ml-2 ${siteResource.enabled ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{siteResource.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</InfoPopup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
{siteResource.alias ? (
|
||||
<>
|
||||
{/* Alias as primary */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="text-base font-semibold text-foreground text-left truncate flex-1">
|
||||
{siteResource.alias}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
siteResource.alias!
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
description:
|
||||
"Resource alias has been copied to your clipboard.",
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Destination as secondary */}
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{siteResource.destination}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Destination as primary when no alias */
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
|
||||
{siteResource.destination}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
siteResource.destination
|
||||
);
|
||||
toast({
|
||||
title: "Copied to clipboard",
|
||||
description:
|
||||
"Resource destination has been copied to your clipboard.",
|
||||
duration: 2000
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 pt-0 mt-auto">
|
||||
<div className="flex items-center justify-center py-2 px-4 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||
<Combine className="h-3.5 w-3.5 mr-2" />
|
||||
Requires Client Connection
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<PaginationControls
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
totalItems={filteredResources.length}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
/>
|
||||
</>
|
||||
|
||||
70
src/components/OptionSelect.tsx
Normal file
70
src/components/OptionSelect.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type OptionSelectOption<TValue extends string> = {
|
||||
value: TValue;
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
type OptionSelectProps<TValue extends string> = {
|
||||
options: ReadonlyArray<OptionSelectOption<TValue>>;
|
||||
value: TValue;
|
||||
onChange: (value: TValue) => void;
|
||||
label?: string;
|
||||
/** Grid columns: 2, 3, 4, 5, etc. Default 5 on md+. */
|
||||
cols?: number;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function OptionSelect<TValue extends string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
cols = 5,
|
||||
className,
|
||||
disabled = false
|
||||
}: OptionSelectProps<TValue>) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<p className="font-bold mb-3">{label}</p>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
cols === 2 && "grid-cols-2",
|
||||
cols === 3 && "grid-cols-2 md:grid-cols-3",
|
||||
cols === 4 && "grid-cols-2 md:grid-cols-4",
|
||||
cols === 5 && "grid-cols-2 md:grid-cols-5",
|
||||
cols === 6 && "grid-cols-2 md:grid-cols-3 lg:grid-cols-6"
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = value === option.value;
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={isSelected ? "squareOutlinePrimary" : "squareOutline"}
|
||||
className={cn(
|
||||
"flex-1 min-w-30 shadow-none",
|
||||
isSelected && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => onChange(option.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{option.icon}
|
||||
{option.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import {
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
@@ -20,12 +19,14 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
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 { useRouter } from "next/navigation";
|
||||
import { useState } from "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";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -43,11 +44,23 @@ export function OrgSelector({
|
||||
const { user } = useUserContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||
|
||||
const sortedOrgs = useMemo(() => {
|
||||
if (!orgs?.length) return orgs ?? [];
|
||||
return [...orgs].sort((a, b) => {
|
||||
const aPrimary = Boolean(a.isPrimaryOrg);
|
||||
const bPrimary = Boolean(b.isPrimaryOrg);
|
||||
if (aPrimary && !bPrimary) return -1;
|
||||
if (!aPrimary && bPrimary) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [orgs]);
|
||||
|
||||
const orgSelectorContent = (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -58,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 ? (
|
||||
@@ -80,73 +93,64 @@ 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>
|
||||
{orgs?.map((org) => (
|
||||
<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);
|
||||
router.push(`/${org.orgId}/settings`);
|
||||
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">
|
||||
<span className="font-medium">
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||
<span className="font-medium truncate text-sm">
|
||||
{org.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("organization")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{org.orgId}
|
||||
</span>
|
||||
{org.isPrimaryOrg && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 text-[10px] px-1.5 py-0 font-medium ml-auto"
|
||||
>
|
||||
{t("primary")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</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"
|
||||
@@ -154,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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
|
||||
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { build } from "@server/build";
|
||||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import Disable2FaForm from "./Disable2FaForm";
|
||||
@@ -187,6 +189,20 @@ export default function ProfileIcon() {
|
||||
<DropdownMenuSeparator />
|
||||
<LocaleSwitcher />
|
||||
<DropdownMenuSeparator />
|
||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href="/auth/delete-account"
|
||||
className="flex cursor-pointer items-center"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("deleteAccount")}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => logout()}>
|
||||
{/* <LogOut className="mr-2 h-4 w-4" /> */}
|
||||
<span>{t("logout")}</span>
|
||||
|
||||
@@ -14,15 +14,19 @@ import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowRight,
|
||||
ArrowUp10Icon,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
Clock,
|
||||
MoreHorizontal,
|
||||
ShieldCheck,
|
||||
@@ -318,7 +322,26 @@ export default function ProxyResourcesTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: () => <span className="p-3">{t("name")}</span>
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
@@ -563,6 +586,14 @@ export default function ProxyResourcesTable({
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSort(column: string) {
|
||||
const newSearch = getNextSortOrder(column, searchParams);
|
||||
|
||||
filter({
|
||||
searchParams: newSearch
|
||||
});
|
||||
}
|
||||
|
||||
const handlePaginationChange = (newPage: PaginationState) => {
|
||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||
|
||||
@@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target = getInternalRedirectTarget(targetOrgId);
|
||||
const target =
|
||||
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
|
||||
468
src/components/RoleForm.tsx
Normal file
468
src/components/RoleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,7 @@ type SignupFormProps = {
|
||||
inviteToken?: string;
|
||||
emailParam?: string;
|
||||
fromSmartLogin?: boolean;
|
||||
skipVerificationEmail?: boolean;
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
@@ -103,7 +104,8 @@ export default function SignupForm({
|
||||
inviteId,
|
||||
inviteToken,
|
||||
emailParam,
|
||||
fromSmartLogin = false
|
||||
fromSmartLogin = false,
|
||||
skipVerificationEmail = false
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -147,7 +149,8 @@ export default function SignupForm({
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt,
|
||||
marketingEmailConsent:
|
||||
build === "saas" ? marketingEmailConsent : undefined
|
||||
build === "saas" ? marketingEmailConsent : undefined,
|
||||
skipVerificationEmail: skipVerificationEmail || undefined
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
||||
@@ -68,15 +68,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
{site.type == "newt" && (
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Address</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.address?.split("/")[0]}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
)}
|
||||
</InfoSections>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -141,7 +141,24 @@ export default function SitesTable({
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => {
|
||||
return <span className="p-3">{t("name")}</span>;
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];
|
||||
|
||||
|
||||
@@ -124,11 +124,6 @@ export function ControlledDataTable<TData, TValue>({
|
||||
return initial;
|
||||
}, [filters]);
|
||||
|
||||
console.log({
|
||||
pagination,
|
||||
rowCount
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
|
||||
@@ -320,11 +320,6 @@ export function DataTable<TData, TValue>({
|
||||
return result;
|
||||
}, [data, tabs, activeTab, filters, activeFilters]);
|
||||
|
||||
console.log({
|
||||
pagination,
|
||||
paginationState
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
@@ -852,12 +847,14 @@ export function DataTable<TData, TValue>({
|
||||
</Table>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
{table.getRowModel().rows?.length > 0 && (
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user