Compare commits

...

6 Commits

Author SHA1 Message Date
miloschwartz
b6caeda0a5 improve targets round robin warning 2026-05-11 22:06:43 -07:00
Owen
77d17af15b Add global hide_powered_by and make it backward 2026-05-11 16:18:57 -07:00
Owen
264c6bf4e8 Use the right param for user 2026-05-11 12:06:36 -07:00
Owen
4aa72eb1a3 Confirm delete of share links 2026-05-11 11:49:51 -07:00
Owen
a066a68e1a Pick the most specific domain
Fixes #3047
2026-05-11 11:28:32 -07:00
miloschwartz
9fb677e952 allow editing self and owner user roles 2026-05-08 17:48:43 -07:00
21 changed files with 254 additions and 117 deletions

View File

@@ -156,6 +156,10 @@
"shareErrorDeleteMessage": "An error occurred deleting link", "shareErrorDeleteMessage": "An error occurred deleting link",
"shareDeleted": "Link deleted", "shareDeleted": "Link deleted",
"shareDeletedDescription": "The link has been deleted", "shareDeletedDescription": "The link has been deleted",
"shareDelete": "Delete Share Link",
"shareDeleteConfirm": "Confirm Delete Share Link",
"shareQuestionRemove": "Are you sure you want to delete this share link?",
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
"accessToken": "Access Token", "accessToken": "Access Token",
"usageExamples": "Usage Examples", "usageExamples": "Usage Examples",
@@ -523,6 +527,12 @@
"userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.", "userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.",
"userRemoveOrgConfirm": "Confirm Remove User", "userRemoveOrgConfirm": "Confirm Remove User",
"userRemoveOrg": "Remove User from Organization", "userRemoveOrg": "Remove User from Organization",
"userQuestionOrgRemoveSelf": "Are you sure you want to remove yourself from this organization?",
"userMessageOrgRemoveSelf": "You will lose access immediately. An administrator can invite you again later, but you will need to accept a new invitation.",
"userRemoveOrgConfirmSelf": "Confirm Remove Myself",
"userRemoveOrgSelf": "Remove yourself from the organization",
"userRemoveOrgSelfWarning": "You will lose access to this organization immediately.",
"userRemoveOrgConfirmPhraseSelf": "REMOVE MYSELF FROM ORG",
"users": "Users", "users": "Users",
"accessRoleMember": "Member", "accessRoleMember": "Member",
"accessRoleOwner": "Owner", "accessRoleOwner": "Owner",
@@ -531,6 +541,11 @@
"emailInvalid": "Invalid email address", "emailInvalid": "Invalid email address",
"inviteValidityDuration": "Please select a duration", "inviteValidityDuration": "Please select a duration",
"accessRoleSelectPlease": "Please select a role", "accessRoleSelectPlease": "Please select a role",
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
"removeOwnAdminRoleConfirmPhrase": "REMOVE MY ADMIN ACCESS",
"ownerMustRetainAdminRole": "The organization owner must keep at least one administrator role.",
"usernameRequired": "Username is required", "usernameRequired": "Username is required",
"idpSelectPlease": "Please select an identity provider", "idpSelectPlease": "Please select an identity provider",
"idpGenericOidc": "Generic OAuth2/OIDC provider.", "idpGenericOidc": "Generic OAuth2/OIDC provider.",
@@ -658,6 +673,7 @@
"targetNoOneDescription": "Adding more than one target above will enable load balancing.", "targetNoOneDescription": "Adding more than one target above will enable load balancing.",
"targetsSubmit": "Save Targets", "targetsSubmit": "Save Targets",
"addTarget": "Add Target", "addTarget": "Add Target",
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.",
"targetErrorInvalidIp": "Invalid IP address", "targetErrorInvalidIp": "Invalid IP address",
"targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname", "targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname",
"targetErrorInvalidPort": "Invalid port", "targetErrorInvalidPort": "Invalid port",

View File

@@ -1227,7 +1227,11 @@ async function getDomainId(
return null; return null;
} }
const domainSelection = validDomains[0].domains; // Pick the most specific (longest baseDomain) valid domain so that, e.g.,
// *.test.dev.example.com is assigned to *.dev.example.com rather than *.example.com.
const domainSelection = validDomains.sort(
(a, b) => b.domains.baseDomain.length - a.domains.baseDomain.length
)[0].domains;
const baseDomain = domainSelection.baseDomain; const baseDomain = domainSelection.baseDomain;
// Wildcard full-domains are not allowed on namespace (provided/free) domains // Wildcard full-domains are not allowed on namespace (provided/free) domains

View File

@@ -97,6 +97,13 @@ export class PrivateConfig {
); );
} }
process.env.BRANDING_HIDE_POWERED_BY =
this.rawPrivateConfig.branding?.hide_powered_by === true ||
this.rawPrivateConfig.branding?.resource_auth_page
?.hide_powered_by === true
? "true"
: "false";
process.env.LOGIN_PAGE_SUBTITLE_TEXT = process.env.LOGIN_PAGE_SUBTITLE_TEXT =
this.rawPrivateConfig.branding?.login_page?.subtitle_text || ""; this.rawPrivateConfig.branding?.login_page?.subtitle_text || "";

View File

@@ -141,6 +141,7 @@ export const privateConfigSchema = z
) )
.optional(), .optional(),
hide_auth_layout_footer: z.boolean().optional().default(false), hide_auth_layout_footer: z.boolean().optional().default(false),
hide_powered_by: z.boolean().optional(),
login_page: z login_page: z
.object({ .object({
subtitle_text: z.string().optional() subtitle_text: z.string().optional()

View File

@@ -98,15 +98,6 @@ export async function addUserRole(
); );
} }
if (existingUser[0].isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the role of the owner of the organization"
)
);
}
const roleExists = await db const roleExists = await db
.select() .select()
.from(roles) .from(roles)

View File

@@ -98,11 +98,11 @@ export async function removeUserRole(
); );
} }
if (existingUser.isOwner) { if (existingUser.isOwner && role.isAdmin === true) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization" "Cannot remove the administrator role from the organization owner"
) )
); );
} }

View File

@@ -87,17 +87,8 @@ export async function setUserOrgRoles(
); );
} }
if (existingUser.isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Cannot change the roles of the owner of the organization"
)
);
}
const orgRoles = await db const orgRoles = await db
.select({ roleId: roles.roleId }) .select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
.from(roles) .from(roles)
.where( .where(
and( and(
@@ -115,6 +106,18 @@ export async function setUserOrgRoles(
); );
} }
if (existingUser.isOwner) {
const hasAdminRole = orgRoles.some((r) => r.isAdmin === true);
if (!hasAdminRole) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"The organization owner must retain an administrator role"
)
);
}
}
let orgClientsToRebuild: Client[] = []; let orgClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
await trx await trx

View File

@@ -88,11 +88,11 @@ export async function addUserRoleLegacy(
); );
} }
if (existingUser.isOwner) { if (existingUser.isOwner && role.isAdmin !== true) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Cannot change the role of the owner of the organization" "The organization owner must retain an administrator role"
) )
); );
} }

View File

@@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) {
.from(userOrgRoles) .from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where( .where(
and( and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
); );
const isAdmin = roleRows.some((r) => r.isAdmin); const isAdmin = roleRows.some((r) => r.isAdmin);
@@ -61,7 +58,8 @@ export async function queryUser(orgId: string, userId: string) {
roleIds: roleRows.map((r) => r.roleId), roleIds: roleRows.map((r) => r.roleId),
roles: roleRows.map((r) => ({ roles: roleRows.map((r) => ({
roleId: r.roleId, roleId: r.roleId,
name: r.roleName ?? "" name: r.roleName ?? "",
isAdmin: r.isAdmin === true
})) }))
}; };
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import IdpTypeBadge from "@app/components/IdpTypeBadge"; import IdpTypeBadge from "@app/components/IdpTypeBadge";
import OrgRolesTagField from "@app/components/OrgRolesTagField"; import OrgRolesTagField from "@app/components/OrgRolesTagField";
import { import {
@@ -25,6 +26,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useUserContext } from "@app/hooks/useUserContext";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -32,7 +34,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useActionState, useEffect } from "react"; 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";
@@ -42,13 +44,15 @@ const accessControlsFormSchema = z.object({
roles: z.array( roles: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
text: 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 { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
@@ -72,7 +76,8 @@ export default function AccessControlsPage() {
autoProvisioned: user.autoProvisioned || false, autoProvisioned: user.autoProvisioned || false,
roles: (user.roles ?? []).map((r) => ({ roles: (user.roles ?? []).map((r) => ({
id: r.roleId.toString(), id: r.roleId.toString(),
text: r.name text: r.name,
isAdmin: r.isAdmin === true
})) }))
} }
}); });
@@ -84,7 +89,8 @@ export default function AccessControlsPage() {
"roles", "roles",
(user.roles ?? []).map((r) => ({ (user.roles ?? []).map((r) => ({
id: r.roleId.toString(), id: r.roleId.toString(),
text: r.name text: r.name,
isAdmin: r.isAdmin === true
})) }))
); );
form.setValue("autoProvisioned", user.autoProvisioned || false); form.setValue("autoProvisioned", user.autoProvisioned || false);
@@ -95,11 +101,11 @@ export default function AccessControlsPage() {
? t("singleRolePerUserPlanNotice") ? t("singleRolePerUserPlanNotice")
: t("singleRolePerUserEditionNotice"); : t("singleRolePerUserEditionNotice");
const [, action, isSubmitting] = useActionState(onSubmit, null); const [isSaving, setIsSaving] = useState(false);
async function onSubmit() { const [confirmRemoveOwnAdminOpen, setConfirmRemoveOwnAdminOpen] =
const isValid = await form.trigger(); useState(false);
if (!isValid) return;
async function executeSave() {
const values = form.getValues(); const values = form.getValues();
if (values.roles.length === 0) { if (values.roles.length === 0) {
@@ -111,6 +117,7 @@ export default function AccessControlsPage() {
return; return;
} }
setIsSaving(true);
try { try {
const roleIds = values.roles.map((r) => parseInt(r.id, 10)); const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const updateRoleRequest = supportsMultipleRolesPerUser const updateRoleRequest = supportsMultipleRolesPerUser
@@ -130,7 +137,8 @@ export default function AccessControlsPage() {
roleIds, roleIds,
roles: values.roles.map((r) => ({ roles: values.roles.map((r) => ({
roleId: parseInt(r.id, 10), roleId: parseInt(r.id, 10),
name: r.text name: r.text,
isAdmin: r.isAdmin === true
})), })),
autoProvisioned: values.autoProvisioned autoProvisioned: values.autoProvisioned
}); });
@@ -149,11 +157,61 @@ export default function AccessControlsPage() {
t("accessRoleErrorAddDescription") t("accessRoleErrorAddDescription")
) )
}); });
} finally {
setIsSaving(false);
} }
} }
async function handleAccessControlsSubmit(e: React.FormEvent) {
e.preventDefault();
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
const willHaveAdminRole = values.roles.some(
(r) => r.isAdmin === true
);
const isRemovingOwnAdmin =
sessionUser.userId === user.userId &&
user.isAdmin &&
!willHaveAdminRole;
if (isRemovingOwnAdmin) {
setConfirmRemoveOwnAdminOpen(true);
return;
}
await executeSave();
}
return ( return (
<SettingsContainer> <SettingsContainer>
<ConfirmDeleteDialog
open={confirmRemoveOwnAdminOpen}
setOpen={setConfirmRemoveOwnAdminOpen}
title={t("removeOwnAdminRoleConfirmTitle")}
dialog={
<div className="space-y-2">
<p>{t("removeOwnAdminRoleConfirmDescription")}</p>
</div>
}
buttonText={t("removeOwnAdminRoleConfirmButton")}
string={t("removeOwnAdminRoleConfirmPhrase")}
onConfirm={executeSave}
/>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -168,7 +226,7 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
action={action} onSubmit={(e) => void handleAccessControlsSubmit(e)}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
@@ -237,8 +295,8 @@ export default function AccessControlsPage() {
<SettingsSectionFooter> <SettingsSectionFooter>
<Button <Button
type="submit" type="submit"
loading={isSubmitting} loading={isSaving}
disabled={isSubmitting} disabled={isSaving}
form="access-controls-form" form="access-controls-form"
> >
{t("accessControlsSubmit")} {t("accessControlsSubmit")}

View File

@@ -84,6 +84,7 @@ import {
AlertTriangle, AlertTriangle,
CircleCheck, CircleCheck,
CircleX, CircleX,
ExternalLink,
Info, Info,
Plus, Plus,
Settings Settings
@@ -961,13 +962,18 @@ function ProxyResourceTargetsForm({
{build === "saas" && {build === "saas" &&
targets.length > 1 && targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && ( new Set(targets.map((t) => t.siteId)).size > 1 && (
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5"> <p className="text-sm text-muted-foreground mt-3">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" /> {t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
<span> <a
Round robin routing will not work between href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
sites that are not connected to the same target="_blank"
node, but failover will work. rel="noopener noreferrer"
</span> className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p> </p>
)} )}
</SettingsSectionBody> </SettingsSectionBody>

View File

@@ -82,8 +82,8 @@ import { AxiosResponse } from "axios";
import { import {
CircleCheck, CircleCheck,
CircleX, CircleX,
ExternalLink,
Info, Info,
InfoIcon,
Plus, Plus,
Settings, Settings,
SquareArrowOutUpRight SquareArrowOutUpRight
@@ -1425,16 +1425,22 @@ export default function Page() {
</Button> </Button>
</div> </div>
)} )}
{build === "enterprise" && {build === "saas" &&
targets.length > 1 && targets.length > 1 &&
new Set(targets.map((t) => t.siteId)).size > 1 && ( new Set(targets.map((t) => t.siteId)).size >
<p className="text-sm text-muted-foreground mt-3 flex items-start gap-1.5"> 1 && (
<InfoIcon className="h-4 w-4 shrink-0 mt-0.5" /> <p className="text-sm text-muted-foreground mt-3">
<span> {t("proxyMultiSiteRoundRobinNodeHelp")}{" "}
Round robin routing will not work between <a
sites that are not connected to the same href="https://docs.pangolin.net/manage/resources/public/targets#distributing-sites-load-across-servers"
node, but failover will work. target="_blank"
</span> rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p> </p>
)} )}
</SettingsSectionBody> </SettingsSectionBody>

View File

@@ -99,7 +99,7 @@ export default function InviteStatusCard({
router.push(redirectUrl); router.push(redirectUrl);
} else if (!user && type === "not_logged_in") { } else if (!user && type === "not_logged_in") {
const redirectUrl = email const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`; : `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl); router.push(redirectUrl);
} else { } else {
@@ -113,7 +113,7 @@ export default function InviteStatusCard({
async function goToLogin() { async function goToLogin() {
await api.post("/auth/logout", {}); await api.post("/auth/logout", {});
const redirectUrl = email const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}` ? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}`
: `/auth/login?redirect=/invite?token=${tokenParam}`; : `/auth/login?redirect=/invite?token=${tokenParam}`;
router.push(redirectUrl); router.push(redirectUrl);
} }

View File

@@ -16,6 +16,7 @@ import Link from "next/link";
import { replacePlaceholder } from "@app/lib/replacePlaceholder"; import { replacePlaceholder } from "@app/lib/replacePlaceholder";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
type OrgLoginPageProps = { type OrgLoginPageProps = {
loginPage: LoadLoginPageResponse | undefined; loginPage: LoadLoginPageResponse | undefined;
@@ -52,19 +53,21 @@ export default async function OrgLoginPage({
const t = await getTranslations(); const t = await getTranslations();
return ( return (
<div> <div>
<div className="text-center mb-2"> {build !== "enterprise" || !env.branding.hidePoweredBy ? (
<span className="text-sm text-muted-foreground"> <div className="text-center mb-2">
{t("poweredBy")}{" "} <span className="text-sm text-muted-foreground">
<Link {t("poweredBy")}{" "}
href="https://pangolin.net/" <Link
target="_blank" href="https://pangolin.net/"
rel="noopener noreferrer" target="_blank"
className="underline" rel="noopener noreferrer"
> className="underline"
{env.branding.appName || "Pangolin"} >
</Link> {env.branding.appName || "Pangolin"}
</span> </Link>
</div> </span>
</div>
) : null}
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
{branding?.logoUrl && ( {branding?.logoUrl && (

View File

@@ -375,7 +375,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{!accessDenied ? ( {!accessDenied ? (
<div> <div>
{isUnlocked() && build === "enterprise" ? ( {isUnlocked() && build === "enterprise" ? (
!env.branding.resourceAuthPage?.hidePoweredBy && ( !env.branding.resourceAuthPage?.hidePoweredBy &&
!env.branding.hidePoweredBy && (
<div className="text-center mb-2"> <div className="text-center mb-2">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{t("poweredBy")}{" "} {t("poweredBy")}{" "}

View File

@@ -61,6 +61,8 @@ export default function ShareLinksTable({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedLink, setSelectedLink] = useState<ShareLinkRow | null>(null);
const [rows, setRows] = useState<ShareLinkRow[]>(shareLinks); const [rows, setRows] = useState<ShareLinkRow[]>(shareLinks);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
@@ -92,6 +94,7 @@ export default function ShareLinksTable({
title: t("shareErrorDelete"), title: t("shareErrorDelete"),
description: formatAxiosError(e, t("shareErrorDeleteMessage")) description: formatAxiosError(e, t("shareErrorDeleteMessage"))
}); });
throw e;
}); });
const newRows = rows.filter((r) => r.accessTokenId !== id); const newRows = rows.filter((r) => r.accessTokenId !== id);
@@ -293,9 +296,10 @@ export default function ShareLinksTable({
{/* </DropdownMenu> */} {/* </DropdownMenu> */}
<Button <Button
variant={"outline"} variant={"outline"}
onClick={() => onClick={() => {
deleteSharelink(row.original.accessTokenId) setSelectedLink(resourceRow);
} setIsDeleteModalOpen(true);
}}
> >
{t("delete")} {t("delete")}
</Button> </Button>
@@ -307,6 +311,30 @@ export default function ShareLinksTable({
return ( return (
<> <>
{selectedLink && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
if (!val) setSelectedLink(null);
}}
dialog={
<div className="space-y-2">
<p>{t("shareQuestionRemove")}</p>
<p>{t("shareMessageRemove")}</p>
</div>
}
buttonText={t("shareDeleteConfirm")}
onConfirm={async () =>
deleteSharelink(selectedLink.accessTokenId)
}
string={
selectedLink.title || selectedLink.resourceName
}
title={t("shareDelete")}
/>
)}
<CreateShareLinkForm <CreateShareLinkForm
open={isCreateModalOpen} open={isCreateModalOpen}
setOpen={setIsCreateModalOpen} setOpen={setIsCreateModalOpen}

View File

@@ -99,6 +99,14 @@ export default function UsersTable({
]; ];
}, [searchParams.toString()]); }, [searchParams.toString()]);
const isRemovingSelf = useMemo(() => {
if (!selectedUser || !user) return false;
return (
`${selectedUser.username}-${selectedUser.idpId}` ===
`${user.username}-${user.idpId}`
);
}, [selectedUser, user]);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | undefined | null value: string | undefined | null
@@ -223,10 +231,7 @@ export default function UsersTable({
header: () => <span className="p-3"></span>, header: () => <span className="p-3"></span>,
cell: ({ row }) => { cell: ({ row }) => {
const userRow = row.original; const userRow = row.original;
const isCurrentUser = const canRemoveFromOrg = !userRow.isOwner;
`${userRow.username}-${userRow.idpId}` ===
`${user?.username}-${user?.idpId}`;
const isDisabled = userRow.isOwner || isCurrentUser;
return ( return (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<div> <div>
@@ -235,7 +240,6 @@ export default function UsersTable({
<Button <Button
variant="ghost" variant="ghost"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
disabled={isDisabled}
> >
<span className="sr-only"> <span className="sr-only">
{t("openMenu")} {t("openMenu")}
@@ -247,16 +251,12 @@ export default function UsersTable({
<Link <Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full" className="block w-full"
aria-disabled={isDisabled}
onClick={(e) =>
isDisabled && e.preventDefault()
}
> >
<DropdownMenuItem disabled={isDisabled}> <DropdownMenuItem>
{t("accessUserManage")} {t("accessUserManage")}
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
{!isDisabled && ( {canRemoveFromOrg && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@@ -271,25 +271,14 @@ export default function UsersTable({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{isDisabled ? ( <Link
<Button href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
variant={"outline"} >
className="ml-2" <Button variant={"outline"} className="ml-2">
disabled
>
{t("manage")} {t("manage")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
) : ( </Link>
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
)}
</div> </div>
); );
} }
@@ -359,22 +348,45 @@ export default function UsersTable({
}} }}
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p>{t("userQuestionOrgRemove")}</p> <p>
<p>{t("userMessageOrgRemove")}</p> {t(
isRemovingSelf
? "userQuestionOrgRemoveSelf"
: "userQuestionOrgRemove"
)}
</p>
<p>
{t(
isRemovingSelf
? "userMessageOrgRemoveSelf"
: "userMessageOrgRemove"
)}
</p>
</div> </div>
} }
buttonText={t("userRemoveOrgConfirm")} buttonText={t(
isRemovingSelf
? "userRemoveOrgConfirmSelf"
: "userRemoveOrgConfirm"
)}
warningText={
isRemovingSelf ? t("userRemoveOrgSelfWarning") : undefined
}
onConfirm={async () => startTransition(removeUser)} onConfirm={async () => startTransition(removeUser)}
string={ string={
selectedUser isRemovingSelf
? getUserDisplayName({ ? t("userRemoveOrgConfirmPhraseSelf")
email: selectedUser.email, : selectedUser
name: selectedUser.name, ? getUserDisplayName({
username: selectedUser.username email: selectedUser.email,
}) name: selectedUser.name,
: "" username: selectedUser.username
})
: ""
} }
title={t("userRemoveOrg")} title={t(
isRemovingSelf ? "userRemoveOrgSelf" : "userRemoveOrg"
)}
/> />
<ControlledDataTable <ControlledDataTable

View File

@@ -11,7 +11,7 @@ import { cn } from "@app/lib/cn";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
export type TagValue = { text: string; id: string }; export type TagValue = { text: string; id: string; isAdmin?: boolean };
export type MultiSelectTagsProps<T extends TagValue> = { export type MultiSelectTagsProps<T extends TagValue> = {
emptyPlaceholder?: string; emptyPlaceholder?: string;

View File

@@ -6,7 +6,7 @@ import { useDebounce } from "use-debounce";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input"; import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
export type SelectedRole = { id: string; text: string }; export type SelectedRole = { id: string; text: string; isAdmin?: boolean };
export type RolesSelectorProps = { export type RolesSelectorProps = {
orgId: string; orgId: string;

View File

@@ -81,6 +81,8 @@ export function pullEnv(): Env {
process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER === "true" process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER === "true"
? true ? true
: false, : false,
hidePoweredBy:
process.env.BRANDING_HIDE_POWERED_BY === "true" ? true : false,
logo: { logo: {
lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string, lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string,
darkPath: process.env.BRANDING_LOGO_DARK_PATH as string, darkPath: process.env.BRANDING_LOGO_DARK_PATH as string,

View File

@@ -41,6 +41,7 @@ export type Env = {
appName?: string; appName?: string;
background_image_path?: string; background_image_path?: string;
hideAuthLayoutFooter?: boolean; hideAuthLayoutFooter?: boolean;
hidePoweredBy?: boolean;
logo?: { logo?: {
lightPath?: string; lightPath?: string;
darkPath?: string; darkPath?: string;