Merge branch 'dev' into feat/login-page-customization

This commit is contained in:
Fred KISSIE
2025-12-05 22:38:07 +01:00
275 changed files with 21920 additions and 6990 deletions

View File

@@ -237,10 +237,11 @@ export type SecurityKeyVerifyResponse = {
};
export async function loginProxy(
request: LoginRequest
request: LoginRequest,
forceLogin?: boolean
): Promise<ResponseT<LoginResponse>> {
const serverPort = process.env.SERVER_EXTERNAL_PORT;
const url = `http://localhost:${serverPort}/api/v1/auth/login`;
const url = `http://localhost:${serverPort}/api/v1/auth/login${forceLogin ? "?forceLogin=true" : ""}`;
console.log("Making login request to:", url);
@@ -248,10 +249,11 @@ export async function loginProxy(
}
export async function securityKeyStartProxy(
request: SecurityKeyStartRequest
request: SecurityKeyStartRequest,
forceLogin?: boolean
): Promise<ResponseT<SecurityKeyStartResponse>> {
const serverPort = process.env.SERVER_EXTERNAL_PORT;
const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start`;
const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start${forceLogin ? "?forceLogin=true" : ""}`;
console.log("Making security key start request to:", url);
@@ -260,10 +262,11 @@ export async function securityKeyStartProxy(
export async function securityKeyVerifyProxy(
request: SecurityKeyVerifyRequest,
tempSessionId: string
tempSessionId: string,
forceLogin?: boolean
): Promise<ResponseT<SecurityKeyVerifyResponse>> {
const serverPort = process.env.SERVER_EXTERNAL_PORT;
const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify`;
const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify${forceLogin ? "?forceLogin=true" : ""}`;
console.log("Making security key verify request to:", url);
@@ -407,10 +410,19 @@ export async function validateOidcUrlCallbackProxy(
export async function generateOidcUrlProxy(
idpId: number,
redirect: string,
orgId?: string
orgId?: string,
forceLogin?: boolean
): Promise<ResponseT<GenerateOidcUrlResponse>> {
const serverPort = process.env.SERVER_EXTERNAL_PORT;
const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${orgId ? `?orgId=${orgId}` : ""}`;
const queryParams = new URLSearchParams();
if (orgId) {
queryParams.append("orgId", orgId);
}
if (forceLogin) {
queryParams.append("forceLogin", "true");
}
const queryString = queryParams.toString();
const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${queryString ? `?${queryString}` : ""}`;
console.log("Making OIDC URL generation request to:", url);

View File

@@ -1,17 +1,15 @@
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { cache } from "react";
import MemberResourcesPortal from "../../components/MemberResourcesPortal";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { ListUserOrgsResponse } from "@server/routers/org";
import MemberResourcesPortal from "@app/components/MemberResourcesPortal";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import EnvProvider from "@app/providers/EnvProvider";
import { orgLangingNavItems } from "@app/app/navigation";
import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
type OrgPageProps = {
params: Promise<{ orgId: string }>;
@@ -22,6 +20,10 @@ export default async function OrgPage(props: OrgPageProps) {
const orgId = params.orgId;
const env = pullEnv();
if (!orgId) {
redirect(`/`);
}
const getUser = cache(verifySession);
const user = await getUser();

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { ExitNodesDataTable } from "./ExitNodesDataTable";
import {
DropdownMenu,
@@ -99,9 +100,10 @@ export default function ExitNodesTable({
});
};
const columns: ColumnDef<RemoteExitNodeRow>[] = [
const columns: ExtendedColumnDef<RemoteExitNodeRow>[] = [
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -118,6 +120,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "online",
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
@@ -152,6 +155,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "type",
friendlyName: t("connectionType"),
header: ({ column }) => {
return (
<Button
@@ -178,6 +182,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "address",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
@@ -194,6 +199,7 @@ export default function ExitNodesTable({
},
{
accessorKey: "endpoint",
friendlyName: "Endpoint",
header: ({ column }) => {
return (
<Button
@@ -230,11 +236,12 @@ export default function ExitNodesTable({
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => {
const nodeRow = row.original;
const remoteExitNodeId = nodeRow.id;
return (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">

View File

@@ -13,9 +13,5 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const { children } = props;
const env = pullEnv();
if (!env.flags.enableClients) {
redirect(`/${params.orgId}/settings`);
}
return children;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import {
SettingsContainer,
SettingsSection,
@@ -10,18 +10,23 @@ import {
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { PickClientDefaultsResponse } from "@server/routers/client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { useClientContext } from "@app/hooks/useClientContext";
import RegenerateCredentialsModal from "@app/components/RegenerateCredentialsModal";
import { build } from "@server/build";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { build } from "@server/build";
import { PickClientDefaultsResponse } from "@server/routers/client";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
export default function CredentialsPage() {
const { env } = useEnvContext();
@@ -32,7 +37,8 @@ export default function CredentialsPage() {
const { client } = useClientContext();
const [modalOpen, setModalOpen] = useState(false);
const [clientDefaults, setClientDefaults] = useState<PickClientDefaultsResponse | null>(null);
const [clientDefaults, setClientDefaults] =
useState<PickClientDefaultsResponse | null>(null);
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const subscription = useSubscriptionStatusContext();
@@ -44,18 +50,19 @@ export default function CredentialsPage() {
return isEnterpriseNotLicensed || isSaasNotSubscribed;
};
const handleConfirmRegenerate = async () => {
const res = await api.get(`/org/${orgId}/pick-client-defaults`);
if (res && res.status === 200) {
const data = res.data.data;
setClientDefaults(data);
await api.post(`/re-key/${client?.clientId}/regenerate-client-secret`, {
olmId: data.olmId,
secret: data.olmSecret,
});
await api.post(
`/re-key/${client?.clientId}/regenerate-client-secret`,
{
olmId: data.olmId,
secret: data.olmSecret
}
);
toast({
title: t("credentialsSaved"),
@@ -95,7 +102,8 @@ export default function CredentialsPage() {
<div className="inline-block">
<Button
onClick={() => setModalOpen(true)}
disabled={isSecurityFeatureDisabled()}>
disabled={isSecurityFeatureDisabled()}
>
{t("regeneratecredentials")}
</Button>
</div>
@@ -121,4 +129,4 @@ export default function CredentialsPage() {
/>
</SettingsContainer>
);
}
}

View File

@@ -1,49 +1,40 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { useClientContext } from "@app/hooks/useClientContext";
import { useForm } from "react-hook-form";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionFooter
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useClientContext } from "@app/hooks/useClientContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useEffect, useState } from "react";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
siteIds: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
name: z.string().nonempty("Name is required")
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -54,15 +45,11 @@ export default function GeneralPage() {
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const router = useRouter();
const [sites, setSites] = useState<Tag[]>([]);
const [clientSites, setClientSites] = useState<Tag[]>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<number | null>(null);
const form = useForm({
resolver: zodResolver(GeneralFormSchema),
defaultValues: {
name: client?.name,
siteIds: []
name: client?.name
},
mode: "onChange"
});
@@ -75,23 +62,6 @@ export default function GeneralPage() {
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${client?.orgId}/sites/`
);
const availableSites = res.data.data.sites
.filter((s) => s.type === "newt" && s.subnet)
.map((site) => ({
id: site.siteId.toString(),
text: site.name
}));
setSites(availableSites);
// Filter sites to only include those assigned to the client
const assignedSites = availableSites.filter((site) =>
client?.siteIds?.includes(parseInt(site.id))
);
setClientSites(assignedSites);
// Set the default values for the form
form.setValue("siteIds", assignedSites);
} catch (e) {
toast({
variant: "destructive",
@@ -114,8 +84,7 @@ export default function GeneralPage() {
try {
await api.post(`/client/${client?.clientId}`, {
name: data.name,
siteIds: data.siteIds.map(site => parseInt(site.id))
name: data.name
});
updateClient({ name: data.name });
@@ -130,10 +99,7 @@ export default function GeneralPage() {
toast({
variant: "destructive",
title: t("clientUpdateFailed"),
description: formatAxiosError(
e,
t("clientUpdateError")
)
description: formatAxiosError(e, t("clientUpdateError"))
});
} finally {
setLoading(false);
@@ -173,39 +139,6 @@ export default function GeneralPage() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>{t("sites")}</FormLabel>
<TagInput
{...field}
activeTagIndex={activeSitesTagIndex}
setActiveTagIndex={setActiveSitesTagIndex}
placeholder={t("selectSites")}
size="sm"
tags={form.getValues().siteIds}
setTags={(newTags) => {
form.setValue(
"siteIds",
newTags as [Tag, ...Tag[]]
);
}}
enableAutocomplete={true}
autocompleteOptions={sites}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={true}
sortTags={true}
/>
<FormDescription>
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
@@ -224,4 +157,4 @@ export default function GeneralPage() {
</SettingsSection>
</SettingsContainer>
);
}
}

View File

@@ -1,19 +1,19 @@
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetClientResponse } from "@server/routers/client";
import ClientInfoCard from "../../../../../components/ClientInfoCard";
import ClientProvider from "@app/providers/ClientProvider";
import { redirect } from "next/navigation";
import ClientInfoCard from "@app/components/ClientInfoCard";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { getTranslations } from "next-intl/server";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import ClientProvider from "@app/providers/ClientProvider";
import { build } from "@server/build";
import { GetClientResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
type SettingsLayoutProps = {
children: React.ReactNode;
params: Promise<{ clientId: number | string; orgId: string }>;
}
};
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
@@ -36,16 +36,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const navItems = [
{
title: t('general'),
href: `/{orgId}/settings/clients/{clientId}/general`
title: t("general"),
href: `/{orgId}/settings/clients/machine/{clientId}/general`
},
...(build === 'enterprise'
? [{
title: t('credentials'),
href: `/{orgId}/settings/clients/{clientId}/credentials`
},
]
: []),
...(build === "enterprise"
? [
{
title: t("credentials"),
href: `/{orgId}/settings/clients/machine/{clientId}/credentials`
}
]
: [])
];
return (
@@ -58,9 +59,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<ClientProvider client={client}>
<div className="space-y-6">
<ClientInfoCard />
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</ClientProvider>
</>

View File

@@ -4,5 +4,7 @@ export default async function ClientPage(props: {
params: Promise<{ orgId: string; clientId: number | string }>;
}) {
const params = await props.params;
redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`);
redirect(
`/${params.orgId}/settings/clients/machine/${params.clientId}/general`
);
}

View File

@@ -42,10 +42,7 @@ import {
FaFreebsd,
FaWindows
} from "react-icons/fa";
import {
SiNixos,
SiKubernetes
} from "react-icons/si";
import { SiNixos, SiKubernetes } from "react-icons/si";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -71,9 +68,11 @@ interface TunnelTypeOption {
disabled?: boolean;
}
type CommandItem = string | { title: string; command: string };
type Commands = {
unix: Record<string, string[]>;
windows: Record<string, string[]>;
unix: Record<string, CommandItem[]>;
windows: Record<string, CommandItem[]>;
};
const platforms = ["unix", "windows"] as const;
@@ -93,18 +92,7 @@ export default function Page() {
.min(2, { message: t("nameMin", { len: 2 }) })
.max(30, { message: t("nameMax", { len: 30 }) }),
method: z.enum(["olm"]),
siteIds: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.refine((val) => val.length > 0, {
message: t("siteRequired")
}),
subnet: z.union([z.ipv4(), z.ipv6()])
.refine((val) => val.length > 0, {
subnet: z.union([z.ipv4(), z.ipv6()]).refine((val) => val.length > 0, {
message: t("subnetRequired")
})
});
@@ -123,10 +111,6 @@ export default function Page() {
]);
const [loadingPage, setLoadingPage] = useState(true);
const [sites, setSites] = useState<Tag[]>([]);
const [activeSitesTagIndex, setActiveSitesTagIndex] = useState<
number | null
>(null);
const [platform, setPlatform] = useState<Platform>("unix");
const [architecture, setArchitecture] = useState("All");
@@ -150,14 +134,26 @@ export default function Page() {
const commands = {
unix: {
All: [
`curl -fsSL https://pangolin.net/get-olm.sh | bash`,
`sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
{
title: t("install"),
command: `curl -fsSL https://pangolin.net/get-olm.sh | bash`
},
{
title: t("run"),
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
windows: {
x64: [
`curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`,
`olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
{
title: t("install"),
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
}
};
@@ -188,8 +184,8 @@ export default function Page() {
}
};
const getCommand = () => {
const placeholder = [t("unknownCommand")];
const getCommand = (): CommandItem[] => {
const placeholder: CommandItem[] = [t("unknownCommand")];
if (!commands) {
return placeholder;
}
@@ -236,12 +232,11 @@ export default function Page() {
}
};
const form = useForm({
const form = useForm<CreateClientFormValues>({
resolver: zodResolver(createClientFormSchema),
defaultValues: {
name: "",
method: "olm",
siteIds: [],
subnet: ""
}
});
@@ -262,7 +257,6 @@ export default function Page() {
const payload: CreateClientBody = {
name: data.name,
type: data.method as "olm",
siteIds: data.siteIds.map((site) => parseInt(site.id)),
olmId: clientDefaults.olmId,
secret: clientDefaults.olmSecret,
subnet: data.subnet
@@ -282,7 +276,7 @@ export default function Page() {
if (res && res.status === 201) {
const data = res.data.data;
router.push(`/${orgId}/settings/clients/${data.clientId}`);
router.push(`/${orgId}/settings/clients/machine/${data.clientId}`);
}
setCreateLoading(false);
@@ -294,18 +288,18 @@ export default function Page() {
// Fetch available sites
const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/`
);
const sites = res.data.data.sites.filter(
(s) => s.type === "newt" && s.subnet
);
setSites(
sites.map((site) => ({
id: site.siteId.toString(),
text: site.name
}))
);
// const res = await api.get<AxiosResponse<ListSitesResponse>>(
// `/org/${orgId}/sites/`
// );
// const sites = res.data.data.sites.filter(
// (s) => s.type === "newt" && s.subnet
// );
// setSites(
// sites.map((site) => ({
// id: site.siteId.toString(),
// text: site.name
// }))
// );
let olmVersion = "latest";
@@ -331,7 +325,7 @@ export default function Page() {
const latestVersion = data.tag_name;
olmVersion = latestVersion;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
if (error instanceof Error && error.name === "AbortError") {
console.error(t("olmErrorFetchTimeout"));
} else {
console.error(
@@ -416,116 +410,68 @@ export default function Page() {
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("address")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t("subnetPlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("addressDescription")}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteIds"
render={(field) => (
<FormItem className="flex flex-col">
<FormLabel>
{t("sites")}
</FormLabel>
<TagInput
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-client-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
activeTagIndex={
activeSitesTagIndex
}
setActiveTagIndex={
setActiveSitesTagIndex
}
placeholder={t("selectSites")}
size="sm"
tags={
form.getValues()
.siteIds
}
setTags={(
olmags
) => {
form.setValue(
"siteIds",
olmags as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
sites
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
<FormDescription>
{t("sitesDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"clientNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("clientAddress")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t(
"subnetPlaceholder"
)}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"addressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
@@ -537,7 +483,9 @@ export default function Page() {
{t("clientOlmCredentials")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("clientOlmCredentialsDescription")}
{t(
"clientOlmCredentialsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -659,13 +607,43 @@ export default function Page() {
<p className="font-bold mb-3">
{t("commands")}
</p>
<div className="mt-2">
<CopyTextBox
text={getCommand().join(
"\n"
)}
outline={true}
/>
<div className="mt-2 space-y-3">
{getCommand().map(
(item, index) => {
const commandText =
typeof item ===
"string"
? item
: item.command;
const title =
typeof item ===
"string"
? undefined
: item.title;
return (
<div
key={index}
>
{title && (
<p className="text-sm font-medium mb-1.5">
{
title
}
</p>
)}
<CopyTextBox
text={
commandText
}
outline={
true
}
/>
</div>
);
}
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,78 @@
import type { ClientRow } from "@app/components/MachineClientsTable";
import MachineClientsTable from "@app/components/MachineClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListClientsResponse } from "@server/routers/client";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
};
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let machineClients: ListClientsResponse["clients"] = [];
try {
const machineRes = await internal.get<
AxiosResponse<ListClientsResponse>
>(
`/org/${params.orgId}/clients?filter=machine`,
await authCookieHeader()
);
machineClients = machineRes.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const mapClientToRow = (
client: ListClientsResponse["clients"][0]
): ClientRow => {
return {
name: client.name,
id: client.clientId,
subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0),
mbOut: formatSize(client.megabytesOut || 0),
orgId: params.orgId,
online: client.online,
olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false,
userId: client.userId,
username: client.username,
userEmail: client.userEmail
};
};
const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow);
return (
<>
<SettingsSectionTitle
title={t("manageMachineClients")}
description={t("manageMachineClientsDescription")}
/>
<MachineClientsTable
machineClients={machineClientRows}
orgId={params.orgId}
/>
</>
);
}

View File

@@ -1,63 +1,13 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import { ClientRow } from "../../../../components/ClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import ClientsTable from "../../../../components/ClientsTable";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
};
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let clients: ListClientsResponse["clients"] = [];
try {
const res = await internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients`,
await authCookieHeader()
);
clients = res.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const clientRows: ClientRow[] = clients.map((client) => {
return {
name: client.name,
id: client.clientId,
subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0),
mbOut: formatSize(client.megabytesOut || 0),
orgId: params.orgId,
online: client.online,
olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false,
};
});
return (
<>
<SettingsSectionTitle
title={t("manageClients")}
description={t("manageClientsDescription")}
/>
<ClientsTable clients={clientRows} orgId={params.orgId} />
</>
);
redirect(`/${params.orgId}/settings/clients/user`);
}

View File

@@ -0,0 +1,75 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import { getTranslations } from "next-intl/server";
import type { ClientRow } from "@app/components/MachineClientsTable";
import UserDevicesTable from "@app/components/UserDevicesTable";
type ClientsPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations();
const params = await props.params;
let userClients: ListClientsResponse["clients"] = [];
try {
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients?filter=user`,
await authCookieHeader()
);
userClients = userRes.data.data.clients;
} catch (e) {}
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const mapClientToRow = (
client: ListClientsResponse["clients"][0]
): ClientRow => {
return {
name: client.name,
id: client.clientId,
subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0),
mbOut: formatSize(client.megabytesOut || 0),
orgId: params.orgId,
online: client.online,
olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false,
userId: client.userId,
username: client.username,
userEmail: client.userEmail
};
};
const userClientRows: ClientRow[] = userClients.map(mapClientToRow);
return (
<>
<SettingsSectionTitle
title={t("manageUserDevices")}
description={t("manageUserDevicesDescription")}
/>
<UserDevicesTable
userClients={userClientRows}
orgId={params.orgId}
/>
</>
);
}

View File

@@ -323,29 +323,25 @@ export default function GeneralPage() {
</FormItem>
)}
/>
{env.flags.enableClients && (
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("subnet")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("subnetDescription")}
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>{t("subnet")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("subnetDescription")}
</FormDescription>
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>

View File

@@ -82,7 +82,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<Layout
orgId={params.orgId}
orgs={orgs}
navItems={orgNavSections(env.flags.enableClients)}
navItems={orgNavSections()}
>
{children}
</Layout>

View File

@@ -466,7 +466,7 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<Link
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
>
<Button
variant="outline"
@@ -640,7 +640,7 @@ export default function GeneralPage() {
}}
defaultSort={{
id: "timestamp",
desc: false
desc: true
}}
// Server-side pagination props
totalCount={totalCount}

View File

@@ -493,7 +493,7 @@ export default function GeneralPage() {
}}
defaultSort={{
id: "timestamp",
desc: false
desc: true
}}
// Server-side pagination props
totalCount={totalCount}

View File

@@ -499,7 +499,7 @@ export default function GeneralPage() {
cell: ({ row }) => {
return (
<Link
href={`/${row.original.orgId}/settings/resources/${row.original.resourceNiceId}`}
href={`/${row.original.orgId}/settings/resources/proxy/${row.original.resourceNiceId}`}
onClick={(e) => e.stopPropagation()}
>
<Button
@@ -778,7 +778,7 @@ export default function GeneralPage() {
}}
defaultSort={{
id: "timestamp",
desc: false
desc: true
}}
// Server-side pagination props
totalCount={totalCount}
@@ -793,4 +793,4 @@ export default function GeneralPage() {
/>
</>
);
}
}

View File

@@ -0,0 +1,92 @@
import type { InternalResourceRow } from "@app/components/ClientResourcesTable";
import ClientResourcesTable from "@app/components/ClientResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
export interface ClientResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
}
export default async function ClientResourcesPage(
props: ClientResourcesPageProps
) {
const params = await props.params;
const t = await getTranslations();
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
);
resources = res.data.data.resources;
} catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) {}
let org = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => {
return {
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
siteName: siteResource.siteName,
siteAddress: siteResource.siteAddress || null,
mode: siteResource.mode || ("port" as any),
// protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,
destination: siteResource.destination,
// destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null,
siteNiceId: siteResource.siteNiceId
};
}
);
return (
<>
<SettingsSectionTitle
title={t("clientResourceTitle")}
description={t("clientResourceDescription")}
/>
<OrgProvider org={org}>
<ClientResourcesTable
internalResources={internalResourceRows}
orgId={params.orgId}
defaultSort={{
id: "name",
desc: false
}}
/>
</OrgProvider>
</>
);
}

View File

@@ -1,146 +1,10 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import ResourcesTable, {
ResourceRow,
InternalResourceRow
} from "../../../../components/ResourcesTable";
import { AxiosResponse } from "axios";
import { ListResourcesResponse } from "@server/routers/resource";
import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { redirect } from "next/navigation";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { toUnicode } from "punycode";
type ResourcesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
};
export const dynamic = "force-dynamic";
export interface ResourcesPageProps {
params: Promise<{ orgId: string }>;
}
export default async function ResourcesPage(props: ResourcesPageProps) {
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslations();
const env = pullEnv();
// Default to 'proxy' view, or use the query param if provided
let defaultView: "proxy" | "internal" = "proxy";
if (env.flags.enableClients) {
defaultView = searchParams.view === "internal" ? "internal" : "proxy";
}
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
);
resources = res.data.data.resources;
} catch (e) { }
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) { }
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const resourceRows: ResourceRow[] = resources.map((resource) => {
return {
id: resource.resourceId,
name: resource.name,
orgId: params.orgId,
nice: resource.niceId,
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
authState: !resource.http
? "none"
: resource.sso ||
resource.pincodeId !== null ||
resource.passwordId !== null ||
resource.whitelist ||
resource.headerAuthId
? "protected"
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
ssl: resource.ssl,
targets: resource.targets?.map(target => ({
targetId: target.targetId,
ip: target.ip,
port: target.port,
enabled: target.enabled,
healthStatus: target.healthStatus
}))
};
});
const internalResourceRows: InternalResourceRow[] = siteResources.map(
(siteResource) => {
return {
id: siteResource.siteResourceId,
name: siteResource.name,
orgId: params.orgId,
siteName: siteResource.siteName,
protocol: siteResource.protocol,
proxyPort: siteResource.proxyPort,
siteId: siteResource.siteId,
destinationIp: siteResource.destinationIp,
destinationPort: siteResource.destinationPort,
siteNiceId: siteResource.siteNiceId
};
}
);
return (
<>
<SettingsSectionTitle
title={t("resourceTitle")}
description={t("resourceDescription")}
/>
<OrgProvider org={org}>
<ResourcesTable
resources={resourceRows}
internalResources={internalResourceRows}
orgId={params.orgId}
defaultView={
env.flags.enableClients ? defaultView : "proxy"
}
defaultSort={{
id: "name",
desc: false
}}
/>
</OrgProvider>
</>
);
}
const params = await props.params;
redirect(`/${params.orgId}/settings/resources/proxy`);
}

View File

@@ -27,9 +27,9 @@ import {
} from "@app/components/ui/form";
import { ListUsersResponse } from "@server/routers/user";
import { Binary, Key, Bot } from "lucide-react";
import SetResourcePasswordForm from "../../../../../../components/SetResourcePasswordForm";
import SetResourcePincodeForm from "../../../../../../components/SetResourcePincodeForm";
import SetResourceHeaderAuthForm from "../../../../../../components/SetResourceHeaderAuthForm";
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm";
import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {

View File

@@ -54,7 +54,7 @@ import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
import { build } from "@server/build";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { DomainRow } from "../../../../../../components/DomainsTable";
import { DomainRow } from "@app/components/DomainsTable";
import { toASCII, toUnicode } from "punycode";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
@@ -235,7 +235,7 @@ export default function GeneralForm() {
});
if (data.niceId && data.niceId !== resource?.niceId) {
router.replace(`/${updated.orgId}/settings/resources/${data.niceId}/general`);
router.replace(`/${updated.orgId}/settings/resources/proxy/${data.niceId}/general`);
} else {
router.refresh();
}

View File

@@ -12,7 +12,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { GetOrgResponse } from "@server/routers/org";
import OrgProvider from "@app/providers/OrgProvider";
import { cache } from "react";
import ResourceInfoBox from "../../../../../components/ResourceInfoBox";
import ResourceInfoBox from "@app/components/ResourceInfoBox";
import { GetSiteResponse } from "@server/routers/site";
import { getTranslations } from 'next-intl/server';
@@ -77,22 +77,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
const navItems = [
{
title: t('general'),
href: `/{orgId}/settings/resources/{niceId}/general`
href: `/{orgId}/settings/resources/proxy/{niceId}/general`
},
{
title: t('proxy'),
href: `/{orgId}/settings/resources/{niceId}/proxy`
href: `/{orgId}/settings/resources/proxy/{niceId}/proxy`
}
];
if (resource.http) {
navItems.push({
title: t('authentication'),
href: `/{orgId}/settings/resources/{niceId}/authentication`
href: `/{orgId}/settings/resources/proxy/{niceId}/authentication`
});
navItems.push({
title: t('rules'),
href: `/{orgId}/settings/resources/{niceId}/rules`
href: `/{orgId}/settings/resources/proxy/{niceId}/rules`
});
}

View File

@@ -5,6 +5,6 @@ export default async function ResourcePage(props: {
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/${params.niceId}/proxy`
`/${params.orgId}/settings/resources/proxy/${params.niceId}/proxy`
);
}

View File

@@ -693,6 +693,7 @@ export default function ReverseProxyTargets(props: {
target.port <= 0 ||
isNaN(target.port)
);
console.log(targetsWithInvalidFields);
if (targetsWithInvalidFields.length > 0) {
toast({
variant: "destructive",
@@ -1139,7 +1140,7 @@ export default function ReverseProxyTargets(props: {
)}
{resource.http && (
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
@@ -1187,7 +1188,7 @@ export default function ReverseProxyTargets(props: {
}
}}
/>
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
@@ -1397,21 +1398,25 @@ export default function ReverseProxyTargets(props: {
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
(header) => {
const isActionsColumn = header.column.id === "actions";
return (
<TableHead
key={header.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
))}
@@ -1424,21 +1429,25 @@ export default function ReverseProxyTargets(props: {
<TableRow key={row.id}>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
))}
.map((cell) => {
const isActionsColumn = cell.column.id === "actions";
return (
<TableCell
key={
cell.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
@@ -1830,9 +1839,7 @@ export default function ReverseProxyTargets(props: {
undefined,
}}
onChanges={async (config) => {
console.log("here");
if (selectedTargetForHealthCheck) {
console.log(config);
updateTargetHealthCheck(
selectedTargetForHealthCheck.targetId,
config

View File

@@ -463,7 +463,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "action",
header: t('rulesAction'),
header: () => (<span className="p-3">{t('rulesAction')}</span>),
cell: ({ row }) => (
<Select
defaultValue={row.original.action}
@@ -486,7 +486,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "match",
header: t('rulesMatchType'),
header: () => (<span className="p-3">{t('rulesMatchType')}</span>),
cell: ({ row }) => (
<Select
defaultValue={row.original.match}
@@ -510,7 +510,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "value",
header: t('value'),
header: () => (<span className="p-3">{t('value')}</span>),
cell: ({ row }) => (
row.original.match === "COUNTRY" ? (
<Popover>
@@ -571,7 +571,7 @@ export default function ResourceRules(props: {
},
{
accessorKey: "enabled",
header: t('enabled'),
header: () => (<span className="p-3">{t('enabled')}</span>),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
@@ -583,8 +583,9 @@ export default function ResourceRules(props: {
},
{
id: "actions",
header: () => (<span className="p-3">{t('actions')}</span>),
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<div className="flex items-center space-x-2">
<Button
variant="outline"
onClick={() => removeRule(row.original.ruleId)}
@@ -829,7 +830,7 @@ export default function ResourceRules(props: {
/>
<Button
type="submit"
variant="secondary"
variant="outline"
disabled={!rulesEnabled}
>
{t('ruleSubmit')}
@@ -841,17 +842,23 @@ export default function ResourceRules(props: {
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
))}
{headerGroup.headers.map((header) => {
const isActionsColumn = header.column.id === "actions";
return (
<TableHead
key={header.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef
.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
@@ -859,14 +866,20 @@ export default function ResourceRules(props: {
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
{row.getVisibleCells().map((cell) => {
const isActionsColumn = cell.column.id === "actions";
return (
<TableCell
key={cell.id}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (

View File

@@ -98,7 +98,7 @@ import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "../../../../../components/DomainsTable";
import { DomainRow } from "@app/components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import {
Tooltip,
@@ -607,7 +607,7 @@ export default function Page() {
}
if (isHttp) {
router.push(`/${orgId}/settings/resources/${niceId}`);
router.push(`/${orgId}/settings/resources/proxy/${niceId}`);
} else {
const tcpUdpData = tcpUdpForm.getValues();
// Only show config snippets if enableProxy is explicitly true
@@ -616,7 +616,7 @@ export default function Page() {
router.refresh();
// } else {
// // If enableProxy is false or undefined, go directly to resource page
// router.push(`/${orgId}/settings/resources/${id}`);
// router.push(`/${orgId}/settings/resources/proxy/${id}`);
// }
}
}
@@ -780,7 +780,7 @@ export default function Page() {
const healthCheckColumn: ColumnDef<LocalTarget> = {
accessorKey: "healthCheck",
header: t("healthCheck"),
header: () => (<span className="p-3">{t("healthCheck")}</span>),
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
@@ -852,7 +852,7 @@ export default function Page() {
const matchPathColumn: ColumnDef<LocalTarget> = {
accessorKey: "path",
header: t("matchPath"),
header: () => (<span className="p-3">{t("matchPath")}</span>),
cell: ({ row }) => {
const hasPathMatch = !!(
row.original.path || row.original.pathMatchType
@@ -914,7 +914,7 @@ export default function Page() {
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: t("address"),
header: () => (<span className="p-3">{t("address")}</span>),
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
@@ -933,7 +933,7 @@ export default function Page() {
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input shadow-2xs rounded-md">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
@@ -1043,7 +1043,7 @@ export default function Page() {
)}
{isHttp && (
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
@@ -1091,7 +1091,7 @@ export default function Page() {
}
}}
/>
<div className="flex items-center justify-center bg-muted px-2 h-9">
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
@@ -1128,7 +1128,7 @@ export default function Page() {
const rewritePathColumn: ColumnDef<LocalTarget> = {
accessorKey: "rewritePath",
header: t("rewritePath"),
header: () => (<span className="p-3">{t("rewritePath")}</span>),
cell: ({ row }) => {
const hasRewritePath = !!(
row.original.rewritePath || row.original.rewritePathType
@@ -1198,7 +1198,7 @@ export default function Page() {
const enabledColumn: ColumnDef<LocalTarget> = {
accessorKey: "enabled",
header: t("enabled"),
header: () => (<span className="p-3">{t("enabled")}</span>),
cell: ({ row }) => (
<div className="flex items-center justify-center w-full">
<Switch
@@ -1219,6 +1219,7 @@ export default function Page() {
const actionsColumn: ColumnDef<LocalTarget> = {
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => (
<div className="flex items-center justify-end w-full">
<Button
@@ -1594,23 +1595,27 @@ export default function Page() {
{headerGroup.headers.map(
(
header
) => (
<TableHead
key={
header.id
}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
)
) => {
const isActionsColumn = header.column.id === "actions";
return (
<TableHead
key={
header.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</TableHead>
);
}
)}
</TableRow>
)
@@ -1633,21 +1638,25 @@ export default function Page() {
.map(
(
cell
) => (
<TableCell
key={
cell.id
}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
)
) => {
const isActionsColumn = cell.column.id === "actions";
return (
<TableCell
key={
cell.id
}
className={isActionsColumn ? "sticky right-0 z-10 w-auto min-w-fit bg-card" : ""}
>
{flexRender(
cell
.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
}
)}
</TableRow>
)
@@ -1895,7 +1904,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${niceId}/proxy`
`/${orgId}/settings/resources/proxy/${niceId}/proxy`
)
}
>

View File

@@ -0,0 +1,112 @@
import type { ResourceRow } from "@app/components/ProxyResourcesTable";
import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { toUnicode } from "punycode";
import { cache } from "react";
export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
}
export default async function ProxyResourcesPage(
props: ProxyResourcesPageProps
) {
const params = await props.params;
const t = await getTranslations();
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
);
resources = res.data.data.resources;
} catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) {}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources`);
}
const resourceRows: ResourceRow[] = resources.map((resource) => {
return {
id: resource.resourceId,
name: resource.name,
orgId: params.orgId,
nice: resource.niceId,
domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`,
protocol: resource.protocol,
proxyPort: resource.proxyPort,
http: resource.http,
authState: !resource.http
? "none"
: resource.sso ||
resource.pincodeId !== null ||
resource.passwordId !== null ||
resource.whitelist ||
resource.headerAuthId
? "protected"
: "not_protected",
enabled: resource.enabled,
domainId: resource.domainId || undefined,
ssl: resource.ssl,
targets: resource.targets?.map((target) => ({
targetId: target.targetId,
ip: target.ip,
port: target.port,
enabled: target.enabled,
healthStatus: target.healthStatus
}))
};
});
return (
<>
<SettingsSectionTitle
title={t("proxyResourceTitle")}
description={t("proxyResourceDescription")}
/>
<OrgProvider org={org}>
<ProxyResourcesTable
resources={resourceRows}
orgId={params.orgId}
defaultSort={{
id: "name",
desc: false
}}
/>
</OrgProvider>
</>
);
}

View File

@@ -33,20 +33,11 @@ import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Tag, TagInput } from "@app/components/tags/tag-input";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(),
remoteSubnets: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional()
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -69,12 +60,6 @@ export default function GeneralPage() {
name: site?.name,
niceId: site?.niceId || "",
dockerSocketEnabled: site?.dockerSocketEnabled ?? false,
remoteSubnets: site?.remoteSubnets
? site.remoteSubnets.split(",").map((subnet, index) => ({
id: subnet.trim(),
text: subnet.trim()
}))
: []
},
mode: "onChange"
});
@@ -87,18 +72,12 @@ export default function GeneralPage() {
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets:
data.remoteSubnets
?.map((subnet) => subnet.text)
.join(",") || ""
});
updateSite({
name: data.name,
niceId: data.niceId,
dockerSocketEnabled: data.dockerSocketEnabled,
remoteSubnets:
data.remoteSubnets?.map((subnet) => subnet.text).join(",") || ""
});
if (data.niceId && data.niceId !== site?.niceId) {
@@ -174,64 +153,6 @@ export default function GeneralPage() {
)}
/>
{env.flags.enableClients && site.type === "newt" ? (
<FormField
control={form.control}
name="remoteSubnets"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("remoteSubnets")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeCidrTagIndex
}
setActiveTagIndex={
setActiveCidrTagIndex
}
placeholder={t(
"enterCidrRange"
)}
size="sm"
tags={
form.getValues()
.remoteSubnets ||
[]
}
setTags={(
newSubnets
) => {
form.setValue(
"remoteSubnets",
newSubnets as Tag[]
);
}}
validateTag={(tag) => {
// Basic CIDR validation regex
const cidrRegex =
/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
return cidrRegex.test(
tag
);
}}
allowDuplicates={false}
sortTags={true}
/>
</FormControl>
<FormDescription>
{t(
"remoteSubnetsDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : null}
{site && site.type === "newt" && (
<FormField
control={form.control}

View File

@@ -78,13 +78,15 @@ interface RemoteExitNodeOption {
disabled?: boolean;
}
type CommandItem = string | { title: string; command: string };
type Commands = {
unix: Record<string, string[]>;
windows: Record<string, string[]>;
docker: Record<string, string[]>;
kubernetes: Record<string, string[]>;
podman: Record<string, string[]>;
nixos: Record<string, string[]>;
unix: Record<string, CommandItem[]>;
windows: Record<string, CommandItem[]>;
docker: Record<string, CommandItem[]>;
kubernetes: Record<string, CommandItem[]>;
podman: Record<string, CommandItem[]>;
nixos: Record<string, CommandItem[]>;
};
const platforms = [
@@ -199,7 +201,7 @@ export default function Page() {
const [wgConfig, setWgConfig] = useState("");
const [createLoading, setCreateLoading] = useState(false);
const [acceptClients, setAcceptClients] = useState(false);
const [acceptClients, setAcceptClients] = useState(true);
const [newtVersion, setNewtVersion] = useState("latest");
const [siteDefaults, setSiteDefaults] =
@@ -238,24 +240,36 @@ PersistentKeepalive = 5`;
secret: string,
endpoint: string,
version: string,
acceptClients: boolean = false
acceptClients: boolean = true
) => {
const acceptClientsFlag = acceptClients ? " --accept-clients" : "";
const acceptClientsEnv = acceptClients
? "\n - ACCEPT_CLIENTS=true"
const acceptClientsFlag = !acceptClients ? " --disable-clients" : "";
const acceptClientsEnv = !acceptClients
? "\n - DISABLE_CLIENTS=true"
: "";
const commands = {
unix: {
All: [
`curl -fsSL https://pangolin.net/get-newt.sh | bash`,
`newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
{
title: t("install"),
command: `curl -fsSL https://pangolin.net/get-newt.sh | bash`
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
]
},
windows: {
x64: [
`curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`,
`newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
{
title: t("install"),
command: `curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`
},
{
title: t("run"),
command: `newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
]
},
docker: {
@@ -296,7 +310,7 @@ ContainerName=newt
Image=docker.io/fosrl/newt
Environment=PANGOLIN_ENDPOINT=${endpoint}
Environment=NEWT_ID=${id}
Environment=NEWT_SECRET=${secret}${acceptClients ? "\nEnvironment=ACCEPT_CLIENTS=true" : ""}
Environment=NEWT_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
# Secret=newt-secret,type=env,target=NEWT_SECRET
[Service]
@@ -356,8 +370,8 @@ WantedBy=default.target`
}
};
const getCommand = () => {
const placeholder = [t("unknownCommand")];
const getCommand = (): CommandItem[] => {
const placeholder: CommandItem[] = [t("unknownCommand")];
if (!commands) {
return placeholder;
}
@@ -409,7 +423,7 @@ WantedBy=default.target`
copied: false,
method: "newt",
clientAddress: "",
acceptClients: false,
acceptClients: true,
exitNodeId: undefined
}
});
@@ -679,109 +693,101 @@ WantedBy=default.target`
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4"
id="create-site-form"
>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh
}
}}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{form.watch("method") === "newt" && (
<FormField
control={form.control}
name="name"
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
{t("siteAddress")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e.target
.value
);
field.onChange(
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{env.flags.enableClients &&
form.watch("method") ===
"newt" && (
<FormField
control={form.control}
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"siteAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
)}
</form>
</Form>
{tunnelTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">{t("type")}</span>
</div>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues(
"method"
)}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</>
)}
</SettingsSectionBody>
</SettingsSection>
{tunnelTypes.length > 1 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("tunnelType")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("siteTunnelDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues("method")}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</SettingsSectionBody>
</SettingsSection>
)}
{form.watch("method") === "newt" && (
<>
<SettingsSection>
@@ -996,7 +1002,7 @@ WantedBy=default.target`
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground mb-4"
className="text-sm text-muted-foreground"
>
{t(
"siteAcceptClientConnectionsDescription"
@@ -1008,13 +1014,43 @@ WantedBy=default.target`
<p className="font-bold mb-3">
{t("commands")}
</p>
<div className="mt-2">
<CopyTextBox
text={getCommand().join(
"\n"
)}
outline={true}
/>
<div className="mt-2 space-y-3">
{getCommand().map(
(item, index) => {
const commandText =
typeof item ===
"string"
? item
: item.command;
const title =
typeof item ===
"string"
? undefined
: item.title;
return (
<div
key={index}
>
{title && (
<p className="text-sm font-medium mb-1.5">
{
title
}
</p>
)}
<CopyTextBox
text={
commandText
}
outline={
true
}
/>
</div>
);
}
)}
</div>
</div>
</div>

View File

@@ -1,6 +1,5 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -18,6 +17,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
export type GlobalUserRow = {
id: string;
@@ -66,9 +66,10 @@ export default function UsersTable({ users }: Props) {
});
};
const columns: ColumnDef<GlobalUserRow>[] = [
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -84,6 +85,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "username",
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
@@ -100,6 +102,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
@@ -116,6 +119,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -132,6 +136,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
@@ -148,6 +153,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
@@ -182,11 +188,21 @@ export default function UsersTable({ users }: Props) {
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2">
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -210,16 +226,6 @@ export default function UsersTable({ users }: Props) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
size="sm"
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</>
);
@@ -238,13 +244,9 @@ export default function UsersTable({ users }: Props) {
}}
dialog={
<div>
<p>
{t("userQuestionRemove")}
</p>
<p>{t("userQuestionRemove")}</p>
<p>
{t("userMessageRemove")}
</p>
<p>{t("userMessageRemove")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}

View File

@@ -154,7 +154,7 @@ export default async function OrgAuthPage(props: {
</Link>
</span>
</div>
<Card className="shadow-md w-full max-w-md">
<Card className="w-full max-w-md">
<CardHeader>
{branding?.logoUrl && (
<div className="flex flex-row items-center justify-center mb-3">

View File

@@ -1,5 +1,13 @@
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { AxiosResponse } from "axios";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
export const metadata: Metadata = {
title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -11,15 +19,117 @@ type AuthLayoutProps = {
};
export default async function AuthLayout({ children }: AuthLayoutProps) {
const env = pullEnv();
const t = await getTranslations();
let hideFooter = false;
if (build == "enterprise") {
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
if (
env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid
) {
hideFooter = true;
}
}
return (
<div className="h-full flex flex-col">
<div className="flex justify-end items-center p-3 space-x-2">
<ThemeSwitcher />
</div>
<div className="flex-1 flex items-center justify-center">
<div className="flex-1 flex md:items-center justify-center">
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!hideFooter && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-xs text-neutral-400 dark:text-neutral-600">
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
© {new Date().getFullYear()} Fossorial, Inc.
</span>
</a>
<Separator orientation="vertical" />
<a
href="https://pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>
{process.env.BRANDING_APP_NAME || "Pangolin"}
</span>
</a>
<Separator orientation="vertical" />
<span>
{build === "oss"
? t("communityEdition")
: build === "enterprise"
? t("enterpriseEdition")
: t("pangolinCloud")}
</span>
{build === "saas" && (
<>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("terms")}</span>
</a>
<Separator orientation="vertical" />
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("privacy")}</span>
</a>
</>
)}
<Separator orientation="vertical" />
<a
href="https://docs.pangolin.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("docs")}</span>
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t("github")}</span>
</a>
</div>
</footer>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import DeviceLoginForm from "@/components/DeviceLoginForm";
import { cache } from "react";
export const dynamic = "force-dynamic";
type Props = {
searchParams: Promise<{ code?: string }>;
};
export default async function DeviceLoginPage({ searchParams }: Props) {
const user = await verifySession({ forceLogin: true });
const params = await searchParams;
const code = params.code || "";
if (!user) {
const redirectDestination = code
? `/auth/login/device?code=${encodeURIComponent(code)}`
: "/auth/login/device";
redirect(`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`);
}
const userName = user?.name || user?.username || "";
return (
<DeviceLoginForm
userEmail={user?.email || ""}
userName={userName}
initialCode={code}
/>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import BrandingLogo from "@app/components/BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { CheckCircle2 } from "lucide-react";
import { useTranslations } from "next-intl";
export default function DeviceAuthSuccessPage() {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card>
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{t("deviceActivation")}</p>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="flex flex-col items-center space-y-4">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div className="space-y-2">
<h3 className="text-xl font-bold text-center">
{t("deviceConnected")}
</h3>
<p className="text-center text-sm text-muted-foreground">
{t("deviceAuthorizedMessage")}
</p>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -25,12 +25,14 @@ export default async function Page(props: {
const user = await getUser({ skipCheckVerifyEmail: true });
const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin;
const forceLogin = forceLoginParam === "true";
const env = pullEnv();
const signUpDisabled = env.flags.disableSignupWithoutInvite;
if (user) {
if (user && !forceLogin) {
redirect("/");
}
@@ -53,7 +55,7 @@ export default async function Page(props: {
if (loginPageDomain) {
const redirectUrl = searchParams.redirect as string | undefined;
let url = `https://${loginPageDomain}/auth/org`;
if (redirectUrl) {
url += `?redirect=${redirectUrl}`;
@@ -96,7 +98,7 @@ export default async function Page(props: {
</div>
)}
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} />
<DashboardLoginForm redirect={redirectUrl} idps={loginIdps} forceLogin={forceLogin} />
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">

View File

@@ -20,7 +20,7 @@ import { Toaster } from "@app/components/ui/toaster";
import { build } from "@server/build";
import { TopLoader } from "@app/components/Toploader";
import Script from "next/script";
import { ReactQueryProvider } from "@app/components/react-query-provider";
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
export const metadata: Metadata = {
@@ -96,16 +96,16 @@ export default async function RootLayout({
strategy="afterInteractive"
/>
)}
<ReactQueryProvider>
<NextIntlClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeDataProvider colors={loadBrandingColors()}>
<EnvProvider env={pullEnv()}>
<NextIntlClientProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ThemeDataProvider colors={loadBrandingColors()}>
<EnvProvider env={env}>
<TanstackQueryProvider>
<LicenseStatusProvider
licenseStatus={licenseStatus}
>
@@ -125,11 +125,11 @@ export default async function RootLayout({
</SupportStatusProvider>
</LicenseStatusProvider>
<Toaster />
</EnvProvider>
</ThemeDataProvider>
</ThemeProvider>
</NextIntlClientProvider>
</ReactQueryProvider>
</TanstackQueryProvider>
</EnvProvider>
</ThemeDataProvider>
</ThemeProvider>
</NextIntlClientProvider>
{process.env.NODE_ENV === "development" && (
<TailwindIndicator />

View File

@@ -18,6 +18,9 @@ import {
Logs,
SquareMousePointer,
ScanEye,
GlobeLock,
Smartphone,
Laptop,
ChartLine
} from "lucide-react";
@@ -32,42 +35,58 @@ export const orgLangingNavItems: SidebarNavItem[] = [
{
title: "sidebarAccount",
href: "/{orgId}",
icon: <User className="h-4 w-4" />
icon: <User className="size-4 flex-none" />
}
];
export const orgNavSections = (
enableClients: boolean = true
): SidebarNavSection[] => [
export const orgNavSections = (): SidebarNavSection[] => [
{
heading: "sidebarGeneral",
items: [
{
title: "sidebarSites",
href: "/{orgId}/settings/sites",
icon: <Combine className="h-4 w-4" />
icon: <Combine className="size-4 flex-none" />
},
{
title: "sidebarResources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
icon: <Waypoints className="size-4 flex-none" />,
items: [
{
title: "sidebarProxyResources",
href: "/{orgId}/settings/resources/proxy",
icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarClientResources",
href: "/{orgId}/settings/resources/client",
icon: <GlobeLock className="size-4 flex-none" />
}
]
},
...(enableClients
? [
{
title: "sidebarClients",
href: "/{orgId}/settings/clients",
icon: <MonitorUp className="h-4 w-4" />,
isBeta: true
}
]
: []),
...(build === "saas"
{
title: "sidebarClients",
icon: <MonitorUp className="size-4 flex-none" />,
isBeta: true,
items: [
{
href: "/{orgId}/settings/clients/user",
title: "sidebarUserDevices",
icon: <Laptop className="size-4 flex-none" />
},
{
href: "/{orgId}/settings/clients/machine",
title: "sidebarMachineClients",
icon: <Server className="size-4 flex-none" />
}
]
},
...(build == "saas"
? [
{
title: "sidebarRemoteExitNodes",
href: "/{orgId}/settings/remote-exit-nodes",
icon: <Server className="h-4 w-4" />,
icon: <Server className="size-4 flex-none" />,
showEE: true
}
]
@@ -75,39 +94,45 @@ export const orgNavSections = (
{
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
icon: <Globe className="h-4 w-4" />
icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="h-4 w-4" />
icon: <ReceiptText className="size-4 flex-none" />
}
]
},
{
heading: "sidebarAccessControl",
heading: "accessControls",
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="h-4 w-4" />
icon: <User className="size-4 flex-none" />,
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="size-4 flex-none" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
icon: <TicketCheck className="size-4 flex-none" />
}
]
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="h-4 w-4" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
icon: <TicketCheck className="h-4 w-4" />
icon: <Users className="size-4 flex-none" />
},
...(build == "saas"
? [
{
title: "sidebarIdentityProviders",
href: "/{orgId}/settings/idp",
icon: <Fingerprint className="h-4 w-4" />,
icon: <Fingerprint className="size-4 flex-none" />,
showEE: true
}
]
@@ -115,38 +140,56 @@ export const orgNavSections = (
{
title: "sidebarShareableLinks",
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
icon: <LinkIcon className="size-4 flex-none" />
}
]
},
{
heading: "sidebarLogAndAnalytics",
items: [
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="h-4 w-4" />
},
{
heading: "sidebarLogsAndAnalytics",
items: (() => {
const logItems: SidebarNavItem[] = [
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" />
},
...(build != "oss"
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
];
const analytics = {
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="h-4 w-4" />
},
...(build != "oss"
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="h-4 w-4" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="h-4 w-4" />
}
]
: [])
]
};
// If only one log item, return it directly without grouping
if (logItems.length === 1) {
return [analytics, ...logItems];
}
// If multiple log items, create a group
return [
analytics,
{
title: "sidebarLogs",
icon: <Logs className="size-4 flex-none" />,
items: logItems
}
];
})()
},
{
heading: "sidebarOrganization",
@@ -154,14 +197,14 @@ export const orgNavSections = (
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />
icon: <KeyRound className="size-4 flex-none" />
},
...(build == "saas"
? [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <CreditCard className="h-4 w-4" />
icon: <CreditCard className="size-4 flex-none" />
}
]
: []),
@@ -170,14 +213,14 @@ export const orgNavSections = (
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: <TicketCheck className="h-4 w-4" />
icon: <TicketCheck className="size-4 flex-none" />
}
]
: []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="h-4 w-4" />
icon: <Settings className="size-4 flex-none" />
}
]
}
@@ -185,29 +228,29 @@ export const orgNavSections = (
export const adminNavSections: SidebarNavSection[] = [
{
heading: "Admin",
heading: "sidebarAdmin",
items: [
{
title: "sidebarAllUsers",
href: "/admin/users",
icon: <Users className="h-4 w-4" />
icon: <Users className="size-4 flex-none" />
},
{
title: "sidebarApiKeys",
href: "/admin/api-keys",
icon: <KeyRound className="h-4 w-4" />
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarIdentityProviders",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
icon: <Fingerprint className="size-4 flex-none" />
},
...(build == "enterprise"
? [
{
title: "sidebarLicense",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
icon: <TicketCheck className="size-4 flex-none" />
}
]
: [])

View File

@@ -80,7 +80,7 @@ export default async function Page(props: {
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
if (lastOrgExists) {
if (lastOrgExists && lastOrgCookie) {
redirect(`/${lastOrgCookie}`);
} else {
let ownedOrg = orgs.find((org) => org.isOwner);

View File

@@ -76,8 +76,8 @@ export default function StepperForm() {
} catch (e) {
console.error("Failed to fetch default subnet:", e);
toast({
title: "Error",
description: "Failed to fetch default subnet",
title: t("error"),
description: t("setupFailedToFetchSubnet"),
variant: "destructive"
});
}
@@ -296,31 +296,27 @@ export default function StepperForm() {
)}
/>
{env.flags.enableClients && (
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
Subnet
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
Network subnet for this
organization. A default
value has been provided.
</FormDescription>
</FormItem>
)}
/>
)}
<FormField
control={orgForm.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("setupSubnetAdvanced")}
</FormLabel>
<FormControl>
<Input
type="text"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t("setupSubnetDescription")}
</FormDescription>
</FormItem>
)}
/>
{orgIdTaken && !orgCreated ? (
<Alert variant="destructive">

View File

@@ -35,6 +35,9 @@ export function IdpDataTable<TData, TValue>({
}}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/AdminIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -77,9 +78,10 @@ export default function IdpTable({ idps }: Props) {
}
};
const columns: ColumnDef<IdpRow>[] = [
const columns: ExtendedColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -96,6 +98,8 @@ export default function IdpTable({ idps }: Props) {
},
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -112,6 +116,7 @@ export default function IdpTable({ idps }: Props) {
},
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -133,10 +138,12 @@ export default function IdpTable({ idps }: Props) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -169,9 +176,7 @@ export default function IdpTable({ idps }: Props) {
</DropdownMenu>
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -32,6 +32,9 @@ export function UsersDataTable<TData, TValue>({
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="username"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -18,6 +19,18 @@ import {
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Credenza,
CredenzaContent,
CredenzaDescription,
CredenzaHeader,
CredenzaTitle,
CredenzaBody,
CredenzaFooter,
CredenzaClose
} from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { AxiosResponse } from "axios";
export type GlobalUserRow = {
id: string;
@@ -36,6 +49,12 @@ type Props = {
users: GlobalUserRow[];
};
type AdminGeneratePasswordResetCodeResponse = {
token: string;
email: string;
url: string;
};
export default function UsersTable({ users }: Props) {
const router = useRouter();
const t = useTranslations();
@@ -47,6 +66,11 @@ export default function UsersTable({ users }: Props) {
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] =
useState(false);
const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const refreshData = async () => {
console.log("Data refreshed");
@@ -85,9 +109,33 @@ export default function UsersTable({ users }: Props) {
});
};
const columns: ColumnDef<GlobalUserRow>[] = [
const generatePasswordResetCode = async (userId: string) => {
setIsGeneratingCode(true);
try {
const res = await api.post<
AxiosResponse<AdminGeneratePasswordResetCodeResponse>
>(`/user/${userId}/generate-password-reset-code`);
if (res.data?.data) {
setPasswordResetCodeData(res.data.data);
setIsPasswordResetCodeDialogOpen(true);
}
} catch (e) {
console.error("Failed to generate password reset code", e);
toast({
variant: "destructive",
title: t("error"),
description: formatAxiosError(e, t("errorOccurred"))
});
} finally {
setIsGeneratingCode(false);
}
};
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -103,6 +151,8 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "username",
enableHiding: false,
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
@@ -119,6 +169,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
@@ -135,6 +186,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -151,6 +203,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
@@ -167,6 +220,7 @@ export default function UsersTable({ users }: Props) {
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
@@ -187,7 +241,7 @@ export default function UsersTable({ users }: Props) {
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
@@ -201,46 +255,49 @@ export default function UsersTable({ users }: Props) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center justify-end gap-2">
<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">
<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">
{r.type !== "internal" && (
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
generatePasswordResetCode(r.id);
}}
>
{t("delete")}
{t("generatePasswordResetCode")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
size="sm"
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
</>
)}
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</div>
);
}
}
@@ -288,6 +345,58 @@ export default function UsersTable({ users }: Props) {
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
<Credenza
open={isPasswordResetCodeDialogOpen}
onOpenChange={setIsPasswordResetCodeDialogOpen}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("passwordResetCodeGenerated")}
</CredenzaTitle>
<CredenzaDescription>
{t("passwordResetCodeGeneratedDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{passwordResetCodeData && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">
{t("email")}
</label>
<CopyToClipboard
text={passwordResetCodeData.email}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("passwordResetCode")}
</label>
<CopyToClipboard
text={passwordResetCodeData.token}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
{t("passwordResetUrl")}
</label>
<CopyToClipboard
text={passwordResetCodeData.url}
isLink={true}
/>
</div>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -59,6 +59,9 @@ export function ApiKeysDataTable<TData, TValue>({
addButtonText={t('apiKeysAdd')}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -85,9 +86,11 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
});
};
const columns: ColumnDef<ApiKeyRow>[] = [
const columns: ExtendedColumnDef<ApiKeyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -104,7 +107,8 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
accessorKey: "key",
header: t("key"),
friendlyName: t("key"),
header: () => (<span className="p-3">{t("key")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -112,7 +116,8 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
accessorKey: "createdAt",
header: t("createdAt"),
friendlyName: t("createdAt"),
header: () => (<span className="p-3">{t("createdAt")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
@@ -120,10 +125,12 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -153,14 +160,14 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center justify-end">
<Link href={`/admin/api-keys/${r.id}`}>
<Button variant={"secondary"} className="ml-2" size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
<Link href={`/admin/api-keys/${r.id}`}>
<Button
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
@@ -178,13 +185,9 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
}}
dialog={
<div>
<p>
{t("apiKeysQuestionRemove")}
</p>
<p>{t("apiKeysQuestionRemove")}</p>
<p>
{t("apiKeysMessageRemove")}
</p>
<p>{t("apiKeysMessageRemove")}</p>
</div>
}
buttonText={t("apiKeysDeleteConfirm")}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
@@ -30,9 +31,10 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
const [isRefreshing, startTransition] = useTransition();
const router = useRouter();
const columns: ColumnDef<BlueprintRow>[] = [
const columns: ExtendedColumnDef<BlueprintRow>[] = [
{
accessorKey: "createdAt",
friendlyName: t("appliedAt"),
header: ({ column }) => {
return (
<Button
@@ -61,6 +63,8 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
},
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -78,6 +82,7 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
{
accessorKey: "source",
friendlyName: t("source"),
header: ({ column }) => {
return (
<Button
@@ -160,9 +165,8 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
},
{
id: "actions",
header: () => {
return null;
},
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
return (
<div className="flex justify-end">
@@ -188,6 +192,9 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
title={t("blueprints")}
searchPlaceholder={t("searchBlueprintProgress")}
searchColumn="name"
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
onAdd={() => {
router.push(`/${orgId}/settings/blueprints/create`);
}}

View File

@@ -0,0 +1,348 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ArrowUpDown, ArrowUpRight, MoreHorizontal } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
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;
orgId: string;
siteName: string;
siteAddress: string | null;
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr";
// protocol: string | null;
// proxyPort: number | null;
siteId: number;
siteNiceId: string;
destination: string;
// destinationPort: number | null;
alias: string | null;
};
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
defaultSort?: {
id: string;
desc: boolean;
};
};
export default function ClientResourcesTable({
internalResources,
orgId,
defaultSort
}: ClientResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: sites = [] } = useQuery(orgQueries.sites({ orgId }));
const [isRefreshing, startTransition] = useTransition();
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteInternalResource = async (
resourceId: number,
siteId: number
) => {
try {
await api
.delete(`/org/${orgId}/site/${siteId}/resource/${resourceId}`)
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
} catch (e) {
console.error(t("resourceErrorDelete"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("v"))
});
}
};
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "siteName",
friendlyName: t("site"),
header: () => <span className="p-3">{t("site")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
},
{
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<span className="p-3">
{t("editInternalResourceDialogMode")}
</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
const modeLabels: Record<"host" | "cidr" | "port", string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
port: t("editInternalResourceDialogModePort")
};
return <span>{modeLabels[resourceRow.mode]}</span>;
}
},
{
accessorKey: "destination",
friendlyName: t("resourcesTableDestination"),
header: () => (
<span className="p-3">{t("resourcesTableDestination")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
return (
<CopyToClipboard
text={resourceRow.destination}
isLink={false}
displayText={resourceRow.destination}
/>
);
}
},
{
accessorKey: "alias",
friendlyName: t("resourcesTableAlias"),
header: () => (
<span className="p-3">{t("resourcesTableAlias")}</span>
),
cell: ({ row }) => {
const resourceRow = row.original;
return resourceRow.mode === "host" && resourceRow.alias ? (
<CopyToClipboard
text={resourceRow.alias}
isLink={false}
displayText={resourceRow.alias}
/>
) : (
<span>-</span>
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
resourceRow
);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}
];
return (
<>
{selectedInternalResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedInternalResource(null);
}}
dialog={
<div>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () =>
deleteInternalResource(
selectedInternalResource!.id,
selectedInternalResource!.siteId
)
}
string={selectedInternalResource.name}
title={t("resourceDelete")}
/>
)}
<DataTable
columns={internalColumns}
data={internalResources}
persistPageSize="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing}
defaultSort={defaultSort}
enableColumnVisibility={true}
persistColumnVisibility="internal-resources"
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
{editingResource && (
<EditInternalResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
router.refresh();
setEditingResource(null);
}}
/>
)}
<CreateInternalResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
sites={sites}
onSuccess={() => {
router.refresh();
}}
/>
</>
);
}

View File

@@ -5,12 +5,23 @@ import {
} from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
type TabFilter = {
id: string;
label: string;
filterFn: (row: any) => boolean;
};
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onRefresh?: () => void;
isRefreshing?: boolean;
addClient?: () => void;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
hideHeader?: boolean;
tabs?: TabFilter[];
defaultTab?: string;
}
export function ClientsDataTable<TData, TValue>({
@@ -18,20 +29,29 @@ export function ClientsDataTable<TData, TValue>({
data,
addClient,
onRefresh,
isRefreshing
isRefreshing,
columnVisibility,
enableColumnVisibility,
hideHeader = false,
tabs,
defaultTab
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
data={data || []}
persistPageSize="clients-table"
title="Clients"
title={hideHeader ? undefined : "Clients"}
searchPlaceholder="Search clients..."
searchColumn="name"
onAdd={addClient}
onRefresh={onRefresh}
onAdd={hideHeader ? undefined : addClient}
onRefresh={hideHeader ? undefined : onRefresh}
isRefreshing={isRefreshing}
addButtonText="Add Client"
addButtonText={hideHeader ? undefined : "Add Client"}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
tabs={tabs}
defaultTab={defaultTab}
/>
);
}

View File

@@ -1,350 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ClientsDataTable } from "@app/components/ClientsDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Check,
MoreHorizontal,
X
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
};
type ClientTableProps = {
clients: ClientRow[];
orgId: string;
};
export default function ClientsTable({ clients, orgId }: ClientTableProps) {
const router = useRouter();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const [rows, setRows] = useState<ClientRow[]>(clients);
const api = createApiClient(useEnvContext());
const [isRefreshing, setIsRefreshing] = useState(false);
const t = useTranslations();
const refreshData = async () => {
console.log("Data refreshed");
setIsRefreshing(true);
try {
await new Promise((resolve) => setTimeout(resolve, 200));
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== clientId);
setRows(newRows);
});
};
const columns: ColumnDef<ClientRow>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
// {
// 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",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
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")}
/>
)}
</div>
);
}
},
{
accessorKey: "subnet",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {
const clientRow = row.original;
return (
<div className="flex items-center 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/clients/${clientRow.id}`}
>
<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={"secondary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>
{t("deleteClientQuestion")}
</p>
<p>
{t("clientMessageRemove")}
</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<ClientsDataTable
columns={columns}
data={rows}
addClient={() => {
router.push(`/${orgId}/settings/clients/create`);
}}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View File

@@ -7,6 +7,7 @@ import {
getFilteredRowModel,
VisibilityState
} from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@/components/ui/button";
import {
Credenza,
@@ -181,17 +182,19 @@ const DockerContainersTable: FC<{
[getExposedPorts]
);
const columns: ColumnDef<Container>[] = [
const columns: ExtendedColumnDef<Container>[] = [
{
accessorKey: "name",
header: t("containerName"),
friendlyName: t("containerName"),
header: () => (<span className="p-3">{t("containerName")}</span>),
cell: ({ row }) => (
<div className="font-medium">{row.original.name}</div>
)
},
{
accessorKey: "image",
header: t("containerImage"),
friendlyName: t("containerImage"),
header: () => (<span className="p-3">{t("containerImage")}</span>),
cell: ({ row }) => (
<div className="text-sm text-muted-foreground">
{row.original.image}
@@ -200,7 +203,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "state",
header: t("containerState"),
friendlyName: t("containerState"),
header: () => (<span className="p-3">{t("containerState")}</span>),
cell: ({ row }) => (
<Badge
variant={
@@ -215,7 +219,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "networks",
header: t("containerNetworks"),
friendlyName: t("containerNetworks"),
header: () => (<span className="p-3">{t("containerNetworks")}</span>),
cell: ({ row }) => {
const networks = Object.keys(row.original.networks);
return (
@@ -233,7 +238,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "hostname",
header: t("containerHostnameIp"),
friendlyName: t("containerHostnameIp"),
header: () => (<span className="p-3">{t("containerHostnameIp")}</span>),
enableHiding: false,
cell: ({ row }) => (
<div className="text-sm font-mono">
@@ -243,7 +249,8 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "labels",
header: t("containerLabels"),
friendlyName: t("containerLabels"),
header: () => (<span className="p-3">{t("containerLabels")}</span>),
cell: ({ row }) => {
const labels = row.original.labels || {};
const labelEntries = Object.entries(labels);
@@ -295,7 +302,7 @@ const DockerContainersTable: FC<{
},
{
accessorKey: "ports",
header: t("containerPorts"),
header: () => (<span className="p-3">{t("containerPorts")}</span>),
enableHiding: false,
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
@@ -353,7 +360,7 @@ const DockerContainersTable: FC<{
},
{
id: "actions",
header: t("containerActions"),
header: () => (<span className="p-3">{t("containerActions")}</span>),
cell: ({ row }) => {
const ports = getExposedPorts(row.original);
return (

View File

@@ -26,24 +26,21 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
const t = useTranslations();
return (
<div className="flex items-center space-x-2 max-w-full">
<div className="flex items-center space-x-2 min-w-0 max-w-full">
{isLink ? (
<Link
href={text}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline text-sm"
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
className="truncate hover:underline text-sm min-w-0 max-w-full"
title={text} // Shows full text on hover
>
{displayValue}
</Link>
) : (
<span
className="truncate text-sm"
className="truncate text-sm min-w-0 max-w-full"
style={{
maxWidth: "100%",
display: "block",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
@@ -55,7 +52,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
)}
<button
type="button"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer"
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
onClick={handleCopy}
>
{!copied ? (

File diff suppressed because it is too large Load Diff

View File

@@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// );
return (
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}>
<div className={cn("px-0 mb-4 space-y-4 overflow-x-hidden min-w-0", className)} {...props}>
{children}
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
import { Badge } from "@app/components/ui/badge";
import { DNSRecordsDataTable } from "./DNSRecordsDataTable";
@@ -50,9 +51,10 @@ export default function DNSRecordsTable({
}
};
const columns: ColumnDef<DNSRecordRow>[] = [
const columns: ExtendedColumnDef<DNSRecordRow>[] = [
{
accessorKey: "baseDomain",
friendlyName: t("recordName", { fallback: "Record name" }),
header: ({ column }) => {
return (
<div>{t("recordName", { fallback: "Record name" })}</div>
@@ -73,6 +75,7 @@ export default function DNSRecordsTable({
},
{
accessorKey: "recordType",
friendlyName: t("type"),
header: ({ column }) => {
return <div>{t("type")}</div>;
},
@@ -83,6 +86,7 @@ export default function DNSRecordsTable({
},
{
accessorKey: "ttl",
friendlyName: t("TTL"),
header: ({ column }) => {
return <div>{t("TTL")}</div>;
},
@@ -92,6 +96,7 @@ export default function DNSRecordsTable({
},
{
accessorKey: "value",
friendlyName: t("value"),
header: () => {
return <div>{t("value")}</div>;
},

View File

@@ -21,11 +21,13 @@ import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type DashboardLoginFormProps = {
redirect?: string;
idps?: LoginFormIDP[];
forceLogin?: boolean;
};
export default function DashboardLoginForm({
redirect,
idps
idps,
forceLogin
}: DashboardLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@@ -36,17 +38,18 @@ export default function DashboardLoginForm({
return t("loginStart");
}
const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 175 : 175;
const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 58 : 58;
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card className="shadow-md w-full max-w-md">
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo
height={logoHeight}
width={logoWidth}
/>
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{getSubtitle()}</p>
@@ -56,12 +59,13 @@ export default function DashboardLoginForm({
<LoginForm
redirect={redirect}
idps={idps}
onLogin={() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
forceLogin={forceLogin}
onLogin={(redirectUrl) => {
if (redirectUrl) {
const safe = cleanRedirect(redirectUrl);
router.replace(safe);
} else {
router.push("/");
router.replace("/");
}
}}
/>

View File

@@ -0,0 +1,144 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertTriangle, CheckCircle2, Monitor } from "lucide-react";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useTranslations } from "next-intl";
type DeviceAuthMetadata = {
ip: string | null;
city: string | null;
deviceName: string | null;
applicationName: string;
createdAt: number;
};
type DeviceAuthConfirmationProps = {
metadata: DeviceAuthMetadata;
onConfirm: () => void;
onCancel: () => void;
loading: boolean;
};
export function DeviceAuthConfirmation({
metadata,
onConfirm,
onCancel,
loading
}: DeviceAuthConfirmationProps) {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short"
});
};
const locationText =
metadata.city && metadata.ip
? `${metadata.city} ${metadata.ip}`
: metadata.ip || t("deviceUnknownLocation");
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
return (
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">{t("deviceActivation")}</p>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<Alert variant="warning">
<AlertDescription>
{t("deviceAuthorizationRequested", {
location: locationText,
date: formatDate(metadata.createdAt)
})}
</AlertDescription>
</Alert>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 border rounded-md">
<Monitor className="h-5 w-5 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium">
{metadata.applicationName}
</p>
{metadata.deviceName && (
<p className="text-xs text-muted-foreground mt-1">
{t("deviceLabel", { deviceName: metadata.deviceName })}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{t("deviceWantsAccess")}
</p>
</div>
</div>
<div className="space-y-2 pt-2">
<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" />
<span>{t("deviceFullAccess")}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span>
{t("deviceOrganizationsAccess")}
</span>
</div>
</div>
</div>
</div>
</CardContent>
<CardFooter className="gap-2">
<Button
variant="outline"
onClick={onCancel}
disabled={loading}
className="w-full"
>
{t("cancel")}
</Button>
<Button
className="w-full"
onClick={onConfirm}
disabled={loading}
loading={loading}
>
{t("deviceAuthorize", { applicationName: metadata.applicationName })}
</Button>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,303 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot
} from "@/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { AlertTriangle } from "lucide-react";
import { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import BrandingLogo from "./BrandingLogo";
import { useTranslations } from "next-intl";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
const createFormSchema = (t: (key: string) => string) =>
z.object({
code: z.string().length(8, t("deviceCodeInvalidFormat"))
});
type DeviceAuthMetadata = {
ip: string | null;
city: string | null;
deviceName: string | null;
applicationName: string;
createdAt: number;
};
type DeviceLoginFormProps = {
userEmail: string;
userName?: string;
initialCode?: string;
};
export default function DeviceLoginForm({
userEmail,
userName,
initialCode = ""
}: DeviceLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<DeviceAuthMetadata | null>(null);
const [code, setCode] = useState<string>("");
const { isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const formSchema = createFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
code: initialCode.replace(/-/g, "").toUpperCase()
}
});
async function onSubmit(data: z.infer<typeof formSchema>) {
setError(null);
setLoading(true);
try {
// split code and add dash if missing
if (!data.code.includes("-") && data.code.length === 8) {
data.code = data.code.slice(0, 4) + "-" + data.code.slice(4);
}
await new Promise((resolve) => setTimeout(resolve, 300));
// First check - get metadata
const res = await api.post(
"/device-web-auth/verify?forceLogin=true",
{
code: data.code.toUpperCase(),
verify: false
}
);
if (res.data.success && res.data.data.metadata) {
setMetadata(res.data.data.metadata);
setCode(data.code.toUpperCase());
} else {
setError(t("deviceCodeInvalidOrExpired"));
}
} catch (e: any) {
const errorMessage = formatAxiosError(e);
setError(errorMessage || t("deviceCodeInvalidOrExpired"));
} finally {
setLoading(false);
}
}
async function onConfirm() {
if (!code || !metadata) return;
setError(null);
setLoading(true);
try {
await new Promise((resolve) => setTimeout(resolve, 300));
// Final verify
await api.post("/device-web-auth/verify", {
code: code,
verify: true
});
// Redirect to success page
router.push("/auth/login/device/success");
} catch (e: any) {
const errorMessage = formatAxiosError(e);
setError(errorMessage || t("deviceCodeVerifyFailed"));
setMetadata(null);
setCode("");
form.reset();
} finally {
setLoading(false);
}
}
const logoWidth = isUnlocked()
? env.branding.logo?.authPage?.width || 175
: 175;
const logoHeight = isUnlocked()
? env.branding.logo?.authPage?.height || 58
: 58;
function onCancel() {
setMetadata(null);
setCode("");
form.reset();
setError(null);
}
const profileLabel = (userName || userEmail || "").trim();
const profileInitial = profileLabel
? profileLabel.charAt(0).toUpperCase()
: "?";
async function handleUseDifferentAccount() {
try {
await api.post("/auth/logout");
} catch (logoutError) {
console.error(
"Failed to logout before switching account",
logoutError
);
} finally {
const currentSearch =
typeof window !== "undefined" ? window.location.search : "";
const redirectTarget = `/auth/login/device${currentSearch || ""}`;
router.push(
`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectTarget)}`
);
router.refresh();
}
}
if (metadata) {
return (
<DeviceAuthConfirmation
metadata={metadata}
onConfirm={onConfirm}
onCancel={onCancel}
loading={loading}
/>
);
}
return (
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</div>
<div className="text-center space-y-1 pt-3">
<p className="text-muted-foreground">
{t("deviceActivation")}
</p>
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="flex items-center gap-3 p-3 mb-4 border rounded-md">
<Avatar className="h-10 w-10">
<AvatarFallback>{profileInitial}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div>
<p className="text-sm font-medium">
{profileLabel || userEmail}
</p>
<p className="text-xs text-muted-foreground break-all">
{t(
"deviceLoginDeviceRequestingAccessToAccount"
)}
</p>
</div>
<Button
type="button"
variant="link"
className="h-auto px-0 text-xs"
onClick={handleUseDifferentAccount}
>
{t("deviceLoginUseDifferentAccount")}
</Button>
</div>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<div className="space-y-2">
<p className="text-sm text-muted-foreground text-center">
{t("deviceCodeEnterPrompt")}
</p>
</div>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={9}
{...field}
value={field.value
.replace(/-/g, "")
.toUpperCase()}
onChange={(value) => {
// Strip hyphens and convert to uppercase
const cleanedValue = value
.replace(/-/g, "")
.toUpperCase();
field.onChange(
cleanedValue
);
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
<InputOTPSlot index={6} />
<InputOTPSlot index={7} />
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={loading}
loading={loading}
>
{t("continue")}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
}

View File

@@ -33,6 +33,9 @@ export function DomainsDataTable<TData, TValue>({
onAdd={onAdd}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="baseDomain"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { DomainsDataTable } from "@app/components/DomainsDataTable";
import { Button } from "@app/components/ui/button";
import {
@@ -135,8 +136,31 @@ export default function DomainsTable({ domains, orgId }: Props) {
}
};
const statusColumn: ColumnDef<DomainRow> = {
const typeColumn: ExtendedColumnDef<DomainRow> = {
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return <Badge variant="secondary">{getTypeDisplay(type)}</Badge>;
}
};
const statusColumn: ExtendedColumnDef<DomainRow> = {
accessorKey: "verified",
friendlyName: t("status"),
header: ({ column }) => {
return (
<Button
@@ -170,9 +194,11 @@ export default function DomainsTable({ domains, orgId }: Props) {
}
};
const columns: ColumnDef<DomainRow>[] = [
const columns: ExtendedColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
enableHiding: false,
friendlyName: t("domain"),
header: ({ column }) => {
return (
<Button
@@ -187,41 +213,49 @@ export default function DomainsTable({ domains, orgId }: Props) {
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
...(env.env.flags.usePangolinDns ? [typeColumn] : []),
...(env.env.flags.usePangolinDns ? [statusColumn] : []),
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const domain = row.original;
const isRestarting = restartingDomains.has(domain.domainId);
return (
<div className="flex items-center justify-end gap-2">
<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={`/${orgId}/settings/domains/${domain.domainId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{domain.failed && (
<Button
variant="outline"
size="sm"
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
@@ -235,50 +269,14 @@ export default function DomainsTable({ domains, orgId }: Props) {
: t("restart", { fallback: "Restart" })}
</Button>
)}
<div className="flex items-center justify-end gap-2">
<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={`/${orgId}/settings/domains/${domain.domainId}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<Button variant={"secondary"} size="sm">
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
<Link
href={`/${orgId}/settings/domains/${domain.domainId}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
{/* <Button
variant="secondary"
size="sm"

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
@@ -36,18 +36,31 @@ 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 { Separator } from "@app/components/ui/separator";
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";
type InternalResourceData = {
id: number;
name: string;
orgId: string;
siteName: string;
protocol: string;
proxyPort: number | null;
// mode: "host" | "cidr" | "port";
mode: "host" | "cidr";
// protocol: string | null;
// proxyPort: number | null;
siteId: number;
destinationIp?: string;
destinationPort?: number;
destination: string;
// destinationPort?: number | null;
alias?: string | null;
};
type EditInternalResourceDialogProps = {
@@ -67,56 +80,252 @@ export default function EditInternalResourceDialog({
}: EditInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = z.object({
name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")),
protocol: z.enum(["tcp", "udp"]),
proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")),
destinationIp: z.string(),
destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax"))
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
mode: z.enum(["host", "cidr", "port"]),
// protocol: z.enum(["tcp", "udp"]).nullish(),
// proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(),
roles: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
users: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional(),
clients: z
.array(
z.object({
id: z.string(),
text: z.string()
})
)
.optional()
});
// .refine(
// (data) => {
// if (data.mode === "port") {
// return data.protocol !== undefined && data.protocol !== null;
// }
// return true;
// },
// {
// message: t("editInternalResourceDialogProtocol") + " is required for port mode",
// path: ["protocol"]
// }
// )
// .refine(
// (data) => {
// if (data.mode === "port") {
// return data.proxyPort !== undefined && data.proxyPort !== null;
// }
// return true;
// },
// {
// message: t("editInternalResourceDialogSitePort") + " is required for port mode",
// path: ["proxyPort"]
// }
// )
// .refine(
// (data) => {
// if (data.mode === "port") {
// return data.destinationPort !== undefined && data.destinationPort !== null;
// }
// return true;
// },
// {
// message: t("targetPort") + " is required for port mode",
// path: ["destinationPort"]
// }
// );
type FormData = z.infer<typeof formSchema>;
const queries = useQueries({
queries: [
orgQueries.roles({ orgId }),
orgQueries.users({ orgId }),
orgQueries.clients({
orgId,
filters: {
filter: "machine"
}
}),
resourceQueries.resourceUsers({ resourceId: resource.id }),
resourceQueries.resourceRoles({ resourceId: resource.id }),
resourceQueries.resourceClients({ resourceId: resource.id })
],
combine: (results) => {
const [
rolesQuery,
usersQuery,
clientsQuery,
resourceUsersQuery,
resourceRolesQuery,
resourceClientsQuery
] = results;
const allRoles = (rolesQuery.data ?? [])
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin");
const allUsers = (usersQuery.data ?? []).map((user) => ({
id: user.id.toString(),
text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}`
}));
const machineClients = (clientsQuery.data ?? [])
.filter((client) => !client.userId)
.map((client) => ({
id: client.clientId.toString(),
text: client.name
}));
const existingClients = (resourceClientsQuery.data ?? []).map(
(c: { clientId: number; name: string }) => ({
id: c.clientId.toString(),
text: c.name
})
);
const formRoles = (resourceRolesQuery.data ?? [])
.map((i) => ({
id: i.roleId.toString(),
text: i.name
}))
.filter((role) => role.text !== "Admin");
const formUsers = (resourceUsersQuery.data ?? []).map((i) => ({
id: i.userId.toString(),
text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}));
return {
allRoles,
allUsers,
machineClients,
existingClients,
formRoles,
formUsers,
hasMachineClients:
machineClients.length > 0 || existingClients.length > 0,
isLoading: results.some((query) => query.isLoading)
};
}
});
const {
allRoles,
allUsers,
machineClients,
existingClients,
formRoles,
formUsers,
hasMachineClients,
isLoading: loadingRolesUsers
} = queries;
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
number | null
>(null);
const [activeUsersTagIndex, setActiveUsersTagIndex] = useState<
number | null
>(null);
const [activeClientsTagIndex, setActiveClientsTagIndex] = useState<
number | null
>(null);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined
mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: [],
clients: []
}
});
useEffect(() => {
if (open) {
form.reset({
name: resource.name,
protocol: resource.protocol as "tcp" | "udp",
proxyPort: resource.proxyPort || undefined,
destinationIp: resource.destinationIp || "",
destinationPort: resource.destinationPort || undefined
});
}
}, [open, resource, form]);
const mode = form.watch("mode");
const handleSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
// Update the site resource
await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, {
name: data.name,
protocol: data.protocol,
proxyPort: data.proxyPort,
destinationIp: data.destinationIp,
destinationPort: data.destinationPort
});
await api.post(
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`,
{
name: data.name,
mode: data.mode,
// protocol: data.mode === "port" ? data.protocol : null,
// proxyPort: data.mode === "port" ? data.proxyPort : null,
// destinationPort: data.mode === "port" ? data.destinationPort : null,
destination: data.destination,
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
}
);
// Update roles, users, and clients
// await Promise.all([
// api.post(`/site-resource/${resource.id}/roles`, {
// roleIds: (data.roles || []).map((r) => parseInt(r.id))
// }),
// api.post(`/site-resource/${resource.id}/users`, {
// userIds: (data.users || []).map((u) => u.id)
// }),
// api.post(`/site-resource/${resource.id}/clients`, {
// clientIds: (data.clients || []).map((c) => parseInt(c.id))
// })
// ]);
await queryClient.invalidateQueries(
resourceQueries.resourceRoles({ resourceId: resource.id })
);
await queryClient.invalidateQueries(
resourceQueries.resourceUsers({ resourceId: resource.id })
);
await queryClient.invalidateQueries(
resourceQueries.resourceClients({ resourceId: resource.id })
);
toast({
title: t("editInternalResourceDialogSuccess"),
description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
),
variant: "default"
});
@@ -126,7 +335,12 @@ export default function EditInternalResourceDialog({
console.error("Error updating internal resource:", error);
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
} finally {
@@ -134,28 +348,100 @@ export default function EditInternalResourceDialog({
}
};
const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null);
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,
roles: [],
users: [],
clients: []
});
previousResourceId.current = resource.id;
}
hasInitialized.current = false;
}
}, [open, resource.id, resource.name, resource.mode, resource.destination, resource.alias, form]);
useEffect(() => {
if (open && !loadingRolesUsers && !hasInitialized.current) {
hasInitialized.current = true;
form.setValue("roles", formRoles);
form.setValue("users", formUsers);
form.setValue("clients", existingClients);
}
}, [open, loadingRolesUsers, formRoles, formUsers, existingClients, form]);
return (
<Credenza open={open} onOpenChange={setOpen}>
<Credenza
open={open}
onOpenChange={(open) => {
if (!open) {
// reset only on close
form.reset({
name: resource.name,
mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined,
destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null,
roles: [],
users: [],
clients: []
});
// Reset previous resource ID to ensure clean state on next open
previousResourceId.current = null;
}
setOpen(open);
}}
>
<CredenzaContent className="max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>{t("editInternalResourceDialogEditClientResource")}</CredenzaTitle>
<CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })}
{t(
"editInternalResourceDialogUpdateResourceProperties",
{ resourceName: resource.name }
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6" id="edit-internal-resource-form">
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6"
id="edit-internal-resource-form"
>
{/* Resource Properties Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogResourceProperties")}</h3>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogName")}</FormLabel>
<FormLabel>
{t(
"editInternalResourceDialogName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
@@ -164,72 +450,133 @@ export default function EditInternalResourceDialog({
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogMode"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
value={field.value}
>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<SelectContent>
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host">
{t(
"editInternalResourceDialogModeHost"
)}
</SelectItem>
<SelectItem value="cidr">
{t(
"editInternalResourceDialogModeCidr"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)} */}
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">{t("editInternalResourceDialogTargetConfiguration")}</h3>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="destinationIp"
render={({ field }) => (
<FormItem>
<FormLabel>{t("targetAddr")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogDestination"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" &&
t(
"editInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"editInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
@@ -239,35 +586,263 @@ export default function EditInternalResourceDialog({
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)} */}
</div>
</div>
{/* Alias */}
{mode !== "cidr" && (
<div>
<FormField
control={form.control}
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogAlias"
)}
</FormLabel>
<FormControl>
<Input
{...field}
value={
field.value ?? ""
}
/>
</FormControl>
<FormDescription>
{t(
"editInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* Access Control Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("resourceUsersRoles")}
</h3>
{loadingRolesUsers ? (
<div className="text-sm text-muted-foreground">
{t("loading")}
</div>
) : (
<div className="space-y-4">
<FormField
control={form.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
form.getValues()
.roles || []
}
setTags={(
newRoles
) => {
form.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
form.getValues()
.users || []
}
size="sm"
setTags={(
newUsers
) => {
form.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{hasMachineClients && (
<FormField
control={form.control}
name="clients"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t(
"machineClients"
)}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeClientsTagIndex
}
setActiveTagIndex={
setActiveClientsTagIndex
}
placeholder={
t(
"accessClientSelect"
) ||
"Select machine clients"
}
size="sm"
tags={
form.getValues()
.clients ||
[]
}
setTags={(
newClients
) => {
form.setValue(
"clients",
newClients as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
machineClients
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
)}
</div>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>

View File

@@ -2,6 +2,7 @@
import { useTranslations } from "next-intl";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "./ui/button";
import { ArrowUpDown } from "lucide-react";
import CopyToClipboard from "./CopyToClipboard";
@@ -63,9 +64,10 @@ export default function GenerateLicenseKeysTable({
}
};
const columns: ColumnDef<GeneratedLicenseKey>[] = [
const columns: ExtendedColumnDef<GeneratedLicenseKey>[] = [
{
accessorKey: "licenseKey",
friendlyName: t("licenseKey"),
header: ({ column }) => {
return (
<Button
@@ -91,6 +93,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "instanceName",
friendlyName: t("instanceName"),
header: ({ column }) => {
return (
<Button
@@ -110,6 +113,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "valid",
friendlyName: t("valid"),
header: ({ column }) => {
return (
<Button
@@ -133,6 +137,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -155,6 +160,7 @@ export default function GenerateLicenseKeysTable({
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
header: ({ column }) => {
return (
<Button

View File

@@ -81,17 +81,33 @@ export default function HealthCheckDialog({
hcMethod: z
.string()
.min(1, { message: t("healthCheckMethodRequired") }),
hcInterval: z.int()
hcInterval: z
.int()
.positive()
.min(5, { message: t("healthCheckIntervalMin") }),
hcTimeout: z.int()
hcTimeout: z
.int()
.positive()
.min(1, { message: t("healthCheckTimeoutMin") }),
hcStatus: z.int().positive().min(100).optional().nullable(),
hcHeaders: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional(),
hcHeaders: z
.array(z.object({ name: z.string(), value: z.string() }))
.nullable()
.optional(),
hcScheme: z.string().optional(),
hcHostname: z.string(),
hcPort: z.number().positive().gt(0).lte(65535),
hcPort: z
.string()
.min(1, { message: t("healthCheckPortInvalid") })
.refine(
(val) => {
const port = parseInt(val);
return port > 0 && port <= 65535;
},
{
message: t("healthCheckPortInvalid")
}
),
hcFollowRedirects: z.boolean(),
hcMode: z.string(),
hcUnhealthyInterval: z.int().positive().min(5),
@@ -128,7 +144,9 @@ export default function HealthCheckDialog({
hcHeaders: initialConfig?.hcHeaders,
hcScheme: getDefaultScheme(),
hcHostname: initialConfig?.hcHostname,
hcPort: initialConfig?.hcPort,
hcPort: initialConfig?.hcPort
? initialConfig.hcPort.toString()
: "",
hcFollowRedirects: initialConfig?.hcFollowRedirects,
hcMode: initialConfig?.hcMode,
hcUnhealthyInterval: initialConfig?.hcUnhealthyInterval,
@@ -142,10 +160,15 @@ export default function HealthCheckDialog({
try {
const currentValues = form.getValues();
const updatedValues = { ...currentValues, [fieldName]: value };
await onChanges({
// Convert hcPort from string to number before passing to parent
const configToSend: HealthCheckConfig = {
...updatedValues,
hcPort: parseInt(updatedValues.hcPort),
hcStatus: updatedValues.hcStatus || null
});
};
await onChanges(configToSend);
} catch (error) {
toast({
title: t("healthCheckError"),
@@ -213,14 +236,20 @@ export default function HealthCheckDialog({
{t("healthScheme")}
</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
onValueChange={(
value
) => {
field.onChange(
value
);
handleFieldChange(
"hcScheme",
value
);
}}
defaultValue={field.value}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
@@ -284,10 +313,8 @@ export default function HealthCheckDialog({
{...field}
onChange={(e) => {
const value =
parseInt(
e.target
.value
);
e.target
.value;
field.onChange(
value
);
@@ -486,10 +513,6 @@ export default function HealthCheckDialog({
</FormItem>
)}
/>
<FormDescription>
{t("timeIsInSeconds")}
</FormDescription>
</div>
{/* Expected Response Codes */}
@@ -578,7 +601,9 @@ export default function HealthCheckDialog({
<HeadersInput
value={field.value}
onChange={(value) => {
field.onChange(value);
field.onChange(
value
);
handleFieldChange(
"hcHeaders",
value

View File

@@ -32,6 +32,9 @@ export function InvitationsDataTable<TData, TValue>({
searchColumn="email"
onRefresh={onRefresh}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
stickyLeftColumn="email"
stickyRightColumn="dots"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,14 +67,17 @@ export default function InvitationsTable({
}
};
const columns: ColumnDef<InvitationRow>[] = [
const columns: ExtendedColumnDef<InvitationRow>[] = [
{
accessorKey: "email",
header: t("email")
enableHiding: false,
friendlyName: t("email"),
header: () => (<span className="p-3">{t("email")}</span>)
},
{
accessorKey: "expiresAt",
header: t("expiresAt"),
friendlyName: t("expiresAt"),
header: () => (<span className="p-3">{t("expiresAt")}</span>),
cell: ({ row }) => {
const expiresAt = new Date(row.original.expiresAt);
const isExpired = expiresAt < new Date();
@@ -87,10 +91,13 @@ export default function InvitationsTable({
},
{
accessorKey: "role",
header: t("role")
friendlyName: t("role"),
header: () => (<span className="p-3">{t("role")}</span>)
},
{
id: "dots",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const invitation = row.original;
return (
@@ -119,13 +126,13 @@ export default function InvitationsTable({
</DropdownMenu>
<Button
variant={"secondary"}
variant={"outline"}
onClick={() => {
setIsRegenerateModalOpen(true);
setSelectedInvitation(invitation);
}}
>
<span>{t("inviteRegenerate")}</span>
{t("regenerate", { fallback: "Regenerate" })}
</Button>
</div>
);

View File

@@ -30,6 +30,7 @@ export async function Layout({
}: LayoutProps) {
const allCookies = await cookies();
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
const hasCookiePreference = sidebarStateCookie !== undefined;
const initialSidebarCollapsed =
sidebarStateCookie === "collapsed" ||
@@ -44,6 +45,7 @@ export async function Layout({
orgs={orgs}
navItems={navItems}
defaultSidebarCollapsed={initialSidebarCollapsed}
hasCookiePreference={hasCookiePreference}
/>
)}
@@ -72,7 +74,7 @@ export async function Layout({
<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-20" // Add top padding only on desktop to account for fixed header
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
)}>
{children}
</div>

View File

@@ -43,17 +43,20 @@ interface LayoutSidebarProps {
orgs?: ListUserOrgsResponse["orgs"];
navItems: SidebarNavSection[];
defaultSidebarCollapsed: boolean;
hasCookiePreference: boolean;
}
export function LayoutSidebar({
orgId,
orgs,
navItems,
defaultSidebarCollapsed
defaultSidebarCollapsed,
hasCookiePreference
}: LayoutSidebarProps) {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(
defaultSidebarCollapsed
);
const [hasManualToggle, setHasManualToggle] = useState(hasCookiePreference);
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
@@ -68,9 +71,26 @@ export function LayoutSidebar({
}
};
// Auto-collapse sidebar at 1650px or less, but only if no cookie preference exists
useEffect(() => {
setSidebarStateCookie(isSidebarCollapsed);
}, [isSidebarCollapsed]);
if (hasManualToggle) {
return; // Don't auto-collapse if user has manually toggled
}
const handleResize = () => {
// print inner width
if (typeof window !== "undefined") {
const shouldCollapse = window.innerWidth <= 1650;
setIsSidebarCollapsed(shouldCollapse);
}
};
// Set initial state based on window width
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [hasManualToggle]);
function loadFooterLinks(): { text: string; href?: string }[] | undefined {
if (!isUnlocked()) {
@@ -92,14 +112,14 @@ export function LayoutSidebar({
isSidebarCollapsed ? "w-16" : "w-64"
)}
>
<div className="p-4 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-4">
<OrgSelector
orgId={orgId}
orgs={orgs}
isCollapsed={isSidebarCollapsed}
/>
</div>
<div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="pb-4">
@@ -138,8 +158,10 @@ export function LayoutSidebar({
</div>
</div>
<div className="p-4 flex flex-col gap-4 shrink-0">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
<div className="p-4 flex flex-col shrink-0">
<div className="mb-3">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
{build === "enterprise" && (
<div className="mb-3">
@@ -230,9 +252,12 @@ export function LayoutSidebar({
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() =>
setIsSidebarCollapsed(!isSidebarCollapsed)
}
onClick={() => {
const newCollapsedState = !isSidebarCollapsed;
setIsSidebarCollapsed(newCollapsedState);
setHasManualToggle(true);
setSidebarStateCookie(newCollapsedState);
}}
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-1"
aria-label={
isSidebarCollapsed

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { DataTable } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import { Badge } from "@app/components/ui/badge";
@@ -30,9 +31,11 @@ export function LicenseKeysDataTable({
}: LicenseKeysDataTableProps) {
const t = useTranslations();
const columns: ColumnDef<LicenseKeyCache>[] = [
const columns: ExtendedColumnDef<LicenseKeyCache>[] = [
{
accessorKey: "licenseKey",
enableHiding: false,
friendlyName: t("licenseKey"),
header: ({ column }) => {
return (
<Button
@@ -58,6 +61,7 @@ export function LicenseKeysDataTable({
},
{
accessorKey: "valid",
friendlyName: t("valid"),
header: ({ column }) => {
return (
<Button
@@ -81,6 +85,7 @@ export function LicenseKeysDataTable({
},
{
accessorKey: "tier",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -103,6 +108,7 @@ export function LicenseKeysDataTable({
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
header: ({ column }) => {
return (
<Button
@@ -123,10 +129,11 @@ export function LicenseKeysDataTable({
},
{
id: "delete",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="secondary"
<div className="flex items-center gap-2 justify-end">
<Button variant={"outline"}
onClick={() => onDelete(row.original)}
>
{t("delete")}
@@ -146,6 +153,9 @@ export function LicenseKeysDataTable({
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText={t("licenseKeyAdd")}
enableColumnVisibility={true}
stickyLeftColumn="licenseKey"
stickyRightColumn="delete"
/>
);
}

View File

@@ -56,6 +56,10 @@ export default function LocaleSwitcher() {
{
value: "nb-NO",
label: "Norsk (Bokmål)"
},
{
value: "zh-TW",
label: "繁體中文"
}
]}
/>

View File

@@ -136,9 +136,6 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
}
}
console.log({
endDate
});
newSearch.set("timeEnd", endDate.toISOString());
}
router.replace(`${path}?${newSearch.toString()}`);
@@ -258,7 +255,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<CardHeader className="flex flex-col gap-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
<InfoSectionTitle>
{t("totalRequests")}
</InfoSectionTitle>
<InfoSectionContent>
@@ -266,7 +263,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle className="text-muted-foreground">
<InfoSectionTitle>
{t("totalBlocked")}
</InfoSectionTitle>
<InfoSectionContent>
@@ -283,7 +280,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<Card className="w-full h-full flex flex-col gap-8">
<CardHeader>
<h3 className="font-medium">{t("requestsByDay")}</h3>
<h3 className="font-semibold">{t("requestsByDay")}</h3>
</CardHeader>
<CardContent>
<RequestChart
@@ -296,7 +293,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<div className="grid lg:grid-cols-2 gap-5">
<Card className="w-full h-full">
<CardHeader>
<h3 className="font-medium">
<h3 className="font-semibold">
{t("requestsByCountry")}
</h3>
</CardHeader>
@@ -313,7 +310,7 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
<Card className="w-full h-full">
<CardHeader>
<h3 className="font-medium">{t("topCountries")}</h3>
<h3 className="font-semibold">{t("topCountries")}</h3>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
<TopCountriesList
@@ -465,7 +462,7 @@ function TopCountriesList(props: TopCountriesListProps) {
return (
<div className="h-full flex flex-col gap-2">
{props.countries.length > 0 && (
<div className="grid grid-cols-7 text-sm text-muted-foreground font-medium h-4">
<div className="grid grid-cols-7 text-sm text-muted-foreground font-semibold h-4">
<div className="col-span-5">{t("countries")}</div>
<div className="text-end">{t("total")}</div>
<div className="text-end">%</div>
@@ -474,7 +471,7 @@ function TopCountriesList(props: TopCountriesListProps) {
{/* `aspect-475/335` is the same aspect ratio as the world map component */}
<ol className="w-full overflow-auto grid gap-1 aspect-475/335">
{props.countries.length === 0 && (
<div className="flex items-center justify-center size-full text-muted-foreground font-mono gap-1">
<div className="flex items-center justify-center size-full text-muted-foreground gap-1">
{props.isLoading ? (
<>
<LoaderIcon className="size-4 animate-spin" />{" "}

View File

@@ -257,7 +257,10 @@ export function LogDataTable<TData, TValue>({
? {}
: { getPaginationRowModel: getPaginationRowModel() }),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
// Disable client-side sorting for server-side pagination since data is already sorted on server
...(isServerPagination
? {}
: { getSortedRowModel: getSortedRowModel() }),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter,
@@ -269,6 +272,7 @@ export function LogDataTable<TData, TValue>({
}
: {}),
initialState: {
sorting: defaultSort ? [defaultSort] : [],
pagination: {
pageSize: pageSize,
pageIndex: currentPage

View File

@@ -58,16 +58,18 @@ export type LoginFormIDP = {
type LoginFormProps = {
redirect?: string;
onLogin?: () => void | Promise<void>;
onLogin?: (redirectUrl?: string) => void | Promise<void>;
idps?: LoginFormIDP[];
orgId?: string;
forceLogin?: boolean;
};
export default function LoginForm({
redirect,
onLogin,
idps,
orgId
orgId,
forceLogin
}: LoginFormProps) {
const router = useRouter();
@@ -141,7 +143,7 @@ export default function LoginForm({
try {
// Start WebAuthn authentication without email
const startResponse = await securityKeyStartProxy({});
const startResponse = await securityKeyStartProxy({}, forceLogin);
if (startResponse.error) {
setError(startResponse.message);
@@ -165,7 +167,8 @@ export default function LoginForm({
// Verify authentication
const verifyResponse = await securityKeyVerifyProxy(
{ credential },
tempSessionId
tempSessionId,
forceLogin
);
if (verifyResponse.error) {
@@ -175,7 +178,7 @@ export default function LoginForm({
if (verifyResponse.success) {
if (onLogin) {
await onLogin();
await onLogin(redirect);
}
}
} catch (error: any) {
@@ -234,12 +237,15 @@ export default function LoginForm({
setShowSecurityKeyPrompt(false);
try {
const response = await loginProxy({
const response = await loginProxy(
{
email,
password,
code,
resourceGuid: resourceGuid as string
});
},
forceLogin
);
try {
const identity = {
@@ -263,7 +269,7 @@ export default function LoginForm({
// Handle case where data is null (e.g., already logged in)
if (!data) {
if (onLogin) {
await onLogin();
await onLogin(redirect);
}
return;
}
@@ -312,7 +318,7 @@ export default function LoginForm({
}
if (onLogin) {
await onLogin();
await onLogin(redirect);
}
} catch (e: any) {
console.error(e);
@@ -333,7 +339,8 @@ export default function LoginForm({
const data = await generateOidcUrlProxy(
idpId,
redirect || "/",
orgId
orgId,
forceLogin
);
const url = data.data?.redirectUrl;
if (data.error) {
@@ -354,6 +361,15 @@ export default function LoginForm({
return (
<div className="space-y-4">
{forceLogin && (
<Alert variant="neutral">
<AlertDescription className="flex items-center gap-2">
<LockIcon className="w-4 h-4" />
{t("loginRequiredForDevice")}
</AlertDescription>
</Alert>
)}
{showSecurityKeyPrompt && (
<Alert>
<FingerprintIcon className="w-5 h-5 mr-2" />

View File

@@ -0,0 +1,430 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
userId: string | null;
username: string | null;
userEmail: string | null;
};
type ClientTableProps = {
machineClients: ClientRow[];
orgId: string;
};
export default function MachineClientsTable({
machineClients,
orgId
}: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const defaultMachineColumnVisibility = {
client: false,
subnet: false,
userId: false
};
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
return machineClients.some((client) => !client.userId) ?? false;
}, [machineClients]);
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userId",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<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",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
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")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// 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/machine/${clientRow.id}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
) : null;
}
});
}
return baseColumns;
}, [hasRowsWithoutUserId, t]);
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<DataTable
columns={columns}
data={machineClients || []}
persistPageSize="machine-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() =>
router.push(`/${orgId}/settings/clients/machine/create`)
}
addButtonText={t("createClient")}
onRefresh={refreshData}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
persistColumnVisibility="machine-clients"
columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}

View File

@@ -34,6 +34,9 @@ export function OrgApiKeysDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('apiKeysAdd')}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { OrgApiKeysDataTable } from "@app/components/OrgApiKeysDataTable";
import {
DropdownMenu,
@@ -88,9 +89,11 @@ export default function OrgApiKeysTable({
});
};
const columns: ColumnDef<OrgApiKeyRow>[] = [
const columns: ExtendedColumnDef<OrgApiKeyRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -107,7 +110,8 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "key",
header: t("key"),
friendlyName: t("key"),
header: () => (<span className="p-3">{t("key")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
@@ -115,7 +119,8 @@ export default function OrgApiKeysTable({
},
{
accessorKey: "createdAt",
header: t("createdAt"),
friendlyName: t("createdAt"),
header: () => (<span className="p-3">{t("createdAt")}</span>),
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")}</span>;
@@ -123,10 +128,12 @@ export default function OrgApiKeysTable({
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -158,12 +165,9 @@ export default function OrgApiKeysTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -54,7 +54,6 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
role="combobox"
aria-expanded={open}
className={cn(
"shadow-2xs",
isCollapsed ? "w-8 h-8" : "w-full h-12 px-3 py-4"
)}
>
@@ -63,7 +62,7 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
) : (
<div className="flex items-center justify-between w-full min-w-0">
<div className="flex items-center min-w-0 flex-1">
<Building2 className="h-4 w-4 mr-2 shrink-0" />
<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')}

View File

@@ -103,19 +103,14 @@ function getActionsCategories(root: boolean) {
[t('actionUpdateClient')]: "updateClient",
[t('actionListClients')]: "listClients",
[t('actionGetClient')]: "getClient"
},
"Logs": {
[t('actionExportLogs')]: "exportLogs",
[t('actionViewLogs')]: "viewLogs",
}
};
if (env.flags.enableClients) {
actionsByCategory["Clients"] = {
"Create Client": "createClient",
"Delete Client": "deleteClient",
"Update Client": "updateClient",
"List Clients": "listClients",
"Get Client": "getClient"
};
}
if (root) {
actionsByCategory["Organization"] = {
[t("actionListOrgs")]: "listOrgs",

View File

@@ -28,6 +28,9 @@ export function PolicyDataTable<TData, TValue>({
searchColumn="orgId"
addButtonText={t('orgPoliciesAdd')}
onAdd={onAdd}
enableColumnVisibility={true}
stickyLeftColumn="orgId"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
ArrowUpDown,
@@ -36,35 +37,11 @@ interface Props {
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
const t = useTranslations();
const columns: ColumnDef<PolicyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onDelete(r.orgId);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
const columns: ExtendedColumnDef<PolicyRow>[] = [
{
accessorKey: "orgId",
enableHiding: false,
friendlyName: t('orgId'),
header: ({ column }) => {
return (
<Button
@@ -81,6 +58,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
},
{
accessorKey: "roleMapping",
friendlyName: t('roleMapping'),
header: ({ column }) => {
return (
<Button
@@ -102,12 +80,13 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
info={mapping}
/>
) : (
"--"
"-"
);
}
},
{
accessorKey: "orgMapping",
friendlyName: t('orgMapping'),
header: ({ column }) => {
return (
<Button
@@ -129,19 +108,37 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
info={mapping}
/>
) : (
"--"
"-"
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const policy = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">{t('openMenu')}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onDelete(policy.orgId);
}}
>
<span className="text-red-500">{t('delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"secondary"}
className="ml-2"
variant={"outline"}
onClick={() => onEdit(policy)}
>
{t('edit')}

View File

@@ -14,7 +14,7 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { Laptop, LogOut, Moon, Sun } from "lucide-react";
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -23,6 +23,7 @@ import Disable2FaForm from "./Disable2FaForm";
import SecurityKeyForm from "./SecurityKeyForm";
import Enable2FaDialog from "./Enable2FaDialog";
import ChangePasswordDialog from "./ChangePasswordDialog";
import ViewDevicesDialog from "./ViewDevicesDialog";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
import LocaleSwitcher from "@app/components/LocaleSwitcher";
@@ -43,6 +44,7 @@ export default function ProfileIcon() {
const [openDisable2fa, setOpenDisable2fa] = useState(false);
const [openSecurityKey, setOpenSecurityKey] = useState(false);
const [openChangePassword, setOpenChangePassword] = useState(false);
const [openViewDevices, setOpenViewDevices] = useState(false);
const t = useTranslations();
@@ -84,6 +86,10 @@ export default function ProfileIcon() {
open={openChangePassword}
setOpen={setOpenChangePassword}
/>
<ViewDevicesDialog
open={openViewDevices}
setOpen={setOpenViewDevices}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -146,6 +152,13 @@ export default function ProfileIcon() {
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => setOpenViewDevices(true)}
>
<Smartphone className="mr-2 h-4 w-4" />
<span>{t("viewDevices") || "View Devices"}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (

View File

@@ -0,0 +1,571 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
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 { UpdateResourceResponse } from "@server/routers/resource";
import { AxiosResponse } from "axios";
import {
ArrowRight,
ArrowUpDown,
CheckCircle2,
ChevronDown,
Clock,
MoreHorizontal,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
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[];
};
function getOverallHealthStatus(
targets?: TargetHealth[]
): "online" | "degraded" | "offline" | "unknown" {
if (!targets || targets.length === 0) {
return "unknown";
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
if (monitoredTargets.length === 0) {
return "unknown";
}
const healthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "healthy"
).length;
const unhealthyCount = monitoredTargets.filter(
(t) => t.healthStatus === "unhealthy"
).length;
if (healthyCount === monitoredTargets.length) {
return "online";
} else if (unhealthyCount === monitoredTargets.length) {
return "offline";
} else {
return "degraded";
}
}
function StatusIcon({
status,
className = ""
}: {
status: "online" | "degraded" | "offline" | "unknown";
className?: string;
}) {
const iconClass = `h-4 w-4 ${className}`;
switch (status) {
case "online":
return <CheckCircle2 className={`${iconClass} text-green-500`} />;
case "degraded":
return <CheckCircle2 className={`${iconClass} text-yellow-500`} />;
case "offline":
return <XCircle className={`${iconClass} text-destructive`} />;
case "unknown":
return <Clock className={`${iconClass} text-muted-foreground`} />;
default:
return null;
}
}
type ProxyResourcesTableProps = {
resources: ResourceRow[];
orgId: string;
defaultSort?: {
id: string;
desc: boolean;
};
};
export default function ProxyResourcesTable({
resources,
orgId,
defaultSort
}: ProxyResourcesTableProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const [isRefreshing, startTransition] = useTransition();
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
.catch((e) => {
console.error(t("resourceErrorDelte"), e);
toast({
variant: "destructive",
title: t("resourceErrorDelte"),
description: formatAxiosError(e, t("resourceErrorDelte"))
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
async function toggleResourceEnabled(val: boolean, resourceId: number) {
await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resourceId}`,
{
enabled: val
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourcesErrorUpdate"),
description: formatAxiosError(
e,
t("resourcesErrorUpdateDescription")
)
});
});
}
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
const overallStatus = getOverallHealthStatus(targets);
if (!targets || targets.length === 0) {
return (
<div className="flex items-center gap-2">
<StatusIcon status="unknown" />
<span className="text-sm">
{t("resourcesTableNoTargets")}
</span>
</div>
);
}
const monitoredTargets = targets.filter(
(t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown"
);
const unknownTargets = targets.filter(
(t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown"
);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 h-8 px-0 font-normal"
>
<StatusIcon status={overallStatus} />
<span className="text-sm">
{overallStatus === "online" &&
t("resourcesTableHealthy")}
{overallStatus === "degraded" &&
t("resourcesTableDegraded")}
{overallStatus === "offline" &&
t("resourcesTableOffline")}
{overallStatus === "unknown" &&
t("resourcesTableUnknown")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status={
target.healthStatus ===
"healthy"
? "online"
: "offline"
}
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span
className={`capitalize ${
target.healthStatus === "healthy"
? "text-green-500"
: "text-destructive"
}`}
>
{target.healthStatus}
</span>
</DropdownMenuItem>
))}
</>
)}
{unknownTargets.length > 0 && (
<>
{unknownTargets.map((target) => (
<DropdownMenuItem
key={target.targetId}
className="flex items-center justify-between gap-4"
>
<div className="flex items-center gap-2">
<StatusIcon
status="unknown"
className="h-3 w-3"
/>
{`${target.ip}:${target.port}`}
</div>
<span className="text-muted-foreground">
{!target.enabled
? t("disabled")
: t("resourcesTableNotMonitored")}
</span>
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
const proxyColumns: ExtendedColumnDef<ResourceRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("niceId"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("niceId")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
accessorKey: "protocol",
friendlyName: t("protocol"),
header: () => <span className="p-3">{t("protocol")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<span>
{resourceRow.http
? resourceRow.ssl
? "HTTPS"
: "HTTP"
: resourceRow.protocol.toUpperCase()}
</span>
);
}
},
{
id: "status",
accessorKey: "status",
friendlyName: t("status"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />;
},
sortingFn: (rowA, rowB) => {
const statusA = getOverallHealthStatus(rowA.original.targets);
const statusB = getOverallHealthStatus(rowB.original.targets);
const statusOrder = {
online: 3,
degraded: 2,
offline: 1,
unknown: 0
};
return statusOrder[statusA] - statusOrder[statusB];
}
},
{
accessorKey: "domain",
friendlyName: t("access"),
header: () => <span className="p-3">{t("access")}</span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center space-x-2">
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort?.toString() || ""}
isLink={false}
/>
) : !resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
);
}
},
{
accessorKey: "authState",
friendlyName: t("authentication"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.authState === "protected" ? (
<span className="flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-green-500" />
<span>{t("protected")}</span>
</span>
) : resourceRow.authState === "not_protected" ? (
<span className="flex items-center space-x-2">
<ShieldOff className="w-4 h-4 text-yellow-500" />
<span>{t("notProtected")}</span>
</span>
) : (
<span>-</span>
)}
</div>
);
}
},
{
accessorKey: "enabled",
friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>,
cell: ({ row }) => (
<Switch
defaultChecked={
row.original.http
? !!row.original.domainId && row.original.enabled
: row.original.enabled
}
disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
/>
)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedResource(resourceRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${resourceRow.orgId}/settings/resources/proxy/${resourceRow.nice}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedResource(null);
}}
dialog={
<div>
<p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p>
</div>
}
buttonText={t("resourceDeleteConfirm")}
onConfirm={async () => deleteResource(selectedResource!.id)}
string={selectedResource.name}
title={t("resourceDelete")}
/>
)}
<DataTable
columns={proxyColumns}
data={resources}
persistPageSize="proxy-resources"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() =>
router.push(`/${orgId}/settings/resources/proxy/create`)
}
addButtonText={t("resourceAdd")}
onRefresh={refreshData}
isRefreshing={isRefreshing}
defaultSort={defaultSort}
enableColumnVisibility={true}
persistColumnVisibility="proxy-resources"
columnVisibility={{ niceId: false }}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}

View File

@@ -34,8 +34,8 @@ import {
ResetPasswordBody,
ResetPasswordResponse
} from "@server/routers/auth";
import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "./ui/alert";
import { Loader2, InfoIcon } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { toast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/api";
@@ -84,22 +84,23 @@ export default function ResetPasswordForm({
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const formSchema = z
.object({
email: z.email({ message: t('emailInvalid') }),
token: z.string().min(8, { message: t('tokenInvalid') }),
email: z.email({ message: t("emailInvalid") }),
token: z.string().min(8, { message: t("tokenInvalid") }),
password: passwordSchema,
confirmPassword: passwordSchema
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: t('passwordNotMatch')
message: t("passwordNotMatch")
});
const mfaSchema = z.object({
code: z.string().length(6, { message: t('pincodeInvalid') })
code: z.string().length(6, { message: t("pincodeInvalid") })
});
const form = useForm({
@@ -139,8 +140,8 @@ export default function ResetPasswordForm({
} as RequestPasswordResetBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorRequestReset'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("passwordErrorRequestReset"), e);
setIsSubmitting(false);
});
@@ -169,8 +170,8 @@ export default function ResetPasswordForm({
} as ResetPasswordBody
)
.catch((e) => {
setError(formatAxiosError(e, t('errorOccurred')));
console.error(t('passwordErrorReset'), e);
setError(formatAxiosError(e, t("errorOccurred")));
console.error(t("passwordErrorReset"), e);
setIsSubmitting(false);
});
@@ -186,7 +187,11 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
setSuccessMessage(
quickstart
? t("accountSetupSuccess")
: t("passwordResetSuccess")
);
// Auto-login after successful password reset
try {
@@ -208,7 +213,10 @@ export default function ResetPasswordForm({
try {
await api.post("/auth/verify-email/request");
} catch (verificationError) {
console.error("Failed to send verification code:", verificationError);
console.error(
"Failed to send verification code:",
verificationError
);
}
if (redirect) {
@@ -229,7 +237,6 @@ export default function ResetPasswordForm({
}
setIsSubmitting(false);
}, 1500);
} catch (loginError) {
// Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError);
@@ -251,47 +258,70 @@ export default function ResetPasswordForm({
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
{quickstart
? t("completeAccountSetup")
: t("passwordReset")}
</CardTitle>
<CardDescription>
{quickstart
? t('completeAccountSetupDescription')
: t('passwordResetDescription')
}
? t("completeAccountSetupDescription")
: t("passwordResetDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{state === "request" && (
<Form {...requestForm}>
<form
onSubmit={requestForm.handleSubmit(
onRequest
)}
className="space-y-4"
id="form"
>
<FormField
control={requestForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupSent')
: t('passwordResetSent')
}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
<>
{!env.email.emailEnabled && (
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("passwordResetSmtpRequired")}
</AlertTitle>
<AlertDescription>
{t(
"passwordResetSmtpRequiredDescription"
)}
</AlertDescription>
</Alert>
)}
{env.email.emailEnabled && (
<Form {...requestForm}>
<form
onSubmit={requestForm.handleSubmit(
onRequest
)}
className="space-y-4"
id="form"
>
<FormField
control={requestForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t(
"accountSetupSent"
)
: t(
"passwordResetSent"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</>
)}
{state === "reset" && (
@@ -306,11 +336,13 @@ export default function ResetPasswordForm({
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('email')}</FormLabel>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled
disabled={env.email.emailEnabled}
/>
</FormControl>
<FormMessage />
@@ -326,9 +358,12 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('accountSetupCode')
: t('passwordResetCode')
}
? t(
"accountSetupCode"
)
: t(
"passwordResetCode"
)}
</FormLabel>
<FormControl>
<Input
@@ -337,12 +372,17 @@ export default function ResetPasswordForm({
/>
</FormControl>
<FormMessage />
<FormDescription>
{quickstart
? t('accountSetupCodeDescription')
: t('passwordResetCodeDescription')
}
</FormDescription>
{env.email.emailEnabled && (
<FormDescription>
{quickstart
? t(
"accountSetupCodeDescription"
)
: t(
"passwordResetCodeDescription"
)}
</FormDescription>
)}
</FormItem>
)}
/>
@@ -355,9 +395,8 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreate')
: t('passwordNew')
}
? t("passwordCreate")
: t("passwordNew")}
</FormLabel>
<FormControl>
<Input
@@ -376,9 +415,12 @@ export default function ResetPasswordForm({
<FormItem>
<FormLabel>
{quickstart
? t('passwordCreateConfirm')
: t('passwordNewConfirm')
}
? t(
"passwordCreateConfirm"
)
: t(
"passwordNewConfirm"
)}
</FormLabel>
<FormControl>
<Input
@@ -407,7 +449,7 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pincodeAuth')}
{t("pincodeAuth")}
</FormLabel>
<FormControl>
<div className="flex justify-center">
@@ -475,26 +517,45 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? (quickstart ? t('completeSetup') : t('passwordReset'))
: t('pincodeSubmit2')}
? quickstart
? t("completeSetup")
: t("passwordReset")
: t("pincodeSubmit2")}
</Button>
)}
{state === "request" && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<div className="flex flex-col gap-2">
{env.email.emailEnabled && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{quickstart
? t("accountSetupSubmit")
: t("passwordResetSubmit")}
</Button>
)}
{quickstart
? t('accountSetupSubmit')
: t('passwordResetSubmit')
}
</Button>
<Button
type="button"
className="w-full"
onClick={() => {
const email =
requestForm.getValues("email");
if (email) {
form.setValue("email", email);
}
setState("reset");
}}
>
{t("passwordResetAlreadyHaveCode")}
</Button>
</div>
)}
{state === "mfa" && (
@@ -507,7 +568,7 @@ export default function ResetPasswordForm({
mfaForm.reset();
}}
>
{t('passwordBack')}
{t("passwordBack")}
</Button>
)}
@@ -521,7 +582,7 @@ export default function ResetPasswordForm({
form.reset();
}}
>
{t('backToEmail')}
{t("backToEmail")}
</Button>
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,9 @@ export function RolesDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('accessRolesAdd')}
enableColumnVisibility={true}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -61,9 +62,11 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
}
};
const columns: ColumnDef<RoleRow>[] = [
const columns: ExtendedColumnDef<RoleRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -80,18 +83,20 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
},
{
accessorKey: "description",
header: t("description")
friendlyName: t("description"),
header: () => (<span className="p-3">{t("description")}</span>)
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const roleRow = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<Button
variant={"secondary"}
size="sm"
variant={"outline"}
disabled={roleRow.isAdmin || false}
onClick={() => {
setIsDeleteModalOpen(true);

View File

@@ -36,6 +36,9 @@ export function ShareLinksDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('shareCreate')}
enableColumnVisibility={true}
stickyLeftColumn="resourceName"
stickyRightColumn="delete"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { ShareLinksDataTable } from "@app/components/ShareLinksDataTable";
import {
DropdownMenu,
@@ -102,9 +103,11 @@ export default function ShareLinksTable({
});
}
const columns: ColumnDef<ShareLinkRow>[] = [
const columns: ExtendedColumnDef<ShareLinkRow>[] = [
{
accessorKey: "resourceName",
enableHiding: false,
friendlyName: t("resource"),
header: ({ column }) => {
return (
<Button
@@ -121,8 +124,8 @@ export default function ShareLinksTable({
cell: ({ row }) => {
const r = row.original;
return (
<Link href={`/${orgId}/settings/resources/${r.resourceNiceId}`}>
<Button variant="outline" size="sm">
<Link href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}>
<Button variant="outline">
{r.resourceName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
@@ -132,6 +135,7 @@ export default function ShareLinksTable({
},
{
accessorKey: "title",
friendlyName: t("title"),
header: ({ column }) => {
return (
<Button
@@ -211,6 +215,7 @@ export default function ShareLinksTable({
// },
{
accessorKey: "createdAt",
friendlyName: t("created"),
header: ({ column }) => {
return (
<Button
@@ -231,6 +236,7 @@ export default function ShareLinksTable({
},
{
accessorKey: "expiresAt",
friendlyName: t("expires"),
header: ({ column }) => {
return (
<Button
@@ -254,10 +260,12 @@ export default function ShareLinksTable({
},
{
id: "delete",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center justify-end space-x-2">
<div className="flex items-center space-x-2 justify-end">
{/* <DropdownMenu> */}
{/* <DropdownMenuTrigger asChild> */}
{/* <Button variant="ghost" className="h-8 w-8 p-0"> */}
@@ -281,9 +289,7 @@ export default function ShareLinksTable({
{/* </DropdownMenuItem> */}
{/* </DropdownMenuContent> */}
{/* </DropdownMenu> */}
<Button
variant="secondary"
size="sm"
<Button variant={"outline"}
onClick={() =>
deleteSharelink(row.original.accessTokenId)
}

View File

@@ -14,14 +14,26 @@ import {
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronDown } from "lucide-react";
import { build } from "@server/build";
export type SidebarNavItem = {
href: string;
href?: string;
title: string;
icon?: React.ReactNode;
showEE?: boolean;
isBeta?: boolean;
items?: SidebarNavItem[];
};
export type SidebarNavSection = {
@@ -36,6 +48,104 @@ export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
isCollapsed?: boolean;
}
type CollapsibleNavItemProps = {
item: SidebarNavItem;
level: number;
isChildActive: boolean;
isDisabled: boolean;
isCollapsed: boolean;
renderNavItem: (item: SidebarNavItem, level: number) => React.ReactNode;
t: (key: string) => string;
build: string;
isUnlocked: () => boolean;
};
function CollapsibleNavItem({
item,
level,
isChildActive,
isDisabled,
isCollapsed,
renderNavItem,
t,
build,
isUnlocked
}: CollapsibleNavItemProps) {
const [isOpen, setIsOpen] = React.useState(isChildActive);
// Update open state when child active state changes
React.useEffect(() => {
if (isChildActive) {
setIsOpen(true);
}
}, [isChildActive]);
return (
<Collapsible
key={item.title}
open={isOpen}
onOpenChange={setIsOpen}
className="group/collapsible"
>
<CollapsibleTrigger asChild>
<button
className={cn(
"flex items-center w-full rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md",
level === 0 ? "p-3 py-1.5" : "py-1.5",
isChildActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 mr-2">{item.icon}</span>
)}
<div className="flex items-center gap-1.5 flex-1">
<span className="text-left">{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge variant="outlinePrimary">
{t("licenseBadge")}
</Badge>
)}
<ChevronDown
className={cn(
"h-4 w-4 transition-transform duration-300 ease-in-out",
"group-data-[state=open]/collapsible:rotate-180"
)}
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div
className={cn(
"border-l ml-3 pl-2 mt-1 space-y-1",
"border-border"
)}
>
{item.items!.map((childItem) =>
renderNavItem(childItem, level + 1)
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
export function SidebarNav({
className,
sections,
@@ -56,7 +166,8 @@ export function SidebarNav({
const { user } = useUserContext();
const t = useTranslations();
function hydrateHref(val: string): string {
function hydrateHref(val?: string): string | undefined {
if (!val) return undefined;
return val
.replace("{orgId}", orgId)
.replace("{niceId}", niceId)
@@ -66,18 +177,56 @@ export function SidebarNav({
.replace("{clientId}", clientId);
}
function isItemOrChildActive(item: SidebarNavItem): boolean {
const hydratedHref = hydrateHref(item.href);
if (hydratedHref && pathname.startsWith(hydratedHref)) {
return true;
}
if (item.items) {
return item.items.some((child) => isItemOrChildActive(child));
}
return false;
}
const renderNavItem = (
item: SidebarNavItem,
hydratedHref: string,
isActive: boolean,
isDisabled: boolean
) => {
level: number = 0
): React.ReactNode => {
const hydratedHref = hydrateHref(item.href);
const hasNestedItems = item.items && item.items.length > 0;
const isActive = hydratedHref
? pathname.startsWith(hydratedHref)
: false;
const isChildActive = hasNestedItems
? isItemOrChildActive(item)
: false;
const isEE = build === "enterprise" && item.showEE && !isUnlocked();
const isDisabled = disabled || isEE;
const tooltipText =
item.showEE && !isUnlocked()
? `${t(item.title)} (${t("licenseBadge")})`
: t(item.title);
const itemContent = (
// If item has nested items, render as collapsible
if (hasNestedItems && !isCollapsed) {
return (
<CollapsibleNavItem
key={item.title}
item={item}
level={level}
isChildActive={isChildActive}
isDisabled={isDisabled || false}
isCollapsed={isCollapsed}
renderNavItem={renderNavItem}
t={t}
build={build}
isUnlocked={isUnlocked}
/>
);
}
// Regular item without nested items
const itemContent = hydratedHref ? (
<Link
href={isDisabled ? "#" : hydratedHref}
className={cn(
@@ -107,33 +256,179 @@ export function SidebarNav({
)}
{!isCollapsed && (
<>
<span>{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="ml-2 text-muted-foreground"
>
{t("beta")}
</Badge>
)}
<div className="flex items-center gap-1.5 flex-1">
<span>{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
{build === "enterprise" &&
item.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
<Badge variant="outlinePrimary">
{t("licenseBadge")}
</Badge>
)}
</>
)}
</Link>
) : (
<div
className={cn(
"flex items-center rounded transition-colors px-3 py-1.5",
"text-muted-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
>
{item.icon && (
<span className="flex-shrink-0 mr-2">{item.icon}</span>
)}
<div className="flex items-center gap-1.5 flex-1">
<span>{t(item.title)}</span>
{item.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
{build === "enterprise" && item.showEE && !isUnlocked() && (
<Badge variant="outlinePrimary">{t("licenseBadge")}</Badge>
)}
</div>
);
if (isCollapsed) {
// If item has nested items, show both tooltip and popover
if (hasNestedItems) {
return (
<TooltipProvider key={item.title}>
<Tooltip>
<Popover>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-2 py-2 justify-center w-full",
isChildActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref =
hydrateHref(childItem.href);
const childIsActive =
childHydratedHref
? pathname.startsWith(
childHydratedHref
)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled =
disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary text-primary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else if (
onItemClick
) {
onItemClick();
}
}}
>
{childItem.icon && (
<span className="flex-shrink-0 mr-2">
{childItem.icon}
</span>
)}
<div className="flex items-center gap-1.5 flex-1">
<span>
{t(childItem.title)}
</span>
{childItem.isBeta && (
<Badge
variant="outline"
className="text-muted-foreground"
>
{t("beta")}
</Badge>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge variant="outlinePrimary">
{t(
"licenseBadge"
)}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
);
}
// Regular item without nested items - show tooltip
return (
<TooltipProvider key={hydratedHref}>
<TooltipProvider key={item.title}>
<Tooltip>
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
@@ -144,9 +439,7 @@ export function SidebarNav({
);
}
return (
<React.Fragment key={hydratedHref}>{itemContent}</React.Fragment>
);
return <React.Fragment key={item.title}>{itemContent}</React.Fragment>;
};
return (
@@ -161,26 +454,12 @@ export function SidebarNav({
{sections.map((section) => (
<div key={section.heading} className="mb-2">
{!isCollapsed && (
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{t(section.heading)}
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wide">
{t(`${section.heading}`)}
</div>
)}
<div className="flex flex-col gap-1">
{section.items.map((item) => {
const hydratedHref = hydrateHref(item.href);
const isActive = pathname.startsWith(hydratedHref);
const isEE =
build === "enterprise" &&
item.showEE &&
!isUnlocked();
const isDisabled = disabled || isEE;
return renderNavItem(
item,
hydratedHref,
isActive,
isDisabled || false
);
})}
{section.items.map((item) => renderNavItem(item, 0))}
</div>
</div>
))}

View File

@@ -202,7 +202,7 @@ export default function SignupForm({
: 58;
return (
<Card className="w-full max-w-md shadow-md">
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />

View File

@@ -35,7 +35,7 @@ export default function SiteInfoCard({ }: SiteInfoCardProps) {
return (
<Alert>
<AlertDescription>
<InfoSections cols={env.flags.enableClients ? 4 : 3}>
<InfoSections cols={4}>
<InfoSection>
<InfoSectionTitle>
{t("identifier")}
@@ -75,7 +75,7 @@ export default function SiteInfoCard({ }: SiteInfoCardProps) {
</InfoSectionContent>
</InfoSection>
{env.flags.enableClients && site.type == "newt" && (
{site.type == "newt" && (
<InfoSection>
<InfoSectionTitle>Address</InfoSectionTitle>
<InfoSectionContent>

View File

@@ -10,6 +10,8 @@ interface DataTableProps<TData, TValue> {
createSite?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
}
export function SitesDataTable<TData, TValue>({
@@ -17,7 +19,9 @@ export function SitesDataTable<TData, TValue>({
data,
createSite,
onRefresh,
isRefreshing
isRefreshing,
columnVisibility,
enableColumnVisibility
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -38,6 +42,10 @@ export function SitesDataTable<TData, TValue>({
id: "name",
desc: false
}}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { Column, ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { SitesDataTable } from "@app/components/SitesDataTable";
import {
DropdownMenu,
@@ -106,9 +107,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
});
};
const columns: ColumnDef<SiteRow>[] = [
const columns: ExtendedColumnDef<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
header: ({ column }) => {
return (
<Button
@@ -123,8 +125,31 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
);
}
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("niceId"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("niceId")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
},
{
accessorKey: "online",
friendlyName: t("online"),
header: ({ column }) => {
return (
<Button
@@ -166,6 +191,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "mbIn",
friendlyName: t("dataIn"),
header: ({ column }) => {
return (
<Button
@@ -185,6 +211,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "mbOut",
friendlyName: t("dataOut"),
header: ({ column }) => {
return (
<Button
@@ -204,6 +231,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "type",
friendlyName: t("connectionType"),
header: ({ column }) => {
return (
<Button
@@ -261,6 +289,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
header: ({ column }) => {
return (
<Button
@@ -299,48 +328,40 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
);
}
},
...(env.flags.enableClients
? [
{
accessorKey: "address",
header: ({
column
}: {
column: Column<SiteRow, unknown>;
}) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
}
]
: []),
{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }: { row: any }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end gap-2">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -369,11 +390,10 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"secondary"} size="sm">
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
@@ -395,9 +415,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}}
dialog={
<div className="">
<p>
{t("siteQuestionRemove")}
</p>
<p>{t("siteQuestionRemove")}</p>
<p>{t("siteMessageRemove")}</p>
</div>
}
@@ -416,6 +434,13 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}
onRefresh={refreshData}
isRefreshing={isRefreshing}
columnVisibility={{
niceId: false,
nice: false,
exitNode: false,
address: false
}}
enableColumnVisibility={true}
/>
</>
);

View File

@@ -3,19 +3,29 @@ import * as React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient } from "@tanstack/react-query";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { durationToMs } from "@app/lib/durationToMs";
export type ReactQueryProviderProps = {
children: React.ReactNode;
};
export function ReactQueryProvider({ children }: ReactQueryProviderProps) {
export function TanstackQueryProvider({ children }: ReactQueryProviderProps) {
const api = createApiClient(useEnvContext());
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 2, // retry twice by default
staleTime: 5 * 60 * 1_000 // 5 minutes
staleTime: 0,
meta: {
api
}
},
mutations: {
meta: { api }
}
}
})

View File

@@ -0,0 +1,421 @@
"use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
export type ClientRow = {
id: number;
name: string;
subnet: string;
// siteIds: string;
mbIn: string;
mbOut: string;
orgId: string;
online: boolean;
olmVersion?: string;
olmUpdateAvailable: boolean;
userId: string | null;
username: string | null;
userEmail: string | null;
};
type ClientTableProps = {
userClients: ClientRow[];
orgId: string;
};
export default function UserDevicesTable({ userClients }: ClientTableProps) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedClient, setSelectedClient] = useState<ClientRow | null>(
null
);
const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition();
const defaultUserColumnVisibility = {
client: false,
subnet: false
};
const refreshData = () => {
startTransition(() => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
};
const deleteClient = (clientId: number) => {
api.delete(`/client/${clientId}`)
.catch((e) => {
console.error("Error deleting client", e);
toast({
variant: "destructive",
title: "Error deleting client",
description: formatAxiosError(e, "Error deleting client")
});
})
.then(() => {
startTransition(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
};
// Check if there are any rows without userIds in the current view's data
const hasRowsWithoutUserId = useMemo(() => {
return userClients.some((client) => !client.userId);
}, [userClients]);
const columns: ExtendedColumnDef<ClientRow>[] = useMemo(() => {
const baseColumns: ExtendedColumnDef<ClientRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: "Name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "userEmail",
friendlyName: "User",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
User
<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",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Connected</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Disconnected</span>
</span>
);
}
}
},
{
accessorKey: "mbIn",
friendlyName: "Data In",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: "Data Out",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "client",
friendlyName: t("client"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("client")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
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")} />
)}
</div>
);
}
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}
];
// 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" />
</Button>
</Link>
</div>
) : null;
}
});
}
return baseColumns;
}, [hasRowsWithoutUserId, t]);
return (
<>
{selectedClient && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedClient(null);
}}
dialog={
<div>
<p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p>
</div>
}
buttonText="Confirm Delete Client"
onConfirm={async () => deleteClient(selectedClient!.id)}
string={selectedClient.name}
title="Delete Client"
/>
)}
<DataTable
columns={columns}
data={userClients || []}
persistPageSize="user-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onRefresh={refreshData}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
persistColumnVisibility="user-clients"
columnVisibility={defaultUserColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}

View File

@@ -36,6 +36,9 @@ export function UsersDataTable<TData, TValue>({
onRefresh={onRefresh}
isRefreshing={isRefreshing}
addButtonText={t('accessUserCreate')}
enableColumnVisibility={true}
stickyLeftColumn="displayUsername"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -70,9 +71,11 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}
};
const columns: ColumnDef<UserRow>[] = [
const columns: ExtendedColumnDef<UserRow>[] = [
{
accessorKey: "displayUsername",
enableHiding: false,
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
@@ -89,6 +92,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
@@ -115,6 +119,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
},
{
accessorKey: "role",
friendlyName: t("role"),
header: ({ column }) => {
return (
<Button
@@ -133,9 +138,6 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return (
<div className="flex flex-row items-center gap-2">
{userRow.isOwner && (
<Crown className="w-4 h-4 text-yellow-600" />
)}
<span>{userRow.role}</span>
</div>
);
@@ -143,82 +145,65 @@ export default function UsersTable({ users: u }: UsersTableProps) {
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex items-center justify-end">
<>
<div>
{userRow.isOwner && (
<MoreHorizontal className="h-4 w-4 opacity-0" />
)}
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
<div>
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${user?.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="sr-only">
{t("openMenu")}
<span className="text-red-500">
{t("accessUserRemove")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${user?.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="text-red-500">
{t(
"accessUserRemove"
)}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
</>
{userRow.isOwner && (
<Button
variant={"secondary"}
className="ml-2"
size="sm"
disabled={true}
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{!userRow.isOwner && (
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button
variant={"secondary"}
variant={"outline"}
className="ml-2"
size="sm"
disabled={userRow.isOwner}
>
{t("manage")}
@@ -274,9 +259,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}}
dialog={
<div>
<p>
{t("userQuestionOrgRemove")}
</p>
<p>{t("userQuestionOrgRemove")}</p>
<p>{t("userMessageOrgRemove")}</p>
</div>
}

View File

@@ -241,7 +241,7 @@ export default function VerifyEmailForm({
<Button
type="button"
variant={"secondary"}
variant={"outline"}
className="w-full"
onClick={logout}
>

View File

@@ -0,0 +1,246 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useTranslations } from "next-intl";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { ListUserOlmsResponse } from "@server/routers/olm";
import { ResponseT } from "@server/types/Response";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Loader2, RefreshCw } from "lucide-react";
import moment from "moment";
import { useUserContext } from "@app/hooks/useUserContext";
type ViewDevicesDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
type Device = {
olmId: string;
dateCreated: string;
version: string | null;
name: string | null;
clientId: number | null;
userId: string | null;
};
export default function ViewDevicesDialog({
open,
setOpen
}: ViewDevicesDialogProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const { user } = useUserContext();
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const fetchDevices = async () => {
setLoading(true);
try {
const res = await api.get<ResponseT<ListUserOlmsResponse>>(
`/user/${user?.userId}/olms`
);
if (res.data.success && res.data.data) {
setDevices(res.data.data.olms);
}
} catch (error: any) {
console.error("Error fetching devices:", error);
toast({
variant: "destructive",
title: t("errorLoadingDevices") || "Error loading devices",
description: formatAxiosError(
error,
t("failedToLoadDevices") || "Failed to load devices"
)
});
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) {
fetchDevices();
}
}, [open]);
const deleteDevice = async (olmId: string) => {
try {
await api.delete(`/user/${user?.userId}/olm/${olmId}`);
toast({
title: t("deviceDeleted") || "Device deleted",
description:
t("deviceDeletedDescription") ||
"The device has been successfully deleted."
});
setDevices(devices.filter((d) => d.olmId !== olmId));
setIsDeleteModalOpen(false);
setSelectedDevice(null);
} catch (error: any) {
console.error("Error deleting device:", error);
toast({
variant: "destructive",
title: t("errorDeletingDevice") || "Error deleting device",
description: formatAxiosError(
error,
t("failedToDeleteDevice") || "Failed to delete device"
)
});
}
};
function reset() {
setDevices([]);
setSelectedDevice(null);
setIsDeleteModalOpen(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
reset();
}
}}
>
<CredenzaContent className="max-w-4xl">
<CredenzaHeader>
<CredenzaTitle>
{t("viewDevices") || "View Devices"}
</CredenzaTitle>
<CredenzaDescription>
{t("viewDevicesDescription") ||
"Manage your connected devices"}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : devices.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("noDevices") || "No devices found"}
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-3">
{t("name") || "Name"}
</TableHead>
<TableHead>
{t("dateCreated") ||
"Date Created"}
</TableHead>
<TableHead>
{t("actions") || "Actions"}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.olmId}>
<TableCell className="font-medium">
{device.name ||
t("unnamedDevice") ||
"Unnamed Device"}
</TableCell>
<TableCell>
{moment(
device.dateCreated
).format("lll")}
</TableCell>
<TableCell>
<Button
variant="outline"
onClick={() => {
setSelectedDevice(
device
);
setIsDeleteModalOpen(
true
);
}}
>
{t("delete") ||
"Delete"}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">
{t("close") || "Close"}
</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedDevice && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) {
setSelectedDevice(null);
}
}}
dialog={
<div>
<p>
{t("deviceQuestionRemove") ||
"Are you sure you want to delete this device?"}
</p>
<p>
{t("deviceMessageRemove") ||
"This action cannot be undone."}
</p>
</div>
}
buttonText={t("deviceDeleteConfirm") || "Delete Device"}
onConfirm={async () => deleteDevice(selectedDevice.olmId)}
string={selectedDevice.name || selectedDevice.olmId}
title={t("deleteDevice") || "Delete Device"}
/>
)}
</>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
@@ -59,9 +60,10 @@ export default function IdpTable({ idps, orgId }: Props) {
};
const columns: ColumnDef<IdpRow>[] = [
const columns: ExtendedColumnDef<IdpRow>[] = [
{
accessorKey: "idpId",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
@@ -78,6 +80,7 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
@@ -94,6 +97,7 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
@@ -117,10 +121,11 @@ export default function IdpTable({ idps, orgId }: Props) {
},
{
id: "actions",
header: () => (<span className="p-3">{t("actions")}</span>),
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center justify-end">
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
@@ -153,9 +158,7 @@ export default function IdpTable({ idps, orgId }: Props) {
</DropdownMenu>
<Link href={`/${orgId}/settings/idp/${siteRow.idpId}/general`}>
<Button
variant={"secondary"}
className="ml-2"
size="sm"
variant={"outline"}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -46,7 +46,7 @@ export const tagVariants = cva(
capitalize: "capitalize"
},
interaction: {
clickable: "cursor-pointer hover:shadow-md",
clickable: "cursor-pointer hover:shadow-sm",
nonClickable: "cursor-default"
},
animation: {

View File

@@ -14,9 +14,9 @@ const alertVariants = cva(
"border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
success:
"border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500",
info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500",
info: "border-blue-500/50 border bg-blue-500/10 text-blue-800 dark:border-blue-400 [&>svg]:text-blue-500",
warning:
"border-yellow-500/50 border bg-yellow-500/10 text-yellow-500 dark:border-yellow-400 [&>svg]:text-yellow-500"
"border-yellow-500 border text-yellow-800 bg-yellow-500/20 dark:bg-yellow-800/20 dark:text-yellow-100 dark:border-yellow-700 [&>svg]:text-yellow-500"
}
},
defaultVariants: {

Some files were not shown because too many files have changed in this diff Show More