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() {
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")}
/>
-