mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
chore: Refactor and improve UI
This commit is contained in:
@@ -3,10 +3,7 @@ import ms from "ms";
|
||||
import { z } from "zod";
|
||||
|
||||
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
|
||||
import {
|
||||
DynamicSecretDataFetchTypes,
|
||||
DynamicSecretProviderSchema
|
||||
} from "@app/ee/services/dynamic-secret/providers/models";
|
||||
import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models";
|
||||
import { DYNAMIC_SECRETS } from "@app/lib/api-docs";
|
||||
import { daysToMillisecond } from "@app/lib/dates";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
@@ -82,26 +79,33 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/fetch-provider-data",
|
||||
url: "/entra-id/users",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
dataFetchType: z.string().min(1).describe("Type of data to fetch"),
|
||||
provider: DynamicSecretProviderSchema.describe(DYNAMIC_SECRETS.CREATE.provider)
|
||||
tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"),
|
||||
applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"),
|
||||
clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
data: z.unknown()
|
||||
})
|
||||
200: z
|
||||
.object({
|
||||
name: z.string().min(1).describe("The name of the user"),
|
||||
id: z.string().min(1).describe("The ID of the user"),
|
||||
email: z.string().min(1).describe("The email of the user")
|
||||
})
|
||||
.array()
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const dataFetchType =
|
||||
DynamicSecretDataFetchTypes[req.body.dataFetchType as keyof typeof DynamicSecretDataFetchTypes];
|
||||
const data = await server.services.dynamicSecret.fetchData({ provider: req.body.provider, dataFetchType });
|
||||
const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({
|
||||
tenantId: req.body.tenantId,
|
||||
applicationId: req.body.applicationId,
|
||||
clientSecret: req.body.clientSecret
|
||||
});
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
TCreateDynamicSecretDTO,
|
||||
TDeleteDynamicSecretDTO,
|
||||
TDetailsDynamicSecretDTO,
|
||||
TDynamicSecretsFetchDataDTO,
|
||||
TListDynamicSecretsDTO,
|
||||
TUpdateDynamicSecretDTO
|
||||
} from "./dynamic-secret-types";
|
||||
import { AzureEntraIDProvider } from "./providers/azure-entra-id";
|
||||
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
|
||||
|
||||
type TDynamicSecretServiceFactoryDep = {
|
||||
@@ -333,12 +333,21 @@ export const dynamicSecretServiceFactory = ({
|
||||
return dynamicSecretCfg;
|
||||
};
|
||||
|
||||
const fetchData = async ({ provider, dataFetchType }: TDynamicSecretsFetchDataDTO) => {
|
||||
const selectedProvider = dynamicSecretProviders[provider.type];
|
||||
if (selectedProvider.fetchData) {
|
||||
const data = selectedProvider.fetchData(provider.inputs, dataFetchType);
|
||||
return data;
|
||||
}
|
||||
const fetchAzureEntraIdUsers = async ({
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret
|
||||
}: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
clientSecret: string;
|
||||
}) => {
|
||||
const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers(
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret
|
||||
);
|
||||
return azureEntraIdUsers;
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -347,6 +356,6 @@ export const dynamicSecretServiceFactory = ({
|
||||
deleteByName,
|
||||
getDetails,
|
||||
list,
|
||||
fetchData
|
||||
fetchAzureEntraIdUsers
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { customAlphabet } from "nanoid";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { AzureEntraIDSchema, DynamicSecretDataFetchTypes, TDynamicProviderFns } from "./models";
|
||||
import { AzureEntraIDSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
|
||||
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
|
||||
@@ -13,7 +13,11 @@ const generatePassword = () => {
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
export const AzureEntraIDProvider = (): TDynamicProviderFns => {
|
||||
type User = { name: string; id: string; email: string };
|
||||
|
||||
export const AzureEntraIDProvider = (): TDynamicProviderFns & {
|
||||
fetchAzureEntraIdUsers: (tenantId: string, applicationId: string, clientSecret: string) => Promise<User[]>;
|
||||
} => {
|
||||
const validateProviderInputs = async (inputs: unknown) => {
|
||||
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
|
||||
return providerInputs;
|
||||
@@ -93,51 +97,42 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns => {
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const fetchData = async (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
|
||||
const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret);
|
||||
const fetchAzureEntraIdUsers = async (tenantId: string, applicationId: string, clientSecret: string) => {
|
||||
const data = await getToken(tenantId, applicationId, clientSecret);
|
||||
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
|
||||
}
|
||||
};
|
||||
const response = await axios.get<{ value: [{ id: string; displayName: string; userPrincipalName: string }] }>(
|
||||
`${MSFT_GRAPH_API_URL}/users`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${data.token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
default:
|
||||
throw new BadRequestError({ message: "Unknown data to fetch" });
|
||||
if (response.status !== 200) {
|
||||
throw new BadRequestError({ message: "Failed to fetch users" });
|
||||
}
|
||||
|
||||
const users = response.data.value.map((user) => {
|
||||
return {
|
||||
name: user.displayName,
|
||||
id: user.id,
|
||||
email: user.userPrincipalName
|
||||
};
|
||||
});
|
||||
return users;
|
||||
};
|
||||
|
||||
return {
|
||||
validateProviderInputs,
|
||||
validateConnection,
|
||||
create,
|
||||
revoke,
|
||||
renew,
|
||||
fetchData
|
||||
fetchAzureEntraIdUsers
|
||||
};
|
||||
};
|
||||
|
||||
@@ -187,10 +187,6 @@ export enum DynamicSecretProviders {
|
||||
AzureEntraID = "azure-entra-id"
|
||||
}
|
||||
|
||||
export enum DynamicSecretDataFetchTypes {
|
||||
Users = "users"
|
||||
}
|
||||
|
||||
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }),
|
||||
@@ -210,5 +206,4 @@ export type TDynamicProviderFns = {
|
||||
validateProviderInputs: (inputs: object) => Promise<unknown>;
|
||||
revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>;
|
||||
renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>;
|
||||
fetchData?: (inputs: unknown, toFetch: DynamicSecretDataFetchTypes) => Promise<{ data: unknown }>;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { apiRequest } from "@app/config/request";
|
||||
import {
|
||||
TDetailsDynamicSecretDTO,
|
||||
TDynamicSecret,
|
||||
TDynamicSecretProvider,
|
||||
TGetDynamicSecretsByEnvsDTO,
|
||||
TListDynamicSecretDTO
|
||||
} from "./types";
|
||||
@@ -22,12 +21,6 @@ export const dynamicSecretKeys = {
|
||||
[{ projectSlug, path, environmentSlug, name }, "dynamic-secret-details"] as const
|
||||
};
|
||||
|
||||
type EntraIDUser = {
|
||||
name: string;
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const useGetDynamicSecrets = ({
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
@@ -79,25 +72,28 @@ export const useGetDynamicSecretDetails = ({
|
||||
};
|
||||
|
||||
export const useGetDynamicSecretProviderData = ({
|
||||
provider,
|
||||
dataFetchType,
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret,
|
||||
enabled
|
||||
}: {
|
||||
provider: TDynamicSecretProvider,
|
||||
dataFetchType: "Users",
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
clientSecret: string;
|
||||
enabled: boolean
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.post<{ data: { users: [EntraIDUser] } }>(
|
||||
"/api/v1/dynamic-secrets/fetch-provider-data",
|
||||
const { data } = await apiRequest.post<{id:string, email: string, name:string}[]>(
|
||||
"/api/v1/dynamic-secrets/entra-id/users",
|
||||
{
|
||||
provider,
|
||||
dataFetchType
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret
|
||||
}
|
||||
);
|
||||
return data.data.users;
|
||||
return data;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
|
||||
@@ -11,10 +11,10 @@ import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Spinner,
|
||||
Input
|
||||
} from "@app/components/v2";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/v2/Dropdown/Dropdown";
|
||||
import { Tooltip } from "@app/components/v2/Tooltip";
|
||||
import { useCreateDynamicSecret } from "@app/hooks/api";
|
||||
import { useGetDynamicSecretProviderData } from "@app/hooks/api/dynamicSecret/queries";
|
||||
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
|
||||
@@ -81,8 +81,10 @@ export const AzureEntraIdInputForm = ({
|
||||
const applicationId = watch("provider.applicationId");
|
||||
const clientSecret = watch("provider.clientSecret");
|
||||
|
||||
const configurationComplete = tenantId && applicationId && clientSecret;
|
||||
const { data, isLoading, isFetched, isError, isFetching } = useGetDynamicSecretProviderData({ dataFetchType: "Users", provider: { type: DynamicSecretProviders.AzureEntraId, inputs: { userId: "unused", email: "unused", tenantId, applicationId, clientSecret } }, enabled: !!configurationComplete });
|
||||
const configurationComplete = !!(tenantId && applicationId && clientSecret);
|
||||
const { data, isLoading, isError, isFetching } = useGetDynamicSecretProviderData({ tenantId, applicationId, clientSecret, enabled: !!configurationComplete });
|
||||
const loading = configurationComplete && isFetching;
|
||||
const errored = configurationComplete && !isFetching && isError;
|
||||
const createDynamicSecret = useCreateDynamicSecret();
|
||||
|
||||
const handleCreateDynamicSecret = async ({ name, selectedUsers, provider, maxTTL, defaultTTL }: TForm) => {
|
||||
@@ -165,7 +167,7 @@ export const AzureEntraIdInputForm = ({
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
|
||||
Configuration
|
||||
Configuration
|
||||
<Link href="https://infisical.com/docs/documentation/platform/dynamic-secrets/azure-entra-id" passHref>
|
||||
<a target="_blank" rel="noopener noreferrer">
|
||||
<div className="ml-2 mb-1 inline-block cursor-default rounded-md bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
@@ -247,64 +249,94 @@ export const AzureEntraIdInputForm = ({
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-4">
|
||||
{
|
||||
configurationComplete && !isError && !isFetching && isFetched && data &&
|
||||
<Controller
|
||||
control={control}
|
||||
name="selectedUsers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="w-72">
|
||||
<Input
|
||||
isReadOnly
|
||||
value={value?.length ? `${value.length} selected` : "None"}
|
||||
className="text-left"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start"
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
<Controller
|
||||
control={control}
|
||||
name="selectedUsers"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isRequired
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
<DropdownMenu >
|
||||
<DropdownMenuTrigger
|
||||
className="w-72"
|
||||
disabled={loading || errored || !configurationComplete}
|
||||
>
|
||||
<Tooltip
|
||||
hidden={!loading && !errored && configurationComplete}
|
||||
content=
|
||||
{
|
||||
<div>
|
||||
{(() => {
|
||||
let icon;
|
||||
if (errored) {
|
||||
icon = <FontAwesomeIcon icon={faWarning} color="red" />;
|
||||
} else if (loading || !configurationComplete) {
|
||||
icon = <FontAwesomeIcon icon={faWarning} color="yellow" />;
|
||||
} else {
|
||||
icon = null;
|
||||
}
|
||||
return icon;
|
||||
})()}
|
||||
<span className="ml-4 cursor-default text-mineshaft-300 hover:text-mineshaft-200">
|
||||
{(() => {
|
||||
let message;
|
||||
if (loading) {
|
||||
message = "Loading, please wait...";
|
||||
} else if (errored) {
|
||||
message = "Check the configuration";
|
||||
} else if (!configurationComplete) {
|
||||
message = "Configuration incomplete";
|
||||
} else {
|
||||
message = ""; // or you can leave it undefined
|
||||
}
|
||||
return message;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{data.map((user) => {
|
||||
const ids = value?.map((selectedUser) => selectedUser.id)
|
||||
const isChecked = ids?.includes(user.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter((el) => el.id !== user.id)
|
||||
: [...(value || []), user]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${user.id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.name} <br /> {`(${user.email})`}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{
|
||||
configurationComplete && isFetching && (<div className="pl-3 pb-2 w-full flex items-center" ><Spinner size="xs" /><p> Loading </p></div>)
|
||||
}
|
||||
{
|
||||
configurationComplete && !isFetching && isError && (<div className="pl-3 pb-2 w-full flex items-center"><FontAwesomeIcon icon={faWarning} /> <p> Error loading users please ensure Entra Id app is installed and configuration is correct</p></div>)
|
||||
}
|
||||
{
|
||||
!configurationComplete && (<div className="pl-3 pb-2 w-full flex items-center" ><FontAwesomeIcon icon={faWarning} /><p> Complete configuration to fetch users</p></div>)
|
||||
}
|
||||
<div>
|
||||
|
||||
<Input
|
||||
isReadOnly
|
||||
value={value?.length ? `${value.length} selected` : ""}
|
||||
className={`text-left ${loading || errored || !configurationComplete ? "cursor-not-allowed" : ""}`}
|
||||
placeholder="Select users"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start"
|
||||
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
|
||||
>
|
||||
{data && data.map((user) => {
|
||||
const ids = value?.map((selectedUser) => selectedUser.id)
|
||||
const isChecked = ids?.includes(user.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
onChange(
|
||||
isChecked
|
||||
? value?.filter((el) => el.id !== user.id)
|
||||
: [...(value || []), user]
|
||||
);
|
||||
}}
|
||||
key={`create-policy-members-${user.id}`}
|
||||
iconPos="right"
|
||||
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
>
|
||||
{user.name} <br /> {`(${user.email})`}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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>
|
||||
</div>
|
||||
<div className="m-auto my-8 flex w-full" />
|
||||
<div className="m-auto mb-2 flex w-full flex-col rounded-md border bg-white/[0.05] border-white-500/30 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 w-full">
|
||||
<p
|
||||
className="text-bold text-white bg-clip-text text-transparent"
|
||||
>
|
||||
Copy Tenant ID add and paste it in the dynamic secret configuration.
|
||||
</p>{" "}
|
||||
</p>
|
||||
</div>
|
||||
</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 w-full">
|
||||
<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="w-full mr-2 flex items-center rounded-md bg-white/[0.05] p-2 text-base text-gray-400">
|
||||
<p className="mr-4 break-all text-left">{router.query.tenant}</p>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
colorSchema="secondary"
|
||||
className="group relative ml-auto"
|
||||
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