Compare commits

...

5 Commits

Author SHA1 Message Date
Owen ed46afd81a add subscription override column 2026-08-10 11:15:48 -04:00
miloschwartz 3dc9c100e9 better form feedback when saving roles 2026-08-10 11:02:44 -04:00
miloschwartz 02e97d6ae4 add copy button to org id 2026-08-10 10:44:25 -04:00
miloschwartz 996160fadc add missing resource policy actions to api key closes #3542 2026-08-10 10:38:40 -04:00
Owen e91c344e64 Update link to be correct 2026-08-07 10:21:15 -04:00
11 changed files with 70 additions and 99 deletions
+3
View File
@@ -1449,8 +1449,11 @@
"actionSetResourcePincode": "Set Resource Pincode", "actionSetResourcePincode": "Set Resource Pincode",
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
"actionListResourcePolicies": "List Resource Policies",
"actionCreateResourcePolicy": "Create Resource Policy",
"actionGetResourcePolicy": "Get Resource Policy", "actionGetResourcePolicy": "Get Resource Policy",
"actionUpdateResourcePolicy": "Update Resource Policy", "actionUpdateResourcePolicy": "Update Resource Policy",
"actionDeleteResourcePolicy": "Delete Resource Policy",
"actionSetResourcePolicyUsers": "Set Resource Policy Users", "actionSetResourcePolicyUsers": "Set Resource Policy Users",
"actionSetResourcePolicyRoles": "Set Resource Policy Roles", "actionSetResourcePolicyRoles": "Set Resource Policy Roles",
"actionSetResourcePolicyPassword": "Set Resource Policy Password", "actionSetResourcePolicyPassword": "Set Resource Policy Password",
+2 -1
View File
@@ -95,7 +95,8 @@ export const subscriptions = pgTable("subscriptions", {
billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }),
expiresAt: bigint("expiresAt", { mode: "number" }), expiresAt: bigint("expiresAt", { mode: "number" }),
trial: boolean("trial").default(false), trial: boolean("trial").default(false),
type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license type: varchar("type", { length: 50 }), // tier1, tier2, tier3, or license
override: boolean("override").default(false)
}); });
export const subscriptionItems = pgTable("subscriptionItems", { export const subscriptionItems = pgTable("subscriptionItems", {
+2 -1
View File
@@ -89,7 +89,8 @@ export const subscriptions = sqliteTable("subscriptions", {
expiresAt: integer("expiresAt"), expiresAt: integer("expiresAt"),
trial: integer("trial", { mode: "boolean" }).default(false), trial: integer("trial", { mode: "boolean" }).default(false),
billingCycleAnchor: integer("billingCycleAnchor"), billingCycleAnchor: integer("billingCycleAnchor"),
type: text("type") // tier1, tier2, tier3, or license type: text("type"), // tier1, tier2, tier3, or license
override: integer("override", { mode: "boolean" }).default(false)
}); });
export const subscriptionItems = sqliteTable("subscriptionItems", { export const subscriptionItems = sqliteTable("subscriptionItems", {
@@ -53,6 +53,15 @@ export async function handleSubscriptionDeleted(
return; return;
} }
// If the subscription has been manually overridden, we lock it down
// so Stripe can no longer change (or delete) its status locally.
if (existingSubscription.override === true) {
logger.info(
`Subscription ${subscription.id} is locked (override=true). Ignoring deletion event from Stripe.`
);
return;
}
await db await db
.delete(subscriptions) .delete(subscriptions)
.where(eq(subscriptions.subscriptionId, subscription.id)); .where(eq(subscriptions.subscriptionId, subscription.id));
@@ -68,13 +68,27 @@ export async function handleSubscriptionUpdated(
const type = getSubType(fullSubscription); const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null; const previousType = existingSubscription.type as SubscriptionType | null;
// If the subscription has been manually overridden, we lock the
// status down so Stripe webhooks can no longer change it.
const isLocked = existingSubscription.override === true;
if (isLocked) {
logger.info(
`Subscription ${subscription.id} is locked (override=true). Ignoring status change from Stripe (would have been ${subscription.status}).`
);
}
const effectiveStatus = isLocked
? existingSubscription.status
: subscription.status;
await db await db
.update(subscriptions) .update(subscriptions)
.set({ .set({
status: subscription.status, status: effectiveStatus,
canceledAt: subscription.canceled_at canceledAt: isLocked
? subscription.canceled_at ? existingSubscription.canceledAt
: null, : subscription.canceled_at
? subscription.canceled_at
: null,
updatedAt: Math.floor(Date.now() / 1000), updatedAt: Math.floor(Date.now() / 1000),
billingCycleAnchor: subscription.billing_cycle_anchor, billingCycleAnchor: subscription.billing_cycle_anchor,
type: type type: type
@@ -275,23 +289,23 @@ export async function handleSubscriptionUpdated(
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses // we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle( await handleSubscriptionLifesycle(
customer.orgId, customer.orgId,
subscription.status, effectiveStatus,
type type
); );
// Handle feature lifecycle when subscription is canceled or becomes unpaid // Handle feature lifecycle when subscription is canceled or becomes unpaid
if ( if (
subscription.status === "canceled" || effectiveStatus === "canceled" ||
subscription.status === "unpaid" || effectiveStatus === "unpaid" ||
subscription.status === "incomplete_expired" effectiveStatus === "incomplete_expired"
) { ) {
logger.info( logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features` `Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
); );
await handleTierChange(customer.orgId, null, previousType ?? undefined); await handleTierChange(customer.orgId, null, previousType ?? undefined);
} }
} else if (type === "license") { } else if (type === "license") {
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
try { try {
// WARNING: // WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
+4 -4
View File
@@ -726,8 +726,8 @@ authenticated.post(
verifyApiKeyResourcePolicyAccess, verifyApiKeyResourcePolicyAccess,
verifyApiKeyRoleAccess, verifyApiKeyRoleAccess,
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers),
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles),
logActionAudit(ActionsEnum.setResourcePolicyUsers), logActionAudit(ActionsEnum.setResourcePolicyUsers),
logActionAudit(ActionsEnum.setResourcePolicyRoles), logActionAudit(ActionsEnum.setResourcePolicyRoles),
policy.setResourcePolicyAccessControl policy.setResourcePolicyAccessControl
@@ -742,8 +742,8 @@ authenticated.put(
verifyApiKeyResourcePolicyAccess, verifyApiKeyResourcePolicyAccess,
verifyApiKeyRoleAccess, verifyApiKeyRoleAccess,
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), verifyApiKeyHasAction(ActionsEnum.setResourcePolicyUsers),
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRoles),
logActionAudit(ActionsEnum.setResourcePolicyUsers), logActionAudit(ActionsEnum.setResourcePolicyUsers),
logActionAudit(ActionsEnum.setResourcePolicyRoles), logActionAudit(ActionsEnum.setResourcePolicyRoles),
policy.setResourcePolicyAccessControl policy.setResourcePolicyAccessControl
@@ -181,7 +181,7 @@ export default function NetworkingPage() {
<SettingsSectionDescription> <SettingsSectionDescription>
{t("remoteExitNodeNetworkingDescription")} {t("remoteExitNodeNetworkingDescription")}
<a <a
href="https://docs.pangolin.net/placeholder" href="https://docs.pangolin.net/manage/remote-node/backhaul"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1" className="text-primary hover:underline inline-flex items-center gap-1"
@@ -38,18 +38,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z.array(
z.object({
id: z.string(),
text: z.string(),
isAdmin: z.boolean().optional()
})
)
});
export default function AccessControlsPage() { export default function AccessControlsPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext(); const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext(); const { user: sessionUser } = useUserContext();
@@ -69,6 +57,20 @@ export default function AccessControlsPage() {
(build === "enterprise" && !isPaid) || (build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid)); (build === "oss" && !isPaid));
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z
.array(
z.object({
id: z.string(),
text: z.string(),
isAdmin: z.boolean().optional()
})
)
.min(1, { message: t("accessRoleSelectPlease") })
});
const form = useForm({ const form = useForm({
resolver: zodResolver(accessControlsFormSchema), resolver: zodResolver(accessControlsFormSchema),
defaultValues: { defaultValues: {
@@ -108,15 +110,6 @@ export default function AccessControlsPage() {
async function executeSave() { async function executeSave() {
const values = form.getValues(); const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease")
});
return;
}
setIsSaving(true); setIsSaving(true);
try { try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
@@ -170,15 +163,6 @@ export default function AccessControlsPage() {
const values = form.getValues(); const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease")
});
return;
}
const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true); const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
const isRemovingOwnAdmin = const isRemovingOwnAdmin =
+4 -1
View File
@@ -8,6 +8,7 @@ import {
InfoSections, InfoSections,
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
type OrgInfoCardProps = {}; type OrgInfoCardProps = {};
@@ -26,7 +27,9 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("orgId")}</InfoSectionTitle> <InfoSectionTitle>{t("orgId")}</InfoSectionTitle>
<InfoSectionContent>{org.org.orgId}</InfoSectionContent> <InfoSectionContent>
<CopyToClipboard text={org.org.orgId} />
</InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("subnet")}</InfoSectionTitle> <InfoSectionTitle>{t("subnet")}</InfoSectionTitle>
+4 -51
View File
@@ -9,17 +9,15 @@ import {
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRef } from "react";
import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
import { RolesSelector, type SelectedRole } from "./roles-selector"; import { RolesSelector, type SelectedRole } from "./roles-selector";
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = { type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
form: Pick< form: Pick<
UseFormReturn<TFieldValues>, UseFormReturn<TFieldValues>,
"control" | "getValues" | "setValue" "control" | "getValues" | "setValue" | "clearErrors"
>; >;
orgId: string; orgId: string;
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
@@ -42,46 +40,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
disabled disabled
}: OrgRolesTagFieldProps<TFieldValues>) { }: OrgRolesTagFieldProps<TFieldValues>) {
const t = useTranslations(); const t = useTranslations();
const isPopoverOpenRef = useRef(false);
const lastValidRolesRef = useRef<SelectedRole[]>(
(form.getValues(name) as SelectedRole[]) ?? []
);
function validateRolesSelection() {
const current = form.getValues(name) as SelectedRole[];
if (current.length === 0 && lastValidRolesRef.current.length > 0) {
form.setValue(name, lastValidRolesRef.current as never, {
shouldDirty: true
});
toast({
variant: "destructive",
title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease")
});
return false;
}
if (current.length > 0) {
lastValidRolesRef.current = current;
}
return true;
}
function handlePopoverOpenChange(open: boolean) {
isPopoverOpenRef.current = open;
if (open) {
const current = form.getValues(name) as SelectedRole[];
if (current.length > 0) {
lastValidRolesRef.current = current;
}
return;
}
validateRolesSelection();
}
function setRoleTags(nextValue: SelectedRole[]) { function setRoleTags(nextValue: SelectedRole[]) {
const prev = form.getValues(name) as SelectedRole[]; const prev = form.getValues(name) as SelectedRole[];
@@ -99,15 +57,14 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
form.setValue(name, [prev[prev.length - 1]] as never, { form.setValue(name, [prev[prev.length - 1]] as never, {
shouldDirty: true shouldDirty: true
}); });
form.clearErrors(name);
return; return;
} }
form.setValue(name, next as never, { shouldDirty: true }); form.setValue(name, next as never, { shouldDirty: true });
if (next.length > 0 && !isPopoverOpenRef.current) { if (next.length > 0) {
lastValidRolesRef.current = next; form.clearErrors(name);
} else if (!isPopoverOpenRef.current) {
validateRolesSelection();
} }
} }
@@ -117,9 +74,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
name={name} name={name}
render={({ field }) => { render={({ field }) => {
const selectedRoles = (field.value ?? []) as SelectedRole[]; const selectedRoles = (field.value ?? []) as SelectedRole[];
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
lastValidRolesRef.current = selectedRoles;
}
return ( return (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
@@ -129,7 +83,6 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
orgId={orgId} orgId={orgId}
selectedRoles={selectedRoles} selectedRoles={selectedRoles}
onSelectRoles={setRoleTags} onSelectRoles={setRoleTags}
onPopoverOpenChange={handlePopoverOpenChange}
disabled={disabled} disabled={disabled}
/> />
</FormControl> </FormControl>
+3
View File
@@ -115,8 +115,11 @@ function getActionsCategories(root: boolean) {
}, },
"Resource Policy": { "Resource Policy": {
[t("actionListResourcePolicies")]: "listResourcePolicies",
[t("actionCreateResourcePolicy")]: "createResourcePolicy",
[t("actionGetResourcePolicy")]: "getResourcePolicy", [t("actionGetResourcePolicy")]: "getResourcePolicy",
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy", [t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
[t("actionDeleteResourcePolicy")]: "deleteResourcePolicy",
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers", [t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles", [t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword", [t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",