mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-21 04:16:38 +00:00
Merge branch 'dev' into feat/logo-path-in-enterprise
This commit is contained in:
24
src/components/ApplyInternalRedirect.tsx
Normal file
24
src/components/ApplyInternalRedirect.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
||||
|
||||
type ApplyInternalRedirectProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export default function ApplyInternalRedirect({
|
||||
orgId
|
||||
}: ApplyInternalRedirectProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const path = consumeInternalRedirectPath();
|
||||
if (path) {
|
||||
router.replace(`/${orgId}${path}`);
|
||||
}
|
||||
}, [orgId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
import { Separator } from "./ui/separator";
|
||||
import { InfoPopup } from "./ui/info-popup";
|
||||
import { ApprovalsEmptyState } from "./ApprovalsEmptyState";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export type ApprovalFeedProps = {
|
||||
orgId: string;
|
||||
@@ -50,9 +52,12 @@ export function ApprovalFeed({
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const { data, isFetching, refetch } = useQuery(
|
||||
approvalQueries.listApprovals(orgId, filters)
|
||||
);
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const { data, isFetching, refetch } = useQuery({
|
||||
...approvalQueries.listApprovals(orgId, filters),
|
||||
enabled: isPaidUser(tierMatrix.deviceApprovals)
|
||||
});
|
||||
|
||||
const approvals = data?.approvals ?? [];
|
||||
|
||||
@@ -209,19 +214,19 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
|
||||
|
||||
{approval.type === "user_device" && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{approval.deviceName ? (
|
||||
<>
|
||||
{t("requestingNewDeviceApproval")}:{" "}
|
||||
{approval.niceId ? (
|
||||
<Link
|
||||
href={`/${orgId}/settings/clients/user/${approval.niceId}/general`}
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{approval.deviceName}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{approval.deviceName}</span>
|
||||
)}
|
||||
{approval.deviceName ? (
|
||||
<>
|
||||
{t("requestingNewDeviceApproval")}:{" "}
|
||||
{approval.niceId ? (
|
||||
<Link
|
||||
href={`/${orgId}/settings/clients/user/${approval.niceId}/general`}
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{approval.deviceName}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{approval.deviceName}</span>
|
||||
)}
|
||||
{approval.fingerprint && (
|
||||
<InfoPopup>
|
||||
<div className="space-y-1 text-sm">
|
||||
@@ -229,7 +234,10 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
|
||||
{t("deviceInformation")}
|
||||
</div>
|
||||
<div className="text-muted-foreground whitespace-pre-line">
|
||||
{formatFingerprintInfo(approval.fingerprint, t)}
|
||||
{formatFingerprintInfo(
|
||||
approval.fingerprint,
|
||||
t
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</InfoPopup>
|
||||
|
||||
@@ -35,6 +35,8 @@ import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
export type AuthPageCustomizationProps = {
|
||||
orgId: string;
|
||||
@@ -162,14 +164,14 @@ export default function AuthPageBrandingForm({
|
||||
`Choose your preferred authentication method for {{resourceName}}`,
|
||||
primaryColor: branding?.primaryColor ?? `#f36117` // default pangolin primary color
|
||||
},
|
||||
disabled: !isPaidUser
|
||||
disabled: !isPaidUser(tierMatrix.loginPageBranding)
|
||||
});
|
||||
|
||||
async function updateBranding() {
|
||||
const isValid = await form.trigger();
|
||||
const brandingData = form.getValues();
|
||||
|
||||
if (!isValid || !isPaidUser) return;
|
||||
if (!isValid || !isPaidUser(tierMatrix.loginPageBranding)) return;
|
||||
|
||||
try {
|
||||
const updateRes = await api.put(
|
||||
@@ -200,8 +202,6 @@ export default function AuthPageBrandingForm({
|
||||
}
|
||||
|
||||
async function deleteBranding() {
|
||||
if (!isPaidUser) return;
|
||||
|
||||
try {
|
||||
const updateRes = await api.delete(
|
||||
`/org/${orgId}/login-page-branding`
|
||||
@@ -244,7 +244,9 @@ export default function AuthPageBrandingForm({
|
||||
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<PaidFeaturesAlert />
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.loginPageBranding}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -357,7 +359,7 @@ export default function AuthPageBrandingForm({
|
||||
</div>
|
||||
|
||||
{build === "saas" ||
|
||||
env.env.flags.useOrgOnlyIdp ? (
|
||||
env.env.app.identityProviderMode === "org" ? (
|
||||
<>
|
||||
<div className="mt-3 mb-6">
|
||||
<SettingsSectionTitle>
|
||||
@@ -472,7 +474,7 @@ export default function AuthPageBrandingForm({
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
!isPaidUser
|
||||
!isPaidUser(tierMatrix.loginPageBranding)
|
||||
}
|
||||
className="gap-1"
|
||||
>
|
||||
@@ -487,7 +489,7 @@ export default function AuthPageBrandingForm({
|
||||
disabled={
|
||||
isUpdatingBranding ||
|
||||
isDeletingBranding ||
|
||||
!isPaidUser
|
||||
!isPaidUser(tierMatrix.loginPageBranding)
|
||||
}
|
||||
>
|
||||
{t("saveAuthPageBranding")}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { ListDomainsResponse } from "@server/routers/domain";
|
||||
import { DomainRow } from "@app/components/DomainsTable";
|
||||
import { toUnicode } from "punycode";
|
||||
import { Globe, Trash2 } from "lucide-react";
|
||||
import CertificateStatus from "@app/components/private/CertificateStatus";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
@@ -42,10 +42,10 @@ import {
|
||||
import DomainPicker from "@app/components/DomainPicker";
|
||||
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { PaidFeaturesAlert } from "../PaidFeaturesAlert";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
// Auth page form schema
|
||||
const AuthPageFormSchema = z.object({
|
||||
@@ -75,7 +75,7 @@ function AuthPageSettings({
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const { hasSaasSubscription } = usePaidStatus();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
// Auth page domain state
|
||||
const [loginPage, setLoginPage] = useState(defaultLoginPage);
|
||||
@@ -177,7 +177,7 @@ function AuthPageSettings({
|
||||
try {
|
||||
// Handle auth page domain
|
||||
if (data.authPageDomainId) {
|
||||
if (build === "enterprise" || hasSaasSubscription) {
|
||||
if (isPaidUser(tierMatrix.loginPageDomain)) {
|
||||
const sanitizedSubdomain = data.authPageSubdomain
|
||||
? finalizeSubdomainSanitize(data.authPageSubdomain)
|
||||
: "";
|
||||
@@ -285,7 +285,7 @@ function AuthPageSettings({
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<PaidFeaturesAlert />
|
||||
<PaidFeaturesAlert tiers={tierMatrix.loginPageDomain} />
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -361,7 +361,11 @@ function AuthPageSettings({
|
||||
onClick={() =>
|
||||
setEditDomainOpen(true)
|
||||
}
|
||||
disabled={!hasSaasSubscription}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.loginPageDomain
|
||||
)
|
||||
}
|
||||
>
|
||||
{form.watch("authPageDomainId")
|
||||
? t("changeDomain")
|
||||
@@ -376,7 +380,9 @@ function AuthPageSettings({
|
||||
clearAuthPageDomain
|
||||
}
|
||||
disabled={
|
||||
!hasSaasSubscription
|
||||
!isPaidUser(
|
||||
tierMatrix.loginPageDomain
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 size="14" />
|
||||
@@ -395,7 +401,9 @@ function AuthPageSettings({
|
||||
|
||||
{env.flags.usePangolinDns &&
|
||||
(build === "enterprise" ||
|
||||
!hasSaasSubscription) &&
|
||||
!isPaidUser(
|
||||
tierMatrix.loginPageDomain
|
||||
)) &&
|
||||
loginPage?.domainId &&
|
||||
loginPage?.fullDomain &&
|
||||
!hasUnsavedChanges && (
|
||||
@@ -424,7 +432,7 @@ function AuthPageSettings({
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!hasUnsavedChanges ||
|
||||
!hasSaasSubscription
|
||||
!isPaidUser(tierMatrix.loginPageDomain)
|
||||
}
|
||||
>
|
||||
{t("saveAuthPageDomain")}
|
||||
@@ -477,7 +485,10 @@ function AuthPageSettings({
|
||||
handleDomainSelection(selectedDomain);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedDomain || !hasSaasSubscription}
|
||||
disabled={
|
||||
!selectedDomain ||
|
||||
!isPaidUser(tierMatrix.loginPageDomain)
|
||||
}
|
||||
>
|
||||
{t("selectDomain")}
|
||||
</Button>
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Control, FieldValues, Path } from "react-hook-form";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type Role = {
|
||||
roleId: number;
|
||||
@@ -49,6 +51,8 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
|
||||
}: AutoProvisionConfigWidgetProps<T>) {
|
||||
const t = useTranslations();
|
||||
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4">
|
||||
@@ -57,6 +61,7 @@ export default function AutoProvisionConfigWidget<T extends FieldValues>({
|
||||
label={t("idpAutoProvisionUsers")}
|
||||
defaultChecked={autoProvision}
|
||||
onCheckedChange={onAutoProvisionChange}
|
||||
disabled={!isPaidUser(tierMatrix.autoProvisioning)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("idpAutoProvisionUsersDescription")}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ClientInfoCardProps = {};
|
||||
@@ -16,6 +17,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
const { client, updateClient } = useClientContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const userDisplayName = getUserDisplayName({
|
||||
email: client.userEmail,
|
||||
name: client.userName,
|
||||
username: client.userUsername
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -25,8 +32,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
|
||||
<InfoSectionContent>{client.name}</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>{client.niceId}</InfoSectionContent>
|
||||
<InfoSectionTitle>
|
||||
{userDisplayName ? t("user") : t("identifier")}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{userDisplayName || client.niceId}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("status")}</InfoSectionTitle>
|
||||
|
||||
@@ -303,7 +303,7 @@ export default function CreateInternalResourceDialog({
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
(site) => site.type === "newt"
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
open: boolean;
|
||||
@@ -51,6 +52,7 @@ export default function CreateRoleForm({
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
@@ -160,9 +162,12 @@ export default function CreateRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build !== "oss" && (
|
||||
<div>
|
||||
<PaidFeaturesAlert />
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.deviceApprovals}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -173,7 +178,9 @@ export default function CreateRoleForm({
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser
|
||||
!isPaidUser(
|
||||
tierMatrix.deviceApprovals
|
||||
)
|
||||
}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
@@ -208,7 +215,7 @@ export default function CreateRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -29,6 +29,7 @@ type DashboardLoginFormProps = {
|
||||
searchParams?: {
|
||||
[key: string]: string | string[] | undefined;
|
||||
};
|
||||
defaultUser?: string;
|
||||
};
|
||||
|
||||
export default function DashboardLoginForm({
|
||||
@@ -36,7 +37,8 @@ export default function DashboardLoginForm({
|
||||
idps,
|
||||
forceLogin,
|
||||
showOrgLogin,
|
||||
searchParams
|
||||
searchParams,
|
||||
defaultUser
|
||||
}: DashboardLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -75,6 +77,7 @@ export default function DashboardLoginForm({
|
||||
redirect={redirect}
|
||||
idps={idps}
|
||||
forceLogin={forceLogin}
|
||||
defaultEmail={defaultUser}
|
||||
onLogin={(redirectUrl) => {
|
||||
if (redirectUrl) {
|
||||
const safe = cleanRedirect(redirectUrl);
|
||||
|
||||
@@ -55,12 +55,14 @@ type DeviceLoginFormProps = {
|
||||
userEmail: string;
|
||||
userName?: string;
|
||||
initialCode?: string;
|
||||
userQueryParam?: string;
|
||||
};
|
||||
|
||||
export default function DeviceLoginForm({
|
||||
userEmail,
|
||||
userName,
|
||||
initialCode = ""
|
||||
initialCode = "",
|
||||
userQueryParam
|
||||
}: DeviceLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
@@ -219,9 +221,12 @@ export default function DeviceLoginForm({
|
||||
const currentSearch =
|
||||
typeof window !== "undefined" ? window.location.search : "";
|
||||
const redirectTarget = `/auth/login/device${currentSearch || ""}`;
|
||||
router.push(
|
||||
`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectTarget)}`
|
||||
);
|
||||
const loginUrl = new URL("/auth/login", "http://x");
|
||||
loginUrl.searchParams.set("forceLogin", "true");
|
||||
loginUrl.searchParams.set("redirect", redirectTarget);
|
||||
if (userQueryParam)
|
||||
loginUrl.searchParams.set("user", userQueryParam);
|
||||
router.push(loginUrl.pathname + loginUrl.search);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ export default function EditInternalResourceDialog({
|
||||
);
|
||||
|
||||
const availableSites = sites.filter(
|
||||
(site) => site.type === "newt" && site.subnet
|
||||
(site) => site.type === "newt"
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
role: Role;
|
||||
@@ -59,6 +60,7 @@ export default function EditRoleForm({
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
@@ -168,9 +170,12 @@ export default function EditRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build !== "oss" && (
|
||||
<div>
|
||||
<PaidFeaturesAlert />
|
||||
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.deviceApprovals}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -181,7 +186,9 @@ export default function EditRoleForm({
|
||||
<CheckboxWithLabel
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser
|
||||
!isPaidUser(
|
||||
tierMatrix.deviceApprovals
|
||||
)
|
||||
}
|
||||
value="on"
|
||||
checked={form.watch(
|
||||
@@ -216,7 +223,7 @@ export default function EditRoleForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -250,20 +250,41 @@ export default function GenerateLicenseKeyForm({
|
||||
const submitLicenseRequest = async (payload: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
// 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" } );
|
||||
|
||||
if (response.data.data?.licenseKey?.licenseKey) {
|
||||
setGeneratedKey(response.data.data.licenseKey.licenseKey);
|
||||
onGenerated?.();
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.success.title"),
|
||||
description: t(
|
||||
"generateLicenseKeyForm.toasts.success.description"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
console.log("Checkout session response:", response.data);
|
||||
const checkoutUrl = response.data.data;
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl;
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to get checkout URL",
|
||||
description: "Please try again later",
|
||||
variant: "destructive"
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Personal license flow
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
|
||||
if (response.data.data?.licenseKey?.licenseKey) {
|
||||
setGeneratedKey(response.data.data.licenseKey.licenseKey);
|
||||
onGenerated?.();
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.success.title"),
|
||||
description: t(
|
||||
"generateLicenseKeyForm.toasts.success.description"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -1066,16 +1087,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>
|
||||
|
||||
@@ -10,12 +10,12 @@ import { Badge } from "./ui/badge";
|
||||
import moment from "moment";
|
||||
import { DataTable } from "./ui/data-table";
|
||||
import { GeneratedLicenseKey } from "@server/routers/generatedLicense/types";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import GenerateLicenseKeyForm from "./GenerateLicenseKeyForm";
|
||||
import NewPricingLicenseForm from "./NewPricingLicenseForm";
|
||||
|
||||
type GnerateLicenseKeysTableProps = {
|
||||
licenseKeys: GeneratedLicenseKey[];
|
||||
@@ -29,12 +29,15 @@ function obfuscateLicenseKey(key: string): string {
|
||||
return `${firstPart}••••••••••••••••••••${lastPart}`;
|
||||
}
|
||||
|
||||
const GENERATE_QUERY = "generate";
|
||||
|
||||
export default function GenerateLicenseKeysTable({
|
||||
licenseKeys,
|
||||
orgId
|
||||
}: GnerateLicenseKeysTableProps) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
@@ -42,6 +45,19 @@ export default function GenerateLicenseKeysTable({
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showGenerateForm, setShowGenerateForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get(GENERATE_QUERY) !== null) {
|
||||
setShowGenerateForm(true);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete(GENERATE_QUERY);
|
||||
const qs = next.toString();
|
||||
const url = qs
|
||||
? `${window.location.pathname}?${qs}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState(null, "", url);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleLicenseGenerated = () => {
|
||||
// Refresh the data after license is generated
|
||||
refreshData();
|
||||
@@ -158,6 +174,48 @@ export default function GenerateLicenseKeysTable({
|
||||
: t("licenseTierPersonal");
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "users",
|
||||
friendlyName: t("users"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("users")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const users = row.original.users;
|
||||
return users === -1 ? "∞" : users;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "sites",
|
||||
friendlyName: t("sites"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("sites")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const sites = row.original.sites;
|
||||
return sites === -1 ? "∞" : sites;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "terminateAt",
|
||||
friendlyName: t("licenseTableValidUntil"),
|
||||
@@ -198,7 +256,7 @@ export default function GenerateLicenseKeysTable({
|
||||
}}
|
||||
/>
|
||||
|
||||
<GenerateLicenseKeyForm
|
||||
<NewPricingLicenseForm
|
||||
open={showGenerateForm}
|
||||
setOpen={setShowGenerateForm}
|
||||
orgId={orgId}
|
||||
|
||||
65
src/components/IdpGlobalModeBanner.tsx
Normal file
65
src/components/IdpGlobalModeBanner.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Info } from "lucide-react";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { build } from "@server/build";
|
||||
|
||||
export function IdpGlobalModeBanner() {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const { isPaidUser, hasEnterpriseLicense } = usePaidStatus();
|
||||
|
||||
const identityProviderModeUndefined =
|
||||
env.app.identityProviderMode === undefined;
|
||||
const paidUserForOrgOidc = isPaidUser(tierMatrix.orgOidc);
|
||||
const enterpriseUnlicensed =
|
||||
build === "enterprise" && !hasEnterpriseLicense;
|
||||
|
||||
if (build === "saas") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!identityProviderModeUndefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const adminPanelLinkRenderer = (chunks: React.ReactNode) => (
|
||||
<Link href="/admin/idp" className="font-medium underline">
|
||||
{chunks}
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert className="mb-6">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{paidUserForOrgOidc
|
||||
? t.rich("idpGlobalModeBanner", {
|
||||
adminPanelLink: adminPanelLinkRenderer,
|
||||
configDocsLink: (chunks) => (
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/identity-providers/add-an-idp#organization-identity-providers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
{chunks}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
: enterpriseUnlicensed
|
||||
? t.rich("idpGlobalModeBannerLicenseRequired", {
|
||||
adminPanelLink: adminPanelLinkRenderer
|
||||
})
|
||||
: t.rich("idpGlobalModeBannerUpgradeRequired", {
|
||||
adminPanelLink: adminPanelLinkRenderer
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export default function InviteStatusCard({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [type, setType] = useState<
|
||||
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in"
|
||||
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
|
||||
>("rejected");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -75,6 +75,11 @@ export default function InviteStatusCard({
|
||||
error.includes("You must be logged in to accept an invite")
|
||||
) {
|
||||
return "not_logged_in";
|
||||
} else if (
|
||||
error.includes("user limit is exceeded") ||
|
||||
error.includes("Can not accept")
|
||||
) {
|
||||
return "user_limit_exceeded";
|
||||
} else {
|
||||
return "rejected";
|
||||
}
|
||||
@@ -145,6 +150,17 @@ export default function InviteStatusCard({
|
||||
<p className="text-center">{t("inviteCreateUser")}</p>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "user_limit_exceeded") {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-center mb-4 font-semibold">
|
||||
Cannot Accept Invite
|
||||
</p>
|
||||
<p className="text-center text-sm">
|
||||
This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +181,16 @@ export default function InviteStatusCard({
|
||||
);
|
||||
} else if (type === "user_does_not_exist") {
|
||||
return <Button onClick={goToSignup}>{t("createAnAccount")}</Button>;
|
||||
} else if (type === "user_limit_exceeded") {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
}}
|
||||
>
|
||||
{t("goHome")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ type DataTableProps<TData, TValue> = {
|
||||
// Row expansion props
|
||||
expandable?: boolean;
|
||||
renderExpandedRow?: (row: TData) => React.ReactNode;
|
||||
isExportDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function LogDataTable<TData, TValue>({
|
||||
@@ -145,7 +146,8 @@ export function LogDataTable<TData, TValue>({
|
||||
isLoading = false,
|
||||
expandable = false,
|
||||
disabled = false,
|
||||
renderExpandedRow
|
||||
renderExpandedRow,
|
||||
isExportDisabled
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -403,7 +405,7 @@ export function LogDataTable<TData, TValue>({
|
||||
onClick={() =>
|
||||
!disabled && onExport()
|
||||
}
|
||||
disabled={isExporting || disabled}
|
||||
disabled={isExporting || disabled || isExportDisabled}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader className="mr-2 size-4 animate-spin" />
|
||||
|
||||
@@ -54,6 +54,7 @@ type LoginFormProps = {
|
||||
idps?: LoginFormIDP[];
|
||||
orgId?: string;
|
||||
forceLogin?: boolean;
|
||||
defaultEmail?: string;
|
||||
};
|
||||
|
||||
export default function LoginForm({
|
||||
@@ -61,7 +62,8 @@ export default function LoginForm({
|
||||
onLogin,
|
||||
idps,
|
||||
orgId,
|
||||
forceLogin
|
||||
forceLogin,
|
||||
defaultEmail
|
||||
}: LoginFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -116,7 +118,7 @@ export default function LoginForm({
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
email: defaultEmail ?? "",
|
||||
password: ""
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Separator } from "./ui/separator";
|
||||
import LoginPasswordForm from "./LoginPasswordForm";
|
||||
import IdpLoginButtons from "./private/IdpLoginButtons";
|
||||
import IdpLoginButtons from "./IdpLoginButtons";
|
||||
import { LookupUserResponse } from "@server/routers/auth/lookupUser";
|
||||
import UserProfileCard from "./UserProfileCard";
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/clients/install-client#docker"
|
||||
href="https://docs.pangolin.net/manage/clients/install-client#docker-pangolin-cli"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
913
src/components/NewPricingLicenseForm.tsx
Normal file
913
src/components/NewPricingLicenseForm.tsx
Normal file
@@ -0,0 +1,913 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useState } from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import { StrategySelect, StrategyOption } from "./StrategySelect";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
|
||||
const TIER_TO_LICENSE_ID = {
|
||||
starter: "small_license",
|
||||
scale: "big_license"
|
||||
} as const;
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
orgId: string;
|
||||
onGenerated?: () => void;
|
||||
};
|
||||
|
||||
export default function NewPricingLicenseForm({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
onGenerated
|
||||
}: FormProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { user } = useUserContext();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
|
||||
const [personalUseOnly, setPersonalUseOnly] = useState(false);
|
||||
const [selectedTier, setSelectedTier] = useState<"starter" | "scale">(
|
||||
"starter"
|
||||
);
|
||||
|
||||
const personalFormSchema = z.object({
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
country: z.string().min(1),
|
||||
phoneNumber: z.string().optional(),
|
||||
agreedToTerms: z.boolean().refine((val) => val === true),
|
||||
complianceConfirmed: z.boolean().refine((val) => val === true)
|
||||
});
|
||||
|
||||
const businessFormSchema = z.object({
|
||||
email: z.email(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
primaryUse: z.string().min(1),
|
||||
industry: z.string().min(1),
|
||||
companyName: z.string().min(1),
|
||||
companyWebsite: z.string().optional(),
|
||||
companyPhoneNumber: z.string().optional(),
|
||||
agreedToTerms: z.boolean().refine((val) => val === true),
|
||||
complianceConfirmed: z.boolean().refine((val) => val === true)
|
||||
});
|
||||
|
||||
type PersonalFormData = z.infer<typeof personalFormSchema>;
|
||||
type BusinessFormData = z.infer<typeof businessFormSchema>;
|
||||
|
||||
const personalForm = useForm<PersonalFormData>({
|
||||
resolver: zodResolver(personalFormSchema) as Resolver<PersonalFormData>,
|
||||
defaultValues: {
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
country: "",
|
||||
phoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
}
|
||||
});
|
||||
|
||||
const businessForm = useForm<BusinessFormData>({
|
||||
resolver: zodResolver(businessFormSchema) as Resolver<BusinessFormData>,
|
||||
defaultValues: {
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
industry: "",
|
||||
companyName: "",
|
||||
companyWebsite: "",
|
||||
companyPhoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
resetForm();
|
||||
setGeneratedKey(null);
|
||||
setPersonalUseOnly(false);
|
||||
setSelectedTier("starter");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function resetForm() {
|
||||
personalForm.reset({
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
country: "",
|
||||
phoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
});
|
||||
businessForm.reset({
|
||||
email: user?.email || "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
primaryUse: "",
|
||||
industry: "",
|
||||
companyName: "",
|
||||
companyWebsite: "",
|
||||
companyPhoneNumber: "",
|
||||
agreedToTerms: false,
|
||||
complianceConfirmed: false
|
||||
});
|
||||
}
|
||||
|
||||
const tierOptions: StrategyOption<"starter" | "scale">[] = [
|
||||
{
|
||||
id: "starter",
|
||||
title: t("newPricingLicenseForm.tiers.starter.title"),
|
||||
description: t("newPricingLicenseForm.tiers.starter.description")
|
||||
},
|
||||
{
|
||||
id: "scale",
|
||||
title: t("newPricingLicenseForm.tiers.scale.title"),
|
||||
description: t("newPricingLicenseForm.tiers.scale.description")
|
||||
}
|
||||
];
|
||||
|
||||
const submitLicenseRequest = async (
|
||||
payload: Record<string, unknown>
|
||||
): Promise<void> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Check if this is a business/enterprise license request
|
||||
if (!personalUseOnly) {
|
||||
const response = await api.put<AxiosResponse<string>>(
|
||||
`/org/${orgId}/license/enterprise`,
|
||||
{ ...payload, tier: TIER_TO_LICENSE_ID[selectedTier] }
|
||||
);
|
||||
|
||||
console.log("Checkout session response:", response.data);
|
||||
const checkoutUrl = response.data.data;
|
||||
if (checkoutUrl) {
|
||||
window.location.href = checkoutUrl;
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to get checkout URL",
|
||||
description: "Please try again later",
|
||||
variant: "destructive"
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Personal license flow
|
||||
const response = await api.put<
|
||||
AxiosResponse<GenerateNewLicenseResponse>
|
||||
>(`/org/${orgId}/license`, payload);
|
||||
|
||||
if (response.data.data?.licenseKey?.licenseKey) {
|
||||
setGeneratedKey(response.data.data.licenseKey.licenseKey);
|
||||
onGenerated?.();
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.success.title"),
|
||||
description: t(
|
||||
"generateLicenseKeyForm.toasts.success.description"
|
||||
),
|
||||
variant: "default"
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
title: t("generateLicenseKeyForm.toasts.error.title"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
t("generateLicenseKeyForm.toasts.error.description")
|
||||
),
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onSubmitPersonal = async (values: PersonalFormData) => {
|
||||
await submitLicenseRequest({
|
||||
email: values.email,
|
||||
useCaseType: "personal",
|
||||
personal: {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
aboutYou: { primaryUse: values.primaryUse },
|
||||
personalInfo: {
|
||||
country: values.country,
|
||||
phoneNumber: values.phoneNumber || ""
|
||||
}
|
||||
},
|
||||
business: undefined,
|
||||
consent: {
|
||||
agreedToTerms: values.agreedToTerms,
|
||||
acknowledgedPrivacyPolicy: values.agreedToTerms,
|
||||
complianceConfirmed: values.complianceConfirmed
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitBusiness = async (values: BusinessFormData) => {
|
||||
const payload = {
|
||||
email: values.email,
|
||||
useCaseType: "business",
|
||||
personal: undefined,
|
||||
business: {
|
||||
firstName: values.firstName,
|
||||
lastName: values.lastName,
|
||||
jobTitle: "N/A",
|
||||
aboutYou: {
|
||||
primaryUse: values.primaryUse,
|
||||
industry: values.industry,
|
||||
prospectiveUsers: 100,
|
||||
prospectiveSites: 100
|
||||
},
|
||||
companyInfo: {
|
||||
companyName: values.companyName,
|
||||
countryOfResidence: "N/A",
|
||||
stateProvinceRegion: "N/A",
|
||||
postalZipCode: "N/A",
|
||||
companyWebsite: values.companyWebsite || "",
|
||||
companyPhoneNumber: values.companyPhoneNumber || ""
|
||||
}
|
||||
},
|
||||
consent: {
|
||||
agreedToTerms: values.agreedToTerms,
|
||||
acknowledgedPrivacyPolicy: values.agreedToTerms,
|
||||
complianceConfirmed: values.complianceConfirmed
|
||||
}
|
||||
};
|
||||
|
||||
await submitLicenseRequest(payload);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setGeneratedKey(null);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={handleClose}>
|
||||
<CredenzaContent className="max-w-4xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{t("newPricingLicenseForm.title")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("newPricingLicenseForm.description")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
{generatedKey ? (
|
||||
<div className="space-y-4">
|
||||
<CopyTextBox
|
||||
text={generatedKey}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Tier selection - required when not personal use */}
|
||||
{!personalUseOnly && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t(
|
||||
"newPricingLicenseForm.chooseTier"
|
||||
)}
|
||||
</label>
|
||||
<StrategySelect
|
||||
options={tierOptions}
|
||||
defaultValue={selectedTier}
|
||||
onChange={(value) =>
|
||||
setSelectedTier(value)
|
||||
}
|
||||
cols={2}
|
||||
/>
|
||||
<a
|
||||
href="https://pangolin.net/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.viewPricingLink"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personal use only checkbox at the bottom of options */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="personal-use-only"
|
||||
checked={personalUseOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
setPersonalUseOnly(
|
||||
checked === true
|
||||
);
|
||||
if (checked) {
|
||||
businessForm.reset();
|
||||
} else {
|
||||
personalForm.reset();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="personal-use-only"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.personalUseOnly"
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* License disclosure - only when personal use */}
|
||||
{personalUseOnly && (
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t(
|
||||
"generateLicenseKeyForm.alerts.commercialUseDisclosure.title"
|
||||
)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"generateLicenseKeyForm.alerts.commercialUseDisclosure.description"
|
||||
)
|
||||
.split(
|
||||
"Fossorial Commercial License Terms"
|
||||
)
|
||||
.map((part, index) => (
|
||||
<span key={index}>
|
||||
{part}
|
||||
{index === 0 && (
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Fossorial
|
||||
Commercial
|
||||
License Terms
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Personal form: only when personal use only is checked */}
|
||||
{personalUseOnly && (
|
||||
<Form {...personalForm}>
|
||||
<form
|
||||
onSubmit={personalForm.handleSubmit(
|
||||
onSubmitPersonal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="new-pricing-license-personal-form"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.firstName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.lastName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={personalForm.control}
|
||||
name="primaryUse"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.primaryUseQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.country"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="phoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.phoneNumberOptional"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="agreedToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t(
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
personalForm.control
|
||||
}
|
||||
name="complianceConfirmed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{/* Business form: when not personal use - enter business info then continue to checkout */}
|
||||
{!personalUseOnly && (
|
||||
<Form {...businessForm}>
|
||||
<form
|
||||
onSubmit={businessForm.handleSubmit(
|
||||
onSubmitBusiness
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="new-pricing-license-business-form"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.firstName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.lastName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="primaryUse"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.primaryUseQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="industry"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.industryQuestion"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={businessForm.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyName"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="companyWebsite"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyWebsite"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="companyPhoneNumber"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.companyPhoneNumber"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="agreedToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t(
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={
|
||||
businessForm.control
|
||||
}
|
||||
name="complianceConfirmed"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"generateLicenseKeyForm.form.complianceConfirmation"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/fcl.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://pangolin.net/fcl.html
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">
|
||||
{t("generateLicenseKeyForm.buttons.close")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
|
||||
{!generatedKey && personalUseOnly && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-pricing-license-personal-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"generateLicenseKeyForm.buttons.generateLicenseKey"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!generatedKey && !personalUseOnly && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-pricing-license-business-form"
|
||||
disabled={loading}
|
||||
loading={loading}
|
||||
>
|
||||
{t(
|
||||
"newPricingLicenseForm.buttons.continueToCheckout"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { IdpDataTable } from "@app/components/private/OrgIdpDataTable";
|
||||
import { IdpDataTable } from "@app/components/OrgIdpDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
LoadLoginPageBrandingResponse,
|
||||
LoadLoginPageResponse
|
||||
} from "@server/routers/loginPage/types";
|
||||
import IdpLoginButtons from "@app/components/private/IdpLoginButtons";
|
||||
import IdpLoginButtons from "@app/components/IdpLoginButtons";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
||||
@@ -1,28 +1,169 @@
|
||||
"use client";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { build } from "@server/build";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
|
||||
export function PaidFeaturesAlert() {
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { build } from "@server/build";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { ExternalLink, KeyRound } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { Tier } from "@server/types/Tiers";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
||||
|
||||
const TIER_TRANSLATION_KEYS: Record<Tier, "subscriptionTierTier1" | "subscriptionTierTier2" | "subscriptionTierTier3" | "subscriptionTierEnterprise"> = {
|
||||
tier1: "subscriptionTierTier1",
|
||||
tier2: "subscriptionTierTier2",
|
||||
tier3: "subscriptionTierTier3",
|
||||
enterprise: "subscriptionTierEnterprise"
|
||||
};
|
||||
|
||||
function getRequiredTier(tiers: Tier[]): Tier | null {
|
||||
if (tiers.length === 0) return null;
|
||||
let min: Tier | null = null;
|
||||
for (const tier of tiers) {
|
||||
const idx = TIER_ORDER.indexOf(tier);
|
||||
if (idx === -1) continue;
|
||||
if (min === null || TIER_ORDER.indexOf(min) > idx) {
|
||||
min = tier;
|
||||
}
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
const bannerClassName =
|
||||
"mb-6 border-purple-500/30 bg-linear-to-br from-purple-500/10 via-background to-background overflow-hidden";
|
||||
const bannerContentClassName = "py-3 px-4";
|
||||
const bannerRowClassName =
|
||||
"flex items-center gap-2.5 text-sm text-muted-foreground";
|
||||
const bannerIconClassName = "size-4 shrink-0 text-purple-500";
|
||||
const docsLinkClassName =
|
||||
"inline-flex items-center gap-1 font-medium text-purple-600 underline";
|
||||
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
|
||||
const ENTERPRISE_DOCS_URL =
|
||||
"https://docs.pangolin.net/self-host/enterprise-edition";
|
||||
|
||||
function getTierLinkRenderer(billingHref: string) {
|
||||
return function tierLinkRenderer(chunks: React.ReactNode) {
|
||||
return (
|
||||
<Link href={billingHref} className={docsLinkClassName}>
|
||||
{chunks}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getPangolinCloudLinkRenderer() {
|
||||
return function pangolinCloudLinkRenderer(chunks: React.ReactNode) {
|
||||
return (
|
||||
<Link
|
||||
href={PANGOLIN_CLOUD_SIGNUP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={docsLinkClassName}
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getDocsLinkRenderer(href: string) {
|
||||
return function docsLinkRenderer(chunks: React.ReactNode) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={docsLinkClassName}
|
||||
>
|
||||
{chunks}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
tiers: Tier[];
|
||||
};
|
||||
|
||||
export function PaidFeaturesAlert({ tiers }: Props) {
|
||||
const t = useTranslations();
|
||||
const { hasSaasSubscription, hasEnterpriseLicense } = usePaidStatus();
|
||||
const params = useParams();
|
||||
const orgId = params?.orgId as string | undefined;
|
||||
const { hasSaasSubscription, hasEnterpriseLicense, isActive, subscriptionTier } = usePaidStatus();
|
||||
const { env } = useEnvContext();
|
||||
const requiredTier = getRequiredTier(tiers);
|
||||
const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null;
|
||||
const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing";
|
||||
const tierLinkRenderer = getTierLinkRenderer(billingHref);
|
||||
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
|
||||
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
|
||||
|
||||
if (env.flags.disableEnterpriseFeatures) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{build === "saas" && !hasSaasSubscription ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{build === "saas" && !hasSaasSubscription(tiers) ? (
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{requiredTierName
|
||||
? isActive
|
||||
? t.rich("upgradeToTierToUse", {
|
||||
tier: requiredTierName,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
: t.rich("subscriptionRequiredTierToUse", {
|
||||
tier: requiredTierName,
|
||||
tierLink: tierLinkRenderer
|
||||
})
|
||||
: isActive
|
||||
? t("mustUpgradeToUse")
|
||||
: t("subscriptionRequiredToUse")}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{build === "enterprise" && !hasEnterpriseLicense ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("licenseRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{t.rich("licenseRequiredToUse", {
|
||||
enterpriseLicenseLink: enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{build === "oss" && !hasEnterpriseLicense ? (
|
||||
<Card className={bannerClassName}>
|
||||
<CardContent className={bannerContentClassName}>
|
||||
<div className={bannerRowClassName}>
|
||||
<KeyRound className={bannerIconClassName} />
|
||||
<span>
|
||||
{t.rich("ossEnterpriseEditionRequired", {
|
||||
enterpriseEditionLink: enterpriseDocsLinkRenderer,
|
||||
pangolinCloudLink: pangolinCloudLinkRenderer
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -118,7 +118,7 @@ function getActionsCategories(root: boolean) {
|
||||
}
|
||||
};
|
||||
|
||||
if (root || build === "saas" || env.flags.useOrgOnlyIdp) {
|
||||
if (root || build === "saas" || env.app.identityProviderMode === "org") {
|
||||
actionsByCategory["Identity Provider (IDP)"] = {
|
||||
[t("actionCreateIdp")]: "createIdp",
|
||||
[t("actionUpdateIdp")]: "updateIdp",
|
||||
|
||||
24
src/components/RedirectToOrg.tsx
Normal file
24
src/components/RedirectToOrg.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||
|
||||
type RedirectToOrgProps = {
|
||||
targetOrgId: string;
|
||||
};
|
||||
|
||||
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const target = getInternalRedirectTarget(targetOrgId);
|
||||
router.replace(target);
|
||||
} catch {
|
||||
router.replace(`/${targetOrgId}`);
|
||||
}
|
||||
}, [targetOrgId, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CertificateStatus from "@app/components/private/CertificateStatus";
|
||||
import CertificateStatus from "@app/components/CertificateStatus";
|
||||
import { toUnicode } from "punycode";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
|
||||
@@ -5,16 +5,12 @@ import DeleteRoleForm from "@app/components/DeleteRoleForm";
|
||||
import { RolesDataTable } from "@app/components/RolesDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Role } from "@server/db";
|
||||
import { ArrowRight, ArrowUpDown, Link, MoreHorizontal } from "lucide-react";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -38,11 +34,6 @@ export default function UsersTable({ roles }: RolesTableProps) {
|
||||
|
||||
const [roleToRemove, setRoleToRemove] = useState<RoleRow | null>(null);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { org } = useOrgContext();
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
|
||||
const t = useTranslations();
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
|
||||
|
||||
@@ -204,7 +204,9 @@ export default function SignupForm({
|
||||
? env.branding.logo?.authPage?.height || 44
|
||||
: 44;
|
||||
|
||||
const showOrgBanner = fromSmartLogin && (build === "saas" || env.flags.useOrgOnlyIdp);
|
||||
const showOrgBanner =
|
||||
fromSmartLogin &&
|
||||
(build === "saas" || env.app.identityProviderMode === "org");
|
||||
const orgBannerHref = redirect
|
||||
? `/auth/org?redirect=${encodeURIComponent(redirect)}`
|
||||
: "/auth/org";
|
||||
@@ -226,388 +228,398 @@ export default function SignupForm({
|
||||
</Alert>
|
||||
)}
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={logoHeight} width={logoWidth} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={!!emailParam}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
{passwordStrength.strength ===
|
||||
"strong" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={logoHeight} width={logoWidth} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setPasswordValue(
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
passwordStrength.strength ===
|
||||
"strong" &&
|
||||
"border-green-500 focus-visible:ring-green-500",
|
||||
passwordStrength.strength ===
|
||||
"medium" &&
|
||||
"border-yellow-500 focus-visible:ring-yellow-500",
|
||||
passwordStrength.strength ===
|
||||
"weak" &&
|
||||
passwordValue.length >
|
||||
0 &&
|
||||
"border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
disabled={!!emailParam}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{passwordValue.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{/* Password Strength Meter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t("passwordStrength")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
passwordStrength.strength ===
|
||||
"strong" &&
|
||||
"text-green-600 dark:text-green-400",
|
||||
passwordStrength.strength ===
|
||||
"medium" &&
|
||||
"text-yellow-600 dark:text-yellow-400",
|
||||
passwordStrength.strength ===
|
||||
"weak" &&
|
||||
"text-red-600 dark:text-red-400"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
passwordStrength.percentage
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requirements Checklist */}
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-foreground mb-2">
|
||||
{t("passwordRequirements")}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.length ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.length
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementLengthText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.uppercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.uppercase
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementUppercaseText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.lowercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.lowercase
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementLowercaseText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.number ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.number
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementNumberText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.special ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.special
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementSpecialText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show FormMessage when not showing our custom requirements */}
|
||||
{passwordValue.length === 0 && (
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>
|
||||
{t("confirmPassword")}
|
||||
</FormLabel>
|
||||
{doPasswordsMatch && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setConfirmPasswordValue(
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
doPasswordsMatch &&
|
||||
"border-green-500 focus-visible:ring-green-500",
|
||||
confirmPasswordValue.length >
|
||||
0 &&
|
||||
!doPasswordsMatch &&
|
||||
"border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
{passwordStrength.strength ===
|
||||
"strong" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
{confirmPasswordValue.length > 0 &&
|
||||
!doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
</p>
|
||||
)}
|
||||
{/* Only show FormMessage when field is empty */}
|
||||
{confirmPasswordValue.length === 0 && (
|
||||
<FormMessage />
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build === "saas" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setPasswordValue(
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t("signUpTerms.and")}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="marketingEmailConsent"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t(
|
||||
"signUpMarketing.keepMeInTheLoop"
|
||||
className={cn(
|
||||
passwordStrength.strength ===
|
||||
"strong" &&
|
||||
"border-green-500 focus-visible:ring-green-500",
|
||||
passwordStrength.strength ===
|
||||
"medium" &&
|
||||
"border-yellow-500 focus-visible:ring-yellow-500",
|
||||
passwordStrength.strength ===
|
||||
"weak" &&
|
||||
passwordValue.length >
|
||||
0 &&
|
||||
"border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{passwordValue.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{/* Password Strength Meter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{t(
|
||||
"passwordStrength"
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
passwordStrength.strength ===
|
||||
"strong" &&
|
||||
"text-green-600 dark:text-green-400",
|
||||
passwordStrength.strength ===
|
||||
"medium" &&
|
||||
"text-yellow-600 dark:text-yellow-400",
|
||||
passwordStrength.strength ===
|
||||
"weak" &&
|
||||
"text-red-600 dark:text-red-400"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
passwordStrength.percentage
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Requirements Checklist */}
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-foreground mb-2">
|
||||
{t(
|
||||
"passwordRequirements"
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.length ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.length
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementLengthText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.uppercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.uppercase
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementUppercaseText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.lowercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.lowercase
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementLowercaseText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.number ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.number
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementNumberText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength
|
||||
.requirements
|
||||
.special ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
passwordStrength
|
||||
.requirements
|
||||
.special
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
"passwordRequirementSpecialText"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show FormMessage when not showing our custom requirements */}
|
||||
{passwordValue.length === 0 && (
|
||||
<FormMessage />
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>
|
||||
{t("confirmPassword")}
|
||||
</FormLabel>
|
||||
{doPasswordsMatch && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setConfirmPasswordValue(
|
||||
e.target.value
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
doPasswordsMatch &&
|
||||
"border-green-500 focus-visible:ring-green-500",
|
||||
confirmPasswordValue.length >
|
||||
0 &&
|
||||
!doPasswordsMatch &&
|
||||
"border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{confirmPasswordValue.length > 0 &&
|
||||
!doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
</p>
|
||||
)}
|
||||
{/* Only show FormMessage when field is empty */}
|
||||
{confirmPasswordValue.length === 0 && (
|
||||
<FormMessage />
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build === "saas" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
field.onChange(
|
||||
checked
|
||||
);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
<div>
|
||||
{t(
|
||||
"signUpTerms.IAgreeToThe"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}{" "}
|
||||
</a>
|
||||
{t(
|
||||
"signUpTerms.and"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://pangolin.net/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="marketingEmailConsent"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t(
|
||||
"signUpMarketing.keepMeInTheLoop"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
@@ -42,6 +42,7 @@ const isValidEmail = (str: string): boolean => {
|
||||
type SmartLoginFormProps = {
|
||||
redirect?: string;
|
||||
forceLogin?: boolean;
|
||||
defaultUser?: string;
|
||||
};
|
||||
|
||||
type ViewState =
|
||||
@@ -59,7 +60,8 @@ type ViewState =
|
||||
|
||||
export default function SmartLoginForm({
|
||||
redirect,
|
||||
forceLogin
|
||||
forceLogin,
|
||||
defaultUser
|
||||
}: SmartLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { lookup, loading, error } = useUserLookup();
|
||||
@@ -72,10 +74,18 @@ export default function SmartLoginForm({
|
||||
const form = useForm<z.infer<typeof identifierSchema>>({
|
||||
resolver: zodResolver(identifierSchema),
|
||||
defaultValues: {
|
||||
identifier: ""
|
||||
identifier: defaultUser ?? ""
|
||||
}
|
||||
});
|
||||
|
||||
const hasAutoLookedUp = useRef(false);
|
||||
useEffect(() => {
|
||||
if (defaultUser?.trim() && !hasAutoLookedUp.current) {
|
||||
hasAutoLookedUp.current = true;
|
||||
void handleLookup({ identifier: defaultUser.trim() });
|
||||
}
|
||||
}, [defaultUser]);
|
||||
|
||||
const handleLookup = async (values: z.infer<typeof identifierSchema>) => {
|
||||
const identifier = values.identifier.trim();
|
||||
const isEmail = isValidEmail(identifier);
|
||||
|
||||
27
src/components/StoreInternalRedirect.tsx
Normal file
27
src/components/StoreInternalRedirect.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { INTERNAL_REDIRECT_KEY } from "@app/lib/internalRedirect";
|
||||
|
||||
const TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export default function StoreInternalRedirect() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const value = params.get("internal_redirect");
|
||||
if (value != null && value !== "") {
|
||||
try {
|
||||
const payload = JSON.stringify({
|
||||
path: value,
|
||||
expiresAt: Date.now() + TTL_MS
|
||||
});
|
||||
window.localStorage.setItem(INTERNAL_REDIRECT_KEY, payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
50
src/components/SubscriptionViolation.tsx
Normal file
50
src/components/SubscriptionViolation.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function SubscriptionViolation() {
|
||||
const context = useSubscriptionStatusContext();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const params = useParams();
|
||||
const orgId = params?.orgId as string | undefined;
|
||||
const t = useTranslations();
|
||||
|
||||
if (!context?.limitsExceeded || isDismissed) return null;
|
||||
|
||||
const billingHref = orgId ? `/${orgId}/settings/billing` : "/";
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-amber-600 text-white p-4 text-center z-50">
|
||||
<div className="flex flex-wrap justify-center items-center gap-2 sm:gap-4">
|
||||
<p className="text-sm sm:text-base">
|
||||
{t("subscriptionViolationMessage")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="bg-white/20 hover:bg-white/30 text-white border-0"
|
||||
asChild
|
||||
>
|
||||
<Link href={billingHref}>
|
||||
{t("subscriptionViolationViewBilling")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover:bg-white/20 text-white"
|
||||
onClick={() => setIsDismissed(true)}
|
||||
>
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -226,6 +226,21 @@ export default function SupporterStatus({
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="my-4 p-4 border border-blue-500/50 bg-blue-500/10 rounded-lg">
|
||||
<p className="text-sm">
|
||||
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
|
||||
<Link
|
||||
href="https://pangolin.net/pricing?hosting=self-host"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline inline-flex items-center gap-1"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="py-6">
|
||||
<p className="mb-3 text-center">
|
||||
{t("supportKeyOptions")}
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import * as NProgress from "nprogress";
|
||||
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
export function TopLoader() {
|
||||
return (
|
||||
<>
|
||||
<NextTopLoader showSpinner={false} color="var(--color-primary)" />
|
||||
<FinishingLoader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FinishingLoader() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
React.useEffect(() => {
|
||||
NProgress.done();
|
||||
}, [pathname, router, searchParams]);
|
||||
React.useEffect(() => {
|
||||
const linkClickListener = (ev: MouseEvent) => {
|
||||
const element = ev.target as HTMLElement;
|
||||
const closestlink = element.closest("a");
|
||||
const isOpenToNewTabClick =
|
||||
ev.ctrlKey ||
|
||||
ev.shiftKey ||
|
||||
ev.metaKey || // apple
|
||||
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
|
||||
|
||||
if (closestlink && isOpenToNewTabClick) {
|
||||
NProgress.done();
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", linkClickListener);
|
||||
return () => window.removeEventListener("click", linkClickListener);
|
||||
}, []);
|
||||
return null;
|
||||
return (
|
||||
<NextTopLoader
|
||||
color="var(--color-primary)"
|
||||
showSpinner={false}
|
||||
height={2}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
const approvalsRes = await api.get<{
|
||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
||||
|
||||
|
||||
const approval = approvalsRes.data.data.approvals[0];
|
||||
|
||||
if (!approval) {
|
||||
@@ -232,7 +232,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
const approvalsRes = await api.get<{
|
||||
data: { approvals: Array<{ approvalId: number; clientId: number }> };
|
||||
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`);
|
||||
|
||||
|
||||
const approval = approvalsRes.data.data.approvals[0];
|
||||
|
||||
if (!approval) {
|
||||
@@ -548,7 +548,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{clientRow.approvalState === "pending" && build !== "oss" && (
|
||||
{clientRow.approvalState === "pending" && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => approveDevice(clientRow)}
|
||||
@@ -652,17 +652,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
}
|
||||
];
|
||||
|
||||
if (build === "oss") {
|
||||
return allOptions.filter((option) => option.value !== "pending" && option.value !== "denied");
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
}, [t]);
|
||||
|
||||
const statusFilterDefaultValues = useMemo(() => {
|
||||
if (build === "oss") {
|
||||
return ["active"];
|
||||
}
|
||||
return ["active", "pending"];
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@ export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
const PLATFORMS = [
|
||||
"unix",
|
||||
"windows",
|
||||
"docker",
|
||||
"kubernetes",
|
||||
"podman",
|
||||
"nixos"
|
||||
"nixos",
|
||||
"windows"
|
||||
] as const;
|
||||
|
||||
type Platform = (typeof PLATFORMS)[number];
|
||||
@@ -91,7 +91,7 @@ export function NewtSiteInstallCommands({
|
||||
- NEWT_SECRET=${secret}${acceptClientsEnv}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -dit fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
`docker run -dit --network host fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||
]
|
||||
},
|
||||
kubernetes: {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Button } from "./ui/button";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
const PLATFORMS = ["unix", "windows", "docker"] as const;
|
||||
const PLATFORMS = ["unix", "docker", "windows"] as const;
|
||||
|
||||
type Platform = (typeof PLATFORMS)[number];
|
||||
|
||||
@@ -43,32 +43,20 @@ export function OlmInstallCommands({
|
||||
All: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -fsSL https://static.pangolin.net/get-olm.sh | bash`
|
||||
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
}
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
{
|
||||
title: t("install"),
|
||||
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
command: `sudo pangolin up --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
|
||||
}
|
||||
]
|
||||
},
|
||||
docker: {
|
||||
"Docker Compose": [
|
||||
`services:
|
||||
olm:
|
||||
image: fosrl/olm
|
||||
container_name: olm
|
||||
pangolin-cli:
|
||||
image: fosrl/pangolin-cli
|
||||
container_name: pangolin-cli
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
@@ -77,11 +65,24 @@ export function OlmInstallCommands({
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
environment:
|
||||
- PANGOLIN_ENDPOINT=${endpoint}
|
||||
- OLM_ID=${id}
|
||||
- OLM_SECRET=${secret}`
|
||||
- CLIENT_ID=${id}
|
||||
- CLIENT_SECRET=${secret}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/pangolin-cli up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
{
|
||||
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"`
|
||||
},
|
||||
{
|
||||
title: t("run"),
|
||||
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user