From fe8aae78398cf26eaa61f8177cf88c9a19f8b5a6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 11 Sep 2026 11:58:51 -0400 Subject: [PATCH] move improvements to user management ui --- messages/en-US.json | 5 +- next.config.ts | 5 + server/routers/user/listUsers.ts | 85 ++-- .../{access-controls => general}/page.tsx | 24 +- .../settings/access/users/[userId]/layout.tsx | 19 +- .../settings/access/users/[userId]/page.tsx | 2 +- .../settings/access/users/create/page.tsx | 9 +- .../[orgId]/settings/access/users/page.tsx | 23 +- .../[orgId]/settings/api-keys/create/page.tsx | 409 +++++++----------- src/app/admin/api-keys/create/page.tsx | 397 +++++++---------- src/components/ApprovalFeed.tsx | 2 +- src/components/UserRoleBadges.tsx | 13 +- src/components/UsersTable.tsx | 38 +- 13 files changed, 466 insertions(+), 565 deletions(-) rename src/app/[orgId]/settings/access/users/[userId]/{access-controls => general}/page.tsx (93%) diff --git a/messages/en-US.json b/messages/en-US.json index 7a2cad775..24587cae6 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -226,9 +226,9 @@ "never": "Never", "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Public Resources", - "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", + "proxyResourceDescription": "Create and manage resources that are publicly accessible via a proxy", "publicResourcesBannerTitle": "Web-based Public Access", - "publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.", + "publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet, like a website or API, and include identity and context-aware access policies. Unlike private resources, they do not require any client-side software to access.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", @@ -718,6 +718,7 @@ "nameOptional": "Name (Optional)", "accessControls": "Access Controls", "userDescription2": "Manage the settings on this user", + "userGeneralSettingsDescription": "Manage this user's roles and settings in the organization", "accessRoleErrorAdd": "Failed to add user to role", "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "userSaved": "User saved", diff --git a/next.config.ts b/next.config.ts index 0c3738433..b4d0da9d2 100644 --- a/next.config.ts +++ b/next.config.ts @@ -34,6 +34,11 @@ const nextConfig: NextConfig = { source: "/:orgId/settings/resources/client/:path*", destination: "/:orgId/settings/resources/private/:path*", permanent: true + }, + { + source: "/:orgId/settings/access/users/:userId/access-controls", + destination: "/:orgId/settings/access/users/:userId/general", + permanent: false } ]; } diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 66438eaf3..25bbd456b 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -95,22 +95,34 @@ const listUsersSchema = z.strictObject({ 'Filter by identity provider id, or "internal" for internal users' }), role_id: z - .preprocess((val) => { - if (val === undefined || val === null || val === "") { - return undefined; - } - const raw = Array.isArray(val) ? val : [val]; - const nums = raw - .map((v) => - typeof v === "string" ? parseInt(v, 10) : Number(v) - ) - .filter((n) => Number.isInteger(n) && n > 0); - const unique = [...new Set(nums)]; - return unique.length ? unique : undefined; - }, z.array(z.number().int().positive()).optional()) + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + const raw = Array.isArray(val) ? val : [val]; + const includeOwner = raw.some((v) => v === "owner"); + const nums = raw + .map((v) => + typeof v === "string" ? parseInt(v, 10) : Number(v) + ) + .filter((n) => Number.isInteger(n) && n > 0); + const unique = [...new Set(nums)]; + if (!unique.length && !includeOwner) { + return undefined; + } + return { roleIds: unique, includeOwner }; + }, + z + .object({ + roleIds: z.array(z.number().int().positive()), + includeOwner: z.boolean() + }) + .optional() + ) .openapi({ description: - "Filter users who have any of these role ids in the organization (repeat query param)" + 'Filter users who have any of these role ids in the organization, or "owner" for organization owners (repeat query param)' }) }); @@ -193,7 +205,8 @@ export async function listUsers( } const { page, pageSize, sort_by, order, query, idp_id, role_id } = parsedQuery.data; - const roleIds = role_id ?? []; + const roleIds = role_id?.roleIds ?? []; + const includeOwner = role_id?.includeOwner ?? false; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -267,21 +280,35 @@ export async function listUsers( conditions.push(eq(users.idpId, idp_id)); } - if (roleIds.length > 0) { - conditions.push( - exists( - db - .select() - .from(userOrgRoles) - .where( - and( - eq(userOrgRoles.userId, users.userId), - eq(userOrgRoles.orgId, orgId), - inArray(userOrgRoles.roleId, roleIds) + if (roleIds.length > 0 || includeOwner) { + const roleFilterParts = []; + + if (includeOwner) { + roleFilterParts.push(eq(userOrgs.isOwner, true)); + } + + if (roleIds.length > 0) { + roleFilterParts.push( + exists( + db + .select() + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, users.userId), + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.roleId, roleIds) + ) ) - ) - ) - ); + ) + ); + } + + if (roleFilterParts.length === 1) { + conditions.push(roleFilterParts[0]); + } else if (roleFilterParts.length > 1) { + conditions.push(or(...roleFilterParts)); + } } const countQuery = db.$count( diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/general/page.tsx similarity index 93% rename from src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx rename to src/app/[orgId]/settings/access/users/[userId]/general/page.tsx index 2f94e78da..8fddfd364 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/general/page.tsx @@ -38,7 +38,7 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -export default function AccessControlsPage() { +export default function GeneralPage() { const { orgUser: user, updateOrgUser } = userOrgUserContext(); const { user: sessionUser } = useUserContext(); const { env } = useEnvContext(); @@ -57,7 +57,7 @@ export default function AccessControlsPage() { (build === "enterprise" && !isPaid) || (build === "oss" && !isPaid)); - const accessControlsFormSchema = z.object({ + const generalFormSchema = z.object({ username: z.string(), autoProvisioned: z.boolean(), roles: z @@ -72,7 +72,7 @@ export default function AccessControlsPage() { }); const form = useForm({ - resolver: zodResolver(accessControlsFormSchema), + resolver: zodResolver(generalFormSchema), defaultValues: { username: user.username!, autoProvisioned: user.autoProvisioned || false, @@ -155,7 +155,7 @@ export default function AccessControlsPage() { } } - async function handleAccessControlsSubmit(e: React.FormEvent) { + async function handleGeneralSubmit(e: React.FormEvent) { e.preventDefault(); const isValid = await form.trigger(); @@ -196,11 +196,9 @@ export default function AccessControlsPage() { - - {t("accessControls")} - + {t("general")} - {t("accessControlsDescription")} + {t("userGeneralSettingsDescription")} @@ -208,11 +206,9 @@ export default function AccessControlsPage() {
- void handleAccessControlsSubmit(e) - } + onSubmit={(e) => void handleGeneralSubmit(e)} className="space-y-4" - id="access-controls-form" + id="user-general-form" > {user.type !== UserType.Internal && user.idpType && ( @@ -281,9 +277,9 @@ export default function AccessControlsPage() { type="submit" loading={isSaving} disabled={isSaving} - form="access-controls-form" + form="user-general-form" > - {t("accessControlsSubmit")} + {t("saveSettings")} diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 0a9815c36..e4ed8adab 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -9,15 +9,16 @@ import { cache } from "react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; export const metadata: Metadata = { title: "User" }; -interface UserLayoutProps { +type UserLayoutProps = { children: React.ReactNode; params: Promise<{ userId: string; orgId: string }>; -} +}; export default async function UserLayoutProps(props: UserLayoutProps) { const params = await props.params; @@ -42,15 +43,23 @@ export default async function UserLayoutProps(props: UserLayoutProps) { const navItems = [ { - title: t("accessControls"), - href: "/{orgId}/settings/access/users/{userId}/access-controls" + title: t("general"), + href: "/{orgId}/settings/access/users/{userId}/general" } ]; return ( <> diff --git a/src/app/[orgId]/settings/access/users/[userId]/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/page.tsx index c56533dad..88eb7575c 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/page.tsx @@ -9,5 +9,5 @@ export default async function UserPage(props: { params: Promise<{ orgId: string; userId: string }>; }) { const { orgId, userId } = await props.params; - redirect(`/${orgId}/settings/access/users/${userId}/access-controls`); + redirect(`/${orgId}/settings/access/users/${userId}/general`); } diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index af390d946..239996e3e 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -1,6 +1,6 @@ "use client"; -import CopyToClipboard from "@app/components/CopyToClipboard"; +import CopyTextBox from "@app/components/CopyTextBox"; import { Credenza, CredenzaBody, @@ -894,12 +894,7 @@ export default function Page() { days: expiresInDays })}

- {inviteLink && ( - - )} + {inviteLink && } diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 462122a95..eb6539538 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -78,12 +78,13 @@ export default async function UsersPage(props: UsersPageProps) { rolesRes && rolesRes.status === 200 ? (rolesRes.data.data.roles ?? []) : []; - const roleFilterOptions = orgRoles.map( - (r: ListRolesResponse["roles"][number]) => ({ + const roleFilterOptions = [ + { value: "owner", label: t("accessRoleOwner") }, + ...orgRoles.map((r: ListRolesResponse["roles"][number]) => ({ value: String(r.roleId), label: r.name - }) - ); + })) + ]; const invitationsRes = await internal .get( @@ -126,14 +127,12 @@ export default async function UsersPage(props: UsersPageProps) { idpId: user.idpId, idpName: user.idpName || t("idpNameInternal"), status: t("userConfirmed"), - roleLabels: user.isOwner - ? [t("accessRoleOwner")] - : (() => { - const names = (user.roles ?? []) - .map((r) => r.roleName) - .filter((n): n is string => Boolean(n?.length)); - return names.length ? names : [t("accessRoleMember")]; - })(), + roleLabels: (() => { + const names = (user.roles ?? []) + .map((r) => r.roleName) + .filter((n): n is string => Boolean(n?.length)); + return names.length ? names : [t("accessRoleMember")]; + })(), isOwner: user.isOwner || false }; }); diff --git a/src/app/[orgId]/settings/api-keys/create/page.tsx b/src/app/[orgId]/settings/api-keys/create/page.tsx index fa062ba19..e27734afe 100644 --- a/src/app/[orgId]/settings/api-keys/create/page.tsx +++ b/src/app/[orgId]/settings/api-keys/create/page.tsx @@ -43,10 +43,18 @@ import { } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import moment from "moment"; -import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox"; import CopyTextBox from "@app/components/CopyTextBox"; import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; import { useTranslations } from "next-intl"; +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; export default function Page() { const { env } = useEnvContext(); @@ -58,6 +66,7 @@ export default function Page() { const [loadingPage, setLoadingPage] = useState(true); const [createLoading, setCreateLoading] = useState(false); const [apiKey, setApiKey] = useState(null); + const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false); const [selectedPermissions, setSelectedPermissions] = useState< Record >({}); @@ -75,22 +84,6 @@ export default function Page() { type CreateFormValues = z.infer; - const copiedFormSchema = z - .object({ - copied: z.boolean() - }) - .refine( - (data) => { - return data.copied; - }, - { - message: t("apiKeysConfirmCopy2"), - path: ["copied"] - } - ); - - type CopiedFormValues = z.infer; - const form = useForm({ resolver: zodResolver(createFormSchema), defaultValues: { @@ -98,12 +91,9 @@ export default function Page() { } }); - const copiedForm = useForm({ - resolver: zodResolver(copiedFormSchema), - defaultValues: { - copied: true - } - }); + function goToApiKeysList() { + router.push(`/${orgId}/settings/api-keys`); + } async function onSubmit(data: CreateFormValues) { setCreateLoading(true); @@ -113,9 +103,10 @@ export default function Page() { }; const res = await api - .put< - AxiosResponse - >(`/org/${orgId}/api-key/`, payload) + .put>( + `/org/${orgId}/api-key/`, + payload + ) .catch((e) => { toast({ variant: "destructive", @@ -125,16 +116,10 @@ export default function Page() { }); if (res && res.status === 201) { - const data = res.data.data; - - console.log({ - actionIds: Object.keys(selectedPermissions).filter( - (key) => selectedPermissions[key] - ) - }); + const created = res.data.data; const actionsRes = await api - .post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, { + .post(`/org/${orgId}/api-key/${created.apiKeyId}/actions`, { actionIds: Object.keys(selectedPermissions).filter( (key) => selectedPermissions[key] ) @@ -149,27 +134,14 @@ export default function Page() { }); if (actionsRes) { - setApiKey(data); + setApiKey(created); + setIsApiKeyDialogOpen(true); } } setCreateLoading(false); } - async function onCopiedSubmit(data: CopiedFormValues) { - if (!data.copied) { - return; - } - - router.push(`/${orgId}/settings/api-keys`); - } - - const formatLabel = (str: string) => { - return str - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/^./, (char) => char.toUpperCase()); - }; - useEffect(() => { const load = async () => { setLoadingPage(false); @@ -185,12 +157,7 @@ export default function Page() { title={t("apiKeysCreate")} description={t("apiKeysCreateDescription")} /> - @@ -198,206 +165,152 @@ export default function Page() { {!loadingPage && (
- {!apiKey && ( - <> - - - - {t("apiKeysTitle")} - - - - - - { - if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh - } - }} - className="space-y-4" - id="create-site-form" - > - ( - - - {t("name")} - - - - - - - )} - /> - - - - - + + + + {t("apiKeysTitle")} + + + + +
+ { + if (e.key === "Enter") { + e.preventDefault(); + } + }} + className="space-y-4" + id="create-site-form" + > + ( + + + {t("name")} + + + + + + + )} + /> + + +
+
+
- - - - {t("apiKeysGeneralSettings")} - - - {t( - "apiKeysGeneralSettingsDescription" - )} - - - - - - - - )} - - {apiKey && ( - - - - {t("apiKeysList")} - - - - - - - {t("name")} - - - - - - - - {t("created")} - - - {moment( - apiKey.createdAt - ).format("lll")} - - - - - - - - {t("apiKeysSave")} - - - {t("apiKeysSaveDescription")} - - - - {/*

*/} - {/* {t('apiKeysInfo')} */} - {/*

*/} - - - - {/*
*/} - {/* */} - {/* ( */} - {/* */} - {/*
*/} - {/* { */} - {/* copiedForm.setValue( */} - {/* "copied", */} - {/* e as boolean */} - {/* ); */} - {/* }} */} - {/* /> */} - {/* */} - {/*
*/} - {/* */} - {/*
*/} - {/* )} */} - {/* /> */} - {/* */} - {/* */} -
-
- )} + + + + {t("apiKeysGeneralSettings")} + + + {t("apiKeysGeneralSettingsDescription")} + + + + + +
- {!apiKey && ( - - )} - {!apiKey && ( - - )} - - {apiKey && ( - - )} + +
)} + + { + setIsApiKeyDialogOpen(open); + if (!open && apiKey) { + goToApiKeysList(); + } + }} + > + + + {t("apiKeysList")} + + {t("apiKeysSaveDescription")} + + + + {apiKey && ( +
+ + + + {t("name")} + + + + + + + + {t("created")} + + + {moment(apiKey.createdAt).format( + "lll" + )} + + + + + + + + {t("apiKeysSave")} + + + {t("apiKeysSaveDescription")} + + + + +
+ )} +
+ + + +
+
); } diff --git a/src/app/admin/api-keys/create/page.tsx b/src/app/admin/api-keys/create/page.tsx index 083ec89d6..f783beba6 100644 --- a/src/app/admin/api-keys/create/page.tsx +++ b/src/app/admin/api-keys/create/page.tsx @@ -46,6 +46,15 @@ import moment from "moment"; import CopyTextBox from "@app/components/CopyTextBox"; import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; import { useTranslations } from "next-intl"; +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; export default function Page() { const { env } = useEnvContext(); @@ -56,6 +65,7 @@ export default function Page() { const [loadingPage, setLoadingPage] = useState(true); const [createLoading, setCreateLoading] = useState(false); const [apiKey, setApiKey] = useState(null); + const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false); const [selectedPermissions, setSelectedPermissions] = useState< Record >({}); @@ -73,22 +83,6 @@ export default function Page() { type CreateFormValues = z.infer; - const copiedFormSchema = z - .object({ - copied: z.boolean() - }) - .refine( - (data) => { - return data.copied; - }, - { - message: t("apiKeysConfirmCopy2"), - path: ["copied"] - } - ); - - type CopiedFormValues = z.infer; - const form = useForm({ resolver: zodResolver(createFormSchema), defaultValues: { @@ -96,12 +90,9 @@ export default function Page() { } }); - const copiedForm = useForm({ - resolver: zodResolver(copiedFormSchema), - defaultValues: { - copied: true - } - }); + function goToApiKeysList() { + router.push(`/admin/api-keys`); + } async function onSubmit(data: CreateFormValues) { setCreateLoading(true); @@ -121,16 +112,10 @@ export default function Page() { }); if (res && res.status === 201) { - const data = res.data.data; - - console.log({ - actionIds: Object.keys(selectedPermissions).filter( - (key) => selectedPermissions[key] - ) - }); + const created = res.data.data; const actionsRes = await api - .post(`/api-key/${data.apiKeyId}/actions`, { + .post(`/api-key/${created.apiKeyId}/actions`, { actionIds: Object.keys(selectedPermissions).filter( (key) => selectedPermissions[key] ) @@ -145,21 +130,14 @@ export default function Page() { }); if (actionsRes) { - setApiKey(data); + setApiKey(created); + setIsApiKeyDialogOpen(true); } } setCreateLoading(false); } - async function onCopiedSubmit(data: CopiedFormValues) { - if (!data.copied) { - return; - } - - router.push(`/admin/api-keys`); - } - useEffect(() => { const load = async () => { setLoadingPage(false); @@ -175,12 +153,7 @@ export default function Page() { title={t("apiKeysCreate")} description={t("apiKeysCreateDescription")} /> - @@ -188,207 +161,153 @@ export default function Page() { {!loadingPage && (
- {!apiKey && ( - <> - - - - {t("apiKeysTitle")} - - - - -
- { - if (e.key === "Enter") { - e.preventDefault(); // block default enter refresh - } - }} - className="space-y-4" - id="create-site-form" - > - ( - - - {t("name")} - - - - - - - )} - /> - - -
-
-
+ + + + {t("apiKeysTitle")} + + + + +
+ { + if (e.key === "Enter") { + e.preventDefault(); + } + }} + className="space-y-4" + id="create-site-form" + > + ( + + + {t("name")} + + + + + + + )} + /> + + +
+
+
- - - - {t("apiKeysGeneralSettings")} - - - {t( - "apiKeysGeneralSettingsDescription" - )} - - - - - - - - )} - - {apiKey && ( - - - - {t("apiKeysList")} - - - - - - - {t("name")} - - - - - - - - {t("created")} - - - {moment( - apiKey.createdAt - ).format("lll")} - - - - - - - - {t("apiKeysSave")} - - - {t("apiKeysSaveDescription")} - - - - {/*

*/} - {/* {t('apiKeysInfo')} */} - {/*

*/} - - - - {/*
*/} - {/* */} - {/* ( */} - {/* */} - {/*
*/} - {/* { */} - {/* copiedForm.setValue( */} - {/* "copied", */} - {/* e as boolean */} - {/* ); */} - {/* }} */} - {/* /> */} - {/* */} - {/*
*/} - {/* */} - {/*
*/} - {/* )} */} - {/* /> */} - {/* */} - {/* */} -
-
- )} + + + + {t("apiKeysGeneralSettings")} + + + {t("apiKeysGeneralSettingsDescription")} + + + + + +
- {!apiKey && ( - - )} - {!apiKey && ( - - )} - - {apiKey && ( - - )} + +
)} + + { + setIsApiKeyDialogOpen(open); + if (!open && apiKey) { + goToApiKeysList(); + } + }} + > + + + {t("apiKeysList")} + + {t("apiKeysSaveDescription")} + + + + {apiKey && ( +
+ + + + {t("name")} + + + + + + + + {t("created")} + + + {moment(apiKey.createdAt).format( + "lll" + )} + + + + + + + + {t("apiKeysSave")} + + + {t("apiKeysSaveDescription")} + + + + +
+ )} +
+ + + +
+
); } diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 14465fe33..611dd0436 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -227,7 +227,7 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
{getUserDisplayName({ diff --git a/src/components/UserRoleBadges.tsx b/src/components/UserRoleBadges.tsx index 4888aa107..d0e915c99 100644 --- a/src/components/UserRoleBadges.tsx +++ b/src/components/UserRoleBadges.tsx @@ -8,27 +8,32 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; const MAX_ROLE_BADGES = 3; export default function UserRoleBadges({ - roleLabels + roleLabels, + isOwner }: { roleLabels: string[]; + isOwner?: boolean; }) { + const t = useTranslations(); const visible = roleLabels.slice(0, MAX_ROLE_BADGES); const overflow = roleLabels.slice(MAX_ROLE_BADGES); return (
+ {isOwner && ( + {t("accessRoleOwner")} + )} {visible.map((label, i) => ( {label} ))} - {overflow.length > 0 && ( - - )} + {overflow.length > 0 && }
); } diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 4c092ac8e..8d7309c1a 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -22,6 +22,7 @@ import { ArrowRight, ArrowUp10Icon, ChevronsUpDownIcon, + Crown, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -33,12 +34,20 @@ import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "./ui/tooltip"; import { ControlledDataTable, type ExtendedColumnDef } from "./ui/controlled-data-table"; import UserRoleBadges from "./UserRoleBadges"; +const OWNER_FILTER_VALUE = "owner"; + export type UserRow = { id: string; email: string | null; @@ -95,7 +104,13 @@ export default function UsersTable({ const roleIdsFromSearchParams = useMemo(() => { const sp = new URLSearchParams(searchParams); return [ - ...new Set(sp.getAll("role_id").filter((id) => /^\d+$/.test(id))) + ...new Set( + sp + .getAll("role_id") + .filter( + (id) => /^\d+$/.test(id) || id === OWNER_FILTER_VALUE + ) + ) ]; }, [searchParams.toString()]); @@ -126,7 +141,7 @@ export default function UsersTable({ sp.delete("role_id"); sp.delete("page"); for (const id of values) { - if (/^\d+$/.test(id)) { + if (/^\d+$/.test(id) || id === OWNER_FILTER_VALUE) { sp.append("role_id", id); } } @@ -183,6 +198,18 @@ export default function UsersTable({ you )} + {row.original.isOwner && ( + + + + + + + {t("accessRoleOwner")} + + + + )}
) }, @@ -235,7 +262,12 @@ export default function UsersTable({ ); }, cell: ({ row }) => { - return ; + return ( + + ); } }, {