mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-30 07:29:04 +01:00
Merge branch 'dev' into feat/redirect-tables
This commit is contained in:
@@ -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 {
|
||||
|
||||
+10
-14
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
·
|
||||
</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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
·
|
||||
</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}>
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user