Merge branch 'main' into feat/addHumanitecIntegration

This commit is contained in:
carlosmonastyrski
2025-03-13 10:55:40 -03:00
103 changed files with 3196 additions and 567 deletions
@@ -34,6 +34,7 @@ import { EncryptionPanel } from "./components/EncryptionPanel";
import { IntegrationPanel } from "./components/IntegrationPanel";
import { RateLimitPanel } from "./components/RateLimitPanel";
import { UserPanel } from "./components/UserPanel";
import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/IdentityPanel";
enum TabSections {
Settings = "settings",
@@ -42,6 +43,7 @@ enum TabSections {
RateLimit = "rate-limit",
Integrations = "integrations",
Users = "users",
Identities = "identities",
Kmip = "kmip"
}
@@ -164,6 +166,7 @@ export const OverviewPage = () => {
<Tab value={TabSections.RateLimit}>Rate Limit</Tab>
<Tab value={TabSections.Integrations}>Integrations</Tab>
<Tab value={TabSections.Users}>Users</Tab>
<Tab value={TabSections.Identities}>Identities</Tab>
</div>
</TabList>
<TabPanel value={TabSections.Settings}>
@@ -409,6 +412,9 @@ export const OverviewPage = () => {
<TabPanel value={TabSections.Users}>
<UserPanel />
</TabPanel>
<TabPanel value={TabSections.Identities}>
<IdentityPanel />
</TabPanel>
</Tabs>
</div>
)}
@@ -0,0 +1,91 @@
import { useState } from "react";
import { faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
Input,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useDebounce } from "@app/hooks";
import { useAdminGetIdentities } from "@app/hooks/api/admin/queries";
const IdentityPanelTable = () => {
const [searchIdentityFilter, setSearchIdentityFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500);
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetIdentities(
{
limit: 20,
searchTerm: debouncedSearchTerm
}
);
const isEmpty = !isPending && !data?.pages?.[0].length;
return (
<>
<div className="flex gap-2">
<Input
value={searchIdentityFilter}
onChange={(e) => setSearchIdentityFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
className="flex-1"
/>
</div>
<div className="mt-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="identities" />}
{!isPending &&
data?.pages?.map((identities) =>
identities.map(({ name, id }) => (
<Tr key={`identity-${id}`} className="w-full">
<Td>{name}</Td>
</Tr>
))
)}
</TBody>
</Table>
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faServer} />}
</TableContainer>
{!isEmpty && (
<Button
className="mt-4 py-3 text-sm"
isFullWidth
variant="star"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={() => fetchNextPage()}
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)}
</div>
</>
);
};
export const IdentityPanel = () => (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
</div>
<IdentityPanelTable />
</div>
);
@@ -60,12 +60,12 @@ const UserPanelTable = ({
const [adminsOnly, setAdminsOnly] = useState(false);
const { user } = useUser();
const userId = user?.id || "";
const [debounedSearchTerm] = useDebounce(searchUserFilter, 500);
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { subscription } = useSubscription();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetUsers({
limit: 20,
searchTerm: debounedSearchTerm,
searchTerm: debouncedSearchTerm,
adminsOnly
});
@@ -28,7 +28,14 @@ type Props = {
const AUDIT_LOG_LIMIT = 15;
const TABLE_HEADERS = ["Timestamp (MM/DD/YYYY)", "Event", "Project", "Actor", "Source", "Metadata"] as const;
const TABLE_HEADERS = [
"Timestamp (MM/DD/YYYY)",
"Event",
"Project",
"Actor",
"Source",
"Metadata"
] as const;
export type TAuditLogTableHeader = (typeof TABLE_HEADERS)[number];
export const LogsTable = ({
@@ -14,9 +14,10 @@ import {
} from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles";
import { useGetOrgRoles, useUpdateOrg } from "@app/hooks/api";
import { GenericResourceNameSchema } from "@app/lib/schemas";
const formSchema = z.object({
name: z.string().max(64, "Too long, maximum length is 64 characters"),
name: GenericResourceNameSchema,
slug: z
.string()
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens"),
@@ -45,8 +45,11 @@ export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }:
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
@@ -39,8 +39,11 @@ export const IdentityManagementPermissionConditions = ({ position = 0, isDisable
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
@@ -1,10 +1,13 @@
import { useMemo } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability";
import { faPlus, faSave } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { AccessTree } from "@app/components/permissions";
import {
Button,
DropdownMenu,
@@ -13,6 +16,8 @@ import {
DropdownMenuTrigger
} from "@app/components/v2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
@@ -115,94 +120,109 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
}
};
const permissions = form.watch("permissions");
const formattedPermissions = useMemo(
() =>
evaluatePermissionsAbility(
formRolePermission2API(permissions) as RawRuleOf<
MongoAbility<ProjectPermissionSet, MongoQuery>
>[]
),
[JSON.stringify(permissions)]
);
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3>
<div className="flex items-center space-x-4">
{isCustomRole && (
<>
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={() => reset()}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
New policy
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{Object.keys(PROJECT_PERMISSION_OBJECT)
.sort((a, b) =>
PROJECT_PERMISSION_OBJECT[
a as keyof typeof PROJECT_PERMISSION_OBJECT
].title
.toLowerCase()
.localeCompare(
PROJECT_PERMISSION_OBJECT[
b as keyof typeof PROJECT_PERMISSION_OBJECT
].title.toLowerCase()
)
)
.map((subject) => (
<DropdownMenuItem
key={`permission-create-${subject}`}
className="py-3"
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
<div className="w-full">
<AccessTree permissions={formattedPermissions} />
<form
onSubmit={handleSubmit(onSubmit)}
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<FormProvider {...form}>
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Policies</h3>
<div className="flex items-center space-x-4">
{isCustomRole && (
<>
{isDirty && (
<Button
className="mr-4 text-mineshaft-300"
variant="link"
isDisabled={isSubmitting}
isLoading={isSubmitting}
onClick={() => reset()}
>
Discard
</Button>
)}
<div className="flex items-center">
<Button
variant="outline_bg"
type="submit"
className={twMerge("h-10 rounded-r-none", isDirty && "bg-primary text-black")}
isDisabled={isSubmitting || !isDirty}
isLoading={isSubmitting}
leftIcon={<FontAwesomeIcon icon={faSave} />}
>
Save
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
New policy
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{Object.keys(PROJECT_PERMISSION_OBJECT)
.sort((a, b) =>
PROJECT_PERMISSION_OBJECT[
a as keyof typeof PROJECT_PERMISSION_OBJECT
].title
.toLowerCase()
.localeCompare(
PROJECT_PERMISSION_OBJECT[
b as keyof typeof PROJECT_PERMISSION_OBJECT
].title.toLowerCase()
)
)
.map((subject) => (
<DropdownMenuItem
key={`permission-create-${subject}`}
className="py-3"
onClick={() => onNewPolicy(subject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[subject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)}
</div>
</div>
</div>
<div className="py-4">
{!isPending && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
</div>
</FormProvider>
</form>
<div className="py-4">
{!isPending && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
</div>
</FormProvider>
</form>
</div>
);
};
@@ -43,8 +43,11 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
@@ -120,7 +120,14 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="API Key" type="text" autoComplete="off" autoCorrect="off" spellCheck="false" />
<Input
{...field}
placeholder="API Key"
type="text"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
/>
</FormControl>
)}
/>
@@ -155,7 +162,16 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
errorText={error?.message}
isOptional
>
<Input {...field} placeholder="Password" type="password" autoComplete="new-password" autoCorrect="off" spellCheck="false" aria-autocomplete="none" data-form-type="other" />
<Input
{...field}
placeholder="Password"
type="password"
autoComplete="new-password"
autoCorrect="off"
spellCheck="false"
aria-autocomplete="none"
data-form-type="other"
/>
</FormControl>
)}
/>
@@ -228,7 +228,8 @@ export const OverviewPage = () => {
setPage
});
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } =
useFolderOverview(folders);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets);
@@ -251,7 +252,7 @@ export const OverviewPage = () => {
"updateFolder"
] as const);
const handleFolderCreate = async (folderName: string, description: string | null) => {
const handleFolderCreate = async (folderName: string, description: string | null) => {
const promises = userAvailableEnvs.map((env) => {
const environment = env.slug;
return createFolder({
@@ -1029,7 +1030,7 @@ export const OverviewPage = () => {
)}
{!isOverviewLoading && visibleEnvs.length > 0 && (
<>
{folderNamesAndDescriptions.map(({name: folderName, description}, index) => (
{folderNamesAndDescriptions.map(({ name: folderName, description }, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
@@ -1161,7 +1162,9 @@ export const OverviewPage = () => {
<FolderForm
isEdit
defaultFolderName={(popUp.updateFolder?.data as Pick<TSecretFolder, "name">)?.name}
defaultDescription={(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description}
defaultDescription={
(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description
}
onUpdateFolder={handleFolderUpdate}
showDescriptionOverwriteWarning
/>
@@ -16,10 +16,10 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { getKeyValue } from "@app/helpers/parseEnvVar";
import { useCreateFolder, useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
import { SecretType } from "@app/hooks/api/types";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
const typeSchema = z
.object({
@@ -23,6 +23,27 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
required: z
.object({
lowercase: z.number().min(0),
uppercase: z.number().min(0),
digits: z.number().min(0),
symbols: z.number().min(0)
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 250;
}, "Sum of required characters cannot exceed 250"),
allowedSymbols: z.string().optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length");
const formSchema = z.object({
provider: z.object({
client: z.nativeEnum(SqlProviders),
@@ -31,6 +52,7 @@ const formSchema = z.object({
database: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
passwordRequirements: passwordRequirementsSchema.optional(),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
@@ -133,11 +155,24 @@ export const SqlDatabaseInputForm = ({
control,
setValue,
formState: { isSubmitting },
handleSubmit
handleSubmit,
watch
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
provider: getSqlStatements(SqlProviders.Postgres)
provider: {
...getSqlStatements(SqlProviders.Postgres),
passwordRequirements: {
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
}
}
}
});
@@ -174,6 +209,10 @@ export const SqlDatabaseInputForm = ({
setValue("provider.renewStatement", sqlStatment.renewStatement);
setValue("provider.revocationStatement", sqlStatment.revocationStatement);
setValue("provider.port", getDefaultPort(type));
// Update password requirements based on provider
const length = type === SqlProviders.Oracle ? 30 : 48;
setValue("provider.passwordRequirements.length", length);
};
return (
@@ -197,6 +236,7 @@ export const SqlDatabaseInputForm = ({
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
@@ -386,10 +426,15 @@ export const SqlDatabaseInputForm = ({
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
<Accordion type="multiple" className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advanced">
<AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing database user lifecycle
</div>
<Controller
control={control}
name="provider.creationStatement"
@@ -450,6 +495,160 @@ export const SqlDatabaseInputForm = ({
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="password-config">
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Set constraints on the generated database password
</div>
<div className="space-y-4">
<div>
<Controller
control={control}
name="provider.passwordRequirements.length"
defaultValue={48}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
const total = Object.values(
watch("provider.passwordRequirements.required") || {}
).reduce((sum, count) => sum + Number(count || 0), 0);
const length = watch("provider.passwordRequirements.length") || 0;
const isError = total > length;
return (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="provider.passwordRequirements.required.lowercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.uppercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.digits"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.passwordRequirements.required.symbols"
defaultValue={0}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Allowed Symbols</h4>
<Controller
control={control}
name="provider.passwordRequirements.allowedSymbols"
defaultValue="-_.~!*"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbols to use in password"
isError={Boolean(error)}
errorText={error?.message}
helperText="Default: -_.~!*"
>
<Input {...field} placeholder="-_.~!*" />
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
@@ -15,7 +15,8 @@ type Props = {
showDescriptionOverwriteWarning?: boolean;
};
const descriptionOverwriteWarningMessage = "Warning: Any changes made here will overwrite any custom edits in individual environment folders."
const descriptionOverwriteWarningMessage =
"Warning: Any changes made here will overwrite any custom edits in individual environment folders.";
const formSchema = z.object({
name: z
@@ -25,9 +26,7 @@ const formSchema = z.object({
/^[a-zA-Z0-9-_]+$/,
"Folder name can only contain letters, numbers, dashes, and underscores"
),
description: z
.string()
.optional()
description: z.string().optional()
});
type TFormData = z.infer<typeof formSchema>;
@@ -59,7 +58,7 @@ export const FolderForm = ({
if (textarea) {
const lines = textarea.value.split("\n");
const maxDescriptionLines = 10;
if (lines.length > maxDescriptionLines) {
textarea.value = lines.slice(0, maxDescriptionLines).join("\n");
}
@@ -90,30 +89,32 @@ export const FolderForm = ({
)}
/>
<Controller
control={control}
name="description"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Folder Description"
isError={Boolean(error)}
tooltipText={showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined}
isOptional
errorText={error?.message}
className="flex-1"
>
<TextArea
placeholder="Folder description"
{...field}
rows={3}
ref={descriptionRef}
onInput={handleInput}
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
maxLength={255}
/>
</FormControl>
)}
/>
control={control}
name="description"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Folder Description"
isError={Boolean(error)}
tooltipText={
showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined
}
isOptional
errorText={error?.message}
className="flex-1"
>
<TextArea
placeholder="Folder description"
{...field}
rows={3}
ref={descriptionRef}
onInput={handleInput}
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
maxLength={255}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}
@@ -23,6 +23,27 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
required: z
.object({
lowercase: z.number().min(0),
uppercase: z.number().min(0),
digits: z.number().min(0),
symbols: z.number().min(0)
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 250; // Sanity check for individual validation
}, "Sum of required characters cannot exceed 250"),
allowedSymbols: z.string().optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length");
const formSchema = z.object({
inputs: z
.object({
@@ -32,6 +53,7 @@ const formSchema = z.object({
database: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
passwordRequirements: passwordRequirementsSchema.optional(),
creationStatement: z.string().min(1),
revocationStatement: z.string().min(1),
renewStatement: z.string().optional(),
@@ -82,6 +104,17 @@ export const EditDynamicSecretSqlProviderForm = ({
secretPath,
projectSlug
}: Props) => {
const getDefaultPasswordRequirements = (provider: SqlProviders) => ({
length: provider === SqlProviders.Oracle ? 30 : 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
});
const {
control,
watch,
@@ -94,10 +127,16 @@ export const EditDynamicSecretSqlProviderForm = ({
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
...(dynamicSecret.inputs as TForm["inputs"]),
passwordRequirements:
(dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements ||
getDefaultPasswordRequirements(
(dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres
)
}
}
});
const { currentWorkspace } = useWorkspace();
const { data: projectGateways, isPending: isProjectGatewaysLoading } = useQuery(
gatewaysQueryKeys.listProjectGateways({ projectId: currentWorkspace.id })
@@ -347,10 +386,15 @@ export const EditDynamicSecretSqlProviderForm = ({
</FormControl>
)}
/>
<Accordion type="multiple" className="w-full bg-mineshaft-700">
<AccordionItem value="modify-sql-statement">
<AccordionTrigger>Modify SQL Statements</AccordionTrigger>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="advanced">
<AccordionTrigger>
Creation, Revocation & Renew Statements (optional)
</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Customize SQL statements for managing database user lifecycle
</div>
<Controller
control={control}
name="inputs.creationStatement"
@@ -418,6 +462,160 @@ export const EditDynamicSecretSqlProviderForm = ({
</AccordionContent>
</AccordionItem>
</Accordion>
<Accordion type="multiple" className="mb-2 mt-4 w-full bg-mineshaft-700">
<AccordionItem value="password-config">
<AccordionTrigger>Password Configuration (optional)</AccordionTrigger>
<AccordionContent>
<div className="mb-4 text-sm text-mineshaft-300">
Set constraints on the generated database password
</div>
<div className="space-y-4">
<div>
<Controller
control={control}
name="inputs.passwordRequirements.length"
defaultValue={48}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Password Length"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
type="number"
min={1}
max={250}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Minimum Required Character Counts</h4>
<div className="text-sm text-gray-500">
{(() => {
const total = Object.values(
watch("inputs.passwordRequirements.required") || {}
).reduce((sum, count) => sum + Number(count || 0), 0);
const length = watch("inputs.passwordRequirements.length") || 0;
const isError = total > length;
return (
<span className={isError ? "text-red-500" : ""}>
Total required characters: {total}{" "}
{isError ? `(exceeds length of ${length})` : ""}
</span>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="inputs.passwordRequirements.required.lowercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Lowercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of lowercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.uppercase"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Uppercase Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of uppercase letters"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.digits"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Digit Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of digits"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.passwordRequirements.required.symbols"
defaultValue={0}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbol Count"
isError={Boolean(error)}
errorText={error?.message}
helperText="Minimum number of symbols"
>
<Input
type="number"
min={0}
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
</FormControl>
)}
/>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">Allowed Symbols</h4>
<Controller
control={control}
name="inputs.passwordRequirements.allowedSymbols"
defaultValue="-_.~!*"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Symbols to use in password"
isError={Boolean(error)}
errorText={error?.message}
helperText="Default: -_.~!*"
>
<Input {...field} placeholder="-_.~!*" />
</FormControl>
)}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
@@ -1,17 +1,17 @@
import { subject } from "@casl/ability";
import { faClose, faFolder, faPencilSquare, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { FolderForm } from "../ActionBar/FolderForm";
@@ -118,16 +118,15 @@ export const FolderListView = ({
onClick={() => handleFolderClick(name)}
>
{name}
{
description &&
{description && (
<Tooltip
position="right"
className="flex items-center space-x-4 max-w-lg py-4 whitespace-pre-wrap"
className="flex max-w-lg items-center space-x-4 whitespace-pre-wrap py-4"
content={description}
>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400 ml-1" />
<FontAwesomeIcon icon={faInfoCircle} className="ml-1 text-mineshaft-400" />
</Tooltip>
}
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
@@ -62,7 +62,7 @@ export const PitDrawer = ({
<div>
{(() => {
const distance = formatDistance(new Date(createdAt), new Date());
return distance.charAt(0).toUpperCase() + distance.slice(1) + " ago";
return `${distance.charAt(0).toUpperCase() + distance.slice(1)} ago`;
})()}
</div>
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === id)}</div>
@@ -58,10 +58,10 @@ import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { camelCaseToSpaces } from "@app/lib/fn/string";
import { CreateReminderForm } from "./CreateReminderForm";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
import { camelCaseToSpaces } from "@app/lib/fn/string";
type Props = {
isOpen?: boolean;
@@ -281,7 +281,7 @@ export const SecretItem = memo(
isVisible={isVisible}
isReadOnly={isReadOnly}
{...field}
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
containerClassName="py-1.5 rounded-md transition-all"
/>
)}
/>
@@ -301,19 +301,19 @@ export const SecretItem = memo(
secretPath={secretPath}
{...field}
defaultValue={secretValueHidden ? "" : undefined}
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
containerClassName="py-1.5 rounded-md transition-all"
/>
)}
/>
)}
<div key="actions" className="flex h-8 flex-shrink-0 self-start transition-all">
<div key="actions" className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2">
<Tooltip content="Copy secret">
<IconButton
isDisabled={secret.secretValueHidden}
ariaLabel="copy-value"
variant="plain"
size="sm"
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5"
className="w-0 overflow-hidden p-0 group-hover:w-5"
onClick={copyTokenToClipboard}
>
<FontAwesomeSymbol
@@ -339,7 +339,7 @@ export const SecretItem = memo(
<Modal>
<ModalTrigger asChild>
<IconButton
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6"
className="w-0 overflow-hidden p-0 group-hover:w-5"
variant="plain"
size="md"
ariaLabel="reference-tree"
@@ -390,7 +390,7 @@ export const SecretItem = memo(
variant="plain"
size="sm"
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-5",
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
hasTagsApplied && "w-5 text-primary"
)}
isDisabled={!isAllowed}
@@ -473,7 +473,7 @@ export const SecretItem = memo(
size="sm"
onClick={handleOverrideClick}
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5",
"w-0 overflow-hidden p-0 group-hover:w-5",
isOverriden && "w-5 text-primary"
)}
>
@@ -498,7 +498,7 @@ export const SecretItem = memo(
<PopoverTrigger asChild disabled={!isAllowed}>
<IconButton
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
"w-0 overflow-hidden p-0 group-hover:w-5",
hasComment && "w-5 text-primary"
)}
variant="plain"
@@ -518,7 +518,7 @@ export const SecretItem = memo(
</ProjectPermissionCan>
<IconButton
isDisabled={secret.secretValueHidden}
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6"
className="w-0 overflow-hidden p-0 group-hover:w-5"
variant="plain"
size="md"
ariaLabel="share-secret"
@@ -206,8 +206,13 @@ export const DatabricksConfigurePage = () => {
<span className="text-md ml-3 text-mineshaft-100">Pro Tip</span>
</div>
<span className="mt-4 text-sm text-mineshaft-300">
After creating an integration, your secrets will start syncing immediately. This might
cause an unexpected override of current secrets in Databricks with secrets from Infisical.
When integrating with Databricks, Infisical is intended to be the source of truth for the
secrets in the configured Databricks scope.
</span>
<span className="mt-4 text-sm text-mineshaft-300">
Any secrets not present in Infisical will be removed from the specified scope. To prevent
removal of secrets not managed by Infisical, Infisical recommends creating a designated
secret scope for your integration.
</span>
</div>
</div>
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate } from "@tanstack/react-router";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@@ -13,7 +14,6 @@ import { Button, FormControl, Input } from "@app/components/v2";
import { useUser } from "@app/context";
import { useResetUserPasswordV2, useSendPasswordSetupEmail } from "@app/hooks/api/auth/queries";
import { UserEncryptionVersion } from "@app/hooks/api/auth/types";
import { useNavigate } from "@tanstack/react-router";
type Errors = {
tooShort?: string;