mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-10 14:28:35 +00:00
feat: Add callback and edit dynamic secret for Azure Entra ID
This commit is contained in:
@@ -11,130 +11,132 @@ const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
|
|||||||
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
|
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
|
||||||
|
|
||||||
const generatePassword = () => {
|
const generatePassword = () => {
|
||||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||||
return customAlphabet(charset, 64)();
|
return customAlphabet(charset, 64)();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AzureEntraIDProvider = (): TDynamicProviderFns => {
|
export const AzureEntraIDProvider = (): TDynamicProviderFns => {
|
||||||
const validateProviderInputs = async (inputs: unknown) => {
|
const validateProviderInputs = async (inputs: unknown) => {
|
||||||
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
|
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
|
||||||
return providerInputs;
|
return providerInputs;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getToken = async (tenantId: string): Promise<{ token?: string; success: boolean }> => {
|
const getToken = async (tenantId: string): Promise<{ token?: string; success: boolean }> => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
const response = await axios.post<{ access_token: string }>(
|
const response = await axios.post<{ access_token: string }>(
|
||||||
`${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`,
|
`${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`,
|
||||||
{
|
{
|
||||||
grant_type: "client_credentials",
|
grant_type: "client_credentials",
|
||||||
client_id: appCfg.MSFT_ENTRA_ID_APPLICATION_ID,
|
client_id: appCfg.MSFT_ENTRA_ID_APPLICATION_ID,
|
||||||
client_secret: appCfg.MSFT_ENTRA_ID_CLIENT_SECRET,
|
client_secret: appCfg.MSFT_ENTRA_ID_CLIENT_SECRET,
|
||||||
scope: "https://graph.microsoft.com/.default"
|
scope: "https://graph.microsoft.com/.default"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded"
|
"Content-Type": "application/x-www-form-urlencoded"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
return { token: response.data.access_token, success: true };
|
|
||||||
}
|
|
||||||
return { success: false };
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateConnection = async (inputs: unknown) => {
|
|
||||||
const providerInputs = await validateProviderInputs(inputs);
|
|
||||||
const data = await getToken(providerInputs.tenantId);
|
|
||||||
return data.success;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renew = async (inputs: unknown, entityId: string) => {
|
|
||||||
// Do nothing
|
|
||||||
return { entityId };
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = async (inputs: unknown) => {
|
|
||||||
const providerInputs = await validateProviderInputs(inputs);
|
|
||||||
const data = await getToken(providerInputs.tenantId);
|
|
||||||
if (!data.success) {
|
|
||||||
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = generatePassword();
|
|
||||||
try {
|
|
||||||
const response = await axios.patch(
|
|
||||||
`${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`,
|
|
||||||
{
|
|
||||||
passwordProfile: {
|
|
||||||
forceChangePasswordNextSignIn: false,
|
|
||||||
password
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${data.token}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (response.status !== 204) {
|
|
||||||
throw new BadRequestError({ message: "Failed to update password" });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.info(JSON.stringify(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { entityId: providerInputs.userId, data: { userId: providerInputs.userId, password } };
|
|
||||||
};
|
|
||||||
|
|
||||||
const revoke = async (inputs: unknown, entityId: string) => {
|
|
||||||
await create(inputs);
|
|
||||||
return { entityId };
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchData = async (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => {
|
|
||||||
const providerInputs = await validateProviderInputs(inputs);
|
|
||||||
|
|
||||||
const data = await getToken(providerInputs.tenantId);
|
|
||||||
if (!data.success) {
|
|
||||||
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (toFetch) {
|
|
||||||
case DynamicSecretDataFetchTypes.Users: {
|
|
||||||
const response = await axios.get<{ value: [{ displayName: string; id: string }] }>(
|
|
||||||
`${MSFT_GRAPH_API_URL}/users`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Authorization: `Bearer ${data.token}`
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
);
|
);
|
||||||
const users = response.data.value.map((user: { displayName: string; id: string }) => {
|
|
||||||
return {
|
if (response.status === 200) {
|
||||||
name: user.displayName,
|
return { token: response.data.access_token, success: true };
|
||||||
id: user.id
|
}
|
||||||
};
|
return { success: false };
|
||||||
});
|
};
|
||||||
return {
|
|
||||||
data: {
|
const validateConnection = async (inputs: unknown) => {
|
||||||
users
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
}
|
const data = await getToken(providerInputs.tenantId);
|
||||||
};
|
return data.success;
|
||||||
}
|
};
|
||||||
default:
|
|
||||||
throw new BadRequestError({ message: "Unknown data to fetch" });
|
const renew = async (inputs: unknown, entityId: string) => {
|
||||||
}
|
// Do nothing
|
||||||
};
|
return { entityId };
|
||||||
return {
|
};
|
||||||
validateProviderInputs,
|
|
||||||
validateConnection,
|
const create = async (inputs: unknown) => {
|
||||||
create,
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
revoke,
|
const data = await getToken(providerInputs.tenantId);
|
||||||
renew,
|
if (!data.success) {
|
||||||
fetchData
|
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const password = generatePassword();
|
||||||
|
|
||||||
|
const response = await axios.patch(
|
||||||
|
`${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`,
|
||||||
|
{
|
||||||
|
passwordProfile: {
|
||||||
|
forceChangePasswordNextSignIn: false,
|
||||||
|
password
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${data.token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.status !== 204) {
|
||||||
|
throw new BadRequestError({ message: "Failed to update password" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entityId: providerInputs.userId, data: { email: providerInputs.email, password } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (inputs: unknown, entityId: string) => {
|
||||||
|
// Creates a new password
|
||||||
|
await create(inputs);
|
||||||
|
return { entityId };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs);
|
||||||
|
|
||||||
|
const data = await getToken(providerInputs.tenantId);
|
||||||
|
if (!data.success) {
|
||||||
|
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (toFetch) {
|
||||||
|
case DynamicSecretDataFetchTypes.Users: {
|
||||||
|
const response = await axios.get<{ value: [{ displayName: string; id: string; userPrincipalName: string }] }>(
|
||||||
|
`${MSFT_GRAPH_API_URL}/users`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization: `Bearer ${data.token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const users = response.data.value.map(
|
||||||
|
(user: { displayName: string; id: string; userPrincipalName: string }) => {
|
||||||
|
return {
|
||||||
|
name: user.displayName,
|
||||||
|
id: user.id,
|
||||||
|
email: user.userPrincipalName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
users
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new BadRequestError({ message: "Unknown data to fetch" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
validateProviderInputs,
|
||||||
|
validateConnection,
|
||||||
|
create,
|
||||||
|
revoke,
|
||||||
|
renew,
|
||||||
|
fetchData
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ export const DynamicSecretMongoDBSchema = z.object({
|
|||||||
|
|
||||||
export const AzureEntraIDSchema = z.object({
|
export const AzureEntraIDSchema = z.object({
|
||||||
tenantId: z.string().trim().min(1),
|
tenantId: z.string().trim().min(1),
|
||||||
userId: z.string().trim().min(1)
|
userId: z.string().trim().min(1),
|
||||||
|
email: z.string().trim().min(1)
|
||||||
});
|
});
|
||||||
|
|
||||||
export enum DynamicSecretProviders {
|
export enum DynamicSecretProviders {
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { faAngleDown, faCheck, faPlus } from "@fortawesome/free-solid-svg-icons"
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { Listbox, Transition } from "@headlessui/react";
|
import { Listbox, Transition } from "@headlessui/react";
|
||||||
|
|
||||||
interface ListBoxProps {
|
interface TextProps {
|
||||||
isSelected: {name:string}[];
|
primaryText: string;
|
||||||
onChange: (arg: unknown) => void;
|
secondaryText: string;
|
||||||
data: {name:string}[] | null;
|
}
|
||||||
|
|
||||||
|
interface ListBoxProps<T extends TextProps> {
|
||||||
|
isSelected: T[];
|
||||||
|
onChange: (value: T[]) => void;
|
||||||
|
data: T[] | null;
|
||||||
text?: string;
|
text?: string;
|
||||||
buttonAction?: () => void;
|
buttonAction?: () => void;
|
||||||
isFull?: boolean;
|
isFull?: boolean;
|
||||||
@@ -22,27 +27,26 @@ interface ListBoxProps {
|
|||||||
* @param {function} obj.buttonAction - if there is a button at the bottom of the list, this is the action that happens when you click the button
|
* @param {function} obj.buttonAction - if there is a button at the bottom of the list, this is the action that happens when you click the button
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
const ListBoxMultiple = ({
|
const ListBoxMultiple = <T extends TextProps>({
|
||||||
isSelected,
|
isSelected,
|
||||||
onChange,
|
onChange,
|
||||||
data,
|
data,
|
||||||
text,
|
text,
|
||||||
buttonAction,
|
buttonAction,
|
||||||
isFull
|
isFull
|
||||||
}: ListBoxProps): JSX.Element => {
|
}: ListBoxProps<T>): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<Listbox value={isSelected} onChange={onChange} multiple>
|
<Listbox value={isSelected} onChange={onChange} multiple>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Listbox.Button
|
<Listbox.Button
|
||||||
className={`relative text-gray-400 ${
|
className={`relative text-gray-400 ${isFull ? "w-full" : "w-52"
|
||||||
isFull ? "w-full" : "w-52"
|
} focus-visible:ring-offset-orange-300 cursor-default rounded-md bg-white/[0.07] py-2.5 pl-3 pr-10 text-left shadow-md duration-200 hover:bg-white/[0.11] focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm`}
|
||||||
} focus-visible:ring-offset-orange-300 cursor-default rounded-md bg-white/[0.07] py-2.5 pl-3 pr-10 text-left shadow-md duration-200 hover:bg-white/[0.11] focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm`}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
{text}
|
{text}
|
||||||
<span className="ml-1 block cursor-pointer truncate font-semibold text-gray-300">
|
<span className="ml-1 block cursor-pointer truncate font-semibold text-gray-300">
|
||||||
{!isSelected || isSelected.length === 0 && "None"}
|
{!isSelected || isSelected.length === 0 && "Select"}
|
||||||
{isSelected && isSelected.length > 0 && isSelected[0].name} {isSelected.length>1 && `(+${isSelected.length-1})`}
|
{isSelected && isSelected.length > 0 && isSelected[0].primaryText} {isSelected.length > 1 && `(+${isSelected.length - 1})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{data && (
|
{data && (
|
||||||
@@ -59,28 +63,25 @@ const ListBoxMultiple = ({
|
|||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<Listbox.Options className="no-scrollbar::-webkit-scrollbar absolute z-[70] mt-1 max-h-60 w-full overflow-auto rounded-md border border-mineshaft-700 bg-bunker p-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 no-scrollbar focus:outline-none sm:text-sm">
|
<Listbox.Options className="no-scrollbar::-webkit-scrollbar absolute z-[70] mt-1 max-h-60 w-full overflow-auto rounded-md border border-mineshaft-700 bg-bunker p-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 no-scrollbar focus:outline-none sm:text-sm">
|
||||||
{data.map((person, personIdx) => (
|
{data.map((user, personIdx) => (
|
||||||
<Listbox.Option
|
<Listbox.Option
|
||||||
key={`${person}.${personIdx + 1}`}
|
key={`${user}.${personIdx + 1}`}
|
||||||
className={({ active, selected }) =>
|
className={({ active, selected }) =>
|
||||||
`relative my-0.5 cursor-default select-none rounded-md py-2 pl-10 pr-4 ${
|
`relative my-0.5 cursor-default select-none rounded-md py-2 pl-10 pr-4 ${selected ? "bg-white/10 font-bold text-gray-400" : ""
|
||||||
selected ? "bg-white/10 font-bold text-gray-400" : ""
|
} ${active && !selected
|
||||||
} ${
|
? "cursor-pointer bg-white/5 text-mineshaft-200"
|
||||||
active && !selected
|
: "text-gray-400"
|
||||||
? "cursor-pointer bg-white/5 text-mineshaft-200"
|
|
||||||
: "text-gray-400"
|
|
||||||
} `
|
} `
|
||||||
}
|
}
|
||||||
value={person}
|
value={user}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className={`block truncate text-primary${
|
className={`block truncate text-primary${selected ? "font-medium" : "font-normal"
|
||||||
selected ? "font-medium" : "font-normal"
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{person.name}
|
{user.primaryText} {user.secondaryText && ` (${user.secondaryText})`}
|
||||||
</span>
|
</span>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center rounded-lg pl-3 text-primary">
|
<span className="absolute inset-y-0 left-0 flex items-center rounded-lg pl-3 text-primary">
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export const dynamicSecretKeys = {
|
|||||||
[{ projectSlug, path, environmentSlug, name }, "dynamic-secret-details"] as const
|
[{ projectSlug, path, environmentSlug, name }, "dynamic-secret-details"] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EntraIDUser = {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const useGetDynamicSecrets = ({
|
export const useGetDynamicSecrets = ({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
environmentSlug,
|
environmentSlug,
|
||||||
@@ -74,15 +80,17 @@ export const useGetDynamicSecretDetails = ({
|
|||||||
|
|
||||||
export const useGetDynamicSecretProviderData = ({
|
export const useGetDynamicSecretProviderData = ({
|
||||||
provider,
|
provider,
|
||||||
dataFetchType
|
dataFetchType,
|
||||||
|
enabled
|
||||||
}: {
|
}: {
|
||||||
provider: TDynamicSecretProvider,
|
provider: TDynamicSecretProvider,
|
||||||
dataFetchType: "Users"
|
dataFetchType: "Users",
|
||||||
|
enabled: boolean
|
||||||
}) => {
|
}) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiRequest.post<{ data: { users: [{ name: string, id: string }] } }>(
|
const { data } = await apiRequest.post<{ data: { users: [EntraIDUser] } }>(
|
||||||
"/api/v1/dynamic-secrets/fetch-provider-data",
|
"/api/v1/dynamic-secrets/fetch-provider-data",
|
||||||
{
|
{
|
||||||
provider,
|
provider,
|
||||||
@@ -90,7 +98,8 @@ export const useGetDynamicSecretProviderData = ({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
return data.data.users;
|
return data.data.users;
|
||||||
}
|
},
|
||||||
|
enabled
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export type TDynamicSecretProvider =
|
|||||||
inputs: {
|
inputs: {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
email: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
import { AzureEntraIdCallbackPage } from "@app/views/callback/AzureEntraIdCallbackPage";
|
||||||
|
|
||||||
|
const AzureEntraId = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Infisical</title>
|
||||||
|
<link rel="icon" href="/infisical.ico" />
|
||||||
|
<meta property="og:image" content="/images/message.png" />
|
||||||
|
<meta property="og:title" content="" />
|
||||||
|
<meta name="og:description" content="" />
|
||||||
|
</Head>
|
||||||
|
<AzureEntraIdCallbackPage />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AzureEntraId;
|
||||||
|
|
||||||
|
AzureEntraId.requireAuth = true;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
|
||||||
|
import { AzureEntraIdCallbackPage } from "@app/views/callback/AzureEntraIdCallbackPage";
|
||||||
|
|
||||||
|
const AzureEntraId = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Infisical</title>
|
||||||
|
<link rel="icon" href="/infisical.ico" />
|
||||||
|
<meta property="og:image" content="/images/message.png" />
|
||||||
|
<meta property="og:title" content="" />
|
||||||
|
<meta name="og:description" content="" />
|
||||||
|
</Head>
|
||||||
|
<AzureEntraIdCallbackPage />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AzureEntraId;
|
||||||
|
|
||||||
|
AzureEntraId.requireAuth = true;
|
||||||
+75
-12
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import ms from "ms";
|
import ms from "ms";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -11,12 +13,16 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
Input,
|
Input,
|
||||||
|
Spinner,
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { useCreateDynamicSecret } from "@app/hooks/api";
|
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||||
import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries";
|
import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries";
|
||||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
|
provider: z.object({
|
||||||
|
tenantId: z.string().min(1),
|
||||||
|
}),
|
||||||
defaultTTL: z.string().superRefine((val, ctx) => {
|
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||||
const valMs = ms(val);
|
const valMs = ms(val);
|
||||||
if (valMs < 60 * 1000)
|
if (valMs < 60 * 1000)
|
||||||
@@ -49,6 +55,12 @@ type Props = {
|
|||||||
environment: string;
|
environment: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EntraIDUser = {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const AzureEntraIdInputForm = ({
|
export const AzureEntraIdInputForm = ({
|
||||||
onCompleted,
|
onCompleted,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -59,24 +71,39 @@ export const AzureEntraIdInputForm = ({
|
|||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
|
watch,
|
||||||
handleSubmit
|
handleSubmit
|
||||||
} = useForm<TForm>({
|
} = useForm<TForm>({
|
||||||
resolver: zodResolver(formSchema)
|
resolver: zodResolver(formSchema)
|
||||||
});
|
});
|
||||||
|
const tenantId = watch("provider.tenantId");
|
||||||
const [selectedUsers, setSelectedUsers] = useState([]);
|
const [selectedUsers, setSelectedUsers] = useState<EntraIDUser[]>([]);
|
||||||
const users = useGetDynamicSecretProviderData({ provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { tenantId: "<tenant id from callback>", userId: "test" } }, dataFetchType: "Users" }).data
|
const { data, isLoading, isFetched, isError, isFetching } = useGetDynamicSecretProviderData({ dataFetchType: "Users", provider: {type: DynamicSecretProviders.AzureEntraId, inputs: { userId : "unused", email: "unused", tenantId }}, enabled: !!tenantId });
|
||||||
|
const mappedUserList = data?.map((user) => ({
|
||||||
|
primaryText: user.name,
|
||||||
|
secondaryText: user.email,
|
||||||
|
id: user.id,
|
||||||
|
}));
|
||||||
|
const handleChange = (selected: { primaryText: string; secondaryText: string; id: string }[]) => {
|
||||||
|
// Map back to EntraIDUser based on the id or other attributes
|
||||||
|
const selectedMappedUsers = data?.filter(user =>
|
||||||
|
selected.some(selectedItem => selectedItem.id === user.id)
|
||||||
|
);
|
||||||
|
if(selectedMappedUsers){
|
||||||
|
setSelectedUsers(selectedMappedUsers);
|
||||||
|
}
|
||||||
|
};
|
||||||
const createDynamicSecret = useCreateDynamicSecret();
|
const createDynamicSecret = useCreateDynamicSecret();
|
||||||
|
|
||||||
const handleCreateDynamicSecret = async ({ name, maxTTL, defaultTTL }: TForm) => {
|
const handleCreateDynamicSecret = async ({ name, provider, maxTTL, defaultTTL }: TForm) => {
|
||||||
// wait till previous request is finished
|
// wait till previous request is finished
|
||||||
if (createDynamicSecret.isLoading) return;
|
if (createDynamicSecret.isLoading) return;
|
||||||
try {
|
try {
|
||||||
selectedUsers.map(async (user: {id: string, name: string})=>{
|
selectedUsers.map(async (user: { id: string, name: string, email: string }) => {
|
||||||
await createDynamicSecret.mutateAsync({
|
await createDynamicSecret.mutateAsync({
|
||||||
provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: "<tenant id from callback>" } },
|
provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: user.id, tenantId: provider.tenantId, email: user.email } },
|
||||||
maxTTL,
|
maxTTL,
|
||||||
name: `${name }-${ user.name}`,
|
name: `${name}-${user.name}`,
|
||||||
path: secretPath,
|
path: secretPath,
|
||||||
defaultTTL,
|
defaultTTL,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
@@ -146,25 +173,61 @@ export const AzureEntraIdInputForm = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||||
|
Configuration
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="provider.tenantId"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Tenant Id"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="Tenant Id from Azure Entra ID App installation" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||||
Select Users
|
Select Users
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{users &&
|
{
|
||||||
|
tenantId && !isError && !isFetching && isFetched && data && mappedUserList &&
|
||||||
<ListBoxMultiple
|
<ListBoxMultiple
|
||||||
isSelected={selectedUsers}
|
isSelected={mappedUserList.filter(user =>
|
||||||
data={users}
|
selectedUsers.some(selectedUser => selectedUser.id === user.id)
|
||||||
onChange={setSelectedUsers}
|
)}
|
||||||
|
onChange={handleChange}
|
||||||
|
data={mappedUserList}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
tenantId && isFetching && (<><Spinner size="xs" /><p> Loading</p></>)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
tenantId && !isFetching && isError && (<><FontAwesomeIcon icon={faWarning} /> <p> Error loading users please ensure Entra Id app is installed and tenant ID is correct</p></>)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!tenantId && (<><FontAwesomeIcon icon={faWarning} /><p> Enter tenant ID to fetch users</p></>)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center space-x-4">
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
<Button type="submit" isLoading={isSubmitting}>
|
<Button type="submit" isLoading={isSubmitting} isDisabled={isLoading || isError}>
|
||||||
Submit
|
Submit
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline_bg" onClick={onCancel}>
|
<Button variant="outline_bg" onClick={onCancel}>
|
||||||
|
|||||||
+11
-3
@@ -16,6 +16,9 @@ export const AzureEntraIdSetup = ({
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="mb-4 mt-4 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||||
|
Azure Entra ID Integration Guide
|
||||||
|
</div>
|
||||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||||
App Installation
|
App Installation
|
||||||
</div>
|
</div>
|
||||||
@@ -26,6 +29,8 @@ export const AzureEntraIdSetup = ({
|
|||||||
Step 2: Choose an account with admin access to Entra Id.
|
Step 2: Choose an account with admin access to Entra Id.
|
||||||
<br />
|
<br />
|
||||||
Step 3: Allow Infisical persmissions to read and write all users full profiles.
|
Step 3: Allow Infisical persmissions to read and write all users full profiles.
|
||||||
|
<br />
|
||||||
|
Step 4: Copy Tenant ID after installation and paste it in the next step.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,10 +52,13 @@ export const AzureEntraIdSetup = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex items-center space-x-4">
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<a href={`https://login.microsoftonline.com/common/adminconsent?client_id=9805c35f-88d4-4625-9daf-66f741e4129c&redirect_uri=${window.location.origin}/integrations/azure-entra-id/callback`} target="_blank" rel="noreferrer">
|
||||||
|
<Button type="submit">
|
||||||
|
Install App
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
<Button type="submit" onClick={onCompleted}>
|
<Button type="submit" onClick={onCompleted}>
|
||||||
{/* <Link target="_blank" href="https://login.microsoftonline.com/common/adminconsent?client_id=9805c35f-88d4-4625-9daf-66f741e4129c&redirect_uri=https://meet-dev.in.ngrok.io" content="Link" > */}
|
Next
|
||||||
Install App
|
|
||||||
{/* </Link> */}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline_bg" onClick={onCancel}>
|
<Button variant="outline_bg" onClick={onCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
+3
-3
@@ -177,14 +177,14 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (provider === DynamicSecretProviders.AzureEntraId) {
|
if (provider === DynamicSecretProviders.AzureEntraId) {
|
||||||
const { userId, password } = data as {
|
const { email, password } = data as {
|
||||||
userId: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<OutputDisplay label="UserId" value={userId} />
|
<OutputDisplay label="Email" value={email} />
|
||||||
<OutputDisplay
|
<OutputDisplay
|
||||||
label="Password"
|
label="Password"
|
||||||
value={password}
|
value={password}
|
||||||
|
|||||||
+168
@@ -0,0 +1,168 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import ms from "ms";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TtlFormLabel } from "@app/components/features";
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useUpdateDynamicSecret } from "@app/hooks/api";
|
||||||
|
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
defaultTTL: z.string().superRefine((val, ctx) => {
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
// a day
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
maxTTL: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.superRefine((val, ctx) => {
|
||||||
|
if (!val) return;
|
||||||
|
const valMs = ms(val);
|
||||||
|
if (valMs < 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||||
|
// a day
|
||||||
|
if (valMs > 24 * 60 * 60 * 1000)
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||||
|
}),
|
||||||
|
newName: z
|
||||||
|
.string()
|
||||||
|
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
type TForm = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void;
|
||||||
|
dynamicSecret: TDynamicSecret & { inputs: unknown };
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditDynamicSecretAzureEntraIdForm = ({
|
||||||
|
onClose,
|
||||||
|
dynamicSecret,
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
projectSlug
|
||||||
|
}: Props) => {
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
formState: { isSubmitting },
|
||||||
|
handleSubmit
|
||||||
|
} = useForm<TForm>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
values: {
|
||||||
|
defaultTTL: dynamicSecret.defaultTTL,
|
||||||
|
maxTTL: dynamicSecret.maxTTL,
|
||||||
|
newName: dynamicSecret.name,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDynamicSecret = useUpdateDynamicSecret();
|
||||||
|
|
||||||
|
const handleUpdateDynamicSecret = async ({ maxTTL, defaultTTL, newName }: TForm) => {
|
||||||
|
// wait till previous request is finished
|
||||||
|
if (updateDynamicSecret.isLoading) return;
|
||||||
|
try {
|
||||||
|
await updateDynamicSecret.mutateAsync({
|
||||||
|
name: dynamicSecret.name,
|
||||||
|
path: secretPath,
|
||||||
|
projectSlug,
|
||||||
|
environmentSlug: environment,
|
||||||
|
data: {
|
||||||
|
maxTTL: maxTTL || undefined,
|
||||||
|
defaultTTL,
|
||||||
|
newName: newName === dynamicSecret.name ? undefined : newName
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
createNotification({
|
||||||
|
type: "success",
|
||||||
|
text: "Successfully updated dynamic secret"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: "Failed to update dynamic secret"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="newName"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Secret Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="dynamic-secret" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="defaultTTL"
|
||||||
|
defaultValue="1h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Default TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="maxTTL"
|
||||||
|
defaultValue="24h"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label={<TtlFormLabel label="Max TTL" />}
|
||||||
|
isError={Boolean(error?.message)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center space-x-4">
|
||||||
|
<Button type="submit" isLoading={isSubmitting}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline_bg" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+19
@@ -6,6 +6,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
|||||||
|
|
||||||
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
|
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
|
||||||
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
|
||||||
|
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
|
||||||
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
|
||||||
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
|
||||||
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
|
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
|
||||||
@@ -202,6 +203,24 @@ export const EditDynamicSecretForm = ({
|
|||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{dynamicSecretDetails?.type === DynamicSecretProviders.AzureEntraId && (
|
||||||
|
<motion.div
|
||||||
|
key="azure-entra-id-edit"
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
initial={{ opacity: 0, translateX: 30 }}
|
||||||
|
animate={{ opacity: 1, translateX: 0 }}
|
||||||
|
exit={{ opacity: 0, translateX: -30 }}
|
||||||
|
>
|
||||||
|
<EditDynamicSecretAzureEntraIdForm
|
||||||
|
onClose={onClose}
|
||||||
|
projectSlug={projectSlug}
|
||||||
|
secretPath={secretPath}
|
||||||
|
dynamicSecret={dynamicSecretDetails}
|
||||||
|
environment={environment}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { IconButton } from "@app/components/v2";
|
||||||
|
|
||||||
|
export const AzureEntraIdCallbackPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col justify-between overflow-auto bg-gradient-to-tr from-mineshaft-700 to-bunker-800 text-gray-200 dark:[color-scheme:dark]">
|
||||||
|
<div />
|
||||||
|
<div className="mx-auto w-full max-w-xl px-4 py-4 md:px-0">
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<div className="mb-4 flex justify-center pt-8">
|
||||||
|
<Link href="https://infisical.com">
|
||||||
|
<Image
|
||||||
|
src="/images/gradientLogo.svg"
|
||||||
|
height={90}
|
||||||
|
width={120}
|
||||||
|
alt="Infisical logo"
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h1 className="bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-4xl font-medium text-transparent">
|
||||||
|
Installed Entra Id App successfully
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div className="m-auto my-8 flex w-full">
|
||||||
|
<div className="w-full border-t border-mineshaft-600" />
|
||||||
|
</div>
|
||||||
|
<div className="m-auto flex w-full flex-col rounded-md border border-primary-500/30 bg-primary/5 p-6 pt-5">
|
||||||
|
<div className="flex flex-col items-start sm:flex-row sm:items-center">
|
||||||
|
<p className="md:text-md text-md mr-4">
|
||||||
|
<p
|
||||||
|
className="text-bold bg-gradient-to-tr from-yellow-500 to-primary-500 bg-clip-text text-transparent"
|
||||||
|
>
|
||||||
|
Tenant ID
|
||||||
|
</p>{" "}
|
||||||
|
<br />
|
||||||
|
<div className="mr-2 flex items-center justify-end rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
||||||
|
<p className="mr-4 break-all">{router.query.tenant}</p>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative ml-2"
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof router.query.tenant === "string") {
|
||||||
|
navigator.clipboard.writeText(router.query.tenant);
|
||||||
|
createNotification({
|
||||||
|
title: "Copied Tenant ID to clipboard succesfully",
|
||||||
|
type: "success",
|
||||||
|
text: ""
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-mineshaft-600 p-2" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user