move improvements to user management ui

This commit is contained in:
miloschwartz
2026-09-11 11:58:51 -04:00
parent 33c1eb1426
commit fe8aae7839
13 changed files with 466 additions and 565 deletions
+3 -2
View File
@@ -226,9 +226,9 @@
"never": "Never", "never": "Never",
"shareErrorSelectResource": "Please select a resource", "shareErrorSelectResource": "Please select a resource",
"proxyResourceTitle": "Manage Public Resources", "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", "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", "clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access", "privateResourcesBannerTitle": "Zero-Trust Private Access",
@@ -718,6 +718,7 @@
"nameOptional": "Name (Optional)", "nameOptional": "Name (Optional)",
"accessControls": "Access Controls", "accessControls": "Access Controls",
"userDescription2": "Manage the settings on this user", "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", "accessRoleErrorAdd": "Failed to add user to role",
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
"userSaved": "User saved", "userSaved": "User saved",
+5
View File
@@ -34,6 +34,11 @@ const nextConfig: NextConfig = {
source: "/:orgId/settings/resources/client/:path*", source: "/:orgId/settings/resources/client/:path*",
destination: "/:orgId/settings/resources/private/:path*", destination: "/:orgId/settings/resources/private/:path*",
permanent: true permanent: true
},
{
source: "/:orgId/settings/access/users/:userId/access-controls",
destination: "/:orgId/settings/access/users/:userId/general",
permanent: false
} }
]; ];
} }
+33 -6
View File
@@ -95,22 +95,34 @@ const listUsersSchema = z.strictObject({
'Filter by identity provider id, or "internal" for internal users' 'Filter by identity provider id, or "internal" for internal users'
}), }),
role_id: z role_id: z
.preprocess((val) => { .preprocess(
(val) => {
if (val === undefined || val === null || val === "") { if (val === undefined || val === null || val === "") {
return undefined; return undefined;
} }
const raw = Array.isArray(val) ? val : [val]; const raw = Array.isArray(val) ? val : [val];
const includeOwner = raw.some((v) => v === "owner");
const nums = raw const nums = raw
.map((v) => .map((v) =>
typeof v === "string" ? parseInt(v, 10) : Number(v) typeof v === "string" ? parseInt(v, 10) : Number(v)
) )
.filter((n) => Number.isInteger(n) && n > 0); .filter((n) => Number.isInteger(n) && n > 0);
const unique = [...new Set(nums)]; const unique = [...new Set(nums)];
return unique.length ? unique : undefined; if (!unique.length && !includeOwner) {
}, z.array(z.number().int().positive()).optional()) return undefined;
}
return { roleIds: unique, includeOwner };
},
z
.object({
roleIds: z.array(z.number().int().positive()),
includeOwner: z.boolean()
})
.optional()
)
.openapi({ .openapi({
description: 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 } = const { page, pageSize, sort_by, order, query, idp_id, role_id } =
parsedQuery.data; parsedQuery.data;
const roleIds = role_id ?? []; const roleIds = role_id?.roleIds ?? [];
const includeOwner = role_id?.includeOwner ?? false;
const parsedParams = listUsersParamsSchema.safeParse(req.params); const parsedParams = listUsersParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -267,8 +280,15 @@ export async function listUsers(
conditions.push(eq(users.idpId, idp_id)); conditions.push(eq(users.idpId, idp_id));
} }
if (roleIds.length > 0 || includeOwner) {
const roleFilterParts = [];
if (includeOwner) {
roleFilterParts.push(eq(userOrgs.isOwner, true));
}
if (roleIds.length > 0) { if (roleIds.length > 0) {
conditions.push( roleFilterParts.push(
exists( exists(
db db
.select() .select()
@@ -284,6 +304,13 @@ export async function listUsers(
); );
} }
if (roleFilterParts.length === 1) {
conditions.push(roleFilterParts[0]);
} else if (roleFilterParts.length > 1) {
conditions.push(or(...roleFilterParts));
}
}
const countQuery = db.$count( const countQuery = db.$count(
queryUsersBase() queryUsersBase()
.where(and(...conditions)) .where(and(...conditions))
@@ -38,7 +38,7 @@ 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";
export default function AccessControlsPage() { export default function GeneralPage() {
const { orgUser: user, updateOrgUser } = userOrgUserContext(); const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { user: sessionUser } = useUserContext(); const { user: sessionUser } = useUserContext();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -57,7 +57,7 @@ export default function AccessControlsPage() {
(build === "enterprise" && !isPaid) || (build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid)); (build === "oss" && !isPaid));
const accessControlsFormSchema = z.object({ const generalFormSchema = z.object({
username: z.string(), username: z.string(),
autoProvisioned: z.boolean(), autoProvisioned: z.boolean(),
roles: z roles: z
@@ -72,7 +72,7 @@ export default function AccessControlsPage() {
}); });
const form = useForm({ const form = useForm({
resolver: zodResolver(accessControlsFormSchema), resolver: zodResolver(generalFormSchema),
defaultValues: { defaultValues: {
username: user.username!, username: user.username!,
autoProvisioned: user.autoProvisioned || false, 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(); e.preventDefault();
const isValid = await form.trigger(); const isValid = await form.trigger();
@@ -196,11 +196,9 @@ export default function AccessControlsPage() {
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>{t("general")}</SettingsSectionTitle>
{t("accessControls")}
</SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("accessControlsDescription")} {t("userGeneralSettingsDescription")}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
@@ -208,11 +206,9 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => onSubmit={(e) => void handleGeneralSubmit(e)}
void handleAccessControlsSubmit(e)
}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="user-general-form"
> >
{user.type !== UserType.Internal && {user.type !== UserType.Internal &&
user.idpType && ( user.idpType && (
@@ -281,9 +277,9 @@ export default function AccessControlsPage() {
type="submit" type="submit"
loading={isSaving} loading={isSaving}
disabled={isSaving} disabled={isSaving}
form="access-controls-form" form="user-general-form"
> >
{t("accessControlsSubmit")} {t("saveSettings")}
</Button> </Button>
</SettingsSectionFooter> </SettingsSectionFooter>
</SettingsSection> </SettingsSection>
@@ -9,15 +9,16 @@ import { cache } from "react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "User" title: "User"
}; };
interface UserLayoutProps { type UserLayoutProps = {
children: React.ReactNode; children: React.ReactNode;
params: Promise<{ userId: string; orgId: string }>; params: Promise<{ userId: string; orgId: string }>;
} };
export default async function UserLayoutProps(props: UserLayoutProps) { export default async function UserLayoutProps(props: UserLayoutProps) {
const params = await props.params; const params = await props.params;
@@ -42,15 +43,23 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
const navItems = [ const navItems = [
{ {
title: t("accessControls"), title: t("general"),
href: "/{orgId}/settings/access/users/{userId}/access-controls" href: "/{orgId}/settings/access/users/{userId}/general"
} }
]; ];
return ( return (
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title={`${user?.email}`} title={
user
? getUserDisplayName({
email: user.email,
name: user.name,
username: user.username
})
: ""
}
description={t("userDescription2")} description={t("userDescription2")}
/> />
<OrgUserProvider orgUser={user}> <OrgUserProvider orgUser={user}>
@@ -9,5 +9,5 @@ export default async function UserPage(props: {
params: Promise<{ orgId: string; userId: string }>; params: Promise<{ orgId: string; userId: string }>;
}) { }) {
const { orgId, userId } = await props.params; const { orgId, userId } = await props.params;
redirect(`/${orgId}/settings/access/users/${userId}/access-controls`); redirect(`/${orgId}/settings/access/users/${userId}/general`);
} }
@@ -1,6 +1,6 @@
"use client"; "use client";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyTextBox from "@app/components/CopyTextBox";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
@@ -894,12 +894,7 @@ export default function Page() {
days: expiresInDays days: expiresInDays
})} })}
</p> </p>
{inviteLink && ( {inviteLink && <CopyTextBox text={inviteLink} />}
<CopyToClipboard
text={inviteLink}
isLink={true}
/>
)}
</div> </div>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
@@ -78,12 +78,13 @@ export default async function UsersPage(props: UsersPageProps) {
rolesRes && rolesRes.status === 200 rolesRes && rolesRes.status === 200
? (rolesRes.data.data.roles ?? []) ? (rolesRes.data.data.roles ?? [])
: []; : [];
const roleFilterOptions = orgRoles.map( const roleFilterOptions = [
(r: ListRolesResponse["roles"][number]) => ({ { value: "owner", label: t("accessRoleOwner") },
...orgRoles.map((r: ListRolesResponse["roles"][number]) => ({
value: String(r.roleId), value: String(r.roleId),
label: r.name label: r.name
}) }))
); ];
const invitationsRes = await internal const invitationsRes = await internal
.get( .get(
@@ -126,9 +127,7 @@ export default async function UsersPage(props: UsersPageProps) {
idpId: user.idpId, idpId: user.idpId,
idpName: user.idpName || t("idpNameInternal"), idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"), status: t("userConfirmed"),
roleLabels: user.isOwner roleLabels: (() => {
? [t("accessRoleOwner")]
: (() => {
const names = (user.roles ?? []) const names = (user.roles ?? [])
.map((r) => r.roleName) .map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length)); .filter((n): n is string => Boolean(n?.length));
+74 -161
View File
@@ -43,10 +43,18 @@ import {
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import moment from "moment"; import moment from "moment";
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
export default function Page() { export default function Page() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -58,6 +66,7 @@ export default function Page() {
const [loadingPage, setLoadingPage] = useState(true); const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null); const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState< const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
@@ -75,22 +84,6 @@ export default function Page() {
type CreateFormValues = z.infer<typeof createFormSchema>; type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({ const form = useForm({
resolver: zodResolver(createFormSchema), resolver: zodResolver(createFormSchema),
defaultValues: { defaultValues: {
@@ -98,12 +91,9 @@ export default function Page() {
} }
}); });
const copiedForm = useForm({ function goToApiKeysList() {
resolver: zodResolver(copiedFormSchema), router.push(`/${orgId}/settings/api-keys`);
defaultValues: {
copied: true
} }
});
async function onSubmit(data: CreateFormValues) { async function onSubmit(data: CreateFormValues) {
setCreateLoading(true); setCreateLoading(true);
@@ -113,9 +103,10 @@ export default function Page() {
}; };
const res = await api const res = await api
.put< .put<AxiosResponse<CreateOrgApiKeyResponse>>(
AxiosResponse<CreateOrgApiKeyResponse> `/org/${orgId}/api-key/`,
>(`/org/${orgId}/api-key/`, payload) payload
)
.catch((e) => { .catch((e) => {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -125,16 +116,10 @@ export default function Page() {
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
const data = res.data.data; const created = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const actionsRes = await api 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( actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key] (key) => selectedPermissions[key]
) )
@@ -149,27 +134,14 @@ export default function Page() {
}); });
if (actionsRes) { if (actionsRes) {
setApiKey(data); setApiKey(created);
setIsApiKeyDialogOpen(true);
} }
} }
setCreateLoading(false); 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(() => { useEffect(() => {
const load = async () => { const load = async () => {
setLoadingPage(false); setLoadingPage(false);
@@ -185,12 +157,7 @@ export default function Page() {
title={t("apiKeysCreate")} title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")} description={t("apiKeysCreateDescription")}
/> />
<Button <Button variant="outline" onClick={goToApiKeysList}>
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t("apiKeysSeeAll")} {t("apiKeysSeeAll")}
</Button> </Button>
</div> </div>
@@ -198,8 +165,6 @@ export default function Page() {
{!loadingPage && ( {!loadingPage && (
<div> <div>
<SettingsContainer> <SettingsContainer>
{!apiKey && (
<>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -212,7 +177,7 @@ export default function Page() {
<form <form
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh e.preventDefault();
} }
}} }}
className="space-y-4" className="space-y-4"
@@ -248,31 +213,60 @@ export default function Page() {
{t("apiKeysGeneralSettings")} {t("apiKeysGeneralSettings")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t( {t("apiKeysGeneralSettingsDescription")}
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<PermissionsSelectBox <PermissionsSelectBox
selectedPermissions={ selectedPermissions={selectedPermissions}
selectedPermissions
}
onChange={setSelectedPermissions} onChange={setSelectedPermissions}
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
</> </SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
</div>
</div>
)} )}
<Credenza
open={isApiKeyDialogOpen}
onOpenChange={(open) => {
setIsApiKeyDialogOpen(open);
if (!open && apiKey) {
goToApiKeysList();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
<CredenzaDescription>
{t("apiKeysSaveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{apiKey && ( {apiKey && (
<SettingsSection> <div className="space-y-4">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}> <InfoSections cols={2}>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
@@ -289,9 +283,9 @@ export default function Page() {
{t("created")} {t("created")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{moment( {moment(apiKey.createdAt).format(
apiKey.createdAt "lll"
).format("lll")} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>
@@ -306,98 +300,17 @@ export default function Page() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox <CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`} text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/> />
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
</div>
</div> </div>
)} )}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</> </>
); );
} }
+70 -151
View File
@@ -46,6 +46,15 @@ import moment from "moment";
import CopyTextBox from "@app/components/CopyTextBox"; import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
export default function Page() { export default function Page() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -56,6 +65,7 @@ export default function Page() {
const [loadingPage, setLoadingPage] = useState(true); const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null); const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
const [selectedPermissions, setSelectedPermissions] = useState< const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
@@ -73,22 +83,6 @@ export default function Page() {
type CreateFormValues = z.infer<typeof createFormSchema>; type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: t("apiKeysConfirmCopy2"),
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
const form = useForm({ const form = useForm({
resolver: zodResolver(createFormSchema), resolver: zodResolver(createFormSchema),
defaultValues: { defaultValues: {
@@ -96,12 +90,9 @@ export default function Page() {
} }
}); });
const copiedForm = useForm({ function goToApiKeysList() {
resolver: zodResolver(copiedFormSchema), router.push(`/admin/api-keys`);
defaultValues: {
copied: true
} }
});
async function onSubmit(data: CreateFormValues) { async function onSubmit(data: CreateFormValues) {
setCreateLoading(true); setCreateLoading(true);
@@ -121,16 +112,10 @@ export default function Page() {
}); });
if (res && res.status === 201) { if (res && res.status === 201) {
const data = res.data.data; const created = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const actionsRes = await api const actionsRes = await api
.post(`/api-key/${data.apiKeyId}/actions`, { .post(`/api-key/${created.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter( actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key] (key) => selectedPermissions[key]
) )
@@ -145,21 +130,14 @@ export default function Page() {
}); });
if (actionsRes) { if (actionsRes) {
setApiKey(data); setApiKey(created);
setIsApiKeyDialogOpen(true);
} }
} }
setCreateLoading(false); setCreateLoading(false);
} }
async function onCopiedSubmit(data: CopiedFormValues) {
if (!data.copied) {
return;
}
router.push(`/admin/api-keys`);
}
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
setLoadingPage(false); setLoadingPage(false);
@@ -175,12 +153,7 @@ export default function Page() {
title={t("apiKeysCreate")} title={t("apiKeysCreate")}
description={t("apiKeysCreateDescription")} description={t("apiKeysCreateDescription")}
/> />
<Button <Button variant="outline" onClick={goToApiKeysList}>
variant="outline"
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
{t("apiKeysSeeAll")} {t("apiKeysSeeAll")}
</Button> </Button>
</div> </div>
@@ -188,8 +161,6 @@ export default function Page() {
{!loadingPage && ( {!loadingPage && (
<div> <div>
<SettingsContainer> <SettingsContainer>
{!apiKey && (
<>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -202,7 +173,7 @@ export default function Page() {
<form <form
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); // block default enter refresh e.preventDefault();
} }
}} }}
className="space-y-4" className="space-y-4"
@@ -238,32 +209,61 @@ export default function Page() {
{t("apiKeysGeneralSettings")} {t("apiKeysGeneralSettings")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t( {t("apiKeysGeneralSettingsDescription")}
"apiKeysGeneralSettingsDescription"
)}
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<PermissionsSelectBox <PermissionsSelectBox
root={true} root={true}
selectedPermissions={ selectedPermissions={selectedPermissions}
selectedPermissions
}
onChange={setSelectedPermissions} onChange={setSelectedPermissions}
/> />
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
</> </SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={goToApiKeysList}
>
{t("cancel")}
</Button>
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
</div>
</div>
)} )}
<Credenza
open={isApiKeyDialogOpen}
onOpenChange={(open) => {
setIsApiKeyDialogOpen(open);
if (!open && apiKey) {
goToApiKeysList();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
<CredenzaDescription>
{t("apiKeysSaveDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{apiKey && ( {apiKey && (
<SettingsSection> <div className="space-y-4">
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("apiKeysList")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}> <InfoSections cols={2}>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
@@ -280,9 +280,9 @@ export default function Page() {
{t("created")} {t("created")}
</InfoSectionTitle> </InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{moment( {moment(apiKey.createdAt).format(
apiKey.createdAt "lll"
).format("lll")} )}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>
@@ -297,98 +297,17 @@ export default function Page() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* <h4 className="font-semibold"> */}
{/* {t('apiKeysInfo')} */}
{/* </h4> */}
<CopyTextBox <CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`} text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/> />
{/* <Form {...copiedForm}> */}
{/* <form */}
{/* className="space-y-4" */}
{/* id="copied-form" */}
{/* > */}
{/* <FormField */}
{/* control={copiedForm.control} */}
{/* name="copied" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <div className="flex items-center space-x-2"> */}
{/* <Checkbox */}
{/* id="terms" */}
{/* defaultChecked={ */}
{/* copiedForm.getValues( */}
{/* "copied" */}
{/* ) as boolean */}
{/* } */}
{/* onCheckedChange={( */}
{/* e */}
{/* ) => { */}
{/* copiedForm.setValue( */}
{/* "copied", */}
{/* e as boolean */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* <label */}
{/* htmlFor="terms" */}
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
{/* > */}
{/* {t('apiKeysConfirmCopy')} */}
{/* </label> */}
{/* </div> */}
{/* <FormMessage /> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
{/* </form> */}
{/* </Form> */}
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
{t("cancel")}
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
{t("generate")}
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
{t("done")}
</Button>
)}
</div>
</div> </div>
)} )}
</CredenzaBody>
<CredenzaFooter>
<Button onClick={goToApiKeysList}>{t("done")}</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</> </>
); );
} }
+1 -1
View File
@@ -227,7 +227,7 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
<div className="inline-flex items-start md:items-center gap-2"> <div className="inline-flex items-start md:items-center gap-2">
<span> <span>
<Link <Link
href={`/${orgId}/settings/access/users/${approval.user.userId}/access-controls`} href={`/${orgId}/settings/access/users/${approval.user.userId}/general`}
className="text-primary hover:underline cursor-pointer" className="text-primary hover:underline cursor-pointer"
> >
{getUserDisplayName({ {getUserDisplayName({
+9 -4
View File
@@ -8,27 +8,32 @@ import {
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { useTranslations } from "next-intl";
const MAX_ROLE_BADGES = 3; const MAX_ROLE_BADGES = 3;
export default function UserRoleBadges({ export default function UserRoleBadges({
roleLabels roleLabels,
isOwner
}: { }: {
roleLabels: string[]; roleLabels: string[];
isOwner?: boolean;
}) { }) {
const t = useTranslations();
const visible = roleLabels.slice(0, MAX_ROLE_BADGES); const visible = roleLabels.slice(0, MAX_ROLE_BADGES);
const overflow = roleLabels.slice(MAX_ROLE_BADGES); const overflow = roleLabels.slice(MAX_ROLE_BADGES);
return ( return (
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{isOwner && (
<Badge variant="secondary">{t("accessRoleOwner")}</Badge>
)}
{visible.map((label, i) => ( {visible.map((label, i) => (
<Badge key={`${label}-${i}`} variant="secondary"> <Badge key={`${label}-${i}`} variant="secondary">
{label} {label}
</Badge> </Badge>
))} ))}
{overflow.length > 0 && ( {overflow.length > 0 && <OverflowRolesPopover labels={overflow} />}
<OverflowRolesPopover labels={overflow} />
)}
</div> </div>
); );
} }
+35 -3
View File
@@ -22,6 +22,7 @@ import {
ArrowRight, ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
Crown,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -33,12 +34,20 @@ import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton"; import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton";
import IdpTypeBadge from "./IdpTypeBadge"; import IdpTypeBadge from "./IdpTypeBadge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import { import {
ControlledDataTable, ControlledDataTable,
type ExtendedColumnDef type ExtendedColumnDef
} from "./ui/controlled-data-table"; } from "./ui/controlled-data-table";
import UserRoleBadges from "./UserRoleBadges"; import UserRoleBadges from "./UserRoleBadges";
const OWNER_FILTER_VALUE = "owner";
export type UserRow = { export type UserRow = {
id: string; id: string;
email: string | null; email: string | null;
@@ -95,7 +104,13 @@ export default function UsersTable({
const roleIdsFromSearchParams = useMemo(() => { const roleIdsFromSearchParams = useMemo(() => {
const sp = new URLSearchParams(searchParams); const sp = new URLSearchParams(searchParams);
return [ 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()]); }, [searchParams.toString()]);
@@ -126,7 +141,7 @@ export default function UsersTable({
sp.delete("role_id"); sp.delete("role_id");
sp.delete("page"); sp.delete("page");
for (const id of values) { for (const id of values) {
if (/^\d+$/.test(id)) { if (/^\d+$/.test(id) || id === OWNER_FILTER_VALUE) {
sp.append("role_id", id); sp.append("role_id", id);
} }
} }
@@ -183,6 +198,18 @@ export default function UsersTable({
<span className="text-primary">you</span> <span className="text-primary">you</span>
</> </>
)} )}
{row.original.isOwner && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Crown className="text-primary size-4 flex-none" />
</TooltipTrigger>
<TooltipContent>
{t("accessRoleOwner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span> </span>
) )
}, },
@@ -235,7 +262,12 @@ export default function UsersTable({
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
return <UserRoleBadges roleLabels={row.original.roleLabels} />; return (
<UserRoleBadges
roleLabels={row.original.roleLabels}
isOwner={row.original.isOwner}
/>
);
} }
}, },
{ {