Merge branch 'dev' into new-pricing

This commit is contained in:
miloschwartz
2026-02-09 15:05:13 -08:00
32 changed files with 750 additions and 735 deletions

View File

@@ -30,6 +30,7 @@ import {
import { Separator } from "./ui/separator";
import { InfoPopup } from "./ui/info-popup";
import { ApprovalsEmptyState } from "./ApprovalsEmptyState";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
export type ApprovalFeedProps = {
orgId: string;
@@ -50,9 +51,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
});
const approvals = data?.approvals ?? [];
@@ -209,19 +213,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 +233,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>

View File

@@ -160,56 +160,51 @@ export default function CreateRoleForm({
</FormItem>
)}
/>
{build !== "oss" && (
<div>
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={!isPaidUser}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -168,56 +168,50 @@ export default function EditRoleForm({
</FormItem>
)}
/>
{build !== "oss" && (
<div>
<PaidFeaturesAlert />
<PaidFeaturesAlert />
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={!isPaidUser}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>

View File

@@ -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: ""
}
});

View File

@@ -1,8 +1,16 @@
"use client";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Card, CardContent } from "@app/components/ui/card";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { ExternalLink, KeyRound, Sparkles } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
const bannerClassName =
"mb-6 border-primary/30 bg-linear-to-br from-primary/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";
export function PaidFeaturesAlert() {
const t = useTranslations();
@@ -10,19 +18,50 @@ export function PaidFeaturesAlert() {
return (
<>
{build === "saas" && !hasSaasSubscription ? (
<Alert variant="info" className="mb-6">
<AlertDescription>
{t("subscriptionRequiredToUse")}
</AlertDescription>
</Alert>
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className="size-4 shrink-0 text-primary" />
<span>{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="size-4 shrink-0 text-primary" />
<span>{t("licenseRequiredToUse")}</span>
</div>
</CardContent>
</Card>
) : null}
{build === "oss" && !hasEnterpriseLicense ? (
<Card className={bannerClassName}>
<CardContent className={bannerContentClassName}>
<div className={bannerRowClassName}>
<KeyRound className="size-4 shrink-0 text-primary" />
<span>
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: (chunks) => (
<Link
href="https://docs.pangolin.net/self-host/enterprise-edition"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-foreground underline"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</Link>
)
})}
</span>
</div>
</CardContent>
</Card>
) : null}
</>
);

View File

@@ -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);

View File

@@ -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"];
}, []);

View File

@@ -43,11 +43,11 @@ 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 | bash`
},
{
title: t("run"),
command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
command: `sudo pangolin up --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
}
]
},