mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-11 23:46:50 +00:00
Merge branch 'dev' into feat/login-page-customization
This commit is contained in:
@@ -14,21 +14,21 @@ export default function AccessPageHeaderAndNav({
|
||||
hasInvitations
|
||||
}: AccessPageHeaderAndNavProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: t('users'),
|
||||
title: t("users"),
|
||||
href: `/{orgId}/settings/access/users`
|
||||
},
|
||||
{
|
||||
title: t('roles'),
|
||||
title: t("roles"),
|
||||
href: `/{orgId}/settings/access/roles`
|
||||
}
|
||||
];
|
||||
|
||||
if (hasInvitations) {
|
||||
navItems.push({
|
||||
title: t('invite'),
|
||||
title: t("invite"),
|
||||
href: `/{orgId}/settings/access/invitations`
|
||||
});
|
||||
}
|
||||
@@ -36,13 +36,11 @@ export default function AccessPageHeaderAndNav({
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={t('accessUsersRoles')}
|
||||
description={t('accessUsersRolesDescription')}
|
||||
title={t("accessUsersRoles")}
|
||||
description={t("accessUsersRolesDescription")}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>
|
||||
{children}
|
||||
</HorizontalTabs>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ type AccessTokenProps = {
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export default function AccessToken({
|
||||
token,
|
||||
resourceId
|
||||
}: AccessTokenProps) {
|
||||
export default function AccessToken({ token, resourceId }: AccessTokenProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
@@ -79,7 +76,7 @@ export default function AccessToken({
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t('accessTokenError'), e);
|
||||
console.error(t("accessTokenError"), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -102,7 +99,7 @@ export default function AccessToken({
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t('accessTokenError'), e);
|
||||
console.error(t("accessTokenError"), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -118,26 +115,22 @@ export default function AccessToken({
|
||||
|
||||
function renderTitle() {
|
||||
if (isValid) {
|
||||
return t('accessGranted');
|
||||
return t("accessGranted");
|
||||
} else {
|
||||
return t('accessUrlInvalid');
|
||||
return t("accessUrlInvalid");
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
if (isValid) {
|
||||
return (
|
||||
<div>
|
||||
{t('accessGrantedDescription')}
|
||||
</div>
|
||||
);
|
||||
return <div>{t("accessGrantedDescription")}</div>;
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
{t('accessUrlInvalidDescription')}
|
||||
{t("accessUrlInvalidDescription")}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
<Link href="/">{t("goHome")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,31 +44,35 @@ export default function AccessTokenSection({
|
||||
<>
|
||||
<div className="flex items-start space-x-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('shareTokenDescription')}
|
||||
{t("shareTokenDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="token" className="w-full mt-4">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="token">{t('accessToken')}</TabsTrigger>
|
||||
<TabsTrigger value="usage">{t('usageExamples')}</TabsTrigger>
|
||||
<TabsTrigger value="token">{t("accessToken")}</TabsTrigger>
|
||||
<TabsTrigger value="usage">
|
||||
{t("usageExamples")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="token" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">{t('tokenId')}</div>
|
||||
<div className="font-bold">{t("tokenId")}</div>
|
||||
<CopyToClipboard text={tokenId} isLink={false} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">{t('token')}</div>
|
||||
<div className="font-bold">{t("token")}</div>
|
||||
<CopyToClipboard text={token} isLink={false} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("requestHeades")}
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
|
||||
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
@@ -76,7 +80,9 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("queryParameter")}
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
|
||||
/>
|
||||
@@ -85,17 +91,17 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('importantNote')}
|
||||
{t("importantNote")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('shareImportantDescription')}
|
||||
{t("shareImportantDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="text-sm text-muted-foreground mt-4">
|
||||
{t('shareTokenSecurety')}
|
||||
{t("shareTokenSecurety")}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -26,10 +26,10 @@ export function IdpDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="idp-table"
|
||||
title={t('idp')}
|
||||
searchPlaceholder={t('idpSearch')}
|
||||
title={t("idp")}
|
||||
searchPlaceholder={t("idpSearch")}
|
||||
searchColumn="name"
|
||||
addButtonText={t('idpAdd')}
|
||||
addButtonText={t("idpAdd")}
|
||||
onAdd={() => {
|
||||
router.push("/admin/idp/create");
|
||||
}}
|
||||
|
||||
@@ -175,9 +175,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
@@ -198,7 +196,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
setSelectedIdp(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t("idpQuestionRemove", {
|
||||
name: selectedIdp.name
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -19,7 +17,6 @@ export function UsersDataTable<TData, TValue>({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -27,8 +24,8 @@ export function UsersDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="userServer-table"
|
||||
title={t('userServer')}
|
||||
searchPlaceholder={t('userSearch')}
|
||||
title={t("userServer")}
|
||||
searchPlaceholder={t("userSearch")}
|
||||
searchColumn="email"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function UsersTable({ users }: Props) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{r.type !== "internal" && (
|
||||
{r.type === "internal" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
generatePasswordResetCode(r.id);
|
||||
@@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) {
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t("userQuestionRemove", {
|
||||
selectedUser:
|
||||
|
||||
@@ -44,7 +44,6 @@ export function ApiKeysDataTable<TData, TValue>({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -52,11 +51,11 @@ export function ApiKeysDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="apiKeys-table"
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
title={t("apiKeys")}
|
||||
searchPlaceholder={t("searchApiKeys")}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
addButtonText={t("apiKeysAdd")}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
enableColumnVisibility={true}
|
||||
|
||||
@@ -108,7 +108,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
{
|
||||
accessorKey: "key",
|
||||
friendlyName: t("key"),
|
||||
header: () => (<span className="p-3">{t("key")}</span>),
|
||||
header: () => <span className="p-3">{t("key")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
@@ -117,7 +117,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
friendlyName: t("createdAt"),
|
||||
header: () => (<span className="p-3">{t("createdAt")}</span>),
|
||||
header: () => <span className="p-3">{t("createdAt")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||
@@ -161,9 +161,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link href={`/admin/api-keys/${r.id}`}>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
@@ -184,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("apiKeysQuestionRemove")}</p>
|
||||
|
||||
<p>{t("apiKeysMessageRemove")}</p>
|
||||
|
||||
@@ -67,7 +67,8 @@ export default function AutoLoginHandler({
|
||||
console.error("Failed to generate OIDC URL:", e);
|
||||
setError(
|
||||
t("autoLoginErrorGeneratingUrl", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -20,7 +20,10 @@ type ChangePasswordDialogProps = {
|
||||
setOpen: (val: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDialogProps) {
|
||||
export default function ChangePasswordDialog({
|
||||
open,
|
||||
setOpen
|
||||
}: ChangePasswordDialogProps) {
|
||||
const t = useTranslations();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -47,18 +50,16 @@ export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDi
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t('changePassword')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaTitle>{t("changePassword")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('changePasswordDescription')}
|
||||
{t("changePasswordDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<ChangePasswordForm
|
||||
ref={formRef}
|
||||
isDialog={true}
|
||||
submitButtonText={t('submit')}
|
||||
submitButtonText={t("submit")}
|
||||
cancelButtonText="Close"
|
||||
showCancelButton={false}
|
||||
onComplete={() => setOpen(false)}
|
||||
@@ -77,7 +78,7 @@ export default function ChangePasswordDialog({ open, setOpen }: ChangePasswordDi
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t('submit')}
|
||||
{t("submit")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
|
||||
@@ -22,11 +22,7 @@ import {
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "./ui/input-otp";
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { ChangePasswordResponse } from "@server/routers/auth";
|
||||
import { cn } from "@app/lib/cn";
|
||||
@@ -114,14 +110,22 @@ const ChangePasswordForm = forwardRef<
|
||||
onLoadingChange?.(loading);
|
||||
}, [loading, onLoadingChange]);
|
||||
|
||||
const passwordSchema = z.object({
|
||||
oldPassword: z.string().min(1, { message: t("passwordRequired") }),
|
||||
newPassword: z.string().min(8, { message: t("passwordRequirementsChars") }),
|
||||
confirmPassword: z.string().min(1, { message: t("passwordRequired") })
|
||||
}).refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t("passwordsDoNotMatch"),
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
oldPassword: z
|
||||
.string()
|
||||
.min(1, { message: t("passwordRequired") }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, { message: t("passwordRequirementsChars") }),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, { message: t("passwordRequired") })
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t("passwordsDoNotMatch"),
|
||||
path: ["confirmPassword"]
|
||||
});
|
||||
|
||||
const mfaSchema = z.object({
|
||||
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||
@@ -143,11 +147,13 @@ const ChangePasswordForm = forwardRef<
|
||||
}
|
||||
});
|
||||
|
||||
const changePassword = async (values: z.infer<typeof passwordSchema>) => {
|
||||
const changePassword = async (
|
||||
values: z.infer<typeof passwordSchema>
|
||||
) => {
|
||||
setLoading(true);
|
||||
|
||||
const endpoint = `/auth/change-password`;
|
||||
const payload = {
|
||||
const payload = {
|
||||
oldPassword: values.oldPassword,
|
||||
newPassword: values.newPassword
|
||||
};
|
||||
@@ -181,7 +187,7 @@ const ChangePasswordForm = forwardRef<
|
||||
|
||||
const endpoint = `/auth/change-password`;
|
||||
const passwordValues = passwordForm.getValues();
|
||||
const payload = {
|
||||
const payload = {
|
||||
oldPassword: passwordValues.oldPassword,
|
||||
newPassword: passwordValues.newPassword,
|
||||
code: values.code
|
||||
@@ -303,7 +309,9 @@ const ChangePasswordForm = forwardRef<
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("passwordStrength")}
|
||||
{t(
|
||||
"passwordStrength"
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -335,7 +343,9 @@ const ChangePasswordForm = forwardRef<
|
||||
{/* Requirements Checklist */}
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-foreground mb-2">
|
||||
{t("passwordRequirements")}
|
||||
{t(
|
||||
"passwordRequirements"
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -505,13 +515,14 @@ const ChangePasswordForm = forwardRef<
|
||||
{confirmPasswordValue.length > 0 &&
|
||||
!doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
{t(
|
||||
"passwordsDoNotMatch"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* Only show FormMessage when field is empty */}
|
||||
{confirmPasswordValue.length === 0 && (
|
||||
<FormMessage />
|
||||
)}
|
||||
{confirmPasswordValue.length ===
|
||||
0 && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -523,7 +534,9 @@ const ChangePasswordForm = forwardRef<
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">{t("otpAuth")}</h3>
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("otpAuth")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("otpAuthDescription")}
|
||||
</p>
|
||||
@@ -551,9 +564,12 @@ const ChangePasswordForm = forwardRef<
|
||||
onChange={(
|
||||
value: string
|
||||
) => {
|
||||
field.onChange(value);
|
||||
field.onChange(
|
||||
value
|
||||
);
|
||||
if (
|
||||
value.length === 6
|
||||
value.length ===
|
||||
6
|
||||
) {
|
||||
mfaForm.handleSubmit(
|
||||
confirmMfa
|
||||
@@ -630,10 +646,7 @@ const ChangePasswordForm = forwardRef<
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
className="w-full"
|
||||
>
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
{t("continueToApplication")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -644,4 +657,4 @@ const ChangePasswordForm = forwardRef<
|
||||
}
|
||||
);
|
||||
|
||||
export default ChangePasswordForm;
|
||||
export default ChangePasswordForm;
|
||||
|
||||
@@ -19,25 +19,27 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{client.niceId}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{client.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
|
||||
@@ -25,32 +25,6 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export type TargetHealth = {
|
||||
targetId: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
||||
};
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
nice: string | null;
|
||||
name: string;
|
||||
orgId: string;
|
||||
domain: string;
|
||||
authState: string;
|
||||
http: boolean;
|
||||
protocol: string;
|
||||
proxyPort: number | null;
|
||||
enabled: boolean;
|
||||
domainId?: string;
|
||||
ssl: boolean;
|
||||
targetHost?: string;
|
||||
targetPort?: number;
|
||||
targets?: TargetHealth[];
|
||||
};
|
||||
|
||||
export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -66,6 +40,10 @@ export type InternalResourceRow = {
|
||||
destination: string;
|
||||
// destinationPort: number | null;
|
||||
alias: string | null;
|
||||
niceId: string;
|
||||
tcpPortRangeString: string | null;
|
||||
udpPortRangeString: string | null;
|
||||
disableIcmp: boolean;
|
||||
};
|
||||
|
||||
type ClientResourcesTableProps = {
|
||||
@@ -158,6 +136,28 @@ export default function ClientResourcesTable({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "niceId",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.niceId || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
friendlyName: t("site"),
|
||||
@@ -287,7 +287,7 @@ export default function ClientResourcesTable({
|
||||
setSelectedInternalResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("resourceQuestionRemove")}</p>
|
||||
<p>{t("resourceMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
|
||||
type TabFilter = {
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
@@ -31,7 +42,9 @@ export function ColumnFilter({
|
||||
}: ColumnFilterProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedOption = options.find(option => option.value === selectedValue);
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -49,7 +62,9 @@ export function ColumnFilter({
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="truncate">
|
||||
{selectedOption ? selectedOption.label : placeholder}
|
||||
{selectedOption
|
||||
? selectedOption.label
|
||||
: placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||
@@ -79,7 +94,9 @@ export function ColumnFilter({
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
onValueChange(
|
||||
selectedValue === option.value ? undefined : option.value
|
||||
selectedValue === option.value
|
||||
? undefined
|
||||
: option.value
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
@@ -101,4 +118,4 @@ export function ColumnFilter({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ const DockerContainersTable: FC<{
|
||||
{
|
||||
accessorKey: "name",
|
||||
friendlyName: t("containerName"),
|
||||
header: () => (<span className="p-3">{t("containerName")}</span>),
|
||||
header: () => <span className="p-3">{t("containerName")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.original.name}</div>
|
||||
)
|
||||
@@ -194,7 +194,7 @@ const DockerContainersTable: FC<{
|
||||
{
|
||||
accessorKey: "image",
|
||||
friendlyName: t("containerImage"),
|
||||
header: () => (<span className="p-3">{t("containerImage")}</span>),
|
||||
header: () => <span className="p-3">{t("containerImage")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.image}
|
||||
@@ -204,7 +204,7 @@ const DockerContainersTable: FC<{
|
||||
{
|
||||
accessorKey: "state",
|
||||
friendlyName: t("containerState"),
|
||||
header: () => (<span className="p-3">{t("containerState")}</span>),
|
||||
header: () => <span className="p-3">{t("containerState")}</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
@@ -220,7 +220,7 @@ const DockerContainersTable: FC<{
|
||||
{
|
||||
accessorKey: "networks",
|
||||
friendlyName: t("containerNetworks"),
|
||||
header: () => (<span className="p-3">{t("containerNetworks")}</span>),
|
||||
header: () => <span className="p-3">{t("containerNetworks")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const networks = Object.keys(row.original.networks);
|
||||
return (
|
||||
@@ -239,7 +239,9 @@ const DockerContainersTable: FC<{
|
||||
{
|
||||
accessorKey: "hostname",
|
||||
friendlyName: t("containerHostnameIp"),
|
||||
header: () => (<span className="p-3">{t("containerHostnameIp")}</span>),
|
||||
header: () => (
|
||||
<span className="p-3">{t("containerHostnameIp")}</span>
|
||||
),
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm font-mono">
|
||||
@@ -250,7 +252,7 @@ const DockerContainersTable: FC<{
|
||||
{
|
||||
accessorKey: "labels",
|
||||
friendlyName: t("containerLabels"),
|
||||
header: () => (<span className="p-3">{t("containerLabels")}</span>),
|
||||
header: () => <span className="p-3">{t("containerLabels")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const labels = row.original.labels || {};
|
||||
const labelEntries = Object.entries(labels);
|
||||
@@ -302,7 +304,7 @@ const DockerContainersTable: FC<{
|
||||
},
|
||||
{
|
||||
accessorKey: "ports",
|
||||
header: () => (<span className="p-3">{t("containerPorts")}</span>),
|
||||
header: () => <span className="p-3">{t("containerPorts")}</span>,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const ports = getExposedPorts(row.original);
|
||||
@@ -360,7 +362,7 @@ const DockerContainersTable: FC<{
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => (<span className="p-3">{t("containerActions")}</span>),
|
||||
header: () => <span className="p-3">{t("containerActions")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const ports = getExposedPorts(row.original);
|
||||
return (
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function CopyTextBox({
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error(t('copyTextFailed'), err);
|
||||
console.error(t("copyTextFailed"), err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export default function CopyTextBox({
|
||||
type="button"
|
||||
className="absolute top-0.5 right-0 z-10 bg-card"
|
||||
onClick={copyToClipboard}
|
||||
aria-label={t('copyTextClipboard')}
|
||||
aria-label={t("copyTextClipboard")}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
|
||||
@@ -9,7 +9,11 @@ type CopyToClipboardProps = {
|
||||
isLink?: boolean;
|
||||
};
|
||||
|
||||
const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => {
|
||||
const CopyToClipboard = ({
|
||||
text,
|
||||
displayText,
|
||||
isLink
|
||||
}: CopyToClipboardProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
@@ -60,7 +64,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
|
||||
) : (
|
||||
<Check className="text-green-500 h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{t('copyText')}</span>
|
||||
<span className="sr-only">{t("copyText")}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,11 +45,16 @@ import {
|
||||
} from "@app/components/InfoSection";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { build } from "@server/build";
|
||||
import { toASCII, toUnicode } from 'punycode';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toASCII, toUnicode } from "punycode";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./ui/select";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
// Helper functions for Unicode domain handling
|
||||
function toPunycode(domain: string): string {
|
||||
try {
|
||||
@@ -76,9 +81,9 @@ function isValidDomainFormat(domain: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parts = domain.split('.');
|
||||
const parts = domain.split(".");
|
||||
for (const part of parts) {
|
||||
if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) {
|
||||
if (part.length === 0 || part.startsWith("-") || part.endsWith("-")) {
|
||||
return false;
|
||||
}
|
||||
if (part.length > 63) {
|
||||
@@ -137,7 +142,8 @@ export default function CreateDomainForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
baseDomain: "",
|
||||
type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
|
||||
type:
|
||||
build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns",
|
||||
certResolver: null,
|
||||
preferWildcardCert: false
|
||||
}
|
||||
@@ -172,7 +178,9 @@ export default function CreateDomainForm({
|
||||
description: t("domainCreatedDescription")
|
||||
});
|
||||
onCreated?.(domainData);
|
||||
router.push(`/${org.org.orgId}/settings/domains/${domainData.domainId}`);
|
||||
router.push(
|
||||
`/${org.org.orgId}/settings/domains/${domainData.domainId}`
|
||||
);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
@@ -182,7 +190,7 @@ export default function CreateDomainForm({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Domain type options
|
||||
let domainOptions: any = [];
|
||||
@@ -225,145 +233,213 @@ export default function CreateDomainForm({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{punycodePreview && (
|
||||
<FormDescription className="flex items-center gap-2 text-xs">
|
||||
<Alert>
|
||||
<Globe className="h-4 w-4" />
|
||||
<AlertTitle>{t("internationaldomaindetected")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p>{t("willbestoredas")} <code className="font-mono px-1 py-0.5 rounded">{punycodePreview}</code></p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{domainType === "wildcard" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certResolver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("certResolver")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={
|
||||
field.value === null ? "default" :
|
||||
(field.value === "" || (field.value && field.value !== "default")) ? "custom" :
|
||||
"default"
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "default") {
|
||||
field.onChange(null);
|
||||
} else if (val === "custom") {
|
||||
field.onChange("");
|
||||
} else {
|
||||
field.onChange(val);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectCertResolver")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certResolverOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={opt.id}>
|
||||
{opt.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
{form.watch("certResolver") !== null &&
|
||||
form.watch("certResolver") !== "default" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certResolver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("enterCustomResolver")}
|
||||
value={field.value || ""}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("domain")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="example.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{punycodePreview && (
|
||||
<FormDescription className="flex items-center gap-2 text-xs">
|
||||
<Alert>
|
||||
<Globe className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t(
|
||||
"internationaldomaindetected"
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<div className="mt-2 space-y-1">
|
||||
<p>
|
||||
{t(
|
||||
"willbestoredas"
|
||||
)}{" "}
|
||||
<code className="font-mono px-1 py-0.5 rounded">
|
||||
{
|
||||
punycodePreview
|
||||
}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{domainType === "wildcard" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certResolver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("certResolver")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={
|
||||
field.value === null
|
||||
? "default"
|
||||
: field.value ===
|
||||
"" ||
|
||||
(field.value &&
|
||||
field.value !==
|
||||
"default")
|
||||
? "custom"
|
||||
: "default"
|
||||
}
|
||||
onValueChange={(
|
||||
val
|
||||
) => {
|
||||
if (
|
||||
val ===
|
||||
"default"
|
||||
) {
|
||||
field.onChange(
|
||||
null
|
||||
);
|
||||
} else if (
|
||||
val === "custom"
|
||||
) {
|
||||
field.onChange(
|
||||
""
|
||||
);
|
||||
} else {
|
||||
field.onChange(
|
||||
val
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectCertResolver"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certResolverOptions.map(
|
||||
(opt) => (
|
||||
<SelectItem
|
||||
key={
|
||||
opt.id
|
||||
}
|
||||
value={
|
||||
opt.id
|
||||
}
|
||||
>
|
||||
{
|
||||
opt.title
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("certResolver") !== null &&
|
||||
form.watch("certResolver") !==
|
||||
"default" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certResolver"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"enterCustomResolver"
|
||||
)}
|
||||
value={
|
||||
field.value ||
|
||||
""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target
|
||||
.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("certResolver") !== null &&
|
||||
form.watch("certResolver") !== "default" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="preferWildcardCert"
|
||||
render={({ field: checkboxField }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
label={t("preferWildcardCert")}
|
||||
checked={checkboxField.value}
|
||||
onCheckedChange={checkboxField.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
{/* <div className="space-y-1 leading-none">
|
||||
{form.watch("certResolver") !== null &&
|
||||
form.watch("certResolver") !==
|
||||
"default" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="preferWildcardCert"
|
||||
render={({
|
||||
field: checkboxField
|
||||
}) => (
|
||||
<FormItem className="flex flex-row items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<CheckboxWithLabel
|
||||
label={t(
|
||||
"preferWildcardCert"
|
||||
)}
|
||||
checked={
|
||||
checkboxField.value
|
||||
}
|
||||
onCheckedChange={
|
||||
checkboxField.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
{/* <div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
{t("preferWildcardCert")}
|
||||
</FormLabel>
|
||||
</div> */}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
|
||||
@@ -42,15 +42,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { ListSitesResponse } from "@server/routers/site";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
@@ -59,6 +58,82 @@ import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
// import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Helper to validate port range string format
|
||||
const isValidPortRangeString = (val: string | undefined | null): boolean => {
|
||||
if (!val || val.trim() === "" || val.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = val.split(",").map((p) => p.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
if (!start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
|
||||
if (isNaN(startPort) || isNaN(endPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort > endPort) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const port = parseInt(part, 10);
|
||||
if (isNaN(port)) {
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Port range string schema for client-side validation
|
||||
const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => isValidPortRangeString(val),
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to determine the port mode from a port range string
|
||||
type PortMode = "all" | "blocked" | "custom";
|
||||
const getPortModeFromString = (val: string | undefined | null): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
// Helper to get the port string for API from mode and custom value
|
||||
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
type Site = ListSitesResponse["sites"][0];
|
||||
|
||||
@@ -103,6 +178,9 @@ export default function CreateInternalResourceDialog({
|
||||
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
|
||||
// .nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -209,6 +287,12 @@ export default function CreateInternalResourceDialog({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Port restriction UI state - default to "all" (*) for new resources
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>("all");
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>("all");
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>("");
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
);
|
||||
@@ -224,6 +308,9 @@ export default function CreateInternalResourceDialog({
|
||||
destination: "",
|
||||
// destinationPort: undefined,
|
||||
alias: "",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
@@ -232,6 +319,32 @@ export default function CreateInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// Update form values when port mode or custom ports change
|
||||
useEffect(() => {
|
||||
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
||||
form.setValue("tcpPortRangeString", tcpValue);
|
||||
}, [tcpPortMode, tcpCustomPorts, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
|
||||
form.setValue("udpPortRangeString", udpValue);
|
||||
}, [udpPortMode, udpCustomPorts, form]);
|
||||
|
||||
// Helper function to check if destination contains letters (hostname vs IP)
|
||||
const isHostname = (destination: string): boolean => {
|
||||
return /[a-zA-Z]/.test(destination);
|
||||
};
|
||||
|
||||
// Helper function to clean resource name for FQDN format
|
||||
const cleanForFQDN = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens
|
||||
.replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen
|
||||
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
|
||||
.replace(/^\.|\.$/g, ""); // Remove leading/trailing dots
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && availableSites.length > 0) {
|
||||
form.reset({
|
||||
@@ -243,16 +356,44 @@ export default function CreateInternalResourceDialog({
|
||||
destination: "",
|
||||
// destinationPort: undefined,
|
||||
alias: "",
|
||||
tcpPortRangeString: "*",
|
||||
udpPortRangeString: "*",
|
||||
disableIcmp: false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode("all");
|
||||
setUdpPortMode("all");
|
||||
setTcpCustomPorts("");
|
||||
setUdpCustomPorts("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Validate: if mode is "host" and destination is a hostname (contains letters),
|
||||
// an alias is required
|
||||
if (data.mode === "host" && isHostname(data.destination)) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
|
||||
if (!currentAlias) {
|
||||
// Prefill alias based on destination
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination.toLowerCase() === "localhost") {
|
||||
// Use resource name cleaned for FQDN with .internal suffix
|
||||
const cleanedName = cleanForFQDN(data.name);
|
||||
aliasValue = `${cleanedName}.internal`;
|
||||
}
|
||||
|
||||
// Update the form with the prefilled alias
|
||||
form.setValue("alias", aliasValue);
|
||||
data.alias = aliasValue;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.put<AxiosResponse<any>>(
|
||||
`/org/${orgId}/site/${data.siteId}/resource`,
|
||||
{
|
||||
@@ -269,6 +410,9 @@ export default function CreateInternalResourceDialog({
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: undefined,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
roleIds: data.roles
|
||||
? data.roles.map((r) => parseInt(r.id))
|
||||
: [],
|
||||
@@ -692,6 +836,163 @@ export default function CreateInternalResourceDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
TCP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
UDP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ICMP Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
ICMP
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(!checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? t("blocked") : t("allowed")}
|
||||
</span>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function CreateRoleForm({
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string({ message: t('nameRequired') }).max(32),
|
||||
name: z.string({ message: t("nameRequired") }).max(32),
|
||||
description: z.string().max(255).optional()
|
||||
});
|
||||
|
||||
@@ -78,10 +78,10 @@ export default function CreateRoleForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorCreate'),
|
||||
title: t("accessRoleErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorCreateDescription')
|
||||
t("accessRoleErrorCreateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -89,8 +89,8 @@ export default function CreateRoleForm({
|
||||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('accessRoleCreated'),
|
||||
description: t('accessRoleCreatedDescription')
|
||||
title: t("accessRoleCreated"),
|
||||
description: t("accessRoleCreatedDescription")
|
||||
});
|
||||
|
||||
if (open) {
|
||||
@@ -117,9 +117,9 @@ export default function CreateRoleForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('accessRoleCreate')}</CredenzaTitle>
|
||||
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('accessRoleCreateDescription')}
|
||||
{t("accessRoleCreateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -134,7 +134,9 @@ export default function CreateRoleForm({
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('accessRoleName')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("accessRoleName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -147,7 +149,9 @@ export default function CreateRoleForm({
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("description")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -160,7 +164,7 @@ export default function CreateRoleForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -168,7 +172,7 @@ export default function CreateRoleForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('accessRoleCreateSubmit')}
|
||||
{t("accessRoleCreateSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
} from "@app/components/ui/collapsible";
|
||||
import AccessTokenSection from "@app/components/AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from 'punycode';
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
@@ -104,7 +104,7 @@ export default function CreateShareLinkForm({
|
||||
>([]);
|
||||
|
||||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: t('shareErrorSelectResource') }),
|
||||
resourceId: z.number({ message: t("shareErrorSelectResource") }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
@@ -113,12 +113,12 @@ export default function CreateShareLinkForm({
|
||||
});
|
||||
|
||||
const timeUnits = [
|
||||
{ unit: "minutes", name: t('minutes') },
|
||||
{ unit: "hours", name: t('hours') },
|
||||
{ unit: "days", name: t('days') },
|
||||
{ unit: "weeks", name: t('weeks') },
|
||||
{ unit: "months", name: t('months') },
|
||||
{ unit: "years", name: t('years') }
|
||||
{ unit: "minutes", name: t("minutes") },
|
||||
{ unit: "hours", name: t("hours") },
|
||||
{ unit: "days", name: t("days") },
|
||||
{ unit: "weeks", name: t("weeks") },
|
||||
{ unit: "months", name: t("months") },
|
||||
{ unit: "years", name: t("years") }
|
||||
];
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -144,10 +144,10 @@ export default function CreateShareLinkForm({
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('shareErrorFetchResource'),
|
||||
title: t("shareErrorFetchResource"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('shareErrorFetchResourceDescription')
|
||||
t("shareErrorFetchResourceDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -204,17 +204,21 @@ export default function CreateShareLinkForm({
|
||||
validForSeconds: neverExpire ? undefined : timeInSeconds,
|
||||
title:
|
||||
values.title ||
|
||||
t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)})
|
||||
t("shareLink", {
|
||||
resource:
|
||||
values.resourceName ||
|
||||
"Resource" + values.resourceId
|
||||
})
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('shareErrorCreate'),
|
||||
title: t("shareErrorCreate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('shareErrorCreateDescription')
|
||||
t("shareErrorCreateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -263,9 +267,9 @@ export default function CreateShareLinkForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
|
||||
<CredenzaTitle>{t("shareCreate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('shareCreateDescription')}
|
||||
{t("shareCreateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -283,7 +287,7 @@ export default function CreateShareLinkForm({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
{t('resource')}
|
||||
{t("resource")}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -301,17 +305,25 @@ export default function CreateShareLinkForm({
|
||||
? getSelectedResourceName(
|
||||
field.value
|
||||
)
|
||||
: t('resourceSelect')}
|
||||
: t(
|
||||
"resourceSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={t('resourceSearch')} />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"resourceSearch"
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{t('resourcesNotFound')}
|
||||
{t(
|
||||
"resourcesNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resources.map(
|
||||
@@ -367,7 +379,9 @@ export default function CreateShareLinkForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('shareTitleOptional')}
|
||||
{t(
|
||||
"shareTitleOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -379,7 +393,9 @@ export default function CreateShareLinkForm({
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t('expireIn')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("expireIn")}
|
||||
</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -391,11 +407,17 @@ export default function CreateShareLinkForm({
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={field.value.toString()}
|
||||
disabled={neverExpire}
|
||||
disabled={
|
||||
neverExpire
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('selectDuration')} />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectDuration"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -458,12 +480,12 @@ export default function CreateShareLinkForm({
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t('neverExpire')}
|
||||
{t("neverExpire")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('shareExpireDescription')}
|
||||
{t("shareExpireDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
@@ -471,16 +493,15 @@ export default function CreateShareLinkForm({
|
||||
)}
|
||||
{link && (
|
||||
<div className="max-w-md space-y-4">
|
||||
<p>
|
||||
{t('shareSeeOnce')}
|
||||
</p>
|
||||
<p>
|
||||
{t('shareAccessHint')}
|
||||
</p>
|
||||
<p>{t("shareSeeOnce")}</p>
|
||||
<p>{t("shareAccessHint")}</p>
|
||||
|
||||
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
|
||||
<div className="bg-white p-6 border rounded-md">
|
||||
<QRCodeCanvas value={link} size={200} />
|
||||
<QRCodeCanvas
|
||||
value={link}
|
||||
size={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -503,12 +524,12 @@ export default function CreateShareLinkForm({
|
||||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t('shareTokenUsage')}
|
||||
{t("shareTokenUsage")}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t('toggle')}
|
||||
{t("toggle")}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -538,7 +559,7 @@ export default function CreateShareLinkForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -546,7 +567,7 @@ export default function CreateShareLinkForm({
|
||||
loading={loading}
|
||||
disabled={link !== null || loading}
|
||||
>
|
||||
{t('createLink')}
|
||||
{t("createLink")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -78,7 +78,10 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
|
||||
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
|
||||
|
||||
return (
|
||||
<CredenzaClose className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)} {...props}>
|
||||
<CredenzaClose
|
||||
className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CredenzaClose>
|
||||
);
|
||||
@@ -128,7 +131,7 @@ const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
|
||||
const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader;
|
||||
|
||||
return (
|
||||
<CredenzaHeader className={className} {...props}>
|
||||
<CredenzaHeader className={cn("-mx-6 px-6", className)} {...props}>
|
||||
{children}
|
||||
</CredenzaHeader>
|
||||
);
|
||||
@@ -155,7 +158,13 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
||||
// );
|
||||
|
||||
return (
|
||||
<div className={cn("px-0 mb-4 space-y-4 overflow-x-hidden min-w-0", className)} {...props}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-0 mb-4 space-y-4 overflow-x-hidden min-w-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -168,7 +177,13 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
||||
|
||||
return (
|
||||
<CredenzaFooter className={cn("mt-8 md:mt-0", className)} {...props}>
|
||||
<CredenzaFooter
|
||||
className={cn(
|
||||
"mt-8 md:mt-0 -mx-6 px-6 pt-4 border-t border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CredenzaFooter>
|
||||
);
|
||||
|
||||
@@ -21,10 +21,7 @@ type Props = {
|
||||
type: string | null;
|
||||
};
|
||||
|
||||
export default function DNSRecordsTable({
|
||||
records,
|
||||
type
|
||||
}: Props) {
|
||||
export default function DNSRecordsTable({ records, type }: Props) {
|
||||
const t = useTranslations();
|
||||
const env = useEnvContext();
|
||||
|
||||
@@ -114,11 +111,5 @@ export default function DNSRecordsTable({
|
||||
...(env.env.flags.usePangolinDns ? [statusColumn] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<DNSRecordsDataTable
|
||||
columns={columns}
|
||||
data={records}
|
||||
type={type}
|
||||
/>
|
||||
);
|
||||
return <DNSRecordsDataTable columns={columns} data={records} type={type} />;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,11 @@ export function DNSRecordsDataTable<TData, TValue>({
|
||||
<h1 className="font-bold">{t("dnsRecord")}</h1>
|
||||
<Badge variant="secondary">{t("required")}</Badge>
|
||||
</div>
|
||||
<Link href="https://docs.pangolin.net/manage/domains" target="_blank" rel="noopener noreferrer">
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/domains"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="outline">
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
{t("howToAddRecords")}
|
||||
@@ -122,9 +126,7 @@ export function DNSRecordsDataTable<TData, TValue>({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
key={headerGroup.id}
|
||||
>
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
|
||||
@@ -35,6 +35,9 @@ export default function DashboardLoginForm({
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
function getSubtitle() {
|
||||
if (isUnlocked() && env.branding?.loginPage?.subtitleText) {
|
||||
return env.branding.loginPage.subtitleText;
|
||||
}
|
||||
return t("loginStart");
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ interface DataTablePaginationProps<TData> {
|
||||
isServerPagination?: boolean;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
pageSize?: number;
|
||||
pageIndex?: number;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
@@ -33,60 +35,84 @@ export function DataTablePagination<TData>({
|
||||
totalCount,
|
||||
isServerPagination = false,
|
||||
isLoading = false,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
pageSize: controlledPageSize,
|
||||
pageIndex: controlledPageIndex
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const t = useTranslations();
|
||||
|
||||
// Use controlled values if provided, otherwise fall back to table state
|
||||
const pageSize = controlledPageSize ?? table.getState().pagination.pageSize;
|
||||
const pageIndex =
|
||||
controlledPageIndex ?? table.getState().pagination.pageIndex;
|
||||
|
||||
// Calculate page boundaries based on controlled state
|
||||
// For server-side pagination, use totalCount if available for accurate page count
|
||||
const pageCount =
|
||||
isServerPagination && totalCount !== undefined
|
||||
? Math.ceil(totalCount / pageSize)
|
||||
: table.getPageCount();
|
||||
const canNextPage = pageIndex < pageCount - 1;
|
||||
const canPreviousPage = pageIndex > 0;
|
||||
|
||||
const handlePageSizeChange = (value: string) => {
|
||||
const newPageSize = Number(value);
|
||||
table.setPageSize(newPageSize);
|
||||
|
||||
|
||||
// Call the callback if provided (for persistence)
|
||||
if (onPageSizeChange) {
|
||||
onPageSizeChange(newPageSize);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageNavigation = (action: 'first' | 'previous' | 'next' | 'last') => {
|
||||
const handlePageNavigation = (
|
||||
action: "first" | "previous" | "next" | "last"
|
||||
) => {
|
||||
if (isServerPagination && onPageChange) {
|
||||
const currentPage = table.getState().pagination.pageIndex;
|
||||
const currentPage = pageIndex;
|
||||
const pageCount = table.getPageCount();
|
||||
|
||||
|
||||
let newPage: number;
|
||||
switch (action) {
|
||||
case 'first':
|
||||
case "first":
|
||||
newPage = 0;
|
||||
break;
|
||||
case 'previous':
|
||||
case "previous":
|
||||
newPage = Math.max(0, currentPage - 1);
|
||||
break;
|
||||
case 'next':
|
||||
case "next":
|
||||
newPage = Math.min(pageCount - 1, currentPage + 1);
|
||||
break;
|
||||
case 'last':
|
||||
case "last":
|
||||
newPage = pageCount - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (newPage !== currentPage) {
|
||||
onPageChange(newPage);
|
||||
}
|
||||
} else {
|
||||
// Use table's built-in navigation for client-side pagination
|
||||
// But add bounds checking to prevent going beyond page boundaries
|
||||
const pageCount = table.getPageCount();
|
||||
switch (action) {
|
||||
case 'first':
|
||||
case "first":
|
||||
table.setPageIndex(0);
|
||||
break;
|
||||
case 'previous':
|
||||
table.previousPage();
|
||||
case "previous":
|
||||
if (pageIndex > 0) {
|
||||
table.previousPage();
|
||||
}
|
||||
break;
|
||||
case 'next':
|
||||
table.nextPage();
|
||||
case "next":
|
||||
if (pageIndex < pageCount - 1) {
|
||||
table.nextPage();
|
||||
}
|
||||
break;
|
||||
case 'last':
|
||||
table.setPageIndex(table.getPageCount() - 1);
|
||||
case "last":
|
||||
table.setPageIndex(Math.max(0, pageCount - 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -96,14 +122,12 @@ export function DataTablePagination<TData>({
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
value={`${pageSize}`}
|
||||
onValueChange={handlePageSizeChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[73px]" disabled={disabled}>
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="bottom">
|
||||
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
|
||||
@@ -117,50 +141,53 @@ export function DataTablePagination<TData>({
|
||||
|
||||
<div className="flex items-center space-x-3 lg:space-x-8">
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
{isServerPagination && totalCount !== undefined ? (
|
||||
t('paginator', {
|
||||
current: table.getState().pagination.pageIndex + 1,
|
||||
last: Math.ceil(totalCount / table.getState().pagination.pageSize)
|
||||
})
|
||||
) : (
|
||||
t('paginator', {current: table.getState().pagination.pageIndex + 1, last: table.getPageCount()})
|
||||
)}
|
||||
{isServerPagination && totalCount !== undefined
|
||||
? t("paginator", {
|
||||
current: pageIndex + 1,
|
||||
last: Math.ceil(totalCount / pageSize)
|
||||
})
|
||||
: t("paginator", {
|
||||
current: pageIndex + 1,
|
||||
last: table.getPageCount()
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => handlePageNavigation('first')}
|
||||
disabled={!table.getCanPreviousPage() || isLoading || disabled}
|
||||
onClick={() => handlePageNavigation("first")}
|
||||
disabled={!canPreviousPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToFirst')}</span>
|
||||
<span className="sr-only">{t("paginatorToFirst")}</span>
|
||||
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handlePageNavigation('previous')}
|
||||
disabled={!table.getCanPreviousPage() || isLoading || disabled}
|
||||
onClick={() => handlePageNavigation("previous")}
|
||||
disabled={!canPreviousPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToPrevious')}</span>
|
||||
<span className="sr-only">
|
||||
{t("paginatorToPrevious")}
|
||||
</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handlePageNavigation('next')}
|
||||
disabled={!table.getCanNextPage() || isLoading || disabled}
|
||||
onClick={() => handlePageNavigation("next")}
|
||||
disabled={!canNextPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToNext')}</span>
|
||||
<span className="sr-only">{t("paginatorToNext")}</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => handlePageNavigation('last')}
|
||||
disabled={!table.getCanNextPage() || isLoading || disabled}
|
||||
onClick={() => handlePageNavigation("last")}
|
||||
disabled={!canNextPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t('paginatorToLast')}</span>
|
||||
<span className="sr-only">{t("paginatorToLast")}</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function DeleteRoleForm({
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const formSchema = z.object({
|
||||
newRoleId: z.string({ message: t('accessRoleErrorNewRequired') })
|
||||
newRoleId: z.string({ message: t("accessRoleErrorNewRequired") })
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,10 +75,10 @@ export default function DeleteRoleForm({
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
title: t("accessRoleErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorFetchDescription')
|
||||
t("accessRoleErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -114,10 +114,10 @@ export default function DeleteRoleForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('accessRoleErrorRemove'),
|
||||
title: t("accessRoleErrorRemove"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('accessRoleErrorRemoveDescription')
|
||||
t("accessRoleErrorRemoveDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
@@ -125,8 +125,8 @@ export default function DeleteRoleForm({
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('accessRoleRemoved'),
|
||||
description: t('accessRoleRemovedDescription')
|
||||
title: t("accessRoleRemoved"),
|
||||
description: t("accessRoleRemovedDescription")
|
||||
});
|
||||
|
||||
if (open) {
|
||||
@@ -153,66 +153,66 @@ export default function DeleteRoleForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('accessRoleRemove')}</CredenzaTitle>
|
||||
<CredenzaTitle>{t("accessRoleRemove")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('accessRoleRemoveDescription')}
|
||||
{t("accessRoleRemoveDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
|
||||
</p>
|
||||
<p>
|
||||
{t('accessRoleRequiredRemove')}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="remove-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newRoleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('role')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
{t("accessRoleQuestionRemove", {
|
||||
name: roleToDelete.name
|
||||
})}
|
||||
</p>
|
||||
<p>{t("accessRoleRequiredRemove")}</p>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="remove-role-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newRoleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"accessRoleSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem
|
||||
key={role.roleId}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -221,7 +221,7 @@ export default function DeleteRoleForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('accessRoleRemoveSubmit')}
|
||||
{t("accessRoleRemoveSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -71,7 +71,9 @@ export function DeviceAuthConfirmation({
|
||||
<BrandingLogo height={logoHeight} width={logoWidth} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{t("deviceActivation")}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("deviceActivation")}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
@@ -93,7 +95,9 @@ export function DeviceAuthConfirmation({
|
||||
</p>
|
||||
{metadata.deviceName && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t("deviceLabel", { deviceName: metadata.deviceName })}
|
||||
{t("deviceLabel", {
|
||||
deviceName: metadata.deviceName
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@@ -103,7 +107,9 @@ export function DeviceAuthConfirmation({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<p className="text-sm font-medium">{t("deviceExistingAccess")}</p>
|
||||
<p className="text-sm font-medium">
|
||||
{t("deviceExistingAccess")}
|
||||
</p>
|
||||
<div className="space-y-1 pl-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
@@ -111,9 +117,7 @@ export function DeviceAuthConfirmation({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>
|
||||
{t("deviceOrganizationsAccess")}
|
||||
</span>
|
||||
<span>{t("deviceOrganizationsAccess")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +140,9 @@ export function DeviceAuthConfirmation({
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t("deviceAuthorize", { applicationName: metadata.applicationName })}
|
||||
{t("deviceAuthorize", {
|
||||
applicationName: metadata.applicationName
|
||||
})}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -51,8 +51,8 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const disableSchema = z.object({
|
||||
password: z.string().min(1, { message: t('passwordRequired') }),
|
||||
code: z.string().min(1, { message: t('verificationCodeRequired') })
|
||||
password: z.string().min(1, { message: t("passwordRequired") }),
|
||||
code: z.string().min(1, { message: t("verificationCodeRequired") })
|
||||
});
|
||||
|
||||
const disableForm = useForm<z.infer<typeof disableSchema>>({
|
||||
@@ -73,10 +73,10 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||
} as Disable2faBody)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
title: t('otpErrorDisable'),
|
||||
title: t("otpErrorDisable"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('otpErrorDisableDescription')
|
||||
t("otpErrorDisableDescription")
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
@@ -111,11 +111,9 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t('otpRemove')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaTitle>{t("otpRemove")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('otpRemoveDescription')}
|
||||
{t("otpRemoveDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -132,7 +130,9 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -150,7 +150,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('otpSetupSecretCode')}
|
||||
{t("otpSetupSecretCode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -171,17 +171,15 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||
size={48}
|
||||
/>
|
||||
<p className="font-semibold text-lg">
|
||||
{t('otpRemoveSuccess')}
|
||||
</p>
|
||||
<p>
|
||||
{t('otpRemoveSuccessMessage')}
|
||||
{t("otpRemoveSuccess")}
|
||||
</p>
|
||||
<p>{t("otpRemoveSuccessMessage")}</p>
|
||||
</div>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
{step === "password" && (
|
||||
<Button
|
||||
@@ -190,7 +188,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('otpRemoveSubmit')}
|
||||
{t("otpRemoveSubmit")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -13,45 +11,40 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Building2,
|
||||
Zap,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
ArrowUpDown
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { createApiClient, formatAxiosError } from "@/lib/api";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useEnvContext } from "@/hooks/useEnvContext";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
|
||||
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { createApiClient } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { build } from "@server/build";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
sanitizeInputRaw,
|
||||
finalizeSubdomainSanitize,
|
||||
validateByDomainType,
|
||||
isValidSubdomainStructure
|
||||
isValidSubdomainStructure,
|
||||
sanitizeInputRaw,
|
||||
validateByDomainType
|
||||
} from "@/lib/subdomain-utils";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building2,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toUnicode } from "punycode";
|
||||
|
||||
type OrganizationDomain = {
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
verified: boolean;
|
||||
type: "ns" | "cname" | "wildcard";
|
||||
};
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
type AvailableOption = {
|
||||
domainNamespaceId: string;
|
||||
@@ -69,128 +62,146 @@ type DomainOption = {
|
||||
domainNamespaceId?: string;
|
||||
};
|
||||
|
||||
interface DomainPicker2Props {
|
||||
interface DomainPickerProps {
|
||||
orgId: string;
|
||||
onDomainChange?: (domainInfo: {
|
||||
domainId: string;
|
||||
domainNamespaceId?: string;
|
||||
type: "organization" | "provided";
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
}) => void;
|
||||
onDomainChange?: (
|
||||
domainInfo: {
|
||||
domainId: string;
|
||||
domainNamespaceId?: string;
|
||||
type: "organization" | "provided";
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
} | null
|
||||
) => void;
|
||||
cols?: number;
|
||||
hideFreeDomain?: boolean;
|
||||
defaultFullDomain?: string | null;
|
||||
defaultSubdomain?: string | null;
|
||||
defaultDomainId?: string | null;
|
||||
}
|
||||
|
||||
export default function DomainPicker2({
|
||||
export default function DomainPicker({
|
||||
orgId,
|
||||
onDomainChange,
|
||||
cols = 2,
|
||||
hideFreeDomain = false
|
||||
}: DomainPicker2Props) {
|
||||
hideFreeDomain = false,
|
||||
defaultSubdomain,
|
||||
defaultFullDomain,
|
||||
defaultDomainId
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||
orgQueries.domains({ orgId })
|
||||
);
|
||||
|
||||
if (!env.flags.usePangolinDns) {
|
||||
hideFreeDomain = true;
|
||||
}
|
||||
|
||||
const [subdomainInput, setSubdomainInput] = useState<string>("");
|
||||
const [subdomainInput, setSubdomainInput] = useState(
|
||||
defaultSubdomain ?? ""
|
||||
);
|
||||
|
||||
const [selectedBaseDomain, setSelectedBaseDomain] =
|
||||
useState<DomainOption | null>(null);
|
||||
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
|
||||
[]
|
||||
);
|
||||
const [organizationDomains, setOrganizationDomains] = useState<
|
||||
OrganizationDomain[]
|
||||
>([]);
|
||||
const [loadingDomains, setLoadingDomains] = useState(false);
|
||||
|
||||
// memoized to prevent reruning the effect that selects the initial domain indefinitely
|
||||
// removing this will break and cause an infinite rerender
|
||||
const organizationDomains = useMemo(() => {
|
||||
return data
|
||||
.filter(
|
||||
(domain) =>
|
||||
domain.type === "ns" ||
|
||||
domain.type === "cname" ||
|
||||
domain.type === "wildcard"
|
||||
)
|
||||
.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain),
|
||||
type: domain.type as "ns" | "cname" | "wildcard"
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Provided domain search states
|
||||
const [userInput, setUserInput] = useState<string>("");
|
||||
const [userInput, setUserInput] = useState<string>(defaultSubdomain ?? "");
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
|
||||
const [selectedProvidedDomain, setSelectedProvidedDomain] =
|
||||
useState<AvailableOption | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrganizationDomains = async () => {
|
||||
setLoadingDomains(true);
|
||||
try {
|
||||
const response = await api.get<
|
||||
AxiosResponse<ListDomainsResponse>
|
||||
>(`/org/${orgId}/domains`);
|
||||
if (response.status === 200) {
|
||||
const domains = response.data.data.domains
|
||||
.filter(
|
||||
(domain) =>
|
||||
domain.type === "ns" ||
|
||||
domain.type === "cname" ||
|
||||
domain.type === "wildcard"
|
||||
)
|
||||
.map((domain) => ({
|
||||
...domain,
|
||||
baseDomain: toUnicode(domain.baseDomain),
|
||||
type: domain.type as "ns" | "cname" | "wildcard"
|
||||
}));
|
||||
setOrganizationDomains(domains);
|
||||
|
||||
// Auto-select first available domain
|
||||
if (domains.length > 0) {
|
||||
// Select the first organization domain
|
||||
const firstOrgDomain = domains[0];
|
||||
const domainOption: DomainOption = {
|
||||
id: `org-${firstOrgDomain.domainId}`,
|
||||
domain: firstOrgDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified: firstOrgDomain.verified,
|
||||
domainType: firstOrgDomain.type,
|
||||
domainId: firstOrgDomain.domainId
|
||||
};
|
||||
setSelectedBaseDomain(domainOption);
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: firstOrgDomain.domainId,
|
||||
type: "organization",
|
||||
subdomain: undefined,
|
||||
fullDomain: firstOrgDomain.baseDomain,
|
||||
baseDomain: firstOrgDomain.baseDomain
|
||||
});
|
||||
} else if (
|
||||
(build === "saas" || build === "enterprise") &&
|
||||
!hideFreeDomain
|
||||
) {
|
||||
// If no organization domains, select the provided domain option
|
||||
const domainOptionText =
|
||||
build === "enterprise"
|
||||
? t("domainPickerProvidedDomain")
|
||||
: t("domainPickerFreeProvidedDomain");
|
||||
const freeDomainOption: DomainOption = {
|
||||
id: "provided-search",
|
||||
domain: domainOptionText,
|
||||
type: "provided-search"
|
||||
};
|
||||
setSelectedBaseDomain(freeDomainOption);
|
||||
}
|
||||
if (!loadingDomains) {
|
||||
let domainOptionToSelect: DomainOption | null = null;
|
||||
if (organizationDomains.length > 0) {
|
||||
// Select the first organization domain or the one provided from props
|
||||
let firstOrExistingDomain = organizationDomains.find(
|
||||
(domain) => domain.domainId === defaultDomainId
|
||||
);
|
||||
// if no default Domain
|
||||
if (!defaultDomainId) {
|
||||
firstOrExistingDomain = organizationDomains[0];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load organization domains:", error);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("domainPickerError"),
|
||||
description: t("domainPickerErrorLoadDomains")
|
||||
});
|
||||
} finally {
|
||||
setLoadingDomains(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrganizationDomains();
|
||||
}, [orgId, api, hideFreeDomain]);
|
||||
if (firstOrExistingDomain) {
|
||||
domainOptionToSelect = {
|
||||
id: `org-${firstOrExistingDomain.domainId}`,
|
||||
domain: firstOrExistingDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified: firstOrExistingDomain.verified,
|
||||
domainType: firstOrExistingDomain.type,
|
||||
domainId: firstOrExistingDomain.domainId
|
||||
};
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: firstOrExistingDomain.domainId,
|
||||
type: "organization",
|
||||
subdomain:
|
||||
firstOrExistingDomain.type !== "cname"
|
||||
? defaultSubdomain || undefined
|
||||
: undefined,
|
||||
fullDomain: firstOrExistingDomain.baseDomain,
|
||||
baseDomain: firstOrExistingDomain.baseDomain
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!domainOptionToSelect &&
|
||||
build !== "oss" &&
|
||||
!hideFreeDomain &&
|
||||
defaultDomainId !== undefined
|
||||
) {
|
||||
// If no organization domains, select the provided domain option
|
||||
const domainOptionText =
|
||||
build === "enterprise"
|
||||
? t("domainPickerProvidedDomain")
|
||||
: t("domainPickerFreeProvidedDomain");
|
||||
// free domain option
|
||||
domainOptionToSelect = {
|
||||
id: "provided-search",
|
||||
domain: domainOptionText,
|
||||
type: "provided-search"
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedBaseDomain(domainOptionToSelect);
|
||||
}
|
||||
}, [
|
||||
loadingDomains,
|
||||
organizationDomains,
|
||||
defaultSubdomain,
|
||||
hideFreeDomain,
|
||||
defaultDomainId
|
||||
]);
|
||||
|
||||
const checkAvailability = useCallback(
|
||||
async (input: string) => {
|
||||
@@ -256,37 +267,6 @@ export default function DomainPicker2({
|
||||
}
|
||||
}, [userInput, debouncedCheckAvailability, selectedBaseDomain]);
|
||||
|
||||
const generateDropdownOptions = (): DomainOption[] => {
|
||||
const options: DomainOption[] = [];
|
||||
|
||||
organizationDomains.forEach((orgDomain) => {
|
||||
options.push({
|
||||
id: `org-${orgDomain.domainId}`,
|
||||
domain: orgDomain.baseDomain,
|
||||
type: "organization",
|
||||
verified: orgDomain.verified,
|
||||
domainType: orgDomain.type,
|
||||
domainId: orgDomain.domainId
|
||||
});
|
||||
});
|
||||
|
||||
if ((build === "saas" || build === "enterprise") && !hideFreeDomain) {
|
||||
const domainOptionText =
|
||||
build === "enterprise"
|
||||
? t("domainPickerProvidedDomain")
|
||||
: t("domainPickerFreeProvidedDomain");
|
||||
options.push({
|
||||
id: "provided-search",
|
||||
domain: domainOptionText,
|
||||
type: "provided-search"
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const dropdownOptions = generateDropdownOptions();
|
||||
|
||||
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
||||
const sanitized = finalizeSubdomainSanitize(sub);
|
||||
|
||||
@@ -383,6 +363,9 @@ export default function DomainPicker2({
|
||||
setSelectedProvidedDomain(null);
|
||||
}
|
||||
|
||||
console.log({
|
||||
setSelectedBaseDomain: option
|
||||
});
|
||||
setSelectedBaseDomain(option);
|
||||
setOpen(false);
|
||||
|
||||
@@ -393,15 +376,21 @@ export default function DomainPicker2({
|
||||
|
||||
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId || "",
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type:
|
||||
option.type === "provided-search" ? "provided" : "organization",
|
||||
subdomain: sub || undefined,
|
||||
fullDomain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
if (option.type === "provided-search") {
|
||||
onDomainChange?.(null); // prevent the modal from closing with `<subdomain>.Free Provided domain`
|
||||
} else {
|
||||
onDomainChange?.({
|
||||
domainId: option.domainId || "",
|
||||
domainNamespaceId: option.domainNamespaceId,
|
||||
type: "organization",
|
||||
subdomain:
|
||||
option.domainType !== "cname"
|
||||
? sub || undefined
|
||||
: undefined,
|
||||
fullDomain,
|
||||
baseDomain: option.domain
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleProvidedDomainSelect = (option: AvailableOption) => {
|
||||
@@ -440,14 +429,22 @@ export default function DomainPicker2({
|
||||
selectedBaseDomain?.type === "provided-search";
|
||||
|
||||
const sortedAvailableOptions = [...availableOptions].sort((a, b) => {
|
||||
const comparison = a.fullDomain.localeCompare(b.fullDomain);
|
||||
return sortOrder === "asc" ? comparison : -comparison;
|
||||
return a.fullDomain.localeCompare(b.fullDomain);
|
||||
});
|
||||
|
||||
const displayedProvidedOptions = sortedAvailableOptions.slice(
|
||||
0,
|
||||
providedDomainsShown
|
||||
);
|
||||
console.log({
|
||||
displayedProvidedOptions
|
||||
});
|
||||
|
||||
const selectedDomainNamespaceId =
|
||||
selectedProvidedDomain?.domainNamespaceId ??
|
||||
displayedProvidedOptions.find(
|
||||
(opt) => opt.fullDomain === defaultFullDomain
|
||||
)?.domainNamespaceId;
|
||||
const hasMoreProvided =
|
||||
sortedAvailableOptions.length > providedDomainsShown;
|
||||
|
||||
@@ -495,16 +492,6 @@ export default function DomainPicker2({
|
||||
{t("domainPickerInvalidSubdomainStructure")}
|
||||
</p>
|
||||
)}
|
||||
{showSubdomainInput && !subdomainInput && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domainPickerEnterSubdomainOrLeaveBlank")}
|
||||
</p>
|
||||
)}
|
||||
{showProvidedDomainSearch && !userInput && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domainPickerEnterSubdomainToSearch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -518,7 +505,7 @@ export default function DomainPicker2({
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedBaseDomain ? (
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-x-2 min-w-0 flex-1">
|
||||
{selectedBaseDomain.type ===
|
||||
"organization" ? null : (
|
||||
<Zap className="h-4 w-4 shrink-0" />
|
||||
@@ -733,10 +720,8 @@ export default function DomainPicker2({
|
||||
{!isChecking && sortedAvailableOptions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<RadioGroup
|
||||
value={
|
||||
selectedProvidedDomain?.domainNamespaceId ||
|
||||
""
|
||||
}
|
||||
value={selectedDomainNamespaceId || ""}
|
||||
defaultValue={selectedDomainNamespaceId}
|
||||
onValueChange={(value) => {
|
||||
const option =
|
||||
displayedProvidedOptions.find(
|
||||
@@ -753,47 +738,50 @@ export default function DomainPicker2({
|
||||
}}
|
||||
className="grid gap-2 grid-cols-1 sm:grid-cols-(--cols)"
|
||||
>
|
||||
{displayedProvidedOptions.map((option) => (
|
||||
<label
|
||||
key={option.domainNamespaceId}
|
||||
htmlFor={option.domainNamespaceId}
|
||||
data-state={
|
||||
selectedProvidedDomain?.domainNamespaceId ===
|
||||
option.domainNamespaceId
|
||||
? "checked"
|
||||
: "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex rounded-lg border p-3 transition-colors cursor-pointer",
|
||||
selectedProvidedDomain?.domainNamespaceId ===
|
||||
option.domainNamespaceId
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.domainNamespaceId}
|
||||
id={option.domainNamespaceId}
|
||||
className="absolute left-3 top-3 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="flex items-center justify-between pl-7 flex-1">
|
||||
<div>
|
||||
<p className="font-mono text-sm">
|
||||
{option.fullDomain}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"domainPickerNamespace",
|
||||
{
|
||||
namespace:
|
||||
option.domainNamespaceId
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
{displayedProvidedOptions.map((option) => {
|
||||
const isSelected =
|
||||
selectedDomainNamespaceId ===
|
||||
option.domainNamespaceId;
|
||||
return (
|
||||
<label
|
||||
key={option.domainNamespaceId}
|
||||
htmlFor={option.domainNamespaceId}
|
||||
data-state={
|
||||
isSelected
|
||||
? "checked"
|
||||
: "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex rounded-lg border p-3 transition-colors cursor-pointer",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-input hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.domainNamespaceId}
|
||||
id={option.domainNamespaceId}
|
||||
className="absolute left-3 top-3 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="flex items-center justify-between pl-7 flex-1">
|
||||
<div>
|
||||
<p className="font-mono text-sm">
|
||||
{option.fullDomain}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"domainPickerNamespace",
|
||||
{
|
||||
namespace:
|
||||
option.domainNamespaceId
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
{hasMoreProvided && (
|
||||
<Button
|
||||
@@ -813,15 +801,6 @@ export default function DomainPicker2({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingDomains && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
<span>{t("domainPickerLoadingDomains")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
||||
setSelectedDomain(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("domainQuestionRemove")}</p>
|
||||
<p>{t("domainMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -36,17 +37,86 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles";
|
||||
import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers";
|
||||
import { ListSiteResourceClientsResponse } from "@server/routers/siteResource/listSiteResourceClients";
|
||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
// import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Helper to validate port range string format
|
||||
const isValidPortRangeString = (val: string | undefined | null): boolean => {
|
||||
if (!val || val.trim() === "" || val.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = val.split(",").map((p) => p.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
if (!start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
|
||||
if (isNaN(startPort) || isNaN(endPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort > endPort) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const port = parseInt(part, 10);
|
||||
if (isNaN(port)) {
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Port range string schema for client-side validation
|
||||
const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => isValidPortRangeString(val),
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to determine the port mode from a port range string
|
||||
type PortMode = "all" | "blocked" | "custom";
|
||||
const getPortModeFromString = (val: string | undefined | null): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
// Helper to get the port string for API from mode and custom value
|
||||
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
type InternalResourceData = {
|
||||
id: number;
|
||||
@@ -61,6 +131,9 @@ type InternalResourceData = {
|
||||
destination: string;
|
||||
// destinationPort?: number | null;
|
||||
alias?: string | null;
|
||||
tcpPortRangeString?: string | null;
|
||||
udpPortRangeString?: string | null;
|
||||
disableIcmp?: boolean;
|
||||
};
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
@@ -94,6 +167,9 @@ export default function EditInternalResourceDialog({
|
||||
destination: z.string().min(1),
|
||||
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -255,6 +331,24 @@ export default function EditInternalResourceDialog({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Port restriction UI state
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.tcpPortRangeString)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.udpPortRangeString)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -265,6 +359,9 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
@@ -273,9 +370,55 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
const mode = form.watch("mode");
|
||||
|
||||
// Update form values when port mode or custom ports change
|
||||
useEffect(() => {
|
||||
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
|
||||
form.setValue("tcpPortRangeString", tcpValue);
|
||||
}, [tcpPortMode, tcpCustomPorts, form]);
|
||||
|
||||
useEffect(() => {
|
||||
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
|
||||
form.setValue("udpPortRangeString", udpValue);
|
||||
}, [udpPortMode, udpCustomPorts, form]);
|
||||
|
||||
// Helper function to check if destination contains letters (hostname vs IP)
|
||||
const isHostname = (destination: string): boolean => {
|
||||
return /[a-zA-Z]/.test(destination);
|
||||
};
|
||||
|
||||
// Helper function to clean resource name for FQDN format
|
||||
const cleanForFQDN = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]/g, "-") // Replace invalid chars with hyphens
|
||||
.replace(/[-]+/g, "-") // Replace multiple hyphens with single hyphen
|
||||
.replace(/^-|-$/g, "") // Remove leading/trailing hyphens
|
||||
.replace(/^\.|\.$/g, ""); // Remove leading/trailing dots
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Validate: if mode is "host" and destination is a hostname (contains letters),
|
||||
// an alias is required
|
||||
if (data.mode === "host" && isHostname(data.destination)) {
|
||||
const currentAlias = data.alias?.trim() || "";
|
||||
|
||||
if (!currentAlias) {
|
||||
// Prefill alias based on destination
|
||||
let aliasValue = data.destination;
|
||||
if (data.destination.toLowerCase() === "localhost") {
|
||||
// Use resource name cleaned for FQDN with .internal suffix
|
||||
const cleanedName = cleanForFQDN(data.name);
|
||||
aliasValue = `${cleanedName}.internal`;
|
||||
}
|
||||
|
||||
// Update the form with the prefilled alias
|
||||
form.setValue("alias", aliasValue);
|
||||
data.alias = aliasValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the site resource
|
||||
await api.post(
|
||||
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,
|
||||
@@ -292,6 +435,9 @@ export default function EditInternalResourceDialog({
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
@@ -354,23 +500,47 @@ export default function EditInternalResourceDialog({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const resourceChanged = previousResourceId.current !== resource.id;
|
||||
|
||||
|
||||
if (resourceChanged) {
|
||||
form.reset({
|
||||
name: resource.name,
|
||||
mode: resource.mode || "host",
|
||||
destination: resource.destination || "",
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
previousResourceId.current = resource.id;
|
||||
}
|
||||
|
||||
|
||||
hasInitialized.current = false;
|
||||
}
|
||||
}, [open, resource.id, resource.name, resource.mode, resource.destination, resource.alias, form]);
|
||||
}, [
|
||||
open,
|
||||
resource.id,
|
||||
resource.name,
|
||||
resource.mode,
|
||||
resource.destination,
|
||||
resource.alias,
|
||||
form
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !loadingRolesUsers && !hasInitialized.current) {
|
||||
@@ -395,10 +565,26 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
// Reset previous resource ID to ensure clean state on next open
|
||||
previousResourceId.current = null;
|
||||
}
|
||||
@@ -631,6 +817,163 @@ export default function EditInternalResourceDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
TCP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
UDP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ICMP Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
ICMP
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(!checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? t("blocked") : t("allowed")}
|
||||
</span>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
@@ -21,7 +21,10 @@ type Enable2FaDialogProps = {
|
||||
setOpen: (val: boolean) => void;
|
||||
};
|
||||
|
||||
export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps) {
|
||||
export default function Enable2FaDialog({
|
||||
open,
|
||||
setOpen
|
||||
}: Enable2FaDialogProps) {
|
||||
const t = useTranslations();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -49,21 +52,22 @@ export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps)
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t('otpSetup')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaTitle>{t("otpSetup")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('otpSetupDescription')}
|
||||
{t("otpSetupDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<TwoFactorSetupForm
|
||||
ref={formRef}
|
||||
isDialog={true}
|
||||
submitButtonText={t('submit')}
|
||||
submitButtonText={t("submit")}
|
||||
cancelButtonText="Close"
|
||||
showCancelButton={false}
|
||||
onComplete={() => {setOpen(false); updateUser({ twoFactorEnabled: true });}}
|
||||
onComplete={() => {
|
||||
setOpen(false);
|
||||
updateUser({ twoFactorEnabled: true });
|
||||
}}
|
||||
onStepChange={setCurrentStep}
|
||||
onLoadingChange={setLoading}
|
||||
/>
|
||||
@@ -79,11 +83,11 @@ export default function Enable2FaDialog({ open, setOpen }: Enable2FaDialogProps)
|
||||
disabled={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t('submit')}
|
||||
{t("submit")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,24 +21,24 @@ export default function ExitNodeInfoCard({}: ExitNodeInfoCardProps) {
|
||||
<Alert>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{remoteExitNode.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{remoteExitNode.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>{t("online")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>{t("offline")}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("address")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
|
||||
@@ -3,18 +3,17 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
|
||||
interface HeadersInputProps {
|
||||
value?: { name: string, value: string }[] | null;
|
||||
onChange: (value: { name: string, value: string }[] | null) => void;
|
||||
value?: { name: string; value: string }[] | null;
|
||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HeadersInput({
|
||||
value = [],
|
||||
onChange,
|
||||
export function HeadersInput({
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = `X-Example-Header: example-value
|
||||
X-Another-Header: another-value`,
|
||||
rows = 4,
|
||||
@@ -25,34 +24,40 @@ X-Another-Header: another-value`,
|
||||
const isUserEditingRef = useRef(false);
|
||||
|
||||
// Convert header objects array to newline-separated string for display
|
||||
const convertToNewlineSeparated = (headers: { name: string, value: string }[] | null): string => {
|
||||
const convertToNewlineSeparated = (
|
||||
headers: { name: string; value: string }[] | null
|
||||
): string => {
|
||||
if (!headers || headers.length === 0) return "";
|
||||
|
||||
|
||||
return headers
|
||||
.map(header => `${header.name}: ${header.value}`)
|
||||
.join('\n');
|
||||
.map((header) => `${header.name}: ${header.value}`)
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
// Convert newline-separated string to header objects array
|
||||
const convertToHeadersArray = (newlineSeparated: string): { name: string, value: string }[] | null => {
|
||||
const convertToHeadersArray = (
|
||||
newlineSeparated: string
|
||||
): { name: string; value: string }[] | null => {
|
||||
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||
|
||||
|
||||
return newlineSeparated
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && line.includes(':'))
|
||||
.map(line => {
|
||||
const colonIndex = line.indexOf(':');
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && line.includes(":"))
|
||||
.map((line) => {
|
||||
const colonIndex = line.indexOf(":");
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const value = line.substring(colonIndex + 1).trim();
|
||||
|
||||
|
||||
// Ensure header name conforms to HTTP header requirements
|
||||
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
|
||||
const normalizedName = name.replace(/[^a-zA-Z0-9\-]/g, '').toLowerCase();
|
||||
|
||||
const normalizedName = name
|
||||
.replace(/[^a-zA-Z0-9\-]/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
return { name: normalizedName, value };
|
||||
})
|
||||
.filter(header => header.name.length > 0); // Filter out headers with invalid names
|
||||
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||
};
|
||||
|
||||
// Update internal value when external value changes
|
||||
@@ -66,26 +71,28 @@ X-Another-Header: another-value`,
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInternalValue(newValue);
|
||||
|
||||
|
||||
// Mark that user is actively editing
|
||||
isUserEditingRef.current = true;
|
||||
|
||||
|
||||
// Only update parent if the input is in a valid state
|
||||
// Valid states: empty/whitespace only, or contains properly formatted headers
|
||||
|
||||
|
||||
if (newValue.trim() === "") {
|
||||
// Empty input is valid - represents no headers
|
||||
onChange([]);
|
||||
} else {
|
||||
// Check if all non-empty lines are properly formatted (contain ':')
|
||||
const lines = newValue.split('\n');
|
||||
const lines = newValue.split("\n");
|
||||
const nonEmptyLines = lines
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
|
||||
const isValid = nonEmptyLines.length === 0 || nonEmptyLines.every(line => line.includes(':'));
|
||||
|
||||
const isValid =
|
||||
nonEmptyLines.length === 0 ||
|
||||
nonEmptyLines.every((line) => line.includes(":"));
|
||||
|
||||
if (isValid) {
|
||||
// Safe to convert and update parent
|
||||
const headersArray = convertToHeadersArray(newValue);
|
||||
|
||||
@@ -51,22 +51,26 @@ type IdpCreateWizardProps = {
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: IdpCreateWizardProps) {
|
||||
export function IdpCreateWizard({
|
||||
onSubmit,
|
||||
defaultValues,
|
||||
loading = false
|
||||
}: IdpCreateWizardProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const createIdpFormSchema = z.object({
|
||||
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
|
||||
name: z.string().min(2, { message: t("nameMin", { len: 2 }) }),
|
||||
type: z.enum(["oidc"]),
|
||||
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
|
||||
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
|
||||
authUrl: z.url({ message: t('idpErrorAuthUrlInvalid') }),
|
||||
tokenUrl: z.url({ message: t('idpErrorTokenUrlInvalid') }),
|
||||
identifierPath: z
|
||||
clientId: z.string().min(1, { message: t("idpClientIdRequired") }),
|
||||
clientSecret: z
|
||||
.string()
|
||||
.min(1, { message: t('idpPathRequired') }),
|
||||
.min(1, { message: t("idpClientSecretRequired") }),
|
||||
authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }),
|
||||
tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }),
|
||||
identifierPath: z.string().min(1, { message: t("idpPathRequired") }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: t('idpScopeRequired') }),
|
||||
scopes: z.string().min(1, { message: t("idpScopeRequired") }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
@@ -80,7 +84,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: t('idpOidcDescription')
|
||||
description: t("idpOidcDescription")
|
||||
}
|
||||
];
|
||||
|
||||
@@ -110,11 +114,9 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t("idpTitle")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpCreateSettingsDescription')}
|
||||
{t("idpCreateSettingsDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -130,12 +132,15 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpDisplayName')}
|
||||
{t("idpDisplayName")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -145,7 +150,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label={t('idpAutoProvisionUsers')}
|
||||
label={t("idpAutoProvisionUsers")}
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
@@ -159,7 +164,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('idpAutoProvisionUsersDescription')}
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
@@ -169,11 +174,9 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t("idpType")}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpTypeDescription')}
|
||||
{t("idpTypeDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -193,10 +196,10 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpOidcConfigure')}
|
||||
{t("idpOidcConfigure")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpOidcConfigureDescription')}
|
||||
{t("idpOidcConfigureDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -212,13 +215,18 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpClientId')}
|
||||
{t("idpClientId")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpClientIdDescription')}
|
||||
{t(
|
||||
"idpClientIdDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -231,7 +239,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpClientSecret')}
|
||||
{t("idpClientSecret")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -241,7 +249,9 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpClientSecretDescription')}
|
||||
{t(
|
||||
"idpClientSecretDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -254,7 +264,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpAuthUrl')}
|
||||
{t("idpAuthUrl")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -264,7 +274,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpAuthUrlDescription')}
|
||||
{t("idpAuthUrlDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -277,7 +287,7 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpTokenUrl')}
|
||||
{t("idpTokenUrl")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -287,7 +297,9 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpTokenUrlDescription')}
|
||||
{t(
|
||||
"idpTokenUrlDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -299,10 +311,10 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('idpOidcConfigureAlert')}
|
||||
{t("idpOidcConfigureAlert")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('idpOidcConfigureAlertDescription')}
|
||||
{t("idpOidcConfigureAlertDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
@@ -311,10 +323,10 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t('idpToken')}
|
||||
{t("idpToken")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
{t('idpTokenDescription')}
|
||||
{t("idpTokenDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
@@ -327,17 +339,19 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t('idpJmespathAbout')}
|
||||
{t("idpJmespathAbout")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('idpJmespathAboutDescription')}{" "}
|
||||
{t("idpJmespathAboutDescription")}{" "}
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
{t('idpJmespathAboutDescriptionLink')}{" "}
|
||||
{t(
|
||||
"idpJmespathAboutDescriptionLink"
|
||||
)}{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
@@ -349,13 +363,18 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpJmespathLabel')}
|
||||
{t("idpJmespathLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpJmespathLabelDescription')}
|
||||
{t(
|
||||
"idpJmespathLabelDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -368,13 +387,20 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpJmespathEmailPathOptional')}
|
||||
{t(
|
||||
"idpJmespathEmailPathOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpJmespathEmailPathOptionalDescription')}
|
||||
{t(
|
||||
"idpJmespathEmailPathOptionalDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -387,13 +413,20 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpJmespathNamePathOptional')}
|
||||
{t(
|
||||
"idpJmespathNamePathOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpJmespathNamePathOptionalDescription')}
|
||||
{t(
|
||||
"idpJmespathNamePathOptionalDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -406,13 +439,20 @@ export function IdpCreateWizard({ onSubmit, defaultValues, loading = false }: Id
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('idpOidcConfigureScopes')}
|
||||
{t(
|
||||
"idpOidcConfigureScopes"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={loading} />
|
||||
<Input
|
||||
{...field}
|
||||
disabled={loading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('idpOidcConfigureScopesDescription')}
|
||||
{t(
|
||||
"idpOidcConfigureScopesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -19,7 +17,6 @@ export function InvitationsDataTable<TData, TValue>({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -27,8 +24,8 @@ export function InvitationsDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="invitations-table"
|
||||
title={t('invite')}
|
||||
searchPlaceholder={t('inviteSearch')}
|
||||
title={t("invite")}
|
||||
searchPlaceholder={t("inviteSearch")}
|
||||
searchColumn="email"
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
|
||||
@@ -72,12 +72,12 @@ export default function InvitationsTable({
|
||||
accessorKey: "email",
|
||||
enableHiding: false,
|
||||
friendlyName: t("email"),
|
||||
header: () => (<span className="p-3">{t("email")}</span>)
|
||||
header: () => <span className="p-3">{t("email")}</span>
|
||||
},
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
friendlyName: t("expiresAt"),
|
||||
header: () => (<span className="p-3">{t("expiresAt")}</span>),
|
||||
header: () => <span className="p-3">{t("expiresAt")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const expiresAt = new Date(row.original.expiresAt);
|
||||
const isExpired = expiresAt < new Date();
|
||||
@@ -92,7 +92,7 @@ export default function InvitationsTable({
|
||||
{
|
||||
accessorKey: "role",
|
||||
friendlyName: t("role"),
|
||||
header: () => (<span className="p-3">{t("role")}</span>)
|
||||
header: () => <span className="p-3">{t("role")}</span>
|
||||
},
|
||||
{
|
||||
id: "dots",
|
||||
@@ -182,10 +182,8 @@ export default function InvitationsTable({
|
||||
setSelectedInvitation(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>
|
||||
{t("inviteQuestionRemove")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p>{t("inviteQuestionRemove")}</p>
|
||||
<p>{t("inviteMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -72,10 +72,12 @@ export async function Layout({
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
|
||||
<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
|
||||
)}>
|
||||
<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
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,38 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
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 {
|
||||
ExternalLink,
|
||||
Server,
|
||||
BookOpenText,
|
||||
Zap,
|
||||
CreditCard,
|
||||
FileText,
|
||||
TicketCheck
|
||||
} from "lucide-react";
|
||||
import { FaDiscord, FaGithub } from "react-icons/fa";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SidebarNavSection } from "@app/app/navigation";
|
||||
import { OrgSelector } from "@app/components/OrgSelector";
|
||||
import { SidebarNav } from "@app/components/SidebarNav";
|
||||
import SupporterStatus from "@app/components/SupporterStatus";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { build } from "@server/build";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
import { ExternalLink, Server } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import SidebarLicenseButton from "./SidebarLicenseButton";
|
||||
import { SidebarSupportButton } from "./SidebarSupportButton";
|
||||
import dynamic from "next/dynamic";
|
||||
import { is } from "drizzle-orm";
|
||||
|
||||
const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
|
||||
ssr: false
|
||||
@@ -48,7 +41,7 @@ interface LayoutSidebarProps {
|
||||
|
||||
export function LayoutSidebar({
|
||||
orgId,
|
||||
orgs,
|
||||
orgs = [],
|
||||
navItems,
|
||||
defaultSidebarCollapsed,
|
||||
hasCookiePreference
|
||||
@@ -60,7 +53,7 @@ export function LayoutSidebar({
|
||||
const pathname = usePathname();
|
||||
const isAdminPage = pathname?.startsWith("/admin");
|
||||
const { user } = useUserContext();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -105,6 +98,10 @@ export function LayoutSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
const currentOrg = orgs.find((org) => org.orgId === orgId);
|
||||
const canShowProductUpdates =
|
||||
user.serverAdmin || Boolean(currentOrg?.isOwner || currentOrg?.isAdmin);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -158,10 +155,12 @@ export function LayoutSidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col shrink-0">
|
||||
<div className="mb-3">
|
||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||
</div>
|
||||
<div className="p-4 pt-1 flex flex-col shrink-0">
|
||||
{canShowProductUpdates && (
|
||||
<div className="mb-3">
|
||||
<ProductUpdates isCollapsed={isSidebarCollapsed} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{build === "enterprise" && (
|
||||
<div className="mb-3">
|
||||
@@ -228,6 +227,18 @@ export function LayoutSidebar({
|
||||
<FaGithub size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("personalUseOnly")}
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("unlicensed")}
|
||||
</div>
|
||||
) : null}
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
|
||||
@@ -133,7 +133,8 @@ export function LicenseKeysDataTable({
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button variant={"outline"}
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
{t("delete")}
|
||||
|
||||
@@ -17,15 +17,13 @@ export default function LicenseViolation() {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
|
||||
<div className="flex justify-between items-center">
|
||||
<p>
|
||||
{t('componentsInvalidKey')}
|
||||
</p>
|
||||
<p>{t("componentsInvalidKey")}</p>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
className="hover:bg-yellow-500"
|
||||
onClick={() => setIsDismissed(true)}
|
||||
>
|
||||
{t('dismiss')}
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,4 +64,4 @@ export default function LocaleSwitcher() {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
logAnalyticsFiltersSchema,
|
||||
logQueries,
|
||||
resourceQueries
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||
import { LoaderIcon, RefreshCw, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "./InfoSection";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -24,23 +29,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./ui/select";
|
||||
import { Label } from "./ui/label";
|
||||
import { Separator } from "./ui/separator";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "./InfoSection";
|
||||
import { WorldMap } from "./WorldMap";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
@@ -49,7 +41,13 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig
|
||||
} from "./ui/chart";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
|
||||
export type AnalyticsContentProps = {
|
||||
orgId: string;
|
||||
@@ -67,17 +65,18 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
||||
const isEmptySearchParams =
|
||||
!filters.resourceId && !filters.timeStart && !filters.timeEnd;
|
||||
|
||||
const env = useEnvContext();
|
||||
const [api] = useState(() => createApiClient(env));
|
||||
const router = useRouter();
|
||||
|
||||
console.log({ filters });
|
||||
const dateRange = {
|
||||
startDate: filters.timeStart ? new Date(filters.timeStart) : undefined,
|
||||
endDate: filters.timeEnd ? new Date(filters.timeEnd) : undefined
|
||||
startDate: filters.timeStart
|
||||
? new Date(filters.timeStart)
|
||||
: getSevenDaysAgo(),
|
||||
endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date()
|
||||
};
|
||||
|
||||
const { data: resources = [], isFetching: isFetchingResources } = useQuery(
|
||||
resourceQueries.listNamesPerOrg(props.orgId, api)
|
||||
resourceQueries.listNamesPerOrg(props.orgId)
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -88,7 +87,6 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
||||
} = useQuery(
|
||||
logQueries.requestAnalytics({
|
||||
orgId: props.orgId,
|
||||
api,
|
||||
filters
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,29 +8,36 @@ import {
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
X,
|
||||
Download,
|
||||
ChevronRight,
|
||||
ChevronDown
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
Loader,
|
||||
RefreshCw
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
PAGE_SIZE: "datatable-page-size",
|
||||
@@ -49,7 +45,10 @@ const STORAGE_KEYS = {
|
||||
tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE
|
||||
};
|
||||
|
||||
export const getStoredPageSize = (tableId?: string, defaultSize = 20): number => {
|
||||
export const getStoredPageSize = (
|
||||
tableId?: string,
|
||||
defaultSize = 20
|
||||
): number => {
|
||||
if (typeof window === "undefined") return defaultSize;
|
||||
|
||||
try {
|
||||
@@ -145,7 +144,7 @@ export function LogDataTable<TData, TValue>({
|
||||
onPageSizeChange: onPageSizeChangeProp,
|
||||
isLoading = false,
|
||||
expandable = false,
|
||||
disabled=false,
|
||||
disabled = false,
|
||||
renderExpandedRow
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
@@ -313,7 +312,7 @@ export function LogDataTable<TData, TValue>({
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
|
||||
setActiveTab(value);
|
||||
// Reset to first page when changing tabs
|
||||
table.setPageIndex(0);
|
||||
@@ -322,7 +321,7 @@ export function LogDataTable<TData, TValue>({
|
||||
// Enhanced pagination component that updates our local state
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
if (disabled) return;
|
||||
|
||||
|
||||
// setPageSize(newPageSize);
|
||||
table.setPageSize(newPageSize);
|
||||
|
||||
@@ -340,7 +339,7 @@ export function LogDataTable<TData, TValue>({
|
||||
// Handle page changes for server pagination
|
||||
const handlePageChange = (newPageIndex: number) => {
|
||||
if (disabled) return;
|
||||
|
||||
|
||||
if (isServerPagination && onPageChange) {
|
||||
onPageChange(newPageIndex);
|
||||
}
|
||||
@@ -351,7 +350,7 @@ export function LogDataTable<TData, TValue>({
|
||||
end: DateTimeValue
|
||||
) => {
|
||||
if (disabled) return;
|
||||
|
||||
|
||||
setStartDate(start);
|
||||
setEndDate(end);
|
||||
onDateRangeChange?.(start, end);
|
||||
@@ -397,12 +396,28 @@ export function LogDataTable<TData, TValue>({
|
||||
</Button>
|
||||
)}
|
||||
{onExport && (
|
||||
<Button onClick={() => !disabled && onExport()} disabled={isExporting || disabled}>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("exportCsv")}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
!disabled && onExport()
|
||||
}
|
||||
disabled={isExporting || disabled}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 size-4" />
|
||||
)}
|
||||
{t("exportCsv")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("exportCsvTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -427,61 +442,63 @@ export function LogDataTable<TData, TValue>({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const isExpanded =
|
||||
expandable && expandedRows.has(row.id);
|
||||
return [
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
onClick={() =>
|
||||
expandable && !disabled
|
||||
? toggleRowExpansion(
|
||||
row.id
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
className="text-xs" // made smaller
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const originalRow =
|
||||
row.original as any;
|
||||
const actionValue =
|
||||
originalRow?.action;
|
||||
let className = "";
|
||||
table
|
||||
.getRowModel()
|
||||
.rows.map((row) => {
|
||||
const isExpanded =
|
||||
expandable &&
|
||||
expandedRows.has(row.id);
|
||||
return [
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() &&
|
||||
"selected"
|
||||
}
|
||||
onClick={() =>
|
||||
expandable && !disabled
|
||||
? toggleRowExpansion(
|
||||
row.id
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
className="text-xs" // made smaller
|
||||
>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell) => {
|
||||
const originalRow =
|
||||
row.original as any;
|
||||
const actionValue =
|
||||
originalRow?.action;
|
||||
let className = "";
|
||||
|
||||
if (
|
||||
typeof actionValue ===
|
||||
"boolean"
|
||||
) {
|
||||
className =
|
||||
actionValue
|
||||
? "bg-green-100 dark:bg-green-900/50"
|
||||
: "bg-red-100 dark:bg-red-900/50";
|
||||
}
|
||||
if (
|
||||
typeof actionValue ===
|
||||
"boolean"
|
||||
) {
|
||||
className =
|
||||
actionValue
|
||||
? "bg-green-100 dark:bg-green-900/50"
|
||||
: "bg-red-100 dark:bg-red-900/50";
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`${className} py-2`} // made smaller
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>,
|
||||
isExpanded &&
|
||||
renderExpandedRow && (
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={`${className} py-2`} // made smaller
|
||||
>
|
||||
{flexRender(
|
||||
cell.column
|
||||
.columnDef
|
||||
.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>,
|
||||
isExpanded && renderExpandedRow && (
|
||||
<TableRow
|
||||
key={`${row.id}-expanded`}
|
||||
>
|
||||
@@ -497,8 +514,9 @@ export function LogDataTable<TData, TValue>({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
].filter(Boolean);
|
||||
}).flat()
|
||||
].filter(Boolean);
|
||||
})
|
||||
.flat()
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
@@ -524,6 +542,8 @@ export function LogDataTable<TData, TValue>({
|
||||
isServerPagination={isServerPagination}
|
||||
isLoading={isLoading}
|
||||
disabled={disabled}
|
||||
pageSize={pageSize}
|
||||
pageIndex={currentPage}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -239,10 +239,10 @@ export default function LoginForm({
|
||||
try {
|
||||
const response = await loginProxy(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
code,
|
||||
resourceGuid: resourceGuid as string
|
||||
email,
|
||||
password,
|
||||
code,
|
||||
resourceGuid: resourceGuid as string
|
||||
},
|
||||
forceLogin
|
||||
);
|
||||
@@ -364,7 +364,7 @@ export default function LoginForm({
|
||||
{forceLogin && (
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="flex items-center gap-2">
|
||||
<LockIcon className="w-4 h-4" />
|
||||
<LockIcon className="w-4 h-4" />
|
||||
{t("loginRequiredForDevice")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -426,7 +426,7 @@ export default function LoginForm({
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href={`${env.app.dashboardUrl}/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
|
||||
href={`${env.app.dashboardUrl}/auth/reset-password${form.getValues().email ? `?email=${encodeURIComponent(form.getValues().email)}` : ""}`}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("passwordForgot")}
|
||||
|
||||
@@ -40,6 +40,8 @@ export type ClientRow = {
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
userEmail: string | null;
|
||||
niceId: string;
|
||||
agent: string | null;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
@@ -64,9 +66,9 @@ export default function MachineClientsTable({
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const defaultMachineColumnVisibility = {
|
||||
client: false,
|
||||
subnet: false,
|
||||
userId: false
|
||||
userId: false,
|
||||
niceId: false
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
@@ -129,8 +131,8 @@ export default function MachineClientsTable({
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userId",
|
||||
friendlyName: "User",
|
||||
accessorKey: "niceId",
|
||||
friendlyName: "Identifier",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -141,54 +143,12 @@ export default function MachineClientsTable({
|
||||
)
|
||||
}
|
||||
>
|
||||
User
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return r.userId ? (
|
||||
<Link
|
||||
href={`/${r.orgId}/settings/access/users/${r.userId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{r.userEmail || r.username || r.userId}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// accessorKey: "siteName",
|
||||
// header: ({ column }) => {
|
||||
// return (
|
||||
// <Button
|
||||
// variant="ghost"
|
||||
// onClick={() =>
|
||||
// column.toggleSorting(column.getIsSorted() === "asc")
|
||||
// }
|
||||
// >
|
||||
// Site
|
||||
// <ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// );
|
||||
// },
|
||||
// cell: ({ row }) => {
|
||||
// const r = row.original;
|
||||
// return (
|
||||
// <Link href={`/${r.orgId}/settings/sites/${r.siteId}`}>
|
||||
// <Button variant="outline">
|
||||
// {r.siteName}
|
||||
// <ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
// </Button>
|
||||
// </Link>
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: "Connectivity",
|
||||
@@ -266,7 +226,7 @@ export default function MachineClientsTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "client",
|
||||
friendlyName: t("client"),
|
||||
friendlyName: t("agent"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -277,7 +237,7 @@ export default function MachineClientsTable({
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("client")}
|
||||
{t("agent")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -287,19 +247,18 @@ export default function MachineClientsTable({
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Olm</span>
|
||||
{originalRow.olmVersion && (
|
||||
<span className="text-xs text-gray-500">
|
||||
v{originalRow.olmVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
<Badge variant="secondary">
|
||||
{originalRow.agent +
|
||||
" v" +
|
||||
originalRow.olmVersion}
|
||||
</Badge>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -369,7 +328,7 @@ export default function MachineClientsTable({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/machine/${clientRow.id}`}
|
||||
href={`/${clientRow.orgId}/settings/clients/machine/${clientRow.niceId}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
@@ -395,7 +354,7 @@ export default function MachineClientsTable({
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("deleteClientQuestion")}</p>
|
||||
<p>{t("clientMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,6 @@ export function OrgApiKeysDataTable<TData, TValue>({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -27,13 +26,13 @@ export function OrgApiKeysDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="Org-apikeys-table"
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
title={t("apiKeys")}
|
||||
searchPlaceholder={t("searchApiKeys")}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
addButtonText={t("apiKeysAdd")}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function OrgApiKeysTable({
|
||||
{
|
||||
accessorKey: "key",
|
||||
friendlyName: t("key"),
|
||||
header: () => (<span className="p-3">{t("key")}</span>),
|
||||
header: () => <span className="p-3">{t("key")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
@@ -120,7 +120,7 @@ export default function OrgApiKeysTable({
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
friendlyName: t("createdAt"),
|
||||
header: () => (<span className="p-3">{t("createdAt")}</span>),
|
||||
header: () => <span className="p-3">{t("createdAt")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")}</span>;
|
||||
@@ -149,7 +149,9 @@ export default function OrgApiKeysTable({
|
||||
setSelected(r);
|
||||
}}
|
||||
>
|
||||
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
|
||||
<Link
|
||||
href={`/${orgId}/settings/api-keys/${r.id}`}
|
||||
>
|
||||
{t("viewSettings")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -166,9 +168,7 @@ export default function OrgApiKeysTable({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
@@ -189,14 +189,10 @@ export default function OrgApiKeysTable({
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<p>
|
||||
{t("apiKeysQuestionRemove")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p>{t("apiKeysQuestionRemove")}</p>
|
||||
|
||||
<p>
|
||||
{t("apiKeysMessageRemove")}
|
||||
</p>
|
||||
<p>{t("apiKeysMessageRemove")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("apiKeysDeleteConfirm")}
|
||||
|
||||
@@ -41,7 +41,8 @@ export default function OrgPolicyResult({
|
||||
accessRes
|
||||
}: OrgPolicyResultProps) {
|
||||
const [show2FaDialog, setShow2FaDialog] = useState(false);
|
||||
const [showChangePasswordDialog, setShowChangePasswordDialog] = useState(false);
|
||||
const [showChangePasswordDialog, setShowChangePasswordDialog] =
|
||||
useState(false);
|
||||
const t = useTranslations();
|
||||
const { user } = useUserContext();
|
||||
const router = useRouter();
|
||||
@@ -77,15 +78,15 @@ export default function OrgPolicyResult({
|
||||
if (accessRes.policies?.maxSessionLength) {
|
||||
const maxSessionPolicy = accessRes.policies?.maxSessionLength;
|
||||
const maxHours = maxSessionPolicy.maxSessionLengthHours;
|
||||
|
||||
|
||||
// Use hours if less than 24, otherwise convert to days
|
||||
const useHours = maxHours < 24;
|
||||
const maxTime = useHours ? maxHours : Math.round(maxHours / 24);
|
||||
|
||||
const descriptionKey = useHours
|
||||
|
||||
const descriptionKey = useHours
|
||||
? "reauthenticationDescriptionHours"
|
||||
: "reauthenticationDescription";
|
||||
|
||||
|
||||
const description = useHours
|
||||
? t(descriptionKey, { maxHours })
|
||||
: t(descriptionKey, { maxDays: maxTime });
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cn } from "@app/lib/cn";
|
||||
@@ -36,7 +36,11 @@ interface OrgSelectorProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorProps) {
|
||||
export function OrgSelector({
|
||||
orgId,
|
||||
orgs,
|
||||
isCollapsed = false
|
||||
}: OrgSelectorProps) {
|
||||
const { user } = useUserContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -65,10 +69,10 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
<Building2 className="h-4 w-4 mr-3 shrink-0" />
|
||||
<div className="flex flex-col items-start min-w-0 flex-1">
|
||||
<span className="font-bold text-sm">
|
||||
{t('org')}
|
||||
{t("org")}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground truncate w-full text-left">
|
||||
{selectedOrg?.name || t('noneSelected')}
|
||||
{selectedOrg?.name || t("noneSelected")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,17 +84,20 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command className="rounded-lg">
|
||||
<CommandInput
|
||||
placeholder={t('searchProgress')}
|
||||
placeholder={t("searchProgress")}
|
||||
className="border-0 focus:ring-0"
|
||||
/>
|
||||
<CommandEmpty className="py-6 text-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t('orgNotFound2')}
|
||||
{t("orgNotFound2")}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
|
||||
<>
|
||||
<CommandGroup heading={t('create')} className="py-2">
|
||||
<CommandGroup
|
||||
heading={t("create")}
|
||||
className="py-2"
|
||||
>
|
||||
<CommandList>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
@@ -103,8 +110,12 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
<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>
|
||||
<span className="font-medium">
|
||||
{t("setupNewOrg")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("createNewOrgDescription")}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
</CommandList>
|
||||
@@ -112,7 +123,7 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
<CommandSeparator className="my-2" />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t('orgs')} className="py-2">
|
||||
<CommandGroup heading={t("orgs")} className="py-2">
|
||||
<CommandList>
|
||||
{orgs?.map((org) => (
|
||||
<CommandItem
|
||||
@@ -127,13 +138,19 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<span className="font-medium">{org.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('organization')}</span>
|
||||
<span className="font-medium">
|
||||
{org.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("organization")}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 text-primary",
|
||||
orgId === org.orgId ? "opacity-100" : "opacity-0"
|
||||
orgId === org.orgId
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
@@ -154,8 +171,12 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">{selectedOrg?.name || t('noneSelected')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('org')}</p>
|
||||
<p className="font-medium">
|
||||
{selectedOrg?.name || t("noneSelected")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("org")}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
CardFooter
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react";
|
||||
import {
|
||||
Users,
|
||||
Globe,
|
||||
Database,
|
||||
Cog,
|
||||
Settings,
|
||||
Waypoints,
|
||||
Combine
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface OrgStat {
|
||||
@@ -43,17 +51,17 @@ export default function OrganizationLandingCard(
|
||||
|
||||
const orgStats: OrgStat[] = [
|
||||
{
|
||||
label: t('sites'),
|
||||
label: t("sites"),
|
||||
value: orgData.overview.stats.sites,
|
||||
icon: <Combine className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: t('resources'),
|
||||
label: t("resources"),
|
||||
value: orgData.overview.stats.resources,
|
||||
icon: <Waypoints className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: t('users'),
|
||||
label: t("users"),
|
||||
value: orgData.overview.stats.users,
|
||||
icon: <Users className="h-6 w-6" />
|
||||
}
|
||||
@@ -84,9 +92,11 @@ export default function OrganizationLandingCard(
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-lg">
|
||||
{t('accessRoleYour')}{" "}
|
||||
{t("accessRoleYour")}{" "}
|
||||
<span className="font-semibold">
|
||||
{orgData.overview.isOwner ? t('accessRoleOwner') : orgData.overview.userRole}
|
||||
{orgData.overview.isOwner
|
||||
? t("accessRoleOwner")
|
||||
: orgData.overview.userRole}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -95,7 +105,7 @@ export default function OrganizationLandingCard(
|
||||
<Link href={`/${orgData.overview.orgId}/settings`}>
|
||||
<Button size="lg" className="w-full md:w-auto">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t('orgGeneralSettings')}
|
||||
{t("orgGeneralSettings")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
|
||||
@@ -60,7 +60,10 @@ export function PathMatchModal({
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const getPlaceholder = () => (matchType === "regex" ? t("pathMatchRegexPlaceholder") : t("pathMatchDefaultPlaceholder"));
|
||||
const getPlaceholder = () =>
|
||||
matchType === "regex"
|
||||
? t("pathMatchRegexPlaceholder")
|
||||
: t("pathMatchDefaultPlaceholder");
|
||||
|
||||
const getHelpText = () => {
|
||||
switch (matchType) {
|
||||
@@ -93,14 +96,22 @@ export function PathMatchModal({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prefix">{t("pathMatchPrefix")}</SelectItem>
|
||||
<SelectItem value="exact">{t("pathMatchExact")}</SelectItem>
|
||||
<SelectItem value="regex">{t("pathMatchRegex")}</SelectItem>
|
||||
<SelectItem value="prefix">
|
||||
{t("pathMatchPrefix")}
|
||||
</SelectItem>
|
||||
<SelectItem value="exact">
|
||||
{t("pathMatchExact")}
|
||||
</SelectItem>
|
||||
<SelectItem value="regex">
|
||||
{t("pathMatchRegex")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="path-value">{t("pathMatchValue")}</Label>
|
||||
<Label htmlFor="path-value">
|
||||
{t("pathMatchValue")}
|
||||
</Label>
|
||||
<Input
|
||||
id="path-value"
|
||||
placeholder={getPlaceholder()}
|
||||
@@ -115,9 +126,9 @@ export function PathMatchModal({
|
||||
<CredenzaFooter className="gap-2">
|
||||
{/* {value?.path && (
|
||||
)} */}
|
||||
<Button variant="outline" onClick={handleClear}>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleClear}>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!path.trim()}>
|
||||
{t("saveChanges")}
|
||||
</Button>
|
||||
@@ -206,7 +217,9 @@ export function PathRewriteModal({
|
||||
</CredenzaHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rewrite-type">{t("pathRewriteType")}</Label>
|
||||
<Label htmlFor="rewrite-type">
|
||||
{t("pathRewriteType")}
|
||||
</Label>
|
||||
<Select
|
||||
value={rewriteType}
|
||||
onValueChange={setRewriteType}
|
||||
@@ -231,7 +244,9 @@ export function PathRewriteModal({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rewrite-value">{t("pathRewriteValue")}</Label>
|
||||
<Label htmlFor="rewrite-value">
|
||||
{t("pathRewriteValue")}
|
||||
</Label>
|
||||
<Input
|
||||
id="rewrite-value"
|
||||
placeholder={getPlaceholder()}
|
||||
@@ -269,7 +284,7 @@ export function PathMatchDisplay({
|
||||
value: { path: string | null; pathMatchType: string | null };
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
if (!value?.path) return null;
|
||||
|
||||
const getTypeLabel = (type: string | null) => {
|
||||
@@ -300,7 +315,7 @@ export function PathRewriteDisplay({
|
||||
value: { rewritePath: string | null; rewritePathType: string | null };
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
if (!value?.rewritePath && value?.rewritePathType !== "stripPrefix")
|
||||
return null;
|
||||
|
||||
|
||||
@@ -23,17 +23,18 @@ function getActionsCategories(root: boolean) {
|
||||
|
||||
const actionsByCategory: Record<string, Record<string, string>> = {
|
||||
Organization: {
|
||||
[t('actionGetOrg')]: "getOrg",
|
||||
[t('actionUpdateOrg')]: "updateOrg",
|
||||
[t('actionGetOrgUser')]: "getOrgUser",
|
||||
[t('actionInviteUser')]: "inviteUser",
|
||||
[t('actionListInvitations')]: "listInvitations",
|
||||
[t('actionRemoveUser')]: "removeUser",
|
||||
[t('actionListUsers')]: "listUsers",
|
||||
[t('actionListOrgDomains')]: "listOrgDomains",
|
||||
[t('updateOrgUser')]: "updateOrgUser",
|
||||
[t('createOrgUser')]: "createOrgUser",
|
||||
[t('actionApplyBlueprint')]: "applyBlueprint",
|
||||
[t("actionGetOrg")]: "getOrg",
|
||||
[t("actionUpdateOrg")]: "updateOrg",
|
||||
[t("actionGetOrgUser")]: "getOrgUser",
|
||||
[t("actionInviteUser")]: "inviteUser",
|
||||
[t("actionRemoveInvitation")]: "removeInvitation",
|
||||
[t("actionListInvitations")]: "listInvitations",
|
||||
[t("actionRemoveUser")]: "removeUser",
|
||||
[t("actionListUsers")]: "listUsers",
|
||||
[t("actionListOrgDomains")]: "listOrgDomains",
|
||||
[t("updateOrgUser")]: "updateOrgUser",
|
||||
[t("createOrgUser")]: "createOrgUser",
|
||||
[t("actionApplyBlueprint")]: "applyBlueprint"
|
||||
},
|
||||
|
||||
Site: {
|
||||
@@ -46,25 +47,25 @@ function getActionsCategories(root: boolean) {
|
||||
},
|
||||
|
||||
Resource: {
|
||||
[t('actionCreateResource')]: "createResource",
|
||||
[t('actionDeleteResource')]: "deleteResource",
|
||||
[t('actionGetResource')]: "getResource",
|
||||
[t('actionListResource')]: "listResources",
|
||||
[t('actionUpdateResource')]: "updateResource",
|
||||
[t('actionListResourceUsers')]: "listResourceUsers",
|
||||
[t('actionSetResourceUsers')]: "setResourceUsers",
|
||||
[t('actionSetAllowedResourceRoles')]: "setResourceRoles",
|
||||
[t('actionListAllowedResourceRoles')]: "listResourceRoles",
|
||||
[t('actionSetResourcePassword')]: "setResourcePassword",
|
||||
[t('actionSetResourcePincode')]: "setResourcePincode",
|
||||
[t('actionSetResourceHeaderAuth')]: "setResourceHeaderAuth",
|
||||
[t('actionSetResourceEmailWhitelist')]: "setResourceWhitelist",
|
||||
[t('actionGetResourceEmailWhitelist')]: "getResourceWhitelist",
|
||||
[t('actionCreateSiteResource')]: "createSiteResource",
|
||||
[t('actionDeleteSiteResource')]: "deleteSiteResource",
|
||||
[t('actionGetSiteResource')]: "getSiteResource",
|
||||
[t('actionListSiteResources')]: "listSiteResources",
|
||||
[t('actionUpdateSiteResource')]: "updateSiteResource"
|
||||
[t("actionCreateResource")]: "createResource",
|
||||
[t("actionDeleteResource")]: "deleteResource",
|
||||
[t("actionGetResource")]: "getResource",
|
||||
[t("actionListResource")]: "listResources",
|
||||
[t("actionUpdateResource")]: "updateResource",
|
||||
[t("actionListResourceUsers")]: "listResourceUsers",
|
||||
[t("actionSetResourceUsers")]: "setResourceUsers",
|
||||
[t("actionSetAllowedResourceRoles")]: "setResourceRoles",
|
||||
[t("actionListAllowedResourceRoles")]: "listResourceRoles",
|
||||
[t("actionSetResourcePassword")]: "setResourcePassword",
|
||||
[t("actionSetResourcePincode")]: "setResourcePincode",
|
||||
[t("actionSetResourceHeaderAuth")]: "setResourceHeaderAuth",
|
||||
[t("actionSetResourceEmailWhitelist")]: "setResourceWhitelist",
|
||||
[t("actionGetResourceEmailWhitelist")]: "getResourceWhitelist",
|
||||
[t("actionCreateSiteResource")]: "createSiteResource",
|
||||
[t("actionDeleteSiteResource")]: "deleteSiteResource",
|
||||
[t("actionGetSiteResource")]: "getSiteResource",
|
||||
[t("actionListSiteResources")]: "listSiteResources",
|
||||
[t("actionUpdateSiteResource")]: "updateSiteResource"
|
||||
},
|
||||
|
||||
Target: {
|
||||
@@ -91,23 +92,23 @@ function getActionsCategories(root: boolean) {
|
||||
},
|
||||
|
||||
"Resource Rule": {
|
||||
[t('actionCreateResourceRule')]: "createResourceRule",
|
||||
[t('actionDeleteResourceRule')]: "deleteResourceRule",
|
||||
[t('actionListResourceRules')]: "listResourceRules",
|
||||
[t('actionUpdateResourceRule')]: "updateResourceRule"
|
||||
[t("actionCreateResourceRule")]: "createResourceRule",
|
||||
[t("actionDeleteResourceRule")]: "deleteResourceRule",
|
||||
[t("actionListResourceRules")]: "listResourceRules",
|
||||
[t("actionUpdateResourceRule")]: "updateResourceRule"
|
||||
},
|
||||
|
||||
"Client": {
|
||||
[t('actionCreateClient')]: "createClient",
|
||||
[t('actionDeleteClient')]: "deleteClient",
|
||||
[t('actionUpdateClient')]: "updateClient",
|
||||
[t('actionListClients')]: "listClients",
|
||||
[t('actionGetClient')]: "getClient"
|
||||
Client: {
|
||||
[t("actionCreateClient")]: "createClient",
|
||||
[t("actionDeleteClient")]: "deleteClient",
|
||||
[t("actionUpdateClient")]: "updateClient",
|
||||
[t("actionListClients")]: "listClients",
|
||||
[t("actionGetClient")]: "getClient"
|
||||
},
|
||||
|
||||
"Logs": {
|
||||
[t('actionExportLogs')]: "exportLogs",
|
||||
[t('actionViewLogs')]: "viewLogs",
|
||||
Logs: {
|
||||
[t("actionExportLogs")]: "exportLogs",
|
||||
[t("actionViewLogs")]: "viewLogs"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -144,7 +145,7 @@ function getActionsCategories(root: boolean) {
|
||||
|
||||
if (build == "saas") {
|
||||
actionsByCategory["SAAS"] = {
|
||||
["Send Usage Notification Email"]: "sendUsageNotification",
|
||||
["Send Usage Notification Email"]: "sendUsageNotification"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,17 @@ export function PolicyDataTable<TData, TValue>({
|
||||
data,
|
||||
onAdd
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="orgPolicies-table"
|
||||
title={t('orgPolicies')}
|
||||
searchPlaceholder={t('orgPoliciesSearch')}
|
||||
title={t("orgPolicies")}
|
||||
searchPlaceholder={t("orgPoliciesSearch")}
|
||||
searchColumn="orgId"
|
||||
addButtonText={t('orgPoliciesAdd')}
|
||||
addButtonText={t("orgPoliciesAdd")}
|
||||
onAdd={onAdd}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="orgId"
|
||||
|
||||
@@ -35,13 +35,18 @@ interface Props {
|
||||
onEdit: (policy: PolicyRow) => void;
|
||||
}
|
||||
|
||||
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
|
||||
export default function PolicyTable({
|
||||
policies,
|
||||
onDelete,
|
||||
onAdd,
|
||||
onEdit
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const columns: ExtendedColumnDef<PolicyRow>[] = [
|
||||
{
|
||||
accessorKey: "orgId",
|
||||
enableHiding: false,
|
||||
friendlyName: t('orgId'),
|
||||
friendlyName: t("orgId"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -50,7 +55,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('orgId')}
|
||||
{t("orgId")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -58,7 +63,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
},
|
||||
{
|
||||
accessorKey: "roleMapping",
|
||||
friendlyName: t('roleMapping'),
|
||||
friendlyName: t("roleMapping"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -67,7 +72,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('roleMapping')}
|
||||
{t("roleMapping")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -76,7 +81,11 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
const mapping = row.original.roleMapping;
|
||||
return mapping ? (
|
||||
<InfoPopup
|
||||
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||
text={
|
||||
mapping.length > 50
|
||||
? `${mapping.substring(0, 50)}...`
|
||||
: mapping
|
||||
}
|
||||
info={mapping}
|
||||
/>
|
||||
) : (
|
||||
@@ -86,7 +95,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
},
|
||||
{
|
||||
accessorKey: "orgMapping",
|
||||
friendlyName: t('orgMapping'),
|
||||
friendlyName: t("orgMapping"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -95,7 +104,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t('orgMapping')}
|
||||
{t("orgMapping")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -104,7 +113,11 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
const mapping = row.original.orgMapping;
|
||||
return mapping ? (
|
||||
<InfoPopup
|
||||
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
|
||||
text={
|
||||
mapping.length > 50
|
||||
? `${mapping.substring(0, 50)}...`
|
||||
: mapping
|
||||
}
|
||||
info={mapping}
|
||||
/>
|
||||
) : (
|
||||
@@ -123,7 +136,9 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -133,7 +148,9 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
onDelete(policy.orgId);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -141,7 +158,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
||||
variant={"outline"}
|
||||
onClick={() => onEdit(policy)}
|
||||
>
|
||||
{t('edit')}
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import * as React from "react";
|
||||
import { gt, valid } from "semver";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Button } from "./ui/button";
|
||||
import { Badge } from "./ui/badge";
|
||||
@@ -72,11 +73,15 @@ export default function ProductUpdates({
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion;
|
||||
const currentVersion = env.app.version;
|
||||
|
||||
const showNewVersionPopup = Boolean(
|
||||
data?.latestVersion?.data &&
|
||||
ignoredVersionUpdate !==
|
||||
data.latestVersion.data?.pangolin.latestVersion &&
|
||||
env.app.version !== data.latestVersion.data?.pangolin.latestVersion
|
||||
latestVersion &&
|
||||
valid(latestVersion) &&
|
||||
valid(currentVersion) &&
|
||||
ignoredVersionUpdate !== latestVersion &&
|
||||
gt(latestVersion, currentVersion)
|
||||
);
|
||||
|
||||
const filteredUpdates = data.updates.filter(
|
||||
@@ -99,7 +104,7 @@ export default function ProductUpdates({
|
||||
: "opacity-0"
|
||||
)}
|
||||
>
|
||||
{filteredUpdates.length > 0 && (
|
||||
{filteredUpdates.length > 1 && (
|
||||
<>
|
||||
<BellIcon className="flex-none size-3" />
|
||||
<span>
|
||||
@@ -180,33 +185,33 @@ function ProductUpdatesListPopup({
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-1 cursor-pointer block",
|
||||
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
|
||||
"rounded-md border bg-muted p-2 py-3 w-full flex flex-col gap-2 text-sm",
|
||||
"transition duration-300 ease-in-out",
|
||||
"data-closed:opacity-0 data-closed:translate-y-full"
|
||||
)}
|
||||
>
|
||||
<div className="rounded-md bg-muted-foreground/20 p-2">
|
||||
<BellIcon className="flex-none size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-md bg-muted-foreground/20 p-2">
|
||||
<BellIcon className="flex-none size-4" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center flex-1">
|
||||
<p className="font-medium text-start">
|
||||
{t("productUpdateWhatsNew")}
|
||||
</p>
|
||||
<div className="p-1 cursor-pointer ml-auto">
|
||||
<div className="p-1 cursor-pointer">
|
||||
<ChevronRightIcon className="size-4 flex-none" />
|
||||
</div>
|
||||
</div>
|
||||
<small
|
||||
className={cn(
|
||||
"text-start text-muted-foreground",
|
||||
"overflow-hidden h-8",
|
||||
"[-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]"
|
||||
)}
|
||||
>
|
||||
{updates[0]?.contents}
|
||||
</small>
|
||||
</div>
|
||||
<small
|
||||
className={cn(
|
||||
"text-start text-muted-foreground",
|
||||
"overflow-hidden h-8",
|
||||
"[-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]"
|
||||
)}
|
||||
>
|
||||
{updates[0]?.contents}
|
||||
</small>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
</Transition>
|
||||
@@ -332,20 +337,31 @@ function NewVersionAvailable({
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-2",
|
||||
"rounded-md border bg-muted p-2 py-3 w-full flex items-start gap-2 text-sm",
|
||||
"rounded-md border bg-muted p-2 py-3 w-full flex flex-col gap-2 text-sm",
|
||||
"transition duration-300 ease-in-out",
|
||||
"data-closed:opacity-0 data-closed:translate-y-full"
|
||||
)}
|
||||
>
|
||||
{version && (
|
||||
<>
|
||||
<div className="rounded-md bg-muted-foreground/20 p-2">
|
||||
<RocketIcon className="flex-none size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-md bg-muted-foreground/20 p-2">
|
||||
<RocketIcon className="flex-none size-4" />
|
||||
</div>
|
||||
<p className="font-medium flex-1">
|
||||
{t("pangolinUpdateAvailable")}
|
||||
</p>
|
||||
<button
|
||||
className="p-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onDimiss();
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4 flex-none" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<small className="text-muted-foreground">
|
||||
{t("pangolinUpdateAvailableInfo", {
|
||||
version: version.pangolin.latestVersion
|
||||
@@ -354,7 +370,7 @@ function NewVersionAvailable({
|
||||
<a
|
||||
href={version.pangolin.releaseNotes}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-0.5 text-xs font-medium"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium"
|
||||
>
|
||||
<span>
|
||||
{t("pangolinUpdateAvailableReleaseNotes")}
|
||||
@@ -362,15 +378,6 @@ function NewVersionAvailable({
|
||||
<ArrowRight className="flex-none size-3" />
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
className="p-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onDimiss();
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4 flex-none" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,10 +25,10 @@ export function ProfessionalContentOverlay({
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
|
||||
<div className="text-center p-6 bg-primary/10 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('licenseTierProfessionalRequired')}
|
||||
{t("licenseTierProfessionalRequired")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{t('licenseTierProfessionalRequiredDescription')}
|
||||
{t("licenseTierProfessionalRequiredDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,13 +82,13 @@ export default function ProfileIcon() {
|
||||
open={openSecurityKey}
|
||||
setOpen={setOpenSecurityKey}
|
||||
/>
|
||||
<ChangePasswordDialog
|
||||
open={openChangePassword}
|
||||
setOpen={setOpenChangePassword}
|
||||
<ChangePasswordDialog
|
||||
open={openChangePassword}
|
||||
setOpen={setOpenChangePassword}
|
||||
/>
|
||||
<ViewDevicesDialog
|
||||
open={openViewDevices}
|
||||
setOpen={setOpenViewDevices}
|
||||
<ViewDevicesDialog
|
||||
open={openViewDevices}
|
||||
setOpen={setOpenViewDevices}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
@@ -152,9 +152,7 @@ export default function ProfileIcon() {
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setOpenViewDevices(true)}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
||||
<Smartphone className="mr-2 h-4 w-4" />
|
||||
<span>{t("viewDevices") || "View Devices"}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -319,7 +319,7 @@ export default function ProxyResourcesTable({
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("niceId"),
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
@@ -329,7 +329,7 @@ export default function ProxyResourcesTable({
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("niceId")}
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -535,7 +535,7 @@ export default function ProxyResourcesTable({
|
||||
setSelectedResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("resourceQuestionRemove")}</p>
|
||||
<p>{t("resourceMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export default function QRContainer({
|
||||
children = <div/>,
|
||||
outline = true
|
||||
}) {
|
||||
|
||||
export default function QRContainer({ children = <div />, outline = true }) {
|
||||
return (
|
||||
<div
|
||||
className={`relative w-fit border-2 rounded-md`}
|
||||
>
|
||||
<div className="bg-white p-6 rounded-md">
|
||||
{children}
|
||||
</div>
|
||||
<div className={`relative w-fit border-2 rounded-md`}>
|
||||
<div className="bg-white p-6 rounded-md">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,11 +29,7 @@ export default function RefreshButton() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refreshData}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<Button variant="outline" onClick={refreshData} disabled={isRefreshing}>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon, AlertTriangle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
|
||||
type CredentialType = "site-wireguard" | "site-newt" | "client-olm" | "remote-exit-node";
|
||||
|
||||
interface RegenerateCredentialsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: CredentialType;
|
||||
onConfirmRegenerate: () => Promise<void>;
|
||||
dashboardUrl: string;
|
||||
credentials?: {
|
||||
// For WireGuard sites
|
||||
wgConfig?: string;
|
||||
|
||||
Id?: string;
|
||||
Secret?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function RegenerateCredentialsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
onConfirmRegenerate,
|
||||
dashboardUrl,
|
||||
credentials
|
||||
}: RegenerateCredentialsModalProps) {
|
||||
const t = useTranslations();
|
||||
const [stage, setStage] = useState<"confirm" | "show">("confirm");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await onConfirmRegenerate();
|
||||
setStage("show");
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setStage("confirm");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (stage === "confirm") {
|
||||
return t("regeneratecredentials");
|
||||
}
|
||||
switch (type) {
|
||||
case "site-wireguard":
|
||||
return t("WgConfiguration");
|
||||
case "site-newt":
|
||||
return t("siteNewtCredentials");
|
||||
case "client-olm":
|
||||
return t("clientOlmCredentials");
|
||||
case "remote-exit-node":
|
||||
return t("remoteExitNodeCreate.generate.title");
|
||||
}
|
||||
};
|
||||
|
||||
const getDescription = () => {
|
||||
if (stage === "confirm") {
|
||||
return t("regenerateCredentialsWarning");
|
||||
}
|
||||
switch (type) {
|
||||
case "site-wireguard":
|
||||
return t("WgConfigurationDescription");
|
||||
case "site-newt":
|
||||
return t("siteNewtCredentialsDescription");
|
||||
case "client-olm":
|
||||
return t("clientOlmCredentialsDescription");
|
||||
case "remote-exit-node":
|
||||
return t("remoteExitNodeCreate.generate.description");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent className="max-h-[80vh] flex flex-col">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{getTitle()}</CredenzaTitle>
|
||||
<CredenzaDescription>{getDescription()}</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
|
||||
<CredenzaBody className="overflow-y-auto px-4">
|
||||
{stage === "confirm" ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("warning")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("regenerateCredentialsConfirmation")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{credentials?.wgConfig && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<CopyTextBox text={credentials.wgConfig} />
|
||||
<div className="relative w-fit border rounded-md">
|
||||
<div className="bg-white p-6 rounded-md">
|
||||
<QRCodeCanvas
|
||||
value={credentials.wgConfig}
|
||||
size={168}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("copyandsavethesecredentials")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("copyandsavethesecredentialsdescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentials?.Id && credentials.Secret && (
|
||||
<div className="space-y-4">
|
||||
<InfoSections cols={1}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("endpoint")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={dashboardUrl} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("Id")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={credentials?.Id} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("SecretKey")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={credentials?.Secret} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
{t("copyandsavethesecredentials")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("copyandsavethesecredentialsdescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
|
||||
<CredenzaFooter>
|
||||
{stage === "confirm" ? (
|
||||
<>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
variant="destructive"
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
{t("close")}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -60,13 +60,13 @@ export default function RegenerateInvitationForm({
|
||||
const t = useTranslations();
|
||||
|
||||
const validForOptions = [
|
||||
{ hours: 24, name: t('day', {count: 1}) },
|
||||
{ hours: 48, name: t('day', {count: 2}) },
|
||||
{ hours: 72, name: t('day', {count: 3}) },
|
||||
{ hours: 96, name: t('day', {count: 4}) },
|
||||
{ hours: 120, name: t('day', {count: 5}) },
|
||||
{ hours: 144, name: t('day', {count: 6}) },
|
||||
{ hours: 168, name: t('day', {count: 7}) }
|
||||
{ hours: 24, name: t("day", { count: 1 }) },
|
||||
{ hours: 48, name: t("day", { count: 2 }) },
|
||||
{ hours: 72, name: t("day", { count: 3 }) },
|
||||
{ hours: 96, name: t("day", { count: 4 }) },
|
||||
{ hours: 120, name: t("day", { count: 5 }) },
|
||||
{ hours: 144, name: t("day", { count: 6 }) },
|
||||
{ hours: 168, name: t("day", { count: 7 }) }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,8 +82,8 @@ export default function RegenerateInvitationForm({
|
||||
if (!org?.org.orgId) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('orgMissing'),
|
||||
description: t('orgMissingMessage'),
|
||||
title: t("orgMissing"),
|
||||
description: t("orgMissingMessage"),
|
||||
duration: 5000
|
||||
});
|
||||
return;
|
||||
@@ -107,15 +107,19 @@ export default function RegenerateInvitationForm({
|
||||
if (sendEmail) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteSent', {email: invitation.email}),
|
||||
title: t("inviteRegenerated"),
|
||||
description: t("inviteSent", {
|
||||
email: invitation.email
|
||||
}),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteGenerate', {email: invitation.email}),
|
||||
title: t("inviteRegenerated"),
|
||||
description: t("inviteGenerate", {
|
||||
email: invitation.email
|
||||
}),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
@@ -132,22 +136,22 @@ export default function RegenerateInvitationForm({
|
||||
if (error.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('inviteDuplicateError'),
|
||||
description: t('inviteDuplicateErrorDescription'),
|
||||
title: t("inviteDuplicateError"),
|
||||
description: t("inviteDuplicateErrorDescription"),
|
||||
duration: 5000
|
||||
});
|
||||
} else if (error.response?.status === 429) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('inviteRateLimitError'),
|
||||
description: t('inviteRateLimitErrorDescription'),
|
||||
title: t("inviteRateLimitError"),
|
||||
description: t("inviteRateLimitErrorDescription"),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('inviteRegenerateError'),
|
||||
description: t('inviteRegenerateErrorDescription'),
|
||||
title: t("inviteRegenerateError"),
|
||||
description: t("inviteRegenerateErrorDescription"),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
@@ -168,16 +172,18 @@ export default function RegenerateInvitationForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('inviteRegenerate')}</CredenzaTitle>
|
||||
<CredenzaTitle>{t("inviteRegenerate")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('inviteRegenerateDescription')}
|
||||
{t("inviteRegenerateDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{!inviteLink ? (
|
||||
<div>
|
||||
<p>
|
||||
{t('inviteQuestionRegenerate', {email: invitation?.email || ""})}
|
||||
{t("inviteQuestionRegenerate", {
|
||||
email: invitation?.email || ""
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Checkbox
|
||||
@@ -188,13 +194,11 @@ export default function RegenerateInvitationForm({
|
||||
}
|
||||
/>
|
||||
<label htmlFor="send-email">
|
||||
{t('inviteSentEmail')}
|
||||
{t("inviteSentEmail")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label>
|
||||
{t('inviteValidityPeriod')}
|
||||
</Label>
|
||||
<Label>{t("inviteValidityPeriod")}</Label>
|
||||
<Select
|
||||
value={validHours.toString()}
|
||||
onValueChange={(value) =>
|
||||
@@ -202,7 +206,11 @@ export default function RegenerateInvitationForm({
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('inviteValidityPeriodSelect')} />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"inviteValidityPeriodSelect"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validForOptions.map((option) => (
|
||||
@@ -219,9 +227,7 @@ export default function RegenerateInvitationForm({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-md">
|
||||
<p>
|
||||
{t('inviteRegenerateMessage')}
|
||||
</p>
|
||||
<p>{t("inviteRegenerateMessage")}</p>
|
||||
<CopyTextBox text={inviteLink} wrapText={false} />
|
||||
</div>
|
||||
)}
|
||||
@@ -230,18 +236,18 @@ export default function RegenerateInvitationForm({
|
||||
{!inviteLink ? (
|
||||
<>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
loading={loading}
|
||||
>
|
||||
{t('inviteRegenerateButton')}
|
||||
{t("inviteRegenerateButton")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
|
||||
@@ -342,7 +342,10 @@ export default function ResetPasswordForm({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={env.email.emailEnabled}
|
||||
disabled={
|
||||
env.email
|
||||
.emailEnabled
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -18,14 +18,14 @@ export default function ResourceAccessDenied() {
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{t('accessDenied')}
|
||||
{t("accessDenied")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{t('accessDeniedDescription')}
|
||||
{t("accessDeniedDescription")}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
<Link href="/">{t("goHome")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -100,7 +100,7 @@ type ResourceAuthPortalProps = {
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const { isUnlocked, licenseStatus } = useLicenseStatusContext();
|
||||
|
||||
const getNumMethods = () => {
|
||||
let colLength = 0;
|
||||
@@ -769,6 +769,22 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
const { resource, authInfo, updateResource } = useResourceContext();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
@@ -25,7 +25,6 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
|
||||
const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`;
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -34,9 +33,7 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
|
||||
>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("identifier")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.niceId}
|
||||
</InfoSectionContent>
|
||||
@@ -49,10 +46,10 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) {
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
authInfo.pincode ||
|
||||
authInfo.sso ||
|
||||
authInfo.whitelist ||
|
||||
authInfo.headerAuth ? (
|
||||
<div className="flex items-start space-x-2">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5 flex-shrink-0 text-green-500" />
|
||||
<span>{t("protected")}</span>
|
||||
|
||||
@@ -4,27 +4,26 @@ import {
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function ResourceNotFound() {
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{t('resourceNotFound')}
|
||||
{t("resourceNotFound")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{t('resourceNotFoundDescription')}
|
||||
{t("resourceNotFoundDescription")}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
<Link href="/">{t("goHome")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -14,7 +14,10 @@ interface RestartDomainButtonProps {
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
export default function RestartDomainButton({ orgId, domainId }: RestartDomainButtonProps) {
|
||||
export default function RestartDomainButton({
|
||||
orgId,
|
||||
domainId
|
||||
}: RestartDomainButtonProps) {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -21,7 +19,6 @@ export function RolesDataTable<TData, TValue>({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -29,13 +26,13 @@ export function RolesDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="roles-table"
|
||||
title={t('roles')}
|
||||
searchPlaceholder={t('accessRolesSearch')}
|
||||
title={t("roles")}
|
||||
searchPlaceholder={t("accessRolesSearch")}
|
||||
searchColumn="name"
|
||||
onAdd={createRole}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('accessRolesAdd')}
|
||||
addButtonText={t("accessRolesAdd")}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="actions"
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
||||
{
|
||||
accessorKey: "description",
|
||||
friendlyName: t("description"),
|
||||
header: () => (<span className="p-3">{t("description")}</span>)
|
||||
header: () => <span className="p-3">{t("description")}</span>
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
|
||||
@@ -530,10 +530,14 @@ export default function SecurityKeyForm({
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Shield className="mb-2 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("securityKeyNoKeysRegistered")}
|
||||
{t(
|
||||
"securityKeyNoKeysRegistered"
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("securityKeyNoKeysDescription")}
|
||||
{t(
|
||||
"securityKeyNoKeysDescription"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -717,7 +721,9 @@ export default function SecurityKeyForm({
|
||||
{t("securityKeyRemoveTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })}
|
||||
{t("securityKeyRemoveDescription", {
|
||||
name: selectedSecurityKey!.name!
|
||||
})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -805,7 +811,9 @@ export default function SecurityKeyForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("securityKeyTwoFactorCode")}
|
||||
{t(
|
||||
"securityKeyTwoFactorCode"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -78,24 +78,27 @@ export default function SetResourceHeaderAuthForm({
|
||||
async function onSubmit(data: SetHeaderAuthFormValues) {
|
||||
setLoading(true);
|
||||
|
||||
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/header-auth`, {
|
||||
user: data.user,
|
||||
password: data.password
|
||||
})
|
||||
api.post<AxiosResponse<Resource>>(
|
||||
`/resource/${resourceId}/header-auth`,
|
||||
{
|
||||
user: data.user,
|
||||
password: data.password
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorHeaderAuthSetup'),
|
||||
title: t("resourceErrorHeaderAuthSetup"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorHeaderAuthSetupDescription')
|
||||
t("resourceErrorHeaderAuthSetupDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: t('resourceHeaderAuthSetup'),
|
||||
description: t('resourceHeaderAuthSetupDescription')
|
||||
title: t("resourceHeaderAuthSetup"),
|
||||
description: t("resourceHeaderAuthSetupDescription")
|
||||
});
|
||||
|
||||
if (onSetHeaderAuth) {
|
||||
@@ -117,9 +120,11 @@ export default function SetResourceHeaderAuthForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('resourceHeaderAuthSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("resourceHeaderAuthSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('resourceHeaderAuthSetupTitleDescription')}
|
||||
{t("resourceHeaderAuthSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -134,7 +139,7 @@ export default function SetResourceHeaderAuthForm({
|
||||
name="user"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('user')}</FormLabel>
|
||||
<FormLabel>{t("user")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -151,7 +156,9 @@ export default function SetResourceHeaderAuthForm({
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -168,7 +175,7 @@ export default function SetResourceHeaderAuthForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -176,7 +183,7 @@ export default function SetResourceHeaderAuthForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('resourceHeaderAuthSubmit')}
|
||||
{t("resourceHeaderAuthSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -81,17 +81,17 @@ export default function SetResourcePasswordForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorPasswordSetup'),
|
||||
title: t("resourceErrorPasswordSetup"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorPasswordSetupDescription')
|
||||
t("resourceErrorPasswordSetupDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: t('resourcePasswordSetup'),
|
||||
description: t('resourcePasswordSetupDescription')
|
||||
title: t("resourcePasswordSetup"),
|
||||
description: t("resourcePasswordSetupDescription")
|
||||
});
|
||||
|
||||
if (onSetPassword) {
|
||||
@@ -113,9 +113,11 @@ export default function SetResourcePasswordForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('resourcePasswordSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePasswordSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('resourcePasswordSetupTitleDescription')}
|
||||
{t("resourcePasswordSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -130,7 +132,9 @@ export default function SetResourcePasswordForm({
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -147,7 +151,7 @@ export default function SetResourcePasswordForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -155,7 +159,7 @@ export default function SetResourcePasswordForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('resourcePasswordSubmit')}
|
||||
{t("resourcePasswordSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -87,17 +87,17 @@ export default function SetResourcePincodeForm({
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('resourceErrorPincodeSetup'),
|
||||
title: t("resourceErrorPincodeSetup"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t('resourceErrorPincodeSetupDescription')
|
||||
t("resourceErrorPincodeSetupDescription")
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: t('resourcePincodeSetup'),
|
||||
description: t('resourcePincodeSetupDescription')
|
||||
title: t("resourcePincodeSetup"),
|
||||
description: t("resourcePincodeSetupDescription")
|
||||
});
|
||||
|
||||
if (onSetPincode) {
|
||||
@@ -119,9 +119,11 @@ export default function SetResourcePincodeForm({
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('resourcePincodeSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaTitle>
|
||||
{t("resourcePincodeSetupTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('resourcePincodeSetupTitleDescription')}
|
||||
{t("resourcePincodeSetupTitleDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -136,7 +138,9 @@ export default function SetResourcePincodeForm({
|
||||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('resourcePincode')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("resourcePincode")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
@@ -182,7 +186,7 @@ export default function SetResourcePincodeForm({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -190,7 +194,7 @@ export default function SetResourcePincodeForm({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('resourcePincodeSubmit')}
|
||||
{t("resourcePincodeSubmit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function SettingsSectionFooter({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex justify-end space-x-2 mt-auto pt-6">
|
||||
<div className="flex flex-col md:flex-row justify-end space-y-2 md:space-y-0 md:space-x-2 mt-auto pt-6">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -21,7 +19,6 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -29,13 +26,13 @@ export function ShareLinksDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="shareLinks-table"
|
||||
title={t('shareLinks')}
|
||||
searchPlaceholder={t('shareSearch')}
|
||||
title={t("shareLinks")}
|
||||
searchPlaceholder={t("shareSearch")}
|
||||
searchColumn="name"
|
||||
onAdd={createShareLink}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('shareCreate')}
|
||||
addButtonText={t("shareCreate")}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="resourceName"
|
||||
stickyRightColumn="delete"
|
||||
|
||||
@@ -34,7 +34,7 @@ export const ShareableLinksSplash = () => {
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label={t('dismiss')}
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -42,23 +42,21 @@ export const ShareableLinksSplash = () => {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Link className="text-blue-500" />
|
||||
{t('share')}
|
||||
{t("share")}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('shareDescription2')}
|
||||
</p>
|
||||
<p className="text-sm">{t("shareDescription2")}</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Share className="text-green-500 w-4 h-4" />
|
||||
{t('shareEasyCreate')}
|
||||
{t("shareEasyCreate")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Clock className="text-yellow-500 w-4 h-4" />
|
||||
{t('shareConfigurableExpirationDuration')}
|
||||
{t("shareConfigurableExpirationDuration")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-red-500 w-4 h-4" />
|
||||
{t('shareSecureAndRevocable')}
|
||||
{t("shareSecureAndRevocable")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +124,9 @@ export default function ShareLinksTable({
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}>
|
||||
<Link
|
||||
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
|
||||
>
|
||||
<Button variant="outline">
|
||||
{r.resourceName}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
@@ -289,7 +291,8 @@ export default function ShareLinksTable({
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </DropdownMenuContent> */}
|
||||
{/* </DropdownMenu> */}
|
||||
<Button variant={"outline"}
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() =>
|
||||
deleteSharelink(row.original.accessTokenId)
|
||||
}
|
||||
|
||||
@@ -71,20 +71,42 @@ function CollapsibleNavItem({
|
||||
build,
|
||||
isUnlocked
|
||||
}: CollapsibleNavItemProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(isChildActive);
|
||||
const storageKey = `pangolin-sidebar-expanded-${item.title}`;
|
||||
|
||||
// Update open state when child active state changes
|
||||
// Get initial state from localStorage or use isChildActive
|
||||
const getInitialState = (): boolean => {
|
||||
if (typeof window === "undefined") {
|
||||
return isChildActive;
|
||||
}
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved !== null) {
|
||||
return saved === "true";
|
||||
}
|
||||
return isChildActive;
|
||||
};
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(getInitialState);
|
||||
|
||||
// Update open state when child active state changes (but don't override user preference)
|
||||
React.useEffect(() => {
|
||||
if (isChildActive) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isChildActive]);
|
||||
|
||||
// Save state to localStorage when it changes
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(storageKey, String(open));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
|
||||
@@ -56,13 +56,15 @@ export function SidebarSupportButton({
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm<SupportFormValues>({
|
||||
resolver: zodResolver(z.object({
|
||||
subject: z
|
||||
.string()
|
||||
.min(1, t("supportSubjectRequired"))
|
||||
.max(255, t("supportSubjectMaxLength")),
|
||||
body: z.string().min(1, t("supportMessageRequired"))
|
||||
})),
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
subject: z
|
||||
.string()
|
||||
.min(1, t("supportSubjectRequired"))
|
||||
.max(255, t("supportSubjectMaxLength")),
|
||||
body: z.string().min(1, t("supportMessageRequired"))
|
||||
})
|
||||
),
|
||||
defaultValues: {
|
||||
subject: "",
|
||||
body: ""
|
||||
@@ -127,7 +129,9 @@ export function SidebarSupportButton({
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{t("support", { defaultValue: "Support" })}</p>
|
||||
<p>
|
||||
{t("support", { defaultValue: "Support" })}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -195,7 +199,9 @@ export function SidebarSupportButton({
|
||||
{isSuccess ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
<h3 className="text-lg font-semibold">{t("supportMessageSent")}</h3>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("supportMessageSent")}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t("supportWillContact")}
|
||||
</p>
|
||||
@@ -206,68 +212,77 @@ export function SidebarSupportButton({
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>{t("supportReplyTo")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={user?.email || ""}
|
||||
disabled
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel>{t("supportReplyTo")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input value={user?.email || ""} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("supportSubject")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("supportSubjectPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subject"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("supportSubject")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"supportSubjectPlaceholder"
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("supportMessage")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t("supportMessagePlaceholder")}
|
||||
rows={5}
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="body"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("supportMessage")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"supportMessagePlaceholder"
|
||||
)}
|
||||
rows={5}
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
{t("supportSend")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("supportSend")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -15,11 +15,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SignUpResponse } from "@server/routers/auth";
|
||||
@@ -147,7 +143,8 @@ export default function SignupForm({
|
||||
inviteId,
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt,
|
||||
marketingEmailConsent: build === "saas" ? marketingEmailConsent : undefined
|
||||
marketingEmailConsent:
|
||||
build === "saas" ? marketingEmailConsent : undefined
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
@@ -179,6 +176,9 @@ export default function SignupForm({
|
||||
}
|
||||
|
||||
function getSubtitle() {
|
||||
if (isUnlocked() && env.branding?.signupPage?.subtitleText) {
|
||||
return env.branding.signupPage.subtitleText;
|
||||
}
|
||||
return t("authCreateAccount");
|
||||
}
|
||||
|
||||
@@ -501,7 +501,9 @@ export default function SignupForm({
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
@@ -551,12 +553,16 @@ export default function SignupForm({
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t("signUpMarketing.keepMeInTheLoop")}
|
||||
{t(
|
||||
"signUpMarketing.keepMeInTheLoop"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({ }: SiteInfoCardProps) {
|
||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
@@ -31,18 +30,13 @@ export default function SiteInfoCard({ }: SiteInfoCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<InfoSections cols={4}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("identifier")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.niceId}
|
||||
</InfoSectionContent>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{site.niceId}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
|
||||
@@ -64,18 +64,20 @@ export function SitePriceCalculator({
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{mode === "license"
|
||||
? t('licensePurchase')
|
||||
: t('licensePurchaseSites')}
|
||||
? t("licensePurchase")
|
||||
: t("licensePurchaseSites")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('licensePurchaseDescription', {selectedMode: mode})}
|
||||
{t("licensePurchaseDescription", {
|
||||
selectedMode: mode
|
||||
})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('numberOfSites')}
|
||||
{t("numberOfSites")}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
@@ -83,7 +85,7 @@ export function SitePriceCalculator({
|
||||
size="icon"
|
||||
onClick={decrementSites}
|
||||
disabled={siteCount <= 1}
|
||||
aria-label={t('sitestCountDecrease')}
|
||||
aria-label={t("sitestCountDecrease")}
|
||||
>
|
||||
<MinusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -94,7 +96,7 @@ export function SitePriceCalculator({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={incrementSites}
|
||||
aria-label={t('sitestCountIncrease')}
|
||||
aria-label={t("sitestCountIncrease")}
|
||||
>
|
||||
<PlusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
@@ -103,14 +105,14 @@ export function SitePriceCalculator({
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-muted-foreground text-sm mt-2 text-center">
|
||||
{t('licensePricingPage')}
|
||||
{t("licensePricingPage")}
|
||||
<a
|
||||
href="https://docs.fossorial.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('pricingPage')}
|
||||
{t("pricingPage")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
@@ -119,10 +121,10 @@ export function SitePriceCalculator({
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button onClick={continueToPayment}>
|
||||
{t('pricingPortal')}
|
||||
{t("pricingPortal")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
||||
@@ -23,7 +23,6 @@ export function SitesDataTable<TData, TValue>({
|
||||
columnVisibility,
|
||||
enableColumnVisibility
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -31,11 +30,11 @@ export function SitesDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="sites-table"
|
||||
title={t('sites')}
|
||||
searchPlaceholder={t('searchSitesProgress')}
|
||||
title={t("sites")}
|
||||
searchPlaceholder={t("searchSitesProgress")}
|
||||
searchColumn="name"
|
||||
onAdd={createSite}
|
||||
addButtonText={t('siteAdd')}
|
||||
addButtonText={t("siteAdd")}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
defaultSort={{
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const SitesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
@@ -38,7 +38,7 @@ export const SitesSplashCard = () => {
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label={t('dismiss')}
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -46,19 +46,17 @@ export const SitesSplashCard = () => {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Globe className="text-blue-500" />
|
||||
Newt ({t('recommended')})
|
||||
Newt ({t("recommended")})
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('siteNewtDescription')}
|
||||
</p>
|
||||
<p className="text-sm">{t("siteNewtDescription")}</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-green-500 w-4 h-4" />
|
||||
{t('siteRunsInDocker')}
|
||||
{t("siteRunsInDocker")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-green-500 w-4 h-4" />
|
||||
{t('siteRunsInShell')}
|
||||
{t("siteRunsInShell")}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -72,7 +70,7 @@ export const SitesSplashCard = () => {
|
||||
className="w-full flex items-center"
|
||||
variant="secondary"
|
||||
>
|
||||
{t('siteInstallNewt')}{" "}
|
||||
{t("siteInstallNewt")}{" "}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -80,19 +78,17 @@ export const SitesSplashCard = () => {
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
{t('siteWg')}
|
||||
{t("siteWg")}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{t('siteWgAnyClients')}
|
||||
</p>
|
||||
<p className="text-sm">{t("siteWgAnyClients")}</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Docker className="text-purple-500 w-4 h-4" />
|
||||
{t('siteWgCompatibleAllClients')}
|
||||
{t("siteWgCompatibleAllClients")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-purple-500 w-4 h-4" />
|
||||
{t('siteWgManualConfigurationRequired')}
|
||||
{t("siteWgManualConfigurationRequired")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("niceId"),
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
@@ -138,7 +138,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("niceId")}
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -231,7 +231,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
friendlyName: t("connectionType"),
|
||||
friendlyName: t("type"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -240,7 +240,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("connectionType")}
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -252,12 +252,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Newt</span>
|
||||
{originalRow.newtVersion && (
|
||||
<span className="text-xs text-gray-500">
|
||||
v{originalRow.newtVersion}
|
||||
</span>
|
||||
<span>v{originalRow.newtVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
@@ -273,7 +271,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
if (originalRow.type === "wireguard") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>WireGuard</span>
|
||||
<Badge variant="secondary">WireGuard</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -281,7 +279,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
if (originalRow.type === "local") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{t("local")}</span>
|
||||
<Badge variant="secondary">Local</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -414,7 +412,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="">
|
||||
<div className="space-y-2">
|
||||
<p>{t("siteQuestionRemove")}</p>
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -61,14 +61,14 @@ export function StrategySelect<TValue extends string>({
|
||||
/>
|
||||
<div className="flex gap-3 pl-7">
|
||||
{option.icon && (
|
||||
<div className="mt-1">
|
||||
{option.icon}
|
||||
</div>
|
||||
<div className="mt-1">{option.icon}</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{option.title}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{typeof option.description === 'string' ? option.description : option.description}
|
||||
{typeof option.description === "string"
|
||||
? option.description
|
||||
: option.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import React from "react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { Star } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function SupporterMessage({ tier }: { tier: string }) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -33,9 +32,9 @@ export default function SupporterMessage({ tier }: { tier: string }) {
|
||||
>
|
||||
Pangolin
|
||||
</span>
|
||||
<Star className="w-3 h-3"/>
|
||||
<Star className="w-3 h-3" />
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 -top-10 hidden group-hover:block text-primary text-sm rounded-md border shadow-md px-4 py-2 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{t('componentsSupporterMessage', {tier: tier})}
|
||||
{t("componentsSupporterMessage", { tier: tier })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
TooltipTrigger
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -60,7 +60,9 @@ interface SupporterStatusProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export default function SupporterStatus({ isCollapsed = false }: SupporterStatusProps) {
|
||||
export default function SupporterStatus({
|
||||
isCollapsed = false
|
||||
}: SupporterStatusProps) {
|
||||
const { supporterStatus, updateSupporterStatus } =
|
||||
useSupporterStatusContext();
|
||||
const [supportOpen, setSupportOpen] = useState(false);
|
||||
@@ -72,11 +74,9 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
githubUsername: z
|
||||
.string()
|
||||
.nonempty({
|
||||
error: "GitHub username is required"
|
||||
}),
|
||||
githubUsername: z.string().nonempty({
|
||||
error: "GitHub username is required"
|
||||
}),
|
||||
key: z.string().nonempty({
|
||||
error: "Supporter key is required"
|
||||
})
|
||||
@@ -112,8 +112,8 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
if (!data || !data.valid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('supportKeyInvalid'),
|
||||
description: t('supportKeyInvalidDescription')
|
||||
title: t("supportKeyInvalid"),
|
||||
description: t("supportKeyInvalidDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -121,8 +121,8 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
// Trigger the toast
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t('supportKeyValid'),
|
||||
description: t('supportKeyValidDescription')
|
||||
title: t("supportKeyValid"),
|
||||
description: t("supportKeyValidDescription")
|
||||
});
|
||||
|
||||
// Fireworks-style confetti
|
||||
@@ -178,10 +178,10 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t('error'),
|
||||
title: t("error"),
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
t('supportKeyErrorValidationDescription')
|
||||
t("supportKeyErrorValidationDescription")
|
||||
)
|
||||
});
|
||||
return;
|
||||
@@ -198,48 +198,44 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t('supportKey')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaTitle>{t("supportKey")}</CredenzaTitle>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<p>
|
||||
{t('supportKeyDescription')}
|
||||
</p>
|
||||
<p>{t("supportKeyDescription")}</p>
|
||||
|
||||
<p>{t("supportKeyPet")}</p>
|
||||
|
||||
<p>
|
||||
{t('supportKeyPet')}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{t('supportKeyPurchase')}{" "}
|
||||
{t("supportKeyPurchase")}{" "}
|
||||
<Link
|
||||
href="https://supporters.fossorial.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('supportKeyPurchaseLink')}
|
||||
{t("supportKeyPurchaseLink")}
|
||||
</Link>{" "}
|
||||
{t('supportKeyPurchase2')}{" "}
|
||||
{t("supportKeyPurchase2")}{" "}
|
||||
<Link
|
||||
href="https://docs.pangolin.net/self-host/supporter-program"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t('supportKeyLearnMore')}
|
||||
{t("supportKeyLearnMore")}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="py-6">
|
||||
<p className="mb-3 text-center">
|
||||
{t('supportKeyOptions')}
|
||||
{t("supportKeyOptions")}
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 grid-cols-1 gap-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('supportKetOptionFull')}</CardTitle>
|
||||
<CardTitle>
|
||||
{t("supportKetOptionFull")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl mb-6">$95</p>
|
||||
@@ -247,19 +243,19 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('forWholeServer')}
|
||||
{t("forWholeServer")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('lifetimePurchase')}
|
||||
{t("lifetimePurchase")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('supporterStatus')}
|
||||
{t("supporterStatus")}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -272,7 +268,7 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
className="w-full"
|
||||
>
|
||||
<Button className="w-full">
|
||||
{t('buy')}
|
||||
{t("buy")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
@@ -282,7 +278,9 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
className={`${supporterStatus?.tier === "Limited Supporter" ? "opacity-50" : ""}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('supportKeyOptionLimited')}</CardTitle>
|
||||
<CardTitle>
|
||||
{t("supportKeyOptionLimited")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl mb-6">$25</p>
|
||||
@@ -290,19 +288,19 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('forFiveUsers')}
|
||||
{t("forFiveUsers")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('lifetimePurchase')}
|
||||
{t("lifetimePurchase")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
{t('supporterStatus')}
|
||||
{t("supporterStatus")}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -317,7 +315,7 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
className="w-full"
|
||||
>
|
||||
<Button className="w-full">
|
||||
{t('buy')}
|
||||
{t("buy")}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
@@ -328,7 +326,7 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
"Limited Supporter"
|
||||
}
|
||||
>
|
||||
{t('buy')}
|
||||
{t("buy")}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
@@ -344,20 +342,20 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
setKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('supportKeyRedeem')}
|
||||
{t("supportKeyRedeem")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => hide()}
|
||||
>
|
||||
{t('supportKeyHideSevenDays')}
|
||||
{t("supportKeyHideSevenDays")}
|
||||
</Button>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
@@ -371,9 +369,9 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t('supportKeyEnter')}</CredenzaTitle>
|
||||
<CredenzaTitle>{t("supportKeyEnter")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t('supportKeyEnterDescription')}
|
||||
{t("supportKeyEnterDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
@@ -389,7 +387,7 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('githubUsername')}
|
||||
{t("githubUsername")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -403,7 +401,9 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('supportKeyInput')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t("supportKeyInput")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -416,10 +416,10 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="form">
|
||||
{t('submit')}
|
||||
{t("submit")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
@@ -441,7 +441,7 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
<p>{t('supportKeyBuy')}</p>
|
||||
<p>{t("supportKeyBuy")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -453,7 +453,7 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
|
||||
setPurchaseOptionsOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('supportKeyBuy')}
|
||||
{t("supportKeyBuy")}
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -39,7 +39,9 @@ export function TopbarNav({
|
||||
href={item.href.replace("{orgId}", orgId || "")}
|
||||
className={cn(
|
||||
"relative md:px-3 px-1 py-3 text-md",
|
||||
pathname.startsWith(item.href.replace("{orgId}", orgId || ""))
|
||||
pathname.startsWith(
|
||||
item.href.replace("{orgId}", orgId || "")
|
||||
)
|
||||
? "border-b-2 border-primary text-primary font-medium"
|
||||
: "hover:text-primary text-muted-foreground font-medium",
|
||||
"whitespace-nowrap",
|
||||
|
||||
@@ -231,7 +231,7 @@ const TwoFactorSetupForm = forwardRef<
|
||||
<p>{t("otpSetupScanQr")}</p>
|
||||
<div className="h-[250px] mx-auto flex items-center justify-center">
|
||||
<div className="bg-white p-6 border rounded-md">
|
||||
<QRCodeCanvas value={secretUri} size={200} />
|
||||
<QRCodeCanvas value={secretUri} size={200} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-md mx-auto">
|
||||
|
||||
@@ -40,6 +40,8 @@ export type ClientRow = {
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
userEmail: string | null;
|
||||
niceId: string;
|
||||
agent: string | null;
|
||||
};
|
||||
|
||||
type ClientTableProps = {
|
||||
@@ -60,8 +62,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
const defaultUserColumnVisibility = {
|
||||
client: false,
|
||||
subnet: false
|
||||
subnet: false,
|
||||
niceId: false
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
@@ -123,6 +125,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "niceId",
|
||||
friendlyName: t("identifier"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(
|
||||
column.getIsSorted() === "asc"
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("identifier")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "userEmail",
|
||||
friendlyName: "User",
|
||||
@@ -261,7 +282,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "client",
|
||||
friendlyName: t("client"),
|
||||
friendlyName: t("agent"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
@@ -272,7 +293,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("client")}
|
||||
{t("agent")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
@@ -282,19 +303,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Olm</span>
|
||||
{originalRow.olmVersion && (
|
||||
<span className="text-xs text-gray-500">
|
||||
v{originalRow.olmVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Badge>
|
||||
{originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
{originalRow.agent && originalRow.olmVersion ? (
|
||||
<Badge variant="secondary">
|
||||
{originalRow.agent +
|
||||
" v" +
|
||||
originalRow.olmVersion}
|
||||
</Badge>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
|
||||
{/*originalRow.olmUpdateAvailable && (
|
||||
<InfoPopup info={t("olmUpdateAvailableInfo")} />
|
||||
)*/}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -320,62 +341,52 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
}
|
||||
];
|
||||
|
||||
// Only include actions column if there are rows without userIds
|
||||
if (hasRowsWithoutUserId) {
|
||||
baseColumns.push({
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return !clientRow.userId ? (
|
||||
<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">
|
||||
Open menu
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Delete
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
baseColumns.push({
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const clientRow = row.original;
|
||||
return !clientRow.userId ? (
|
||||
<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">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
});
|
||||
}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{/* <Link */}
|
||||
{/* className="block w-full" */}
|
||||
{/* href={`/${clientRow.orgId}/settings/sites/${clientRow.nice}`} */}
|
||||
{/* > */}
|
||||
{/* <DropdownMenuItem> */}
|
||||
{/* View settings */}
|
||||
{/* </DropdownMenuItem> */}
|
||||
{/* </Link> */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedClient(clientRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
href={`/${clientRow.orgId}/settings/clients/${clientRow.id}`}
|
||||
>
|
||||
<Button variant={"outline"}>
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
});
|
||||
|
||||
return baseColumns;
|
||||
}, [hasRowsWithoutUserId, t]);
|
||||
@@ -390,7 +401,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("deleteClientQuestion")}</p>
|
||||
<p>{t("clientMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -21,7 +19,6 @@ export function UsersDataTable<TData, TValue>({
|
||||
onRefresh,
|
||||
isRefreshing
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
@@ -29,13 +26,13 @@ export function UsersDataTable<TData, TValue>({
|
||||
columns={columns}
|
||||
data={data}
|
||||
persistPageSize="users-table"
|
||||
title={t('users')}
|
||||
searchPlaceholder={t('accessUsersSearch')}
|
||||
title={t("users")}
|
||||
searchPlaceholder={t("accessUsersSearch")}
|
||||
searchColumn="email"
|
||||
onAdd={inviteUser}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
addButtonText={t('accessUserCreate')}
|
||||
addButtonText={t("accessUserCreate")}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="displayUsername"
|
||||
stickyRightColumn="actions"
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("userQuestionOrgRemove")}</p>
|
||||
<p>{t("userMessageOrgRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,11 @@ type ValidateOidcTokenParams = {
|
||||
stateCookie: string | undefined;
|
||||
idp: { name: string };
|
||||
loginPageId?: number;
|
||||
providerError?: {
|
||||
error: string;
|
||||
description?: string | null;
|
||||
uri?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
@@ -35,14 +40,65 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isProviderError, setIsProviderError] = useState(false);
|
||||
|
||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
let isCancelled = false;
|
||||
|
||||
async function runValidation() {
|
||||
setLoading(true);
|
||||
setIsProviderError(false);
|
||||
|
||||
if (props.providerError?.error) {
|
||||
const providerMessage =
|
||||
props.providerError.description ||
|
||||
t("idpErrorOidcProviderRejected", {
|
||||
error: props.providerError.error,
|
||||
defaultValue:
|
||||
"The identity provider returned an error: {error}."
|
||||
});
|
||||
const suffix = props.providerError.uri
|
||||
? ` (${props.providerError.uri})`
|
||||
: "";
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(true);
|
||||
setError(`${providerMessage}${suffix}`);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.code) {
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(
|
||||
t("idpErrorOidcMissingCode", {
|
||||
defaultValue:
|
||||
"The identity provider did not return an authorization code."
|
||||
})
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.expectedState || !props.stateCookie) {
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(
|
||||
t("idpErrorOidcMissingState", {
|
||||
defaultValue:
|
||||
"The login request is missing state information. Please restart the login process."
|
||||
})
|
||||
);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(t("idpOidcTokenValidating"), {
|
||||
code: props.code,
|
||||
@@ -57,22 +113,28 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
try {
|
||||
const response = await validateOidcUrlCallbackProxy(
|
||||
props.idpId,
|
||||
props.code || "",
|
||||
props.expectedState || "",
|
||||
props.stateCookie || "",
|
||||
props.code,
|
||||
props.expectedState,
|
||||
props.stateCookie,
|
||||
props.loginPageId
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(response.message);
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
if (!data) {
|
||||
setError("Unable to validate OIDC token");
|
||||
setLoading(false);
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError("Unable to validate OIDC token");
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,8 +144,11 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
router.push(env.app.dashboardUrl);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setLoading(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = data.redirectUrl; // this is validated by the parent using this component
|
||||
@@ -92,17 +157,27 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(
|
||||
t("idpErrorOidcTokenValidating", {
|
||||
defaultValue: "An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
if (!isCancelled) {
|
||||
setIsProviderError(false);
|
||||
setError(
|
||||
t("idpErrorOidcTokenValidating", {
|
||||
defaultValue:
|
||||
"An unexpected error occurred. Please try again."
|
||||
})
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate();
|
||||
runValidation();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -133,12 +208,16 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
{t("idpErrorConnectingTo", {
|
||||
name: props.idp.name
|
||||
})}
|
||||
<span className="text-sm font-medium">
|
||||
{isProviderError
|
||||
? error
|
||||
: t("idpErrorConnectingTo", {
|
||||
name: props.idp.name
|
||||
})}
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
{!isProviderError && (
|
||||
<span className="text-xs">{error}</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function ViewDevicesDialog({
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t("deviceQuestionRemove") ||
|
||||
"Are you sure you want to delete this device?"}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user