mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-18 23:05:21 +00:00
♻️ replace roles & user selectors in machines & create user
This commit is contained in:
@@ -1,44 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
||||||
|
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionForm,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel
|
||||||
FormMessage
|
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Checkbox } from "@app/components/ui/checkbox";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
||||||
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AxiosResponse } from "axios";
|
import { build } from "@server/build";
|
||||||
import { useEffect, useState } from "react";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
|
||||||
import { userOrgUserContext } from "@app/hooks/useOrgUserContext";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionFooter
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
|
||||||
import { createApiClient } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import IdpTypeBadge from "@app/components/IdpTypeBadge";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
|
|
||||||
const accessControlsFormSchema = z.object({
|
const accessControlsFormSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
@@ -59,12 +55,6 @@ export default function AccessControlsPage() {
|
|||||||
|
|
||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
|
||||||
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const isPaid = isPaidUser(tierMatrix.fullRbac);
|
const isPaid = isPaidUser(tierMatrix.fullRbac);
|
||||||
@@ -97,44 +87,21 @@ export default function AccessControlsPage() {
|
|||||||
text: r.name
|
text: r.name
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}, [user.userId, currentRoleIds.join(",")]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchRoles() {
|
|
||||||
const res = await api
|
|
||||||
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
|
|
||||||
.catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("accessRoleErrorFetch"),
|
|
||||||
description: formatAxiosError(
|
|
||||||
e,
|
|
||||||
t("accessRoleErrorFetchDescription")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res?.status === 200) {
|
|
||||||
setRoles(res.data.data.roles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchRoles();
|
|
||||||
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
form.setValue("autoProvisioned", user.autoProvisioned || false);
|
||||||
}, []);
|
}, [user.userId, user.autoProvisioned, currentRoleIds.join(",")]);
|
||||||
|
|
||||||
const allRoleOptions = roles.map((role) => ({
|
|
||||||
id: role.roleId.toString(),
|
|
||||||
text: role.name
|
|
||||||
}));
|
|
||||||
|
|
||||||
const paywallMessage =
|
const paywallMessage =
|
||||||
build === "saas"
|
build === "saas"
|
||||||
? t("singleRolePerUserPlanNotice")
|
? t("singleRolePerUserPlanNotice")
|
||||||
: t("singleRolePerUserEditionNotice");
|
: t("singleRolePerUserEditionNotice");
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
|
const [, action, isSubmitting] = useActionState(onSubmit, null);
|
||||||
|
async function onSubmit() {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = form.getValues();
|
||||||
|
|
||||||
if (values.roles.length === 0) {
|
if (values.roles.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -144,7 +111,6 @@ export default function AccessControlsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(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
|
||||||
@@ -184,7 +150,6 @@ export default function AccessControlsPage() {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,7 +168,7 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
action={action}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="access-controls-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
@@ -226,9 +191,7 @@ export default function AccessControlsPage() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={form}
|
form={form}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t("accessRoleSelect2")}
|
|
||||||
allRoleOptions={allRoleOptions}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -236,9 +199,6 @@ export default function AccessControlsPage() {
|
|||||||
showMultiRolePaywallMessage
|
showMultiRolePaywallMessage
|
||||||
}
|
}
|
||||||
paywallMessage={paywallMessage}
|
paywallMessage={paywallMessage}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={activeRoleTagIndex}
|
|
||||||
setActiveTagIndex={setActiveRoleTagIndex}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{user.idpAutoProvision && (
|
{user.idpAutoProvision && (
|
||||||
@@ -277,8 +237,8 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionFooter>
|
<SettingsSectionFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={loading}
|
loading={isSubmitting}
|
||||||
disabled={loading}
|
disabled={isSubmitting}
|
||||||
form="access-controls-form"
|
form="access-controls-form"
|
||||||
>
|
>
|
||||||
{t("accessControlsSubmit")}
|
{t("accessControlsSubmit")}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
|
|||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -91,7 +91,7 @@ export default function Page() {
|
|||||||
"internal"
|
"internal"
|
||||||
);
|
);
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||||
@@ -311,10 +311,29 @@ export default function Page() {
|
|||||||
setUserOptions(options);
|
setUserOptions(options);
|
||||||
}, [idps, t]);
|
}, [idps, t]);
|
||||||
|
|
||||||
async function onSubmitInternal(
|
const [, submitInternalAction, isSubmittingInternal] = useActionState(
|
||||||
values: z.infer<typeof internalFormSchema>
|
onSubmitInternal,
|
||||||
) {
|
null
|
||||||
setLoading(true);
|
);
|
||||||
|
const [, submitGoogleAzureAction, isSubmittingGoogleAzure] = useActionState(
|
||||||
|
onSubmitGoogleAzure,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [, submitGenericOidcAction, isSubmittingGenericOidc] = useActionState(
|
||||||
|
onSubmitGenericOidc,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
isSubmittingInternal ||
|
||||||
|
isSubmittingGoogleAzure ||
|
||||||
|
isSubmittingGenericOidc;
|
||||||
|
|
||||||
|
async function onSubmitInternal() {
|
||||||
|
const isValid = await internalForm.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = internalForm.getValues();
|
||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
@@ -357,25 +376,24 @@ export default function Page() {
|
|||||||
|
|
||||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmitGoogleAzure(
|
async function onSubmitGoogleAzure() {
|
||||||
values: z.infer<typeof googleAzureFormSchema>
|
const isValid = await googleAzureForm.trigger();
|
||||||
) {
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = googleAzureForm.getValues();
|
||||||
|
|
||||||
const selectedUserOption = userOptions.find(
|
const selectedUserOption = userOptions.find(
|
||||||
(opt) => opt.id === selectedOption
|
(opt) => opt.id === selectedOption
|
||||||
);
|
);
|
||||||
if (!selectedUserOption?.idpId) return;
|
if (!selectedUserOption?.idpId) return;
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.put(`/org/${orgId}/user`, {
|
.put(`/org/${orgId}/user`, {
|
||||||
username: values.email, // Use email as username for Google/Azure
|
username: values.email,
|
||||||
email: values.email || undefined,
|
email: values.email || undefined,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
type: "oidc",
|
type: "oidc",
|
||||||
@@ -401,20 +419,19 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
router.push(`/${orgId}/settings/access/users`);
|
router.push(`/${orgId}/settings/access/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmitGenericOidc(
|
async function onSubmitGenericOidc() {
|
||||||
values: z.infer<typeof genericOidcFormSchema>
|
const isValid = await genericOidcForm.trigger();
|
||||||
) {
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const values = genericOidcForm.getValues();
|
||||||
|
|
||||||
const selectedUserOption = userOptions.find(
|
const selectedUserOption = userOptions.find(
|
||||||
(opt) => opt.id === selectedOption
|
(opt) => opt.id === selectedOption
|
||||||
);
|
);
|
||||||
if (!selectedUserOption?.idpId) return;
|
if (!selectedUserOption?.idpId) return;
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
const roleIds = values.roles.map((r) => parseInt(r.id, 10));
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
@@ -445,8 +462,6 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
router.push(`/${orgId}/settings/access/users`);
|
router.push(`/${orgId}/settings/access/users`);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -513,9 +528,9 @@ export default function Page() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...internalForm}>
|
<Form {...internalForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={internalForm.handleSubmit(
|
action={
|
||||||
onSubmitInternal
|
submitInternalAction
|
||||||
)}
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-user-form"
|
id="create-user-form"
|
||||||
>
|
>
|
||||||
@@ -595,13 +610,7 @@ export default function Page() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={internalForm}
|
form={internalForm}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t(
|
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
allRoleOptions={
|
|
||||||
allRoleOptions
|
|
||||||
}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -611,13 +620,6 @@ export default function Page() {
|
|||||||
paywallMessage={
|
paywallMessage={
|
||||||
invitePaywallMessage
|
invitePaywallMessage
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={
|
|
||||||
activeInviteRoleTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveInviteRoleTagIndex
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{env.email.emailEnabled && (
|
{env.email.emailEnabled && (
|
||||||
@@ -712,9 +714,9 @@ export default function Page() {
|
|||||||
})() && (
|
})() && (
|
||||||
<Form {...googleAzureForm}>
|
<Form {...googleAzureForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={googleAzureForm.handleSubmit(
|
action={
|
||||||
onSubmitGoogleAzure
|
submitGoogleAzureAction
|
||||||
)}
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-user-form"
|
id="create-user-form"
|
||||||
>
|
>
|
||||||
@@ -763,13 +765,7 @@ export default function Page() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={googleAzureForm}
|
form={googleAzureForm}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t(
|
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
allRoleOptions={
|
|
||||||
allRoleOptions
|
|
||||||
}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -779,13 +775,6 @@ export default function Page() {
|
|||||||
paywallMessage={
|
paywallMessage={
|
||||||
invitePaywallMessage
|
invitePaywallMessage
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={
|
|
||||||
activeOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -808,9 +797,9 @@ export default function Page() {
|
|||||||
})() && (
|
})() && (
|
||||||
<Form {...genericOidcForm}>
|
<Form {...genericOidcForm}>
|
||||||
<form
|
<form
|
||||||
onSubmit={genericOidcForm.handleSubmit(
|
action={
|
||||||
onSubmitGenericOidc
|
submitGenericOidcAction
|
||||||
)}
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="create-user-form"
|
id="create-user-form"
|
||||||
>
|
>
|
||||||
@@ -888,13 +877,7 @@ export default function Page() {
|
|||||||
<OrgRolesTagField
|
<OrgRolesTagField
|
||||||
form={genericOidcForm}
|
form={genericOidcForm}
|
||||||
name="roles"
|
name="roles"
|
||||||
label={t("roles")}
|
orgId={orgId as string}
|
||||||
placeholder={t(
|
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
allRoleOptions={
|
|
||||||
allRoleOptions
|
|
||||||
}
|
|
||||||
supportsMultipleRolesPerUser={
|
supportsMultipleRolesPerUser={
|
||||||
supportsMultipleRolesPerUser
|
supportsMultipleRolesPerUser
|
||||||
}
|
}
|
||||||
@@ -904,13 +887,6 @@ export default function Page() {
|
|||||||
paywallMessage={
|
paywallMessage={
|
||||||
invitePaywallMessage
|
invitePaywallMessage
|
||||||
}
|
}
|
||||||
loading={loading}
|
|
||||||
activeTagIndex={
|
|
||||||
activeOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveOidcRoleTagIndex
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
return (
|
return (
|
||||||
<CredenzaContent
|
<CredenzaContent
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 max-h-[100dvh] flex-col overflow-hidden md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
"flex min-h-0 max-h-[100dvh] flex-col overflow-y-auto md:top-[clamp(1.5rem,12vh,200px)] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:translate-y-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import DomainPicker from "@app/components/DomainPicker";
|
|||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import CertificateStatus from "@app/components/CertificateStatus";
|
import CertificateStatus from "@app/components/CertificateStatus";
|
||||||
import { UsersSelector } from "./users-selector";
|
import { UsersSelector } from "./users-selector";
|
||||||
|
import { RolesSelector } from "./roles-selector";
|
||||||
|
|
||||||
// --- Helpers (shared) ---
|
// --- Helpers (shared) ---
|
||||||
|
|
||||||
@@ -1477,40 +1478,22 @@ export function InternalResourceForm({
|
|||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("roles")}</FormLabel>
|
<FormLabel>{t("roles")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<RolesSelector
|
||||||
{...field}
|
selectedRoles={
|
||||||
activeTagIndex={
|
field.value ?? []
|
||||||
activeRolesTagIndex
|
|
||||||
}
|
}
|
||||||
setActiveTagIndex={
|
orgId={orgId}
|
||||||
setActiveRolesTagIndex
|
onSelectRoles={(
|
||||||
}
|
newUsers
|
||||||
placeholder={t(
|
) => {
|
||||||
"accessRoleSelect2"
|
|
||||||
)}
|
|
||||||
size="sm"
|
|
||||||
tags={
|
|
||||||
form.getValues()
|
|
||||||
.roles ?? []
|
|
||||||
}
|
|
||||||
setTags={(newRoles) =>
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"roles",
|
"roles",
|
||||||
newRoles as [
|
newUsers as [
|
||||||
Tag,
|
Tag,
|
||||||
...Tag[]
|
...Tag[]
|
||||||
]
|
]
|
||||||
)
|
);
|
||||||
}
|
}}
|
||||||
enableAutocomplete
|
|
||||||
autocompleteOptions={
|
|
||||||
allRoles
|
|
||||||
}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -1523,45 +1506,7 @@ export function InternalResourceForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{t("users")}</FormLabel>
|
<FormLabel>{t("users")}</FormLabel>
|
||||||
{/* <FormControl>
|
|
||||||
<TagInput
|
|
||||||
{...field}
|
|
||||||
activeTagIndex={
|
|
||||||
activeUsersTagIndex
|
|
||||||
}
|
|
||||||
setActiveTagIndex={
|
|
||||||
setActiveUsersTagIndex
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"accessUserSelect"
|
|
||||||
)}
|
|
||||||
tags={
|
|
||||||
form.getValues()
|
|
||||||
.users ?? []
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
setTags={(newUsers) =>
|
|
||||||
form.setValue(
|
|
||||||
"users",
|
|
||||||
newUsers as [
|
|
||||||
Tag,
|
|
||||||
...Tag[]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={
|
|
||||||
allUsers
|
|
||||||
}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={
|
|
||||||
true
|
|
||||||
}
|
|
||||||
sortTags={true}
|
|
||||||
/>
|
|
||||||
</FormControl> */}
|
|
||||||
<UsersSelector
|
<UsersSelector
|
||||||
{...field}
|
|
||||||
selectedUsers={
|
selectedUsers={
|
||||||
field.value ?? []
|
field.value ?? []
|
||||||
}
|
}
|
||||||
@@ -1590,7 +1535,6 @@ export function InternalResourceForm({
|
|||||||
{t("machineClients")}
|
{t("machineClients")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<MachinesSelector
|
<MachinesSelector
|
||||||
{...field}
|
|
||||||
selectedMachines={
|
selectedMachines={
|
||||||
field.value ?? []
|
field.value ?? []
|
||||||
}
|
}
|
||||||
@@ -1604,73 +1548,6 @@ export function InternalResourceForm({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* <Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"justify-between w-full",
|
|
||||||
"text-muted-foreground pl-1.5 cursor-text"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1",
|
|
||||||
"overflow-x-auto"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{(
|
|
||||||
field.value ??
|
|
||||||
[]
|
|
||||||
).map(
|
|
||||||
(
|
|
||||||
client
|
|
||||||
) => (
|
|
||||||
<span
|
|
||||||
key={
|
|
||||||
client.clientId
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"bg-muted-foreground/20 font-normal text-foreground rounded-sm",
|
|
||||||
"py-1 px-1.5 text-xs"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
client.name
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<span className="pl-1 font-normal">
|
|
||||||
{t(
|
|
||||||
"accessClientSelect"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<ChevronDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0">
|
|
||||||
<MachinesSelector
|
|
||||||
selectedMachines={
|
|
||||||
field.value ??
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
orgId={orgId}
|
|
||||||
onSelectMachines={(
|
|
||||||
machines
|
|
||||||
) => {
|
|
||||||
form.setValue(
|
|
||||||
"clients",
|
|
||||||
machines
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover> */}
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,51 +8,42 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
|
||||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
|
||||||
|
|
||||||
export type RoleTag = {
|
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||||
id: string;
|
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
type OrgRolesTagFieldProps<TFieldValues extends FieldValues> = {
|
||||||
form: Pick<UseFormReturn<TFieldValues>, "control" | "getValues" | "setValue">;
|
form: Pick<
|
||||||
|
UseFormReturn<TFieldValues>,
|
||||||
|
"control" | "getValues" | "setValue"
|
||||||
|
>;
|
||||||
|
orgId: string;
|
||||||
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
/** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */
|
||||||
name?: Path<TFieldValues>;
|
name?: Path<TFieldValues>;
|
||||||
label: string;
|
label?: string;
|
||||||
placeholder: string;
|
|
||||||
allRoleOptions: Tag[];
|
|
||||||
supportsMultipleRolesPerUser: boolean;
|
supportsMultipleRolesPerUser: boolean;
|
||||||
showMultiRolePaywallMessage: boolean;
|
showMultiRolePaywallMessage: boolean;
|
||||||
paywallMessage: string;
|
paywallMessage: string;
|
||||||
loading?: boolean;
|
disabled?: boolean;
|
||||||
activeTagIndex: number | null;
|
|
||||||
setActiveTagIndex: Dispatch<SetStateAction<number | null>>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
||||||
form,
|
form,
|
||||||
name = "roles" as Path<TFieldValues>,
|
name = "roles" as Path<TFieldValues>,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
orgId,
|
||||||
allRoleOptions,
|
|
||||||
supportsMultipleRolesPerUser,
|
supportsMultipleRolesPerUser,
|
||||||
showMultiRolePaywallMessage,
|
showMultiRolePaywallMessage,
|
||||||
paywallMessage,
|
paywallMessage,
|
||||||
loading = false,
|
disabled
|
||||||
activeTagIndex,
|
|
||||||
setActiveTagIndex
|
|
||||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
|
function setRoleTags(nextValue: SelectedRole[]) {
|
||||||
const prev = form.getValues(name) as Tag[];
|
const prev = form.getValues(name) as SelectedRole[];
|
||||||
const nextValue =
|
|
||||||
typeof updater === "function" ? updater(prev) : updater;
|
|
||||||
const next = supportsMultipleRolesPerUser
|
const next = supportsMultipleRolesPerUser
|
||||||
? nextValue
|
? nextValue
|
||||||
: nextValue.length > 1
|
: nextValue.length > 1
|
||||||
@@ -88,22 +79,13 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{label}</FormLabel>
|
<FormLabel>{label ?? t("roles")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<TagInput
|
<RolesSelector
|
||||||
{...field}
|
orgId={orgId}
|
||||||
activeTagIndex={activeTagIndex}
|
selectedRoles={field.value ?? []}
|
||||||
setActiveTagIndex={setActiveTagIndex}
|
onSelectRoles={setRoleTags}
|
||||||
placeholder={placeholder}
|
disabled={disabled}
|
||||||
size="sm"
|
|
||||||
tags={field.value}
|
|
||||||
setTags={setRoleTags}
|
|
||||||
enableAutocomplete={true}
|
|
||||||
autocompleteOptions={allRoleOptions}
|
|
||||||
allowDuplicates={false}
|
|
||||||
restrictTagsToAutocompleteOptions={true}
|
|
||||||
sortTags={true}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{showMultiRolePaywallMessage && (
|
{showMultiRolePaywallMessage && (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type MultiSelectTagsProps<T extends TagValue> = {
|
|||||||
onChange: (newValue: Array<T>) => void;
|
onChange: (newValue: Array<T>) => void;
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
ref?: Ref<HTMLButtonElement>;
|
ref?: Ref<HTMLButtonElement>;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MultiSelectContent<T extends TagValue>({
|
export function MultiSelectContent<T extends TagValue>({
|
||||||
@@ -40,8 +41,7 @@ export function MultiSelectContent<T extends TagValue>({
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onValueChange={onSearch}
|
onValueChange={onSearch}
|
||||||
/>
|
/>
|
||||||
{/* FIXME: why isn't this list scrolling ????? */}
|
<CommandList>
|
||||||
<CommandList className="scroll-py-0 max-h-20">
|
|
||||||
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
|
<CommandEmpty>{emptyPlaceholder}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
|
import { buttonVariants } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { Button, buttonVariants } from "@app/components/ui/button";
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
type TagValue,
|
|
||||||
type MultiSelectTagsProps,
|
type MultiSelectTagsProps,
|
||||||
|
type TagValue,
|
||||||
MultiSelectContent
|
MultiSelectContent
|
||||||
} from "./multi-select-content";
|
} from "./multi-select-content";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export interface MultiSelectInputProps<
|
export interface MultiSelectInputProps<
|
||||||
T extends TagValue
|
T extends TagValue
|
||||||
@@ -36,7 +35,8 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
}),
|
}),
|
||||||
"justify-between w-full inline-flex",
|
"justify-between w-full inline-flex",
|
||||||
"text-muted-foreground pl-1.5 cursor-text",
|
"text-muted-foreground pl-1.5 cursor-text",
|
||||||
"hover:bg-transparent hover:text-muted-foreground"
|
"hover:bg-transparent hover:text-muted-foreground",
|
||||||
|
props.disabled && "pointer-events-none opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -57,6 +57,7 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
{option.text}
|
{option.text}
|
||||||
<button
|
<button
|
||||||
className="p-0.5 flex-none cursor-pointer"
|
className="p-0.5 flex-none cursor-pointer"
|
||||||
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
let newValues = [];
|
let newValues = [];
|
||||||
|
|||||||
@@ -1 +1,64 @@
|
|||||||
// TODO
|
import { orgQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useDebounce } from "use-debounce";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { MultiSelectTagInput } from "./multi-select/multi-select-tag-input";
|
||||||
|
|
||||||
|
export type SelectedRole = { id: string; text: string };
|
||||||
|
|
||||||
|
export type RolesSelectorProps = {
|
||||||
|
orgId: string;
|
||||||
|
selectedRoles?: SelectedRole[];
|
||||||
|
onSelectRoles: (roles: SelectedRole[]) => void;
|
||||||
|
disabled?: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RolesSelector({
|
||||||
|
orgId,
|
||||||
|
selectedRoles = [],
|
||||||
|
onSelectRoles,
|
||||||
|
disabled
|
||||||
|
}: RolesSelectorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [roleSearchQuery, setRoleSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const [debouncedValue] = useDebounce(roleSearchQuery, 150);
|
||||||
|
|
||||||
|
const perPage = 7;
|
||||||
|
|
||||||
|
const { data: roles = [] } = useQuery(
|
||||||
|
orgQueries.roles({ orgId, perPage, query: debouncedValue })
|
||||||
|
);
|
||||||
|
|
||||||
|
// always include the selected roles in the list (if the user isn't searching)
|
||||||
|
const rolesShown = useMemo(() => {
|
||||||
|
const allRoles: Array<SelectedRole> = roles.map((r) => ({
|
||||||
|
id: r.roleId.toString(),
|
||||||
|
text: r.name
|
||||||
|
}));
|
||||||
|
if (debouncedValue.trim().length === 0) {
|
||||||
|
for (const role of selectedRoles) {
|
||||||
|
if (!allRoles.find((r) => r.id === role.id)) {
|
||||||
|
allRoles.unshift(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allRoles;
|
||||||
|
}, [roles, selectedRoles, debouncedValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MultiSelectTagInput
|
||||||
|
buttonText={t("selectRole")}
|
||||||
|
searchPlaceholder={t("search")}
|
||||||
|
emptyPlaceholder={t("roles")}
|
||||||
|
searchQuery={roleSearchQuery}
|
||||||
|
onSearch={setRoleSearchQuery}
|
||||||
|
options={rolesShown}
|
||||||
|
value={selectedRoles}
|
||||||
|
onChange={onSelectRoles}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ function CommandGroup({
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
data-slot="command-group"
|
data-slot="command-group"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-y-auto p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ export function UsersSelector({
|
|||||||
|
|
||||||
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
const [debouncedValue] = useDebounce(userSearchQuery, 150);
|
||||||
|
|
||||||
// TODO: switch back to 7 items
|
const perPage = 7;
|
||||||
const perPage = 1;
|
|
||||||
|
|
||||||
const { data: users = [] } = useQuery(
|
const { data: users = [] } = useQuery(
|
||||||
orgQueries.users({ orgId, perPage, query: debouncedValue })
|
orgQueries.users({ orgId, perPage, query: debouncedValue })
|
||||||
|
|||||||
@@ -152,13 +152,29 @@ export const orgQueries = {
|
|||||||
return res.data.data.users;
|
return res.data.data.users;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
roles: ({ orgId }: { orgId: string }) =>
|
roles: ({
|
||||||
|
orgId,
|
||||||
|
query,
|
||||||
|
perPage = 10_000
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
query?: string;
|
||||||
|
perPage?: number;
|
||||||
|
}) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ["ORG", orgId, "ROLES"] as const,
|
queryKey: ["ORG", orgId, "ROLES", { query, perPage }] as const,
|
||||||
queryFn: async ({ signal, meta }) => {
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const sp = new URLSearchParams({
|
||||||
|
pageSize: perPage.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query?.trim()) {
|
||||||
|
sp.set("query", query);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await meta!.api.get<
|
const res = await meta!.api.get<
|
||||||
AxiosResponse<ListRolesResponse>
|
AxiosResponse<ListRolesResponse>
|
||||||
>(`/org/${orgId}/roles`, { signal });
|
>(`/org/${orgId}/roles?${sp.toString()}`, { signal });
|
||||||
|
|
||||||
return res.data.data.roles;
|
return res.data.data.roles;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user