Merge branch 'dev' into feat/redirect-tables

This commit is contained in:
Fred KISSIE
2026-09-18 20:59:18 +02:00
197 changed files with 11958 additions and 4383 deletions
+2 -6
View File
@@ -1,9 +1,5 @@
import { Loader2 } from "lucide-react";
import OrgRouteLoading from "@app/components/OrgRouteLoading";
export default function OrgPageLoading() {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
return <OrgRouteLoading />;
}
@@ -749,12 +749,6 @@ export default function BillingPage() {
return 0;
};
// Get license key count
const getLicenseKeyCount = (): number => {
if (!licenseSubscription?.items) return 0;
return licenseSubscription.items.length;
};
// Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = (
targetTier: Tier | "basic"
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
</SettingsSection>
{/* Paid License Keys Section */}
{(licenseSubscription || getLicenseKeyCount() > 0) && (
{licenseSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
<SettingsFormGrid>
<SettingsFormCell span="full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button
variant="outline"
onClick={handleModifySubscription}
@@ -1,4 +1,5 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
licenseKeys = data.data.data;
} catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
const hasNonPersonalLicenseKey = licenseKeys.some(
(key) => key.tier !== "personal"
);
return (
<>
{hasNonPersonalLicenseKey && (
<LicenseBillingBanner orgId={orgId} />
)}
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
</>
);
}
@@ -10,6 +10,7 @@ import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import { build } from "@server/build";
import { redirect } from "next/navigation";
import { useEnvContext } from "@app/hooks/useEnvContext";
export const metadata: Metadata = {
title: "Remote Exit Nodes"
@@ -24,10 +25,6 @@ export const dynamic = "force-dynamic";
export default async function RemoteExitNodesPage(
props: RemoteExitNodesPageProps
) {
if (build != "saas") {
redirect("/");
}
const params = await props.params;
let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = [];
try {
@@ -38,7 +38,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export default function AccessControlsPage() {
export default function GeneralPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext();
const { env } = useEnvContext();
@@ -57,7 +57,7 @@ export default function AccessControlsPage() {
(build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid));
const accessControlsFormSchema = z.object({
const generalFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z
@@ -72,7 +72,7 @@ export default function AccessControlsPage() {
});
const form = useForm({
resolver: zodResolver(accessControlsFormSchema),
resolver: zodResolver(generalFormSchema),
defaultValues: {
username: user.username!,
autoProvisioned: user.autoProvisioned || false,
@@ -155,7 +155,7 @@ export default function AccessControlsPage() {
}
}
async function handleAccessControlsSubmit(e: React.FormEvent) {
async function handleGeneralSubmit(e: React.FormEvent) {
e.preventDefault();
const isValid = await form.trigger();
@@ -196,11 +196,9 @@ export default function AccessControlsPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("accessControls")}
</SettingsSectionTitle>
<SettingsSectionTitle>{t("general")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("accessControlsDescription")}
{t("userGeneralSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
@@ -208,11 +206,9 @@ export default function AccessControlsPage() {
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={(e) =>
void handleAccessControlsSubmit(e)
}
onSubmit={(e) => void handleGeneralSubmit(e)}
className="space-y-4"
id="access-controls-form"
id="user-general-form"
>
{user.type !== UserType.Internal &&
user.idpType && (
@@ -281,9 +277,9 @@ export default function AccessControlsPage() {
type="submit"
loading={isSaving}
disabled={isSaving}
form="access-controls-form"
form="user-general-form"
>
{t("accessControlsSubmit")}
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -9,15 +9,16 @@ import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
export const metadata: Metadata = {
title: "User"
};
interface UserLayoutProps {
type UserLayoutProps = {
children: React.ReactNode;
params: Promise<{ userId: string; orgId: string }>;
}
};
export default async function UserLayoutProps(props: UserLayoutProps) {
const params = await props.params;
@@ -42,15 +43,23 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [
{
title: t("accessControls"),
href: "/{orgId}/settings/access/users/{userId}/access-controls"
title: t("general"),
href: "/{orgId}/settings/access/users/{userId}/general"
}
];
return (
<>
<SettingsSectionTitle
title={`${user?.email}`}
title={
user
? getUserDisplayName({
email: user.email,
name: user.name,
username: user.username
})
: ""
}
description={t("userDescription2")}
/>
<OrgUserProvider orgUser={user}>
@@ -9,5 +9,5 @@ export default async function UserPage(props: {
params: Promise<{ orgId: string; userId: string }>;
}) {
const { orgId, userId } = await props.params;
redirect(`/${orgId}/settings/access/users/${userId}/access-controls`);
redirect(`/${orgId}/settings/access/users/${userId}/general`);
}
@@ -1,24 +1,20 @@
"use client";
import CopyTextBox from "@app/components/CopyTextBox";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import {
useActionState,
useRef,
useState,
startTransition
} from "react";
import { useActionState, useRef, useState, startTransition } from "react";
import {
Form,
FormControl,
@@ -42,7 +38,6 @@ import { AxiosResponse } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
@@ -55,7 +50,15 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import OrgRolesTagField from "@app/components/OrgRolesTagField";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
type UserType = "internal" | "oidc";
@@ -96,6 +99,7 @@ export default function Page() {
"internal"
);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
@@ -246,9 +250,9 @@ export default function Page() {
build === "saas" || env.app.identityProviderMode === "org";
const res = await api
.get<
AxiosResponse<ListIdpsResponse>
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp")
.get<AxiosResponse<ListIdpsResponse>>(
useOrgIdps ? `/org/${orgId}/idp` : "/idp"
)
.catch((e) => {
console.error(e);
toast({
@@ -350,13 +354,13 @@ export default function Page() {
if (res && res.status === 200) {
setInviteLink(res.data.data.inviteLink);
setExpiresInDays(parseInt(values.validForHours) / 24);
setIsInviteDialogOpen(true);
toast({
variant: "default",
title: t("userInvited"),
description: t("userInvitedDescription")
});
setExpiresInDays(parseInt(values.validForHours) / 24);
}
}
@@ -460,6 +464,7 @@ export default function Page() {
setSendEmail(env.email.emailEnabled);
internalForm.reset();
setInviteLink(null);
setIsInviteDialogOpen(false);
setExpiresInDays(1);
} else {
googleAzureForm.reset();
@@ -486,7 +491,7 @@ export default function Page() {
<div>
<SettingsContainer>
{!inviteLink && userOptions.length > 1 ? (
{userOptions.length > 1 ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -508,177 +513,132 @@ export default function Page() {
) : null}
{selectedOption === "internal" && dataLoaded && (
<>
{!inviteLink ? (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
submitInternalAction();
});
}}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"inviteValid"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectDuration"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<OrgRolesTagField
form={internalForm}
name="roles"
orgId={orgId as string}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={
sendEmail
}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"inviteEmailSent"
)}
</label>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
) : (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userInvited")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{sendEmail
? t(
"inviteEmailSentDescription"
)
: t("inviteSentDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
<CopyToClipboard
text={inviteLink}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("userSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("userSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={(e) => {
e.preventDefault();
startTransition(() => {
submitInternalAction();
});
}}
className="space-y-4"
id="create-user-form"
>
<FormField
control={internalForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("email")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SettingsSectionBody>
</SettingsSection>
)}
</>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("inviteEmailSent")}
</label>
</div>
)}
<FormField
control={internalForm.control}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("inviteValid")}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"selectDuration"
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<OrgRolesTagField
form={internalForm}
name="roles"
orgId={orgId as string}
supportsMultipleRolesPerUser={
supportsMultipleRolesPerUser
}
showMultiRolePaywallMessage={
showMultiRolePaywallMessage
}
paywallMessage={
invitePaywallMessage
}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{selectedOption &&
@@ -898,24 +858,56 @@ export default function Page() {
<div className="flex justify-end space-x-2 mt-8">
{selectedOption && dataLoaded && (
<Button
type={inviteLink ? "button" : "submit"}
form={inviteLink ? undefined : "create-user-form"}
type="submit"
form="create-user-form"
loading={loading}
disabled={loading}
onClick={
inviteLink
? () =>
router.push(
`/${orgId}/settings/access/users`
)
: undefined
}
>
{inviteLink ? t("done") : t("accessUserCreate")}
{t("accessUserCreate")}
</Button>
)}
</div>
</div>
<Credenza
open={isInviteDialogOpen}
onOpenChange={(open) => {
setIsInviteDialogOpen(open);
if (!open) {
setInviteLink(null);
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("userInvited")}</CredenzaTitle>
<CredenzaDescription>
{sendEmail
? t("inviteEmailSentDescription")
: t("inviteSentDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
{inviteLink && <CopyTextBox text={inviteLink} />}
</div>
</CredenzaBody>
<CredenzaFooter>
<Button
onClick={() =>
router.push(`/${orgId}/settings/access/users`)
}
>
{t("done")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
+11 -12
View File
@@ -78,12 +78,13 @@ export default async function UsersPage(props: UsersPageProps) {
rolesRes && rolesRes.status === 200
? (rolesRes.data.data.roles ?? [])
: [];
const roleFilterOptions = orgRoles.map(
(r: ListRolesResponse["roles"][number]) => ({
const roleFilterOptions = [
{ value: "owner", label: t("accessRoleOwner") },
...orgRoles.map((r: ListRolesResponse["roles"][number]) => ({
value: String(r.roleId),
label: r.name
})
);
}))
];
const invitationsRes = await internal
.get(
@@ -126,14 +127,12 @@ export default async function UsersPage(props: UsersPageProps) {
idpId: user.idpId,
idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"),
roleLabels: user.isOwner
? [t("accessRoleOwner")]
: (() => {
const names = (user.roles ?? [])
.map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length));
return names.length ? names : [t("accessRoleMember")];
})(),
roleLabels: (() => {
const names = (user.roles ?? [])
.map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length));
return names.length ? names : [t("accessRoleMember")];
})(),
isOwner: user.isOwner || false
};
});
+161 -248
View File
@@ -43,10 +43,18 @@ import {
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import moment from "moment";
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
export default function Page() {
const { env } = useEnvContext();
@@ -58,6 +66,7 @@ export default function Page() {
const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
@@ -75,22 +84,6 @@ export default function Page() {
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({
resolver: zodResolver(createFormSchema),
defaultValues: {
@@ -98,12 +91,9 @@ export default function Page() {
}
});
const copiedForm = useForm({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: true
}
});
function goToApiKeysList() {
router.push(`/${orgId}/settings/api-keys`);
}
async function onSubmit(data: CreateFormValues) {
setCreateLoading(true);
@@ -113,9 +103,10 @@ export default function Page() {
};
const res = await api
.put<
AxiosResponse<CreateOrgApiKeyResponse>
>(`/org/${orgId}/api-key/`, payload)
.put<AxiosResponse<CreateOrgApiKeyResponse>>(
`/org/${orgId}/api-key/`,
payload
)
.catch((e) => {
toast({
variant: "destructive",
@@ -125,16 +116,10 @@ export default function Page() {
});
if (res && res.status === 201) {
const data = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const created = res.data.data;
const actionsRes = await api
.post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, {
.post(`/org/${orgId}/api-key/${created.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
@@ -149,27 +134,14 @@ export default function Page() {
});
if (actionsRes) {
setApiKey(data);
setApiKey(created);
setIsApiKeyDialogOpen(true);
}
}
setCreateLoading(false);
}
async function onCopiedSubmit(data: CopiedFormValues) {
if (!data.copied) {
return;
}
router.push(`/${orgId}/settings/api-keys`);
}
const formatLabel = (str: string) => {
return str
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase());
};
useEffect(() => {
const load = async () => {
setLoadingPage(false);
@@ -185,12 +157,7 @@ export default function Page() {
title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")}
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
<Button variant="outline" onClick={goToApiKeysList}>
{t("apiKeysSeeAll")}
</Button>
</div>
@@ -198,206 +165,152 @@ export default function Page() {
{!loadingPage && (
<div>
<SettingsContainer>
{!apiKey && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</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"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
selectedPermissions={
selectedPermissions
}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{apiKey && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(
apiKey.createdAt
).format("lll")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("apiKeysGeneralSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
selectedPermissions={selectedPermissions}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
</div>
</div>
)}
<Credenza
open={isApiKeyDialogOpen}
onOpenChange={(open) => {
setIsApiKeyDialogOpen(open);
if (!open && apiKey) {
goToApiKeysList();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
<CredenzaDescription>
{t("apiKeysSaveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{apiKey && (
<div className="space-y-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(apiKey.createdAt).format(
"lll"
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
@@ -132,9 +132,10 @@ export default function Page() {
};
const res = await api
.put<
AxiosResponse<CreateClientResponse>
>(`/org/${orgId}/client`, payload)
.put<AxiosResponse<CreateClientResponse>>(
`/org/${orgId}/client`,
payload
)
.catch((e) => {
toast({
variant: "destructive",
@@ -406,7 +407,6 @@ export default function Page() {
id={olmId}
endpoint={env.app.dashboardUrl}
secret={olmSecret}
version={olmVersion}
/>
</>
)}
@@ -19,7 +19,6 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import ActionBanner from "@app/components/ActionBanner";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -41,13 +40,6 @@ import { useParams } from "next/navigation";
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
productUpdatesQueries,
type LatestVersionResponse
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import semver from "semver";
import { InfoPopup } from "@app/components/ui/info-popup";
function formatTimestamp(timestamp: number | null | undefined): string {
if (!timestamp) return "-";
@@ -173,34 +165,6 @@ export default function GeneralPage() {
}>(null);
const [isCheckingCache, setIsCheckingCache] = useState(false);
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
const data = useQuery(productUpdatesQueries.latestVersion(true));
const latestPlatformVersions = data.data?.data;
const agentVersionMap: Record<string, string> = {
"Pangolin Windows": "windows",
"Pangolin Android": "android",
"Pangolin iOS": "ios",
"Pangolin iPadOS": "ios",
"Pangolin macOS": "mac",
"Pangolin CLI": "cli",
"Olm CLI": "olm"
};
let updateAvailable = false;
if (client.agent && client.olmVersion && latestPlatformVersions) {
const agent = agentVersionMap[
client.agent
] as keyof LatestVersionResponse;
if (agent in latestPlatformVersions) {
const agentVersion = latestPlatformVersions[agent];
updateAvailable = Boolean(
semver.valid(client.olmVersion) &&
semver.lt(client.olmVersion, agentVersion.latestVersion)
);
}
}
// get "imp" from local storage to determine if we should show the verify button (imp = "1" means show)
const showVerifyButton =
@@ -467,7 +431,7 @@ export default function GeneralPage() {
)}
{/* Device Information Section */}
{(client.fingerprint || (client.agent && client.olmVersion)) && (
{client.fingerprint && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
@@ -479,33 +443,6 @@ export default function GeneralPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
{client.agent && client.olmVersion && (
<div className="mb-6">
<InfoSection>
<InfoSectionTitle>
{t("agent")}
</InfoSectionTitle>
<InfoSectionContent>
<div className="flex items-center">
<Badge variant="secondary">
{client.agent +
" v" +
client.olmVersion}
</Badge>
{updateAvailable && (
<InfoPopup
info={t(
"updateAvailableInfo"
)}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
</div>
)}
{client.fingerprint &&
(() => {
const platform = client.fingerprint.platform;
@@ -74,6 +74,8 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
type: site.type as any,
online: site.online,
newtVersion: site.newtVersion || undefined,
agent: site.agent || undefined,
agentVersion: site.agentVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
@@ -76,11 +76,13 @@ export default function PrivateResourceInferencePage() {
})
),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigDomainId: z
.string()
.min(1, { message: t("domainRequired") }),
httpConfigFullDomain: z.string().nullish(),
ssl: z.boolean().optional()
}),
[]
[t]
);
type FormValues = z.infer<typeof formSchema>;
@@ -103,7 +105,7 @@ export default function PrivateResourceInferencePage() {
defaultValues: {
providers: [],
httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null,
httpConfigDomainId: siteResource.domainId ?? "",
httpConfigFullDomain: siteResource.fullDomain ?? null,
ssl: siteResource.ssl ?? false
}
@@ -289,50 +291,74 @@ export default function PrivateResourceInferencePage() {
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="full">
<DomainPicker
key={`inference-domain-${siteResource.id}`}
orgId={siteResource.orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ??
undefined
}
onDomainChange={(res) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
<FormField
control={form.control}
name="httpConfigDomainId"
render={() => (
<FormItem>
<DomainPicker
key={`inference-domain-${siteResource.id}`}
orgId={
siteResource.orgId
}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ??
undefined
}
defaultDomainId={
httpConfigDomainId ??
undefined
}
defaultFullDomain={
httpConfigFullDomain ??
undefined
}
onDomainChange={(
res
) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
"",
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ??
null
);
form.setValue(
"httpConfigDomainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
@@ -954,7 +954,6 @@ export const ProxyResourceTargetsForm = forwardRef<
colSpan={columns.length}
message={emptyMessage ?? t("targetNoOne")}
action={addTargetButton}
compact
/>
)}
</TableBody>
@@ -139,6 +139,22 @@ export default function GeneralForm() {
: "Port number should not be set for HTTP resources",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (
["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
)
) {
return !!data.domainId;
}
return true;
},
{
message: t("domainRequired"),
path: ["domainId"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -434,63 +450,87 @@ export default function GeneralForm() {
resource.mode
) && (
<SettingsFormCell span="full">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={
resource.mode !==
"inference"
}
key={
resource.resourceId
}
orgId={orgId as string}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ?? undefined
}
defaultDomainId={
form.watch(
"domainId"
) ?? undefined
}
defaultFullDomain={
resourceFullDomainName ||
undefined
}
onDomainChange={(
res
) => {
if (res === null) {
form.setValue(
"domainId",
undefined
);
form.setValue(
"subdomain",
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://`
);
return;
}
form.setValue(
"domainId",
res.domainId
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
}}
/>
</div>
<FormField
control={form.control}
name="domainId"
render={() => (
<FormItem>
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={
resource.mode !==
"inference"
}
key={
resource.resourceId
}
orgId={
orgId as string
}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ??
undefined
}
defaultDomainId={
form.watch(
"domainId"
) ??
undefined
}
defaultFullDomain={
resourceFullDomainName ||
undefined
}
onDomainChange={(
res
) => {
if (
res ===
null
) {
form.setValue(
"domainId",
undefined,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://`
);
return;
}
form.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
}}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
{!["tcp", "udp", "inference"].includes(
@@ -309,7 +309,6 @@ export default function CredentialsPage() {
id={displayNewtId ?? "**********"}
secret={displaySecret ?? "**************"}
endpoint={env.app.dashboardUrl}
version={newtVersion}
/>
</>
)}
@@ -196,8 +196,7 @@ export default function Page() {
const { data: latestVersions } = useQuery(
productUpdatesQueries.latestVersion(true)
);
const newtVersion =
latestVersions?.data?.newt?.latestVersion ?? "latest";
const newtVersion = latestVersions?.data?.newt?.latestVersion ?? "latest";
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
@@ -285,9 +284,10 @@ export default function Page() {
}
const res = await api
.put<
AxiosResponse<CreateSiteResponse>
>(`/org/${orgId}/site/`, payload)
.put<AxiosResponse<CreateSiteResponse>>(
`/org/${orgId}/site/`,
payload
)
.catch((e) => {
toast({
variant: "destructive",
@@ -641,7 +641,6 @@ export default function Page() {
id={newtId}
secret={newtSecret}
endpoint={env.app.dashboardUrl}
version={newtVersion}
/>
</>
)}
+2
View File
@@ -70,6 +70,8 @@ export default async function SitesPage(props: SitesPageProps) {
type: site.type as any,
online: site.online,
newtVersion: site.newtVersion || undefined,
agent: site.agent || undefined,
agentVersion: site.agentVersion || undefined,
newtUpdateAvailable: site.newtUpdateAvailable || false,
exitNodeName: site.exitNodeName || undefined,
exitNodeEndpoint: site.exitNodeEndpoint || undefined,
+158 -239
View File
@@ -46,6 +46,15 @@ import moment from "moment";
import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
export default function Page() {
const { env } = useEnvContext();
@@ -56,6 +65,7 @@ export default function Page() {
const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
@@ -73,22 +83,6 @@ export default function Page() {
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({
resolver: zodResolver(createFormSchema),
defaultValues: {
@@ -96,12 +90,9 @@ export default function Page() {
}
});
const copiedForm = useForm({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: true
}
});
function goToApiKeysList() {
router.push(`/admin/api-keys`);
}
async function onSubmit(data: CreateFormValues) {
setCreateLoading(true);
@@ -121,16 +112,10 @@ export default function Page() {
});
if (res && res.status === 201) {
const data = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const created = res.data.data;
const actionsRes = await api
.post(`/api-key/${data.apiKeyId}/actions`, {
.post(`/api-key/${created.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
@@ -145,21 +130,14 @@ export default function Page() {
});
if (actionsRes) {
setApiKey(data);
setApiKey(created);
setIsApiKeyDialogOpen(true);
}
}
setCreateLoading(false);
}
async function onCopiedSubmit(data: CopiedFormValues) {
if (!data.copied) {
return;
}
router.push(`/admin/api-keys`);
}
useEffect(() => {
const load = async () => {
setLoadingPage(false);
@@ -175,12 +153,7 @@ export default function Page() {
title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")}
/>
<Button
variant="outline"
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
<Button variant="outline" onClick={goToApiKeysList}>
{t("apiKeysSeeAll")}
</Button>
</div>
@@ -188,207 +161,153 @@ export default function Page() {
{!loadingPage && (
<div>
<SettingsContainer>
{!apiKey && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</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"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysTitle")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
root={true}
selectedPermissions={
selectedPermissions
}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{apiKey && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(
apiKey.createdAt
).format("lll")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysGeneralSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("apiKeysGeneralSettingsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
root={true}
selectedPermissions={selectedPermissions}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
</div>
</div>
)}
<Credenza
open={isApiKeyDialogOpen}
onOpenChange={(open) => {
setIsApiKeyDialogOpen(open);
if (!open && apiKey) {
goToApiKeysList();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
<CredenzaDescription>
{t("apiKeysSaveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{apiKey && (
<div className="space-y-4">
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("name")}
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("created")}
</InfoSectionTitle>
<InfoSectionContent>
{moment(apiKey.createdAt).format(
"lll"
)}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("apiKeysSave")}
</AlertTitle>
<AlertDescription>
{t("apiKeysSaveDescription")}
</AlertDescription>
</Alert>
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgsTable from "@app/components/OrgsTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { AdminListOrgsResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "Organizations"
};
export const dynamic = "force-dynamic";
type OrganizationsPageProps = {
searchParams: Promise<Record<string, string>>;
};
export default async function OrganizationsPage(props: OrganizationsPageProps) {
const searchParams = new URLSearchParams(await props.searchParams);
let orgs: AdminListOrgsResponse["orgs"] = [];
let pagination: AdminListOrgsResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try {
const res = await internal.get<AxiosResponse<AdminListOrgsResponse>>(
`/admin/orgs?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
orgs = responseData.orgs;
pagination = responseData.pagination;
} catch (e) {}
const t = await getTranslations();
return (
<>
<SettingsSectionTitle
title={t("orgsManage")}
description={t("orgsDescription")}
/>
<OrgsTable
orgs={orgs}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}
+1 -1
View File
@@ -65,7 +65,7 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #0C0C0D;
--sidebar: #0c0c0d;
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
+9 -5
View File
@@ -6,10 +6,10 @@ import {
Bot,
Boxes,
Building2,
Building2Icon,
Cable,
ChartLine,
Coins,
Combine,
CreditCard,
Fingerprint,
Globe,
@@ -20,7 +20,6 @@ import {
LayoutGrid,
Link as LinkIcon,
Logs,
MessageSquare,
MessagesSquare,
MonitorUp,
Plug,
@@ -146,7 +145,7 @@ export const orgNavSections = (
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(build === "saas"
...(env?.flags.usePangolinDns
? [
{
title: "sidebarRemoteExitNodes",
@@ -382,6 +381,11 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
href: "/admin/users",
icon: <Users className="size-4 flex-none" />
},
{
title: "sidebarOrgs",
href: "/admin/organizations",
icon: <Building2Icon className="size-4 flex-none" />
},
{
title: "sidebarApiKeys",
href: "/admin/api-keys",
@@ -398,7 +402,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
}
]
: []),
...(build == "enterprise"
...(build === "enterprise"
? [
{
title: "sidebarLicense",
@@ -472,7 +476,7 @@ export const commandBarNavSections = (
href: "/{orgId}/settings/redirects",
icon: <Repeat className="size-4 flex-none" />
},
...(build === "saas"
...(env?.flags.usePangolinDns
? [
{
title: "commandRemoteExitNodes",
+227 -2
View File
@@ -19,7 +19,8 @@ import {
ArrowRight,
ArrowUp10Icon,
ChevronsUpDownIcon,
MoreHorizontal
MoreHorizontal,
ShieldUserIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -43,6 +44,13 @@ import {
CredenzaClose
} from "@app/components/Credenza";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import { useUserContext } from "@app/hooks/useUserContext";
export type GlobalUserRow = {
id: string;
@@ -90,6 +98,11 @@ export default function UsersTable({
const [passwordResetCodeData, setPasswordResetCodeData] =
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
const [promoting, setPromoting] = useState<GlobalUserRow | null>(null);
const [isDemoteModalOpen, setIsDemoteModalOpen] = useState(false);
const [demoting, setDemoting] = useState<GlobalUserRow | null>(null);
const user = useUserContext();
const [isRefreshing, startTransition] = useTransition();
const {
@@ -109,6 +122,11 @@ export default function UsersTable({
.optional()
.catch(undefined);
const serverAdminFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | undefined | null
@@ -184,6 +202,54 @@ export default function UsersTable({
}
};
const setServerAdmin = async (
targetUser: GlobalUserRow,
serverAdmin: boolean
) => {
const successTitleKey = serverAdmin
? "promoteServerAdminSuccess"
: "demoteServerAdminSuccess";
const successDescriptionKey = serverAdmin
? "promoteServerAdminSuccessDescription"
: "demoteServerAdminSuccessDescription";
const errorKey = serverAdmin
? "promoteServerAdminError"
: "demoteServerAdminError";
try {
await api.post(`/user/${targetUser.id}/server-admin`, {
serverAdmin
});
toast({
title: t(successTitleKey),
description: t(successDescriptionKey, {
selectedUser: getUserDisplayName({
email: targetUser.email,
name: targetUser.name,
username: targetUser.username
})
})
});
startTransition(() => {
router.refresh();
});
} catch (e) {
console.error(t(errorKey), e);
toast({
variant: "destructive",
title: t(errorKey),
description: formatAxiosError(e, t(errorKey))
});
} finally {
setIsPromoteModalOpen(false);
setPromoting(null);
setIsDemoteModalOpen(false);
setDemoting(null);
}
};
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
@@ -235,7 +301,32 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
cell: ({ row }) => (
<span className="inline-flex gap-1 items-center">
{row.original.username}{" "}
{row.original.id === user.user.userId && (
<>
<span className="text-muted-foreground">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.serverAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ShieldUserIcon className="text-primary size-4 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("serverAdmin")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
)
},
{
accessorKey: "email",
@@ -341,6 +432,37 @@ export default function UsersTable({
);
}
},
{
accessorKey: "serverAdmin",
friendlyName: t("serverAdmin"),
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("yes") },
{ value: "false", label: t("no") }
]}
selectedValue={serverAdminFilterSchema.parse(
searchParams.get("server_admin") ?? undefined
)}
onValueChange={(value) =>
handleFilterChange("server_admin", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("serverAdmin")}
className="p-3"
/>
),
cell: ({ row }) => (
<span>
{row.original.serverAdmin ? (
<span>{t("yes")}</span>
) : (
<span>{t("no")}</span>
)}
</span>
)
},
{
id: "actions",
enableHiding: false,
@@ -369,11 +491,34 @@ export default function UsersTable({
{t("generatePasswordResetCode")}
</DropdownMenuItem>
)}
{r.type === "internal" && !r.serverAdmin && (
<DropdownMenuItem
onClick={() => {
setPromoting(r);
setIsPromoteModalOpen(true);
}}
>
{t("promoteServerAdmin")}
</DropdownMenuItem>
)}
{r.type === "internal" &&
r.serverAdmin &&
r.id !== user.user.userId && (
<DropdownMenuItem
onClick={() => {
setDemoting(r);
setIsDemoteModalOpen(true);
}}
>
{t("demoteServerAdmin")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
className="text-red-400"
>
{t("delete")}
</DropdownMenuItem>
@@ -435,6 +580,86 @@ export default function UsersTable({
/>
)}
{promoting && (
<ConfirmDeleteDialog
open={isPromoteModalOpen}
setOpen={(val) => {
setIsPromoteModalOpen(val);
if (!val) {
setPromoting(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("promoteServerAdminQuestion", {
selectedUser: getUserDisplayName({
email: promoting.email,
name: promoting.name,
username: promoting.username
})
})}
</p>
<p>{t("promoteServerAdminMessage")}</p>
</div>
}
buttonText={t("promoteServerAdminConfirm")}
onConfirm={async () => setServerAdmin(promoting, true)}
string={getUserDisplayName({
email: promoting.email,
name: promoting.name,
username: promoting.username
})}
warningText={t("promoteServerAdminWarning")}
title={t("promoteServerAdminTitle")}
/>
)}
{demoting && (
<ConfirmDeleteDialog
open={isDemoteModalOpen}
setOpen={(val) => {
setIsDemoteModalOpen(val);
if (!val) {
setDemoting(null);
}
}}
dialog={
<div className="space-y-2">
<p>
{t("demoteServerAdminQuestion", {
selectedUser: getUserDisplayName({
email: demoting.email,
name: demoting.name,
username: demoting.username
})
})}
</p>
<p>
{t("demoteServerAdminMessage", {
selectedUser: getUserDisplayName({
email: demoting.email,
name: demoting.name,
username: demoting.username
})
})}
</p>
</div>
}
buttonText={t("demoteServerAdminConfirm")}
onConfirm={async () => setServerAdmin(demoting, false)}
string={getUserDisplayName({
email: demoting.email,
name: demoting.name,
username: demoting.username
})}
warningText={t("demoteServerAdminWarning")}
title={t("demoteServerAdminTitle")}
/>
)}
<ControlledDataTable
columns={columns}
rows={users}
+1 -1
View File
@@ -227,7 +227,7 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
<div className="inline-flex items-start md:items-center gap-2">
<span>
<Link
href={`/${orgId}/settings/access/users/${approval.user.userId}/access-controls`}
href={`/${orgId}/settings/access/users/${approval.user.userId}/general`}
className="text-primary hover:underline cursor-pointer"
>
{getUserDisplayName({
+48 -103
View File
@@ -1,15 +1,12 @@
"use client";
import { Button } from "@app/components/ui/button";
import { Card, CardContent } from "@app/components/ui/card";
import {
ShieldCheck,
Check,
Ban,
User,
Settings,
ArrowRight
} from "lucide-react";
SettingsSection,
SettingsSectionBody,
SettingsSectionFooter
} from "@app/components/Settings";
import { ArrowRight, Settings, ShieldCheck, User } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
@@ -21,102 +18,50 @@ export function ApprovalsEmptyState({ orgId }: ApprovalsEmptyStateProps) {
const t = useTranslations();
return (
<div className="flex flex-col gap-6">
<Card>
<CardContent className="p-6 md:p-12">
<div className="flex flex-col items-center text-center gap-4 md:gap-6 max-w-2xl mx-auto">
<div className="space-y-2">
<h3 className="text-xl md:text-2xl font-semibold">
{t("approvalsEmptyStateTitle")}
</h3>
<p className="text-muted-foreground text-sm md:text-lg">
{t("approvalsEmptyStateDescription")}
</p>
</div>
<SettingsSection>
<SettingsSectionBody>
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
<ShieldCheck className="h-8 w-8 text-primary" />
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
{t("approvalsEmptyStateTitle")}
</h2>
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
{t("approvalsEmptyStateDescription")}
</p>
<div className="w-full space-y-3 md:space-y-4 mt-2 md:mt-4">
<div className="bg-muted/50 rounded-lg p-4 md:p-6 space-y-3 md:space-y-4 border">
<div className="flex items-start gap-3 md:gap-4">
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
<Settings className="w-4 h-4 md:w-5 md:h-5 text-primary" />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className="font-semibold mb-1 text-sm md:text-base">
{t("approvalsEmptyStateStep1Title")}
</h4>
<p className="text-xs md:text-sm text-muted-foreground">
{t(
"approvalsEmptyStateStep1Description"
)}
</p>
</div>
</div>
<div className="flex items-start gap-3 md:gap-4">
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
<User className="w-4 h-4 md:w-5 md:h-5 text-primary" />
</div>
<div className="flex-1 text-left min-w-0">
<h4 className="font-semibold mb-1 text-sm md:text-base">
{t("approvalsEmptyStateStep2Title")}
</h4>
<p className="text-xs md:text-sm text-muted-foreground">
{t(
"approvalsEmptyStateStep2Description"
)}
</p>
</div>
</div>
</div>
{/* Abstract UI Preview - Hidden on mobile */}
<div className="hidden md:block bg-muted/50 rounded-lg p-6 border">
<div className="space-y-3">
<div className="flex items-center justify-between p-3 bg-background rounded border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
<div>
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex gap-2">
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-background rounded border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<User className="w-4 h-4 text-primary" />
</div>
<div>
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
</div>
</div>
<div className="flex gap-2">
<div className="h-6 w-16 bg-green-500/20 rounded flex items-center justify-center">
<Check className="w-3 h-3 text-green-600" />
</div>
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<Link href={`/${orgId}/settings/access/roles`} className="w-full md:w-auto">
<Button className="gap-2 mt-2 w-full md:w-auto">
{t("approvalsEmptyStateButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<div className="mt-8 w-full max-w-lg text-left space-y-3">
<p className="text-sm font-medium text-center">
{t("approvalsEmptyStateHowToTitle")}
</p>
<ul className="text-sm text-muted-foreground space-y-2">
<li className="flex items-start gap-2">
<Settings className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep1Description")}
</span>
</li>
<li className="flex items-start gap-2">
<User className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>
{t("approvalsEmptyStateStep2Description")}
</span>
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
{t("approvalsEmptyStatePreviewDescription")}
</p>
</div>
</SettingsSectionBody>
<SettingsSectionFooter className="justify-center md:justify-center">
<Button asChild>
<Link href={`/${orgId}/settings/access/roles`}>
{t("approvalsEmptyStateButtonText")}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</SettingsSectionFooter>
</SettingsSection>
);
}
+61 -1
View File
@@ -11,9 +11,26 @@ import {
import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useTranslations } from "next-intl";
import {
productUpdatesQueries,
type LatestVersionResponse
} from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import semver from "semver";
import { InfoPopup } from "@app/components/ui/info-popup";
type ClientInfoCardProps = {};
const agentVersionMap: Record<string, string> = {
"Pangolin Windows": "windows",
"Pangolin Android": "android",
"Pangolin iOS": "ios",
"Pangolin iPadOS": "ios",
"Pangolin macOS": "mac",
"Pangolin CLI": "cli",
"Olm CLI": "olm"
};
export default function SiteInfoCard({}: ClientInfoCardProps) {
const { client, updateClient } = useClientContext();
const t = useTranslations();
@@ -24,14 +41,57 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
username: client.userUsername
});
const data = useQuery(productUpdatesQueries.latestVersion(true));
const latestPlatformVersions = data.data?.data;
let updateAvailable = false;
if (client.agent && client.olmVersion && latestPlatformVersions) {
const agent = agentVersionMap[
client.agent
] as keyof LatestVersionResponse;
if (agent in latestPlatformVersions) {
const agentVersion = latestPlatformVersions[agent];
updateAvailable = Boolean(
semver.valid(client.olmVersion) &&
semver.lt(client.olmVersion, agentVersion.latestVersion)
);
}
}
const cols =
2 +
(userDisplayName ? 1 : 0) +
(client.agent && client.olmVersion ? 1 : 0);
return (
<Alert>
<AlertDescription>
<InfoSections cols={userDisplayName ? 3 : 2}>
<InfoSections cols={cols}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{client.name}</InfoSectionContent>
</InfoSection>
{client.agent && client.olmVersion ? (
<InfoSection>
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
<InfoSectionContent>
<div className="flex items-center gap-2">
<span>
{client.agent +
" v" +
client.olmVersion}
</span>
{updateAvailable && (
<InfoPopup
info={t("updateAvailableInfo")}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
) : null}
{userDisplayName ? (
<InfoSection>
<InfoSectionTitle>{t("user")}</InfoSectionTitle>
+14 -13
View File
@@ -252,9 +252,10 @@ export default function GenerateLicenseKeyForm({
try {
// Check if this is a business/enterprise license request
if (payload.useCaseType === "business") {
const response = await api.put<
AxiosResponse<string>
>(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } );
const response = await api.put<AxiosResponse<string>>(
`/org/${orgId}/license/enterprise`,
{ ...payload, tier: "tier2" }
);
console.log("Checkout session response:", response.data);
const checkoutUrl = response.data.data;
@@ -1087,16 +1088,16 @@ export default function GenerateLicenseKeyForm({
)}
{!generatedKey && useCaseType === "business" && (
<Button
type="submit"
form="generate-license-business-form"
disabled={loading}
loading={loading}
>
{t(
"generateLicenseKeyForm.buttons.generateLicenseKey"
)}
</Button>
<Button
type="submit"
form="generate-license-business-form"
disabled={loading}
loading={loading}
>
{t(
"generateLicenseKeyForm.buttons.generateLicenseKey"
)}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
+16 -6
View File
@@ -78,13 +78,16 @@ export default function GenerateLicenseKeysTable({
);
toast({
title: t("success"),
description: "Instance name cleared successfully"
description: "Server ID cleared successfully"
});
await refreshData();
} catch (error) {
toast({
title: t("error"),
description: formatAxiosError(error, "Failed to clear instance name"),
description: formatAxiosError(
error,
"Failed to clear server ID"
),
variant: "destructive"
});
} finally {
@@ -198,9 +201,16 @@ export default function GenerateLicenseKeysTable({
},
cell: ({ row }) => {
const tier = row.original.tier;
return tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
switch (tier) {
case "enterprise":
return t("licenseTierEnterprise");
case "tier1":
return t("licenseTierTier1");
case "tier2":
return t("licenseTierTier2");
default:
return t("licenseTierPersonal");
}
}
},
{
@@ -291,7 +301,7 @@ export default function GenerateLicenseKeysTable({
clearInstanceName(key.licenseKey)
}
>
Clear Instance Name
{t("clearInstanceName")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Globe, CreditCard, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import DismissableBanner from "./DismissableBanner";
type LicenseBillingBannerProps = {
orgId: string;
};
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="license-billing-banner-dismissed"
version={1}
title={t("licenseBillingBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("licenseBillingBannerDescription")}
>
<Link href={`/${orgId}/settings/billing`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
{t("licenseBillingBannerButton")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default LicenseBillingBanner;
+10 -3
View File
@@ -100,9 +100,16 @@ export function LicenseKeysDataTable({
},
cell: ({ row }) => {
const tier = row.original.tier;
return tier === "enterprise"
? t("licenseTierEnterprise")
: t("licenseTierPersonal");
switch (tier) {
case "enterprise":
return t("licenseTierEnterprise");
case "tier1":
return t("licenseTierTier1");
case "tier2":
return t("licenseTierTier2");
default:
return t("licenseTierPersonal");
}
}
},
{
+56
View File
@@ -0,0 +1,56 @@
import { cn } from "@app/lib/cn";
type LoadingDotsProps = {
className?: string;
size?: "sm" | "md";
};
const sizeClasses = {
sm: {
gap: "gap-1.5",
dot: "h-1.5 w-1.5"
},
md: {
gap: "gap-2.5",
dot: "h-2.5 w-2.5"
}
} as const;
export default function LoadingDots({
className,
size = "md"
}: LoadingDotsProps) {
const classes = sizeClasses[size];
return (
<span
className={cn(
"flex items-center text-muted-foreground",
classes.gap,
className
)}
>
<span
className={cn(
"rounded-full bg-current animate-dot-pulse",
classes.dot
)}
style={{ animationDelay: "0ms" }}
/>
<span
className={cn(
"rounded-full bg-current animate-dot-pulse",
classes.dot
)}
style={{ animationDelay: "200ms" }}
/>
<span
className={cn(
"rounded-full bg-current animate-dot-pulse",
classes.dot
)}
style={{ animationDelay: "400ms" }}
/>
</span>
);
}
+22 -28
View File
@@ -1,19 +1,18 @@
"use client";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { Check, Globe, Languages } from "lucide-react";
import clsx from "clsx";
import { Check, Languages } from "lucide-react";
import { useTransition } from "react";
import { Locale } from "@/i18n/config";
import { setUserLocale } from "@/services/locale";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
type Props = {
defaultValue: string;
@@ -43,36 +42,31 @@ export default function LocaleSwitcherSelect({
const selected = items.find((item) => item.value === defaultValue);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className={clsx(
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
isPending && "pointer-events-none"
)}
aria-label={label}
>
<Languages className="text-muted-foreground h-4 w-4" />
<span className="text-left flex-1">
{selected?.label ?? label}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[8rem]">
<DropdownMenuSub>
<DropdownMenuSubTrigger
className={cn(
"[&_svg:not([class*='text-'])]:text-muted-foreground",
isPending && "pointer-events-none"
)}
aria-label={label}
>
<Languages className="mr-2 h-4 w-4" />
<span>{selected?.label ?? label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[8rem]">
{items.map((item) => (
<DropdownMenuItem
key={item.value}
onClick={() => onChange(item.value)}
className="flex items-center gap-2"
>
{item.value === defaultValue && (
<Check className="h-4 w-4" />
)}
<span>{item.label}</span>
{item.value === defaultValue && (
<Check className="ml-auto h-4 w-4" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
+3 -3
View File
@@ -40,8 +40,8 @@ import { InfoIcon } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
const TIER_TO_LICENSE_ID = {
starter: "small_license",
scale: "big_license"
starter: "tier1",
scale: "tier2"
} as const;
type FormProps = {
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
cols={2}
/>
<a
href="https://pangolin.net/pricing"
href="https://pangolin.net/pricing#Self-Hosted"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
+45
View File
@@ -0,0 +1,45 @@
"use client";
import Link from "next/link";
import BrandingLogo from "@app/components/BrandingLogo";
import LoadingDots from "@app/components/LoadingDots";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
export default function OrgRouteLoading() {
const { env } = useEnvContext();
const { isUnlocked } = useLicenseStatusContext();
const logoWidth = isUnlocked()
? env.branding.logo?.navbar?.width || 98
: 98;
const logoHeight = isUnlocked()
? env.branding.logo?.navbar?.height || 32
: 32;
return (
<div className="relative h-screen-safe overflow-hidden">
<div className="absolute top-0 left-0 right-0 z-50">
<div className="px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="flex h-16 items-center">
<Link
href="/"
className="flex shrink-0 items-center"
>
<BrandingLogo
width={logoWidth}
height={logoHeight}
/>
</Link>
</div>
</div>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center">
<LoadingDots size="md" />
</div>
</div>
);
}
+1 -9
View File
@@ -27,15 +27,7 @@ export function OrgSelector({
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
const picker = (
<OrgPicker
orgId={orgId}
orgs={orgs}
contentClassName={
isCollapsed
? "w-[320px]"
: "w-[var(--radix-popover-trigger-width)]"
}
>
<OrgPicker orgId={orgId} orgs={orgs} contentClassName="w-[320px]">
<div
role="combobox"
className={cn(
+290
View File
@@ -0,0 +1,290 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import type { AdminOrgRow } from "@server/routers/org";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon
} from "lucide-react";
import moment from "moment";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import CopyToClipboard from "./CopyToClipboard";
type OrgTableProps = {
orgs: AdminOrgRow[];
pagination: PaginationState;
rowCount: number;
};
export default function OrgsTable({
orgs,
pagination,
rowCount
}: OrgTableProps) {
const router = useRouter();
const t = useTranslations();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [isRefreshing, startTransition] = useTransition();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<AdminOrgRow | null>();
const api = createApiClient(useEnvContext());
function refreshData() {
startTransition(async () => {
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
function sortableHeader(column: string, label: string) {
const sortOrder = getSortDirection(column, searchParams);
const Icon =
sortOrder === "asc"
? ArrowDown01Icon
: sortOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
className="p-3"
onClick={() => toggleSort(column)}
>
{label}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
const columns = useMemo<ExtendedColumnDef<AdminOrgRow>[]>(() => {
return [
{
accessorKey: "name",
friendlyName: t("name"),
enableHiding: false,
header: () => sortableHeader("name", t("name"))
},
{
accessorKey: "orgId",
friendlyName: t("orgId"),
header: () => <span className="p-3">{t("orgId")}</span>,
cell: ({ row }) => (
<CopyToClipboard text={row.original.orgId} isLink={false} />
)
},
{
accessorKey: "createdAt",
friendlyName: t("createdAt"),
header: () => sortableHeader("createdAt", t("createdAt")),
cell: ({ row }) => {
const createdAt = row.original.createdAt;
return (
<span>
{createdAt ? moment(createdAt).format("lll") : "-"}
</span>
);
}
},
{
accessorKey: "owner",
friendlyName: t("accessRoleOwner"),
header: () => (
<span className="p-3">{t("accessRoleOwner")}</span>
),
cell: ({ row }) => {
const owner = row.original.owner;
return owner ? (
<Button
className="tabular-nums"
asChild
variant="outline"
size="sm"
>
<Link href={`/admin/users/${owner.userId}`}>
{owner.username}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Link>
</Button>
) : (
<code>-</code>
);
}
},
{
accessorKey: "subnet",
friendlyName: t("subnet"),
header: () => <span className="p-3">{t("subnet")}</span>,
cell: ({ row }) => <span>{row.original.subnet || "-"}</span>
},
{
accessorKey: "utilitySubnet",
friendlyName: t("utilitySubnet"),
header: () => <span className="p-3">{t("utilitySubnet")}</span>,
cell: ({ row }) => (
<span>{row.original.utilitySubnet || "-"}</span>
)
},
{
accessorKey: "userCount",
friendlyName: t("users"),
header: () => <span className="p-3">{t("users")}</span>,
cell: ({ row }) => <span>{row.original.userCount}</span>
},
{
accessorKey: "siteCount",
friendlyName: t("sites"),
header: () => <span className="p-3">{t("sites")}</span>,
cell: ({ row }) => <span>{row.original.siteCount}</span>
},
{
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => <span>{row.original.resourceCount}</span>
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const orgRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<Button
onClick={() => {
setSelectedOrg(orgRow);
setIsDeleteModalOpen(true);
}}
variant="outline"
>
{t("delete")}
</Button>
</div>
);
}
}
];
}, [t, searchParams]);
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
async function deleteOrg(orgId: string) {
try {
const res = await api.delete(`/admin/org/${orgId}`);
toast({
title: t("orgDeleted"),
description: t("orgDeletedMessage")
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: t("orgErrorDelete"),
description: formatAxiosError(err, t("orgErrorDeleteMessage"))
});
} finally {
router.refresh();
}
}
return (
<>
{selectedOrg && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedOrg(null);
}}
dialog={
<div className="space-y-2">
<p>{t("orgQuestionRemove")}</p>
<p>{t("orgMessageRemove")}</p>
</div>
}
buttonText={t("orgDeleteConfirm")}
onConfirm={async () => {
startTransition(() => deleteOrg(selectedOrg.orgId));
}}
string={selectedOrg.name}
title={t("orgDelete")}
/>
)}
<ControlledDataTable
columns={columns}
rows={orgs}
tableId="admin-orgs-table"
searchPlaceholder={t("orgSearch")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
onRefresh={refreshData}
isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
columnVisibility={{
subnet: false,
utilitySubnet: false
}}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
</>
);
}
+8 -4
View File
@@ -319,13 +319,16 @@ export default function PendingSitesTable({
const originalRow = row.original;
if (originalRow.type === "newt") {
const isCli = originalRow.agent === "cli";
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>Newt</span>
{originalRow.newtVersion && (
<span>v{originalRow.newtVersion}</span>
<span>{isCli ? "CLI" : "Newt"}</span>
{originalRow.agentVersion && (
<span>
v{originalRow.agentVersion}
</span>
)}
</div>
</Badge>
@@ -402,7 +405,8 @@ export default function PendingSitesTable({
"saturn",
"uranus",
"neptune",
"pluto"
"pluto",
"erid"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {
+39 -29
View File
@@ -9,13 +9,16 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
import { Check, Laptop, Moon, Sun, Trash2 } from "lucide-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/navigation";
import Link from "next/link";
@@ -149,42 +152,49 @@ export default function ProfileIcon() {
>
<span>{t("changePassword")}</span>
</DropdownMenuItem>
<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) => (
<DropdownMenuItem
key={themeOption}
onClick={() => handleThemeChange(themeOption)}
>
{themeOption === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)}
</DropdownMenuItem>
)
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
{userTheme === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{userTheme === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{userTheme === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">{t(userTheme)}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[8rem]">
{(["light", "dark", "system"] as const).map(
(themeOption) => (
<DropdownMenuItem
key={themeOption}
onClick={() =>
handleThemeChange(themeOption)
}
className="flex items-center gap-2"
>
<span className="capitalize">
{t(themeOption)}
</span>
{userTheme === themeOption && (
<Check className="ml-auto h-4 w-4" />
)}
</DropdownMenuItem>
)
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("language")}</DropdownMenuLabel>
<LocaleSwitcher />
<DropdownMenuSeparator />
{user?.type === UserType.Internal && !user?.serverAdmin && (
+1 -3
View File
@@ -21,14 +21,12 @@ import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { orgQueries } from "@app/lib/queries";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { UpdateResourceResponse } from "@server/routers/resource";
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useQuery } from "@tanstack/react-query";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
+55 -37
View File
@@ -52,9 +52,11 @@ export default function RegenerateInvitationForm({
}: RegenerateInvitationFormProps) {
const [loading, setLoading] = useState(false);
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [sendEmail, setSendEmail] = useState(true);
const [expiresInDays, setExpiresInDays] = useState(3);
const { env } = useEnvContext();
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [validHours, setValidHours] = useState(72);
const api = createApiClient(useEnvContext());
const api = createApiClient({ env });
const { org } = useOrgContext();
const t = useTranslations();
@@ -71,10 +73,11 @@ export default function RegenerateInvitationForm({
useEffect(() => {
if (open) {
setSendEmail(true);
setSendEmail(env.email.emailEnabled);
setValidHours(72);
setExpiresInDays(3);
}
}, [open]);
}, [open, env.email.emailEnabled]);
async function handleRegenerate() {
if (!invitation) return;
@@ -96,15 +99,16 @@ export default function RegenerateInvitationForm({
email: invitation.email,
roleIds: invitation.roleIds,
validHours,
sendEmail,
sendEmail: env.email.emailEnabled && sendEmail,
regenerate: true
});
if (res.status === 200) {
const link = res.data.data.inviteLink;
setInviteLink(link);
setExpiresInDays(validHours / 24);
if (sendEmail) {
if (sendEmail && env.email.emailEnabled) {
toast({
variant: "default",
title: t("inviteRegenerated"),
@@ -127,9 +131,7 @@ export default function RegenerateInvitationForm({
onRegenerate({
id: invitation.id,
email: invitation.email,
expiresAt: new Date(
res.data.data.expiresAt
).toISOString(),
expiresAt: new Date(res.data.data.expiresAt).toISOString(),
roleLabels: invitation.roleLabels,
roleIds: invitation.roleIds
});
@@ -174,33 +176,47 @@ export default function RegenerateInvitationForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("inviteRegenerate")}</CredenzaTitle>
<CredenzaTitle>
{inviteLink
? t("inviteRegenerated")
: t("inviteRegenerate")}
</CredenzaTitle>
<CredenzaDescription>
{t("inviteRegenerateDescription")}
{inviteLink
? sendEmail && env.email.emailEnabled
? t("inviteEmailSentDescription")
: t("inviteSentDescription")
: t("inviteRegenerateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!inviteLink ? (
<div>
<p>
{t("inviteQuestionRegenerate", {
email: invitation?.email || ""
})}
</p>
<div className="flex items-center space-x-2 mt-4">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label htmlFor="send-email">
{t("inviteSentEmail")}
</label>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("email")}</Label>
<p className="text-sm">{invitation?.email}</p>
</div>
<div className="mt-4 space-y-2">
<Label>{t("inviteValidityPeriod")}</Label>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(e as boolean)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("inviteEmailSent")}
</label>
</div>
)}
<div className="space-y-2">
<Label>{t("inviteValid")}</Label>
<Select
value={validHours.toString()}
onValueChange={(value) =>
@@ -209,9 +225,7 @@ export default function RegenerateInvitationForm({
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"inviteValidityPeriodSelect"
)}
placeholder={t("selectDuration")}
/>
</SelectTrigger>
<SelectContent>
@@ -228,9 +242,13 @@ export default function RegenerateInvitationForm({
</div>
</div>
) : (
<div className="space-y-4 max-w-md">
<p>{t("inviteRegenerateMessage")}</p>
<CopyTextBox text={inviteLink} wrapText={false} />
<div className="space-y-4">
<p>
{t("inviteExpiresIn", {
days: expiresInDays
})}
</p>
{inviteLink && <CopyTextBox text={inviteLink} />}
</div>
)}
</CredenzaBody>
@@ -249,7 +267,7 @@ export default function RegenerateInvitationForm({
</>
) : (
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
<Button>{t("done")}</Button>
</CredenzaClose>
)}
</CredenzaFooter>
+16 -7
View File
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
) : null;
if (site.type === "newt") {
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel = site.agent == "cli" ? "Pangolin CLI" : "Newt";
const agentVersion = site.agentVersion ?? site.newtVersion;
return (
<Alert>
<AlertDescription>
@@ -72,16 +78,19 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSectionTitle>
{t("connectionType")}
</InfoSectionTitle>
<InfoSectionContent>Newt</InfoSectionContent>
<InfoSectionContent>
{t("pangolinSite")}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("newtVersion")}
</InfoSectionTitle>
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
<InfoSectionContent>
{site.newtVersion
? `v${site.newtVersion}`
: "-"}
<div className="flex items-center space-x-1">
<span>{agentLabel}</span>
{agentVersion && (
<span>v{agentVersion}</span>
)}
</div>
</InfoSectionContent>
</InfoSection>
{endpointSection}
+23 -9
View File
@@ -52,7 +52,6 @@ import {
} from "./ui/controlled-data-table";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { durationToMs } from "@app/lib/durationToMs";
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import semver from "semver";
@@ -68,6 +67,8 @@ export type SiteRow = {
orgId: string;
type: "newt" | "wireguard" | "local";
newtVersion?: string;
agent?: string;
agentVersion?: string;
newtUpdateAvailable?: boolean;
online?: boolean | null;
address?: string;
@@ -370,9 +371,9 @@ export default function SitesTable({
},
{
accessorKey: "type",
friendlyName: t("type"),
friendlyName: t("agent"),
header: () => {
return <span className="p-3">{t("type")}</span>;
return <span className="p-3">{t("agent")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
@@ -385,15 +386,27 @@ export default function SitesTable({
);
if (originalRow.type === "newt") {
if (!originalRow.agent && !originalRow.newtVersion) {
// it has not checked in yet
return <span>-</span>;
}
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel =
originalRow.agent == "cli"
? "Pangolin CLI"
: "Newt";
const agentVersion =
originalRow.agentVersion ?? originalRow.newtVersion;
return (
<div className="flex items-center space-x-1">
<Badge variant="secondary">
<div className="flex items-center space-x-1">
<span>Newt</span>
{originalRow.newtVersion && (
<span>
v{originalRow.newtVersion}
</span>
<span>{agentLabel}</span>
{agentVersion && (
<span>v{agentVersion}</span>
)}
</div>
</Badge>
@@ -470,7 +483,8 @@ export default function SitesTable({
"saturn",
"uranus",
"neptune",
"pluto"
"pluto",
"erid"
].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) {
+9 -4
View File
@@ -8,27 +8,32 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
const MAX_ROLE_BADGES = 3;
export default function UserRoleBadges({
roleLabels
roleLabels,
isOwner
}: {
roleLabels: string[];
isOwner?: boolean;
}) {
const t = useTranslations();
const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
const overflow = roleLabels.slice(MAX_ROLE_BADGES);
return (
<div className="flex flex-wrap items-center gap-1">
{isOwner && (
<Badge variant="secondary">{t("accessRoleOwner")}</Badge>
)}
{visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary">
{label}
</Badge>
))}
{overflow.length > 0 && (
<OverflowRolesPopover labels={overflow} />
)}
{overflow.length > 0 && <OverflowRolesPopover labels={overflow} />}
</div>
);
}
+49 -4
View File
@@ -22,6 +22,7 @@ import {
ArrowRight,
ArrowUp10Icon,
ChevronsUpDownIcon,
Crown,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
@@ -33,12 +34,20 @@ import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton";
import IdpTypeBadge from "./IdpTypeBadge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
import UserRoleBadges from "./UserRoleBadges";
const OWNER_FILTER_VALUE = "owner";
export type UserRow = {
id: string;
email: string | null;
@@ -95,7 +104,13 @@ export default function UsersTable({
const roleIdsFromSearchParams = useMemo(() => {
const sp = new URLSearchParams(searchParams);
return [
...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id)))
...new Set(
sp
.getAll("role_id")
.filter(
(id) => /^\d+$/.test(id) || id === OWNER_FILTER_VALUE
)
)
];
}, [searchParams.toString()]);
@@ -126,7 +141,7 @@ export default function UsersTable({
sp.delete("role_id");
sp.delete("page");
for (const id of values) {
if (/^\d+$/.test(id)) {
if (/^\d+$/.test(id) || id === OWNER_FILTER_VALUE) {
sp.append("role_id", id);
}
}
@@ -171,7 +186,32 @@ export default function UsersTable({
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
cell: ({ row }) => (
<span className="inline-flex gap-1 items-center">
{row.original.displayUsername}{" "}
{row.original.id === user.userId && (
<>
<span className="text-muted-foreground">
&middot;
</span>{" "}
<span className="text-primary">you</span>
</>
)}
{row.original.isOwner && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Crown className="text-primary size-4 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("accessRoleOwner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
)
},
{
accessorKey: "idpName",
@@ -222,7 +262,12 @@ export default function UsersTable({
);
},
cell: ({ row }) => {
return <UserRoleBadges roleLabels={row.original.roleLabels} />;
return (
<UserRoleBadges
roleLabels={row.original.roleLabels}
isOwner={row.original.isOwner}
/>
);
}
},
{
@@ -155,13 +155,13 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
}
/>
<CommandList className="max-h-118 min-h-0 h-(--cmdk-list-height) scroll-pb-4 scroll-pt-2 transition-[height] duration-250 ease-in-out">
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
<CommandGroup
heading={t("commandActionModeInfo")}
className="[&_[cmdk-group-heading]]:text-sm"
/>
<CommandEmpty>{t("commandPaletteNoResults")}</CommandEmpty>
{!isActionMode &&
navigationGroups.map((group, groupIndex) => (
<React.Fragment key={group.heading}>
+114 -49
View File
@@ -8,6 +8,7 @@ import {
SettingsSectionTitle
} from "./Settings";
import { CheckboxWithLabel } from "./ui/checkbox";
import { Button } from "./ui/button";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react";
import {
@@ -18,11 +19,14 @@ import {
FaLinux,
FaWindows
} from "react-icons/fa";
import { ExternalLink } from "lucide-react";
import { Download, ExternalLink } from "lucide-react";
import { SiKubernetes, SiNixos } from "react-icons/si";
import { useEnvContext } from "@app/hooks/useEnvContext";
export type CommandItem = string | { title: string; command: string };
export type CommandItem =
| string
| { title: string; command: string }
| { title: string; link: string };
const PLATFORMS = [
"linux",
@@ -41,14 +45,12 @@ export type NewtSiteInstallCommandsProps = {
id: string;
secret: string;
endpoint: string;
version?: string;
};
export function NewtSiteInstallCommands({
id,
secret,
endpoint,
version = "latest"
endpoint
}: NewtSiteInstallCommandsProps) {
const t = useTranslations();
const { env } = useEnvContext();
@@ -88,25 +90,39 @@ export function NewtSiteInstallCommands({
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `${runAsRootPrefix}newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
command: `${runAsRootPrefix}pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
}
],
"Systemd Service": [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `sudo pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
},
{
title: t("check"),
command: `sudo pangolin service status site`
}
],
"Manual Systemd Service": [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("envFile"),
command: `# Create the directory and environment file
sudo install -d -m 0755 /etc/newt
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
NEWT_ID=${id}
NEWT_SECRET=${secret}
sudo install -d -m 0755 /etc/pangolin
sudo tee /etc/pangolin/pangolin-site.env > /dev/null << 'EOF'
SITE_ID=${id}
SITE_SECRET=${secret}
PANGOLIN_ENDPOINT=${endpoint}${
!acceptClients
? `
@@ -119,13 +135,13 @@ DISABLE_SSH=true`
: ""
}
EOF
sudo chmod 600 /etc/newt/newt.env`
sudo chmod 600 /etc/pangolin/pangolin-site.env`
},
{
title: t("serviceFile"),
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF'
command: `sudo tee /etc/systemd/system/pangolin-site.service > /dev/null << 'EOF'
[Unit]
Description=Newt
Description=Pangolin Site
Wants=network-online.target
After=network-online.target
@@ -133,8 +149,8 @@ After=network-online.target
Type=simple
User=root
Group=root
EnvironmentFile=/etc/newt/newt.env
ExecStart=/usr/local/bin/newt
EnvironmentFile=/etc/pangolin/pangolin-site.env
ExecStart=/usr/local/bin/pangolin up site
Restart=always
RestartSec=2
UMask=0077
@@ -148,7 +164,7 @@ EOF`
{
title: t("enableAndStart"),
command: `sudo systemctl daemon-reload
sudo systemctl enable --now newt`
sudo systemctl enable --now pangolin-site`
}
]
},
@@ -156,43 +172,72 @@ sudo systemctl enable --now newt`
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
command: `pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
],
Service: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `sudo pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
},
{
title: t("check"),
command: `sudo pangolin service status site`
}
]
},
windows: {
x64: [
Run: [
{
title: t("install"),
command: `curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
},
{
title: t("run"),
command: `newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
command: `pangolin up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
}
],
Service: [
{
title: t("install"),
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
},
{
title: t("run"),
command: `pangolin service install site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
},
{
title: t("check"),
command: `pangolin service status site`
}
]
},
docker: {
"Docker Compose": [
`services:
newt:
image: fosrl/newt
container_name: newt
pangolin-site:
image: fosrl/pangolin-cli
container_name: pangolin-site
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=${endpoint}
- NEWT_ID=${id}
- NEWT_SECRET=${secret}${acceptClientsEnv}`
- SITE_ID=${id}
- SITE_SECRET=${secret}${acceptClientsEnv}`
],
"Docker Run": [
`docker run -dit --network host fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
`docker run -dit --network host fosrl/pangolin-cli up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
},
kubernetes: {
// we are leaving this using newt until we change it to use the cli
"Helm Chart": [
`helm repo add fossorial https://charts.fossorial.io`,
`helm repo update fossorial`,
@@ -216,15 +261,15 @@ sudo systemctl enable --now newt`
podman: {
"Podman Quadlet": [
`[Unit]
Description=Newt container
Description=Pangolin Site Container
[Container]
ContainerName=newt
Image=docker.io/fosrl/newt
ContainerName=pangolin-site
Image=docker.io/fosrl/pangolin-cli
Environment=PANGOLIN_ENDPOINT=${endpoint}
Environment=NEWT_ID=${id}
Environment=NEWT_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
# Secret=newt-secret,type=env,target=NEWT_SECRET
Environment=SITE_ID=${id}
Environment=SITE_SECRET=${secret}${!acceptClients ? "\nEnvironment=DISABLE_CLIENTS=true" : ""}
# Secret=pangolin-secret,type=env,target=SITE_SECRET
[Service]
Restart=always
@@ -233,12 +278,12 @@ Restart=always
WantedBy=default.target`
],
"Podman Run": [
`podman run -dit docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
`podman run -dit docker.io/fosrl/pangolin-cli up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
]
},
nixos: {
Flake: [
`${runAsRootPrefix}nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
`${runAsRootPrefix}nix run 'nixpkgs#pangolin-cli' -- up site --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}${disableSshFlag}`
]
}
};
@@ -277,9 +322,7 @@ WantedBy=default.target`
/>
<OptionSelect<string>
label={
platform === "windows" ? t("architecture") : t("method")
}
label={t("method")}
options={getArchitectures(platform).map((arch) => ({
value: arch,
label: arch
@@ -377,14 +420,23 @@ WantedBy=default.target`
)}
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const isLink =
typeof item !== "string" && "link" in item;
const commandText =
typeof item === "string" ? item : item.command;
typeof item === "string"
? item
: isLink
? undefined
: item.command;
const linkHref = isLink
? (item as { link: string }).link
: undefined;
const title =
typeof item === "string"
? undefined
: item.title;
const key = `${title ?? ""}::${commandText}`;
const key = `${title ?? ""}::${commandText ?? linkHref}`;
return (
<div key={key}>
@@ -393,10 +445,23 @@ WantedBy=default.target`
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
{isLink ? (
<Button
asChild
variant="outline"
className="w-full"
>
<a href={linkHref}>
<Download className="h-4 w-4 mr-2" />
{t("downloadInstaller")}
</a>
</Button>
) : (
<CopyTextBox
text={commandText!}
outline={true}
/>
)}
</div>
);
})}
@@ -456,11 +521,11 @@ function getPlatformName(platformName: Platform) {
function getArchitectures(platform: Platform) {
switch (platform) {
case "linux":
return ["Run", "Systemd Service"];
return ["Run", "Systemd Service", "Manual Systemd Service"];
case "macos":
return ["Run"];
return ["Run", "Service"];
case "windows":
return ["x64"];
return ["Run", "Service"];
case "docker":
return ["Docker Compose", "Docker Run"];
case "kubernetes":
+71 -22
View File
@@ -1,4 +1,4 @@
import { Terminal } from "lucide-react";
import { Download, Terminal } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { FaDocker, FaWindows } from "react-icons/fa";
@@ -10,9 +10,13 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "./Settings";
import { Button } from "./ui/button";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
export type CommandItem = string | { title: string; command: string };
export type CommandItem =
| string
| { title: string; command: string }
| { title: string; link: string };
const PLATFORMS = ["unix", "docker", "windows"] as const;
@@ -22,14 +26,12 @@ export type OlmInstallCommandsProps = {
id: string;
secret: string;
endpoint: string;
version?: string;
};
export function OlmInstallCommands({
id,
secret,
endpoint,
version = "latest"
endpoint
}: OlmInstallCommandsProps) {
const t = useTranslations();
@@ -40,14 +42,28 @@ export function OlmInstallCommands({
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
unix: {
All: [
Run: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
},
{
title: t("run"),
command: `sudo pangolin up --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
command: `sudo pangolin up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
}
],
Service: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
},
{
title: t("run"),
command: `sudo pangolin service install client --id ${id} --secret ${secret} --endpoint ${endpoint}`
},
{
title: t("check"),
command: `sudo pangolin service status client`
}
]
},
@@ -73,15 +89,28 @@ export function OlmInstallCommands({
]
},
windows: {
x64: [
Run: [
{
title: t("install"),
command: `# Download and run the installer to install Olm first\n
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
command: `pangolin up client --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
],
Service: [
{
title: t("install"),
link: `https://github.com/fosrl/cli/releases/latest/download/pangolin-cli_windows_installer.msi`
},
{
title: t("run"),
command: `pangolin service install client --id ${id} --secret ${secret} --endpoint ${endpoint}`
},
{
title: t("check"),
command: `pangolin service status client`
}
]
}
@@ -121,9 +150,7 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
/>
<OptionSelect<string>
label={
platform === "docker" ? t("method") : t("architecture")
}
label={t("method")}
options={getArchitectures(platform).map((arch) => ({
value: arch,
label: arch
@@ -138,8 +165,17 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
<p className="font-semibold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const isLink =
typeof item !== "string" && "link" in item;
const commandText =
typeof item === "string" ? item : item.command;
typeof item === "string"
? item
: isLink
? undefined
: item.command;
const linkHref = isLink
? (item as { link: string }).link
: undefined;
const title =
typeof item === "string"
? undefined
@@ -152,10 +188,23 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
{isLink ? (
<Button
asChild
variant="outline"
className="w-full"
>
<a href={linkHref}>
<Download className="h-4 w-4 mr-2" />
{t("downloadInstaller")}
</a>
</Button>
) : (
<CopyTextBox
text={commandText!}
outline={true}
/>
)}
</div>
);
})}
@@ -169,13 +218,13 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
function getArchitectures(platform: Platform) {
switch (platform) {
case "unix":
return ["All"];
return ["Run", "Service"];
case "windows":
return ["x64"];
return ["Run", "Service"];
case "docker":
return ["Docker Compose", "Docker Run"];
default:
return ["x64"];
return ["Run"];
}
}
@@ -9,6 +9,7 @@ import type {
} from "@server/routers/launcher/types";
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import LoadingDots from "@app/components/LoadingDots";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
@@ -81,8 +82,8 @@ export function LauncherFlatResourceList({
if (resources.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<div className="flex items-center justify-center py-16">
<LoadingDots size="sm" />
</div>
);
}
@@ -8,6 +8,7 @@ import type {
LauncherViewConfig
} from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import LoadingDots from "@app/components/LoadingDots";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
@@ -109,8 +110,8 @@ export function LauncherGroupList({
if (groups.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<div className="flex items-center justify-center py-16">
<LoadingDots size="sm" />
</div>
);
}
@@ -22,6 +22,7 @@ import {
LAUNCHER_UNLABELED_GROUP_KEY
} from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import LoadingDots from "@app/components/LoadingDots";
import { Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
@@ -169,8 +170,8 @@ export function LauncherGroupSection({
<CollapsibleContent className="w-full">
{showInitialLoader ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
<div className="flex items-center justify-center py-10">
<LoadingDots size="sm" />
</div>
) : resources.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">
@@ -1,6 +1,8 @@
"use client";
import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
import { AiConfigBlocks } from "@app/components/ai-client-config/AiConfigBlocks";
import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
@@ -28,7 +30,23 @@ import {
} from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
import type { AiConfigBlock } from "@app/lib/aiClientConfig";
import { cn } from "@app/lib/cn";
import {
derivePublicAuthState,
formatPublicResourceType
@@ -36,7 +54,10 @@ import {
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
import type { LauncherResource } from "@server/routers/launcher/types";
import type {
LauncherResource,
LauncherSiteInfo
} from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
@@ -44,15 +65,18 @@ import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
ChevronsUpDown,
Clock,
ExternalLink,
Loader2,
MoreHorizontal,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useState } from "react";
type LauncherResourcePanelProps = {
open: boolean;
@@ -60,6 +84,7 @@ type LauncherResourcePanelProps = {
resource: LauncherResource | null;
orgId: string;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
};
type LauncherResourceDetailResult =
@@ -153,6 +178,249 @@ function HealthStatusDisplay({
const PUBLIC_AUTH_METHODS_MODES = ["http", "ssh", "rdp", "vnc"];
const PUBLIC_AUTH_BADGE_MODES = [...PUBLIC_AUTH_METHODS_MODES, "inference"];
type SitesSortKey = "name" | "status";
type SitesSortOrder = "asc" | "desc";
function siteStatusSortValue(site: LauncherSiteInfo): number {
if (site.type !== "newt" && site.type !== "wireguard") {
return -1;
}
if (typeof site.online !== "boolean") {
return -1;
}
return site.online ? 1 : 0;
}
function SiteStatusCell({ site }: { site: LauncherSiteInfo }) {
const t = useTranslations();
if (site.type !== "newt" && site.type !== "wireguard") {
return <span>-</span>;
}
if (typeof site.online !== "boolean") {
return <span>-</span>;
}
return (
<span className="flex items-center gap-2">
<span
className={cn(
"size-2 shrink-0 rounded-full",
site.online ? "bg-green-500" : "bg-neutral-500"
)}
/>
<span>{site.online ? t("online") : t("offline")}</span>
</span>
);
}
function LauncherResourceSitesSection({
orgId,
sites,
isAdmin,
onFilterBySite
}: {
orgId: string;
sites: LauncherSiteInfo[];
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const [sortKey, setSortKey] = useState<SitesSortKey>("name");
const [sortOrder, setSortOrder] = useState<SitesSortOrder>("asc");
if (sites.length === 0) {
return null;
}
function toggleSort(key: SitesSortKey) {
if (sortKey === key) {
setSortOrder((order) => (order === "asc" ? "desc" : "asc"));
return;
}
setSortKey(key);
setSortOrder("asc");
}
const sortedSites = [...sites].sort((a, b) => {
const cmp =
sortKey === "name"
? a.name.localeCompare(b.name, undefined, {
sensitivity: "base"
})
: siteStatusSortValue(a) - siteStatusSortValue(b);
return sortOrder === "desc" ? -cmp : cmp;
});
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("sites")}</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherSitesDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="min-w-0 overflow-x-auto overflow-y-hidden">
<Table sticky>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">
<Button
variant="ghost"
className="h-8 px-3"
onClick={() => toggleSort("name")}
>
{t("name")}
<ChevronsUpDown className="ml-2 size-4" />
</Button>
</TableHead>
<TableHead className="whitespace-nowrap">
<Button
variant="ghost"
className="h-8 px-3"
onClick={() => toggleSort("status")}
>
{t("status")}
<ChevronsUpDown className="ml-2 size-4" />
</Button>
</TableHead>
<TableHead
className={cn(
"whitespace-nowrap text-right",
"sticky right-0 z-10 w-auto min-w-fit bg-card",
"[mask-image:linear-gradient(to_right,transparent_0%,black_20px)]"
)}
>
<span className="sr-only">
{t("actions")}
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSites.map((site) => (
<TableRow key={site.siteId}>
<TableCell className="whitespace-nowrap">
{site.name}
</TableCell>
<TableCell className="whitespace-nowrap">
<SiteStatusCell site={site} />
</TableCell>
<TableCell
className={cn(
"whitespace-nowrap text-right",
"sticky right-0 z-10 w-auto min-w-fit bg-card",
"[mask-image:linear-gradient(to_right,transparent_0%,black_20px)]"
)}
>
<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">
{isAdmin ? (
<DropdownMenuItem asChild>
<Link
href={`/${orgId}/settings/sites/${site.niceId}`}
>
{t(
"resourceLauncherViewSiteAsAdmin"
)}
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem
onClick={() =>
onFilterBySite(site)
}
>
{t(
"resourceLauncherFilterBySite"
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SettingsSectionBody>
</SettingsSection>
);
}
function LauncherPrivateSshCommandSection({
alias,
niceId,
pamMode
}: {
alias?: string | null;
niceId: string;
pamMode?: string | null;
}) {
const t = useTranslations();
const trimmedAlias = alias?.trim() || null;
const hasDistinctAlias = Boolean(trimmedAlias && trimmedAlias !== niceId);
const useUserPrefix = pamMode !== "push";
function formatSshTarget(target: string) {
return useUserPrefix ? `user@${target}` : target;
}
const blocks: AiConfigBlock[] = hasDistinctAlias
? [
{
id: "alias",
label: t("editInternalResourceDialogAlias"),
displayText: `pangolin ssh ${formatSshTarget(trimmedAlias!)}`
},
{
id: "niceId",
label: t("identifier"),
displayText: `pangolin ssh ${formatSshTarget(niceId)}`
}
]
: [
{
id: "niceId",
label: t("identifier"),
displayText: `pangolin ssh ${formatSshTarget(niceId)}`
}
];
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherSshCommand")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherSshCommandDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{hasDistinctAlias ? (
<AiConfigBlocks blocks={blocks} relation="options" />
) : (
<CopyTextBox text={blocks[0].displayText} />
)}
</SettingsSectionBody>
</SettingsSection>
);
}
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
const t = useTranslations();
@@ -235,12 +503,16 @@ function PublicResourceDetails({
orgId,
launcherResource,
resource,
authInfo
authInfo,
isAdmin,
onFilterBySite
}: {
orgId: string;
launcherResource: LauncherResource;
resource: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const mode = resource.mode || "";
@@ -328,6 +600,12 @@ function PublicResourceDetails({
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
<LauncherResourceSitesSection
orgId={orgId}
sites={launcherResource.sites ?? []}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
{showAuthMethods ? (
<PublicResourceAuthMethods authInfo={authInfo} />
) : null}
@@ -363,14 +641,19 @@ function PublicResourceDetails({
function PrivateResourceDetails({
orgId,
launcherResource,
resource
resource,
isAdmin,
onFilterBySite
}: {
orgId: string;
launcherResource: LauncherResource;
resource: GetSiteResourceResponse;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const isInference = resource.mode === "inference";
const isSsh = resource.mode === "ssh";
return (
<div className="space-y-4">
@@ -417,6 +700,19 @@ function PrivateResourceDetails({
/>
</SettingsSectionBody>
</SettingsSection>
<LauncherResourceSitesSection
orgId={orgId}
sites={launcherResource.sites ?? []}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
{isSsh ? (
<LauncherPrivateSshCommandSection
alias={resource.alias}
niceId={resource.niceId}
pamMode={resource.pamMode}
/>
) : null}
{isInference ? (
<>
<LauncherInferenceModelsSection
@@ -440,11 +736,15 @@ function PrivateResourceDetails({
function LauncherResourcePanelBody({
orgId,
resource,
open
open,
isAdmin,
onFilterBySite
}: {
orgId: string;
resource: LauncherResource;
open: boolean;
isAdmin: boolean;
onFilterBySite: (site: LauncherSiteInfo) => void;
}) {
const t = useTranslations();
const { data, isPending, isError } = useQuery({
@@ -477,6 +777,8 @@ function LauncherResourcePanelBody({
launcherResource={resource}
resource={detail.data}
authInfo={detail.authInfo}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
);
}
@@ -486,6 +788,8 @@ function LauncherResourcePanelBody({
orgId={orgId}
launcherResource={resource}
resource={detail.data}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
);
}
@@ -495,7 +799,8 @@ export function LauncherResourcePanel({
onOpenChange,
resource,
orgId,
isAdmin
isAdmin,
onFilterBySite
}: LauncherResourcePanelProps) {
const t = useTranslations();
@@ -511,6 +816,8 @@ export function LauncherResourcePanel({
orgId={orgId}
resource={resource}
open={open}
isAdmin={isAdmin}
onFilterBySite={onFilterBySite}
/>
) : null}
</SidePanelBody>
@@ -44,6 +44,7 @@ import {
type LauncherGroup,
type LauncherResource,
type LauncherScaleInfo,
type LauncherSiteInfo,
type LauncherViewConfig,
type LauncherViewRecord,
type ListLauncherResourcesResponse
@@ -584,6 +585,14 @@ export default function ResourceLauncher({
}
}, []);
const handleFilterBySite = useCallback(
(site: LauncherSiteInfo) => {
applyConfigPatch({ siteIds: [site.siteId] });
handlePanelOpenChange(false);
},
[applyConfigPatch, handlePanelOpenChange]
);
const hasHandledAutoOpen = useRef(false);
useEffect(() => {
@@ -795,6 +804,7 @@ export default function ResourceLauncher({
resource={selectedResource}
orgId={orgId}
isAdmin={isAdmin}
onFilterBySite={handleFilterBySite}
/>
{activeSavedView ? (
@@ -961,7 +961,6 @@ export function PolicyAccessRulesTable({
colSpan={columns.length}
message={t("rulesNoOne")}
action={emptyStateAction}
compact
/>
)}
</TableBody>
+3 -3
View File
@@ -81,15 +81,15 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
<span className="absolute inset-0 flex items-center justify-center">
<span className="flex items-center gap-1.5">
<span
className="h-1 w-1 bg-current animate-dot-pulse"
className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
style={{ animationDelay: "0ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
style={{ animationDelay: "200ms" }}
/>
<span
className="h-1 w-1 bg-current animate-dot-pulse"
className="h-1 w-1 rounded-full bg-current animate-dot-pulse"
style={{ animationDelay: "400ms" }}
/>
</span>
+1 -1
View File
@@ -87,7 +87,7 @@ function Calendar({
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
+17 -23
View File
@@ -5,52 +5,46 @@ import { useTranslations } from "next-intl";
import { type ReactNode } from "react";
const PLACEHOLDER_ROW_COUNT = 5;
const COMPACT_PLACEHOLDER_ROW_COUNT = 2;
type DataTableEmptyStateProps = {
colSpan: number;
action?: ReactNode;
message?: string;
compact?: boolean;
};
export function DataTableEmptyState({
colSpan,
action,
message,
compact = false
message
}: DataTableEmptyStateProps) {
const t = useTranslations();
const placeholderRows = compact
? COMPACT_PLACEHOLDER_ROW_COUNT
: PLACEHOLDER_ROW_COUNT;
const minHeightClass = compact ? "min-h-[4.5rem]" : "min-h-[11rem]";
return (
<TableRow className="hidden sm:table-row hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={colSpan} className="p-0">
<div
className={`relative w-full overflow-hidden ${minHeightClass}`}
>
<div className="relative w-full overflow-hidden min-h-[12.5rem]">
<div
className="absolute inset-0 flex flex-col justify-start"
className="pointer-events-none absolute inset-0 flex flex-col justify-start opacity-50"
aria-hidden
>
{Array.from({ length: placeholderRows }).map((_, i) => (
<div key={i} className="h-10 shrink-0" />
))}
{Array.from({ length: PLACEHOLDER_ROW_COUNT }).map(
(_, i) => (
<div
key={i}
className="flex h-10 shrink-0 items-center border-b border-border/60 px-4 last:border-b-0"
>
<div className="h-3.5 w-full rounded bg-muted/60" />
</div>
)
)}
</div>
<div
className={`relative flex w-full flex-col items-center justify-center px-4 ${
compact
? "min-h-[4.5rem] gap-3 py-4"
: "min-h-[11rem] gap-4 py-8"
}`}
>
<div className="relative flex w-full flex-col items-center justify-center px-4 min-h-[12.5rem] gap-4 py-8">
<p className="text-sm text-muted-foreground">
{message ?? t("noResults")}
</p>
{action}
{action ? (
<div className="bg-background">{action}</div>
) : null}
</div>
</div>
</TableCell>
+7
View File
@@ -523,6 +523,13 @@ export function createCreateFormSchema(t: TranslateFn) {
});
}
}
if (data.mode === "inference" && !data.httpConfigDomainId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("domainRequired"),
path: ["httpConfigDomainId"]
});
}
});
}
+2 -3
View File
@@ -1,3 +1,4 @@
import { build } from "@server/build";
import { Env } from "./types/env";
export function pullEnv(): Env {
@@ -34,9 +35,7 @@ export function pullEnv(): Env {
: false
},
identityProviderMode: process.env.IDENTITY_PROVIDER_MODE as
| "org"
| "global"
| undefined
"org" | "global" | undefined
},
email: {
emailEnabled: process.env.EMAIL_ENABLED === "true" ? true : false