Merge branch 'main' into feat/improveSecretReferenceWarning

This commit is contained in:
carlosmonastyrski
2025-04-29 08:43:35 -03:00
376 changed files with 9209 additions and 1020 deletions
@@ -269,12 +269,19 @@ export const InitialStep = ({
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
const searchParams = new URLSearchParams();
window.open(
`/api/v1/sso/redirect/google${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
if (callbackPort) {
searchParams.append("callback_port", callbackPort);
}
if (isAdmin) {
searchParams.append("is_admin_login", "true");
}
const queryString = searchParams.toString();
window.open(`/api/v1/sso/redirect/google${queryString ? `?${queryString}` : ""}`);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
@@ -291,13 +298,19 @@ export const InitialStep = ({
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
const searchParams = new URLSearchParams();
window.open(
`/api/v1/sso/redirect/github${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
if (callbackPort) {
searchParams.append("callback_port", callbackPort);
}
if (isAdmin) {
searchParams.append("is_admin_login", "true");
}
const queryString = searchParams.toString();
window.open(`/api/v1/sso/redirect/github${queryString ? `?${queryString}` : ""}`);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
@@ -314,13 +327,19 @@ export const InitialStep = ({
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
const searchParams = new URLSearchParams();
window.open(
`/api/v1/sso/redirect/gitlab${
callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
if (callbackPort) {
searchParams.append("callback_port", callbackPort);
}
if (isAdmin) {
searchParams.append("is_admin_login", "true");
}
const queryString = searchParams.toString();
window.open(`/api/v1/sso/redirect/gitlab${queryString ? `?${queryString}` : ""}`);
window.close();
}}
className="h-10 w-full bg-mineshaft-600"
@@ -27,9 +27,16 @@ type Props = {
email: string;
password: string;
setPassword: (password: string) => void;
isAdminLogin?: boolean;
};
export const PasswordStep = ({ providerAuthToken, email, password, setPassword }: Props) => {
export const PasswordStep = ({
providerAuthToken,
email,
password,
setPassword,
isAdminLogin
}: Props) => {
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const navigate = useNavigate();
@@ -114,7 +121,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
navigateToSelectOrganization(callbackPort, isAdminLogin);
}
// case: no orgs found, so we navigate the user to create an org
else {
@@ -216,7 +223,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
// case: user has orgs, so we navigate the user to select an org
if (userOrgs.length > 0) {
navigateToSelectOrganization(callbackPort);
navigateToSelectOrganization(callbackPort, isAdminLogin);
}
// case: no orgs found, so we navigate the user to create an org
else {
@@ -249,7 +256,7 @@ export const PasswordStep = ({ providerAuthToken, email, password, setPassword }
const userOrgs = await fetchOrganizations();
if (userOrgs.length > 0) {
navigateToSelectOrganization();
navigateToSelectOrganization(undefined, isAdminLogin);
} else {
await navigateUserToOrg(navigate);
}
@@ -34,6 +34,7 @@ export const LoginSsoPage = () => {
email={username}
password={password}
setPassword={setPassword}
isAdminLogin={search.isAdminLogin}
/>
);
default:
@@ -5,7 +5,8 @@ import { z } from "zod";
import { LoginSsoPage } from "./LoginSsoPage";
const LoginSSOQueryParamsSchema = z.object({
token: z.string()
token: z.string(),
isAdminLogin: z.boolean().optional().catch(false)
});
export const Route = createFileRoute("/_restrict-login-signup/login/sso")({
@@ -1,7 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { AxiosError } from "axios";
import { addSeconds, formatISO } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { SessionStorageKeys } from "@app/const";
import { ROUTE_PATHS } from "@app/const/routes";
import { userKeys } from "@app/hooks/api";
import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
@@ -24,6 +26,16 @@ export const Route = createFileRoute("/_authenticate")({
title: "Access Restricted",
text: " You need to log in to access this page. Please log in to continue."
});
// persist current URL in session storage so that we can come back to this after successful login
sessionStorage.setItem(
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
JSON.stringify({
expiry: formatISO(addSeconds(new Date(), 60)),
data: window.location.href
})
);
throw redirect({
to: "/login"
});
@@ -18,8 +18,10 @@ import { DatabricksConnectionForm } from "./DatabricksConnectionForm";
import { GcpConnectionForm } from "./GcpConnectionForm";
import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { LdapConnectionForm } from "./LdapConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
import { TeamCityConnectionForm } from "./TeamCityConnectionForm";
import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
import { VercelConnectionForm } from "./VercelConnectionForm";
import { WindmillConnectionForm } from "./WindmillConnectionForm";
@@ -89,6 +91,10 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <WindmillConnectionForm onSubmit={onSubmit} />;
case AppConnection.Auth0:
return <Auth0ConnectionForm onSubmit={onSubmit} />;
case AppConnection.LDAP:
return <LdapConnectionForm onSubmit={onSubmit} />;
case AppConnection.TeamCity:
return <TeamCityConnectionForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled App ${app}`);
}
@@ -153,6 +159,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <WindmillConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Auth0:
return <Auth0ConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.LDAP:
return <LdapConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.TeamCity:
return <TeamCityConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
default:
throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`);
}
@@ -0,0 +1,329 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { DistinguishedNameRegex } from "@app/helpers/string";
import {
LdapConnectionMethod,
LdapConnectionProvider,
TLdapConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TLdapConnection;
onSubmit: (formData: FormData) => Promise<void>;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.LDAP)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(LdapConnectionMethod.SimpleBind),
credentials: z.object({
provider: z.nativeEnum(LdapConnectionProvider),
url: z
.string()
.regex(/^ldaps?:\/\//, 'Must start with "ldaps://" or "ldap://"')
.url()
.trim()
.min(1, "LDAP URL required"),
dn: z
.string()
.trim()
.regex(DistinguishedNameRegex, "Invalid Distinguished Name format")
.min(1, "Distinguished Name (DN) required"),
password: z.string().trim().min(1, "Password required"),
sslRejectUnauthorized: z.boolean(),
sslCertificate: z
.string()
.trim()
.transform((value) => value || undefined)
.optional()
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.LDAP,
method: LdapConnectionMethod.SimpleBind,
credentials: {
provider: LdapConnectionProvider.ActiveDirectory,
url: "",
dn: "",
password: "",
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty },
watch
} = form;
const selectedProvider = watch("credentials.provider");
const sslEnabled = watch("credentials.url")?.startsWith("ldaps://") ?? false;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
setSelectedTabIndex(0);
handleSubmit(onSubmit)(e);
}}
>
{!isUpdate && <GenericAppConnectionsFields />}
<div className="grid grid-cols-2 items-center gap-2">
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.LDAP].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(LdapConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.provider"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="LDAP Provider"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(LdapConnectionProvider).map((provider) => {
return (
<SelectItem value={provider} className="capitalize" key={provider}>
{provider.replace("-", " ")}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
</div>
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
<Tab.List
className={`-pb-1 ${selectedTabIndex === 1 ? "mb-3" : "mb-6"} w-full border-b-2 border-mineshaft-600`}
>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
Configuration
</Tab>
<Tab
className={({ selected }) =>
`w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${
selected
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
: "text-bunker-300"
}`
}
>
SSL ({sslEnabled ? "Enabled" : "Disabled"})
</Tab>
</Tab.List>
{selectedTabIndex === 1 && (
<div className="mb-2 text-xs text-mineshaft-300">Requires ldaps:// URL</div>
)}
<Tab.Panels className="mb-4 rounded border border-mineshaft-600 bg-mineshaft-700/70 p-3 pb-0">
<Tab.Panel>
<Controller
name="credentials.url"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="LDAP URL"
>
<Input {...field} placeholder="ldap://domain-or-ip:389" />
</FormControl>
)}
/>
<div className="grid grid-cols-2 gap-2">
<Controller
name="credentials.dn"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Binding Distinguished Name (DN)"
>
<Input {...field} placeholder="CN=John,OU=Users,DC=example,DC=com" />
</FormControl>
)}
/>
<Controller
name="credentials.password"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Binding Password"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
</div>
</Tab.Panel>
<Tab.Panel>
<Controller
name="credentials.sslCertificate"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className={sslEnabled ? "" : "opacity-50"}
label="SSL Certificate"
isOptional
>
<TextArea
className="h-[3.6rem] !resize-none"
{...field}
isDisabled={!sslEnabled}
/>
</FormControl>
)}
/>
<Controller
name="credentials.sslRejectUnauthorized"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className={sslEnabled ? "" : "opacity-50"}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="ssl-reject-unauthorized"
thumbClassName="bg-mineshaft-800"
isChecked={sslEnabled ? value : false}
onCheckedChange={onChange}
isDisabled={!sslEnabled}
>
<p className="w-[9.5rem]">
Reject Unauthorized
<Tooltip
className="max-w-md"
content={
<p>
If enabled, Infisical will only connect to the server if it has a
valid, trusted SSL certificate.
</p>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
<div className="mt-8 flex items-center">
<Button
className="mr-4 capitalize"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : `Connect to ${selectedProvider.replace("-", " ")}`}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,150 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
Input,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import { TeamCityConnectionMethod, TTeamCityConnection } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TTeamCityConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.TeamCity)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(TeamCityConnectionMethod.AccessToken),
credentials: z.object({
accessToken: z.string().trim().min(1, "Access Token required"),
instanceUrl: z.string().trim().url("Invalid Instance URL")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const TeamCityConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.TeamCity,
method: TeamCityConnectionMethod.AccessToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.TeamCity].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(TeamCityConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.instanceUrl"
control={control}
shouldUnregister
render={({ field, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Instance URL"
tooltipClassName="max-w-sm"
tooltipText="The URL at which your TeamCity instance is hosted."
>
<Input {...field} placeholder="https://yourcompany.teamcity.com" />
</FormControl>
)}
/>
<Controller
name="credentials.accessToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Access Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to TeamCity"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -125,7 +125,7 @@ export const AppConnectionRow = ({
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuItem
icon={<FontAwesomeIcon icon={isIdCopied ? faCheck : faCopy} />}
onClick={() => handleCopyId()}
@@ -110,6 +110,7 @@ export const formSchema = z.object({
"secret-scanning": generalPermissionSchema,
sso: generalPermissionSchema,
scim: generalPermissionSchema,
[OrgPermissionSubjects.GithubOrgSync]: generalPermissionSchema,
ldap: generalPermissionSchema,
billing: generalPermissionSchema,
identity: identityPermissionSchema,
@@ -63,6 +63,10 @@ const SIMPLE_PERMISSION_OPTIONS = [
title: "SCIM",
formName: "scim"
},
{
title: "GitHub Organization Sync",
formName: OrgPermissionSubjects.GithubOrgSync
},
{
title: "External KMS",
formName: OrgPermissionSubjects.Kms
@@ -0,0 +1,172 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal, FormControl, Input } from "@app/components/v2";
import {
useCreateGithubSyncOrgConfig,
useDeleteGithubSyncOrgConfig,
useUpdateGithubSyncOrgConfig
} from "@app/hooks/api";
import { TGithubOrgSyncConfig } from "@app/hooks/api/githubOrgSyncConfig/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
githubOrgName: z.string(),
githubOrgAccessToken: z.string().optional()
});
export type FormData = z.infer<typeof schema>;
type Props = {
data?: TGithubOrgSyncConfig;
popUp: UsePopUpState<["githubOrgSyncConfig", "deleteGithubOrgSyncConfig"]>;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteGithubOrgSyncConfig"]>,
data?: {
scimTokenId: string;
}
) => void;
handlePopUpToggle: (
popUpName: keyof UsePopUpState<["githubOrgSyncConfig", "deleteGithubOrgSyncConfig"]>,
state?: boolean
) => void;
};
export const GithubOrgSyncConfigModal = ({
popUp,
handlePopUpOpen,
handlePopUpToggle,
data
}: Props) => {
const isUpdate = Boolean(data);
const { mutateAsync: createGithubSyncOrgConfig } = useCreateGithubSyncOrgConfig();
const { mutateAsync: updateGithubSyncOrgConfig } = useUpdateGithubSyncOrgConfig();
const { mutateAsync: deleteGithubSyncOrgConfig } = useDeleteGithubSyncOrgConfig();
const {
control,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
values: data ? { githubOrgName: data.githubOrgName } : undefined
});
const onFormSubmit = async ({ githubOrgName, githubOrgAccessToken }: FormData) => {
try {
if (isUpdate) {
await updateGithubSyncOrgConfig({
githubOrgName,
githubOrgAccessToken
});
createNotification({
text: "Successfully updated GitHub Organization Sync",
type: "success"
});
} else {
await createGithubSyncOrgConfig({
githubOrgName,
githubOrgAccessToken,
isActive: false
});
createNotification({
text: "Successfully created GitHub Organization Sync",
type: "success"
});
}
handlePopUpToggle("githubOrgSyncConfig");
} catch {
createNotification({
text: "Failed to setup GitHub Organization Sync",
type: "error"
});
}
};
const onDelete = async () => {
try {
await deleteGithubSyncOrgConfig();
handlePopUpToggle("deleteGithubOrgSyncConfig", false);
handlePopUpToggle("githubOrgSyncConfig", false);
createNotification({
text: "Successfully deleted GitHub Organization Sync",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to delete GitHub Organization Sync",
type: "error"
});
}
};
return (
<>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
defaultValue=""
name="githubOrgName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="GitHub Organization Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="example" />
</FormControl>
)}
/>
{/* <Controller
control={control}
name="githubOrgAccessToken"
render={({ field, fieldState: { error } }) => (
<FormControl
label="GitHub Org Scoped Access Token"
isError={Boolean(error)}
isOptional
errorText={error?.message}
helperText="A GitHub access token is required only for private organizations. It will not be visible after saving."
>
<Input {...field} placeholder="example" />
</FormControl>
)}
/> */}
<div className="flex gap-8 pt-4">
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
{isUpdate ? "Update" : "Configure"}
</Button>
<Button
variant="plain"
colorSchema="secondary"
onClick={() => handlePopUpToggle("githubOrgSyncConfig", false)}
>
Cancel
</Button>
<div className="flex-grow" />
{isUpdate && (
<Button
onClick={() => handlePopUpOpen("deleteGithubOrgSyncConfig")}
colorSchema="danger"
>
Delete
</Button>
)}
</div>
</form>
<DeleteActionModal
isOpen={popUp.deleteGithubOrgSyncConfig.isOpen}
title="Are you sure want to remove GitHub organization sync?"
onChange={(isOpen) => handlePopUpToggle("deleteGithubOrgSyncConfig", isOpen)}
deleteKey="confirm"
onDeleteApproved={onDelete}
/>
</>
);
};
@@ -387,6 +387,7 @@ export const OIDCModal = ({ popUp, handlePopUpClose, handlePopUpToggle, hideDele
<SelectItem value={OIDCJWTSignatureAlgorithm.RS256}>RS256</SelectItem>
<SelectItem value={OIDCJWTSignatureAlgorithm.RS512}>RS512</SelectItem>
<SelectItem value={OIDCJWTSignatureAlgorithm.HS256}>HS256</SelectItem>
<SelectItem value={OIDCJWTSignatureAlgorithm.EDDSA}>EdDSA</SelectItem>
</Select>
</FormControl>
)}
@@ -18,6 +18,7 @@ import { LDAPModal } from "./LDAPModal";
import { OIDCModal } from "./OIDCModal";
import { OrgGeneralAuthSection } from "./OrgGeneralAuthSection";
import { OrgGenericAuthSection } from "./OrgGenericAuthSection";
import { OrgGithubSyncSection } from "./OrgGithubSyncSection";
import { OrgLDAPSection } from "./OrgLDAPSection";
import { OrgOIDCSection } from "./OrgOIDCSection";
import { OrgScimSection } from "./OrgSCIMSection";
@@ -181,6 +182,7 @@ export const OrgAuthTab = withPermission(
</>
)}
<OrgScimSection />
<OrgGithubSyncSection />
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -0,0 +1,114 @@
import { useQuery } from "@tanstack/react-query";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { OrgPermissionCan } from "@app/components/permissions";
import { Button, Modal, ModalContent, Skeleton, Spinner, Switch } from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useSubscription } from "@app/context";
import { githubOrgSyncConfigQueryKeys, useUpdateGithubSyncOrgConfig } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { GithubOrgSyncConfigModal } from "./GithubOrgSyncConfigModal";
export const OrgGithubSyncSection = () => {
const { subscription } = useSubscription();
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"upgradePlan",
"githubOrgSyncConfig",
"deleteGithubOrgSyncConfig"
] as const);
const githubOrgSyncConfig = useQuery({
...githubOrgSyncConfigQueryKeys.get(),
enabled: subscription.githubOrgSync,
retry: false
});
const updateGithubSyncOrgConfig = useUpdateGithubSyncOrgConfig();
const isPending = subscription.githubOrgSync && githubOrgSyncConfig.isPending;
const data = !isPending && !githubOrgSyncConfig?.isError ? githubOrgSyncConfig?.data : undefined;
return (
<div className="mt-4 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-6">
<p className="text-xl font-semibold text-gray-200">
Sync user groups from your GitHub Organization
</p>
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">GitHub Organization</h2>
<div className="flex gap-4">
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.GithubOrgSync}>
{(isAllowed) => (
<Button
onClick={() =>
subscription.githubOrgSync
? handlePopUpOpen("githubOrgSyncConfig")
: handlePopUpOpen("upgradePlan")
}
colorSchema="secondary"
isDisabled={!isAllowed}
isLoading={isPending}
>
Configure
</Button>
)}
</OrgPermissionCan>
</div>
</div>
<p className="text-sm text-mineshaft-300">
{isPending ? <Skeleton /> : null}
{data ? data?.githubOrgName : "Not configured"}
</p>
</div>
{data && (
<div className="py-4">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-md text-mineshaft-100">Enable GitHub Sync</h2>
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.GithubOrgSync}>
{(isAllowed) => (
<Switch
id="enable-sync"
onCheckedChange={(value) =>
updateGithubSyncOrgConfig.mutate({
isActive: value
})
}
isChecked={githubOrgSyncConfig?.data?.isActive ?? false}
isDisabled={!isAllowed}
>
{updateGithubSyncOrgConfig?.isPending && <Spinner size="xs" />}
</Switch>
)}
</OrgPermissionCan>
</div>
<p className="text-sm text-mineshaft-300">
Allow group provisioning/deprovisioning with GitHub
</p>
</div>
)}
<Modal
isOpen={popUp?.githubOrgSyncConfig?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("githubOrgSyncConfig", isOpen);
}}
>
<ModalContent
title="Manage GitHub Organization Sync"
subTitle="Sync your GitHub teams to Infisical organization groups"
>
<GithubOrgSyncConfigModal
data={data}
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/>
</ModalContent>
</Modal>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use GitHub Organization Plan if you switch to Infisical's Enterprise plan."
/>
</div>
);
};
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
@@ -68,7 +69,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props)
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member
);
@@ -37,6 +37,7 @@ import {
import { SecretPathInput } from "@app/components/v2/SecretPathInput";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -88,7 +89,8 @@ export const SpecificPrivilegeSecretForm = ({
] as const);
const { permission } = useProjectPermission();
const isMemberEditDisabled =
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member) &&
Boolean(privilege);
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
const requestAccess = useCreateAccessRequest();
@@ -1,9 +1,12 @@
import { useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faChevronRight,
faClock,
faEllipsisV,
faFilter,
faMagnifyingGlass,
faSearch,
faTrash,
@@ -15,6 +18,14 @@ import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
DropdownSubMenu,
DropdownSubMenuContent,
DropdownSubMenuTrigger,
EmptyState,
HoverCard,
HoverCardContent,
@@ -40,7 +51,7 @@ import {
useWorkspace
} from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -65,13 +76,22 @@ enum MembersOrderBy {
Email = "email"
}
type Filter = {
roles: string[];
};
export const MembersTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { user } = useUser();
const navigate = useNavigate();
const [filter, setFilter] = useState<Filter>({
roles: []
});
const filterRoles = useMemo(() => filter.roles, [filter.roles]);
const userId = user?.id || "";
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles } = useGetProjectRoles(workspaceId);
const {
search,
@@ -86,9 +106,20 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, {
initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10)
});
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString());
};
const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(
workspaceId,
undefined,
filterRoles
);
const filteredUsers = useMemo(
() =>
@@ -142,14 +173,81 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
setOrderDirection(OrderByDirection.ASC);
};
const isTableFiltered = Boolean(filter.roles.length);
const handleRoleToggle = useCallback(
(roleSlug: string) =>
setFilter((state) => {
const roles = state.roles || [];
if (roles.includes(roleSlug)) {
return { ...state, roles: roles.filter((role) => role !== roleSlug) };
}
return { ...state, roles: [...roles, roleSlug] };
}),
[]
);
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<div className="flex gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Users"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-0">
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
<DropdownSubMenu>
<DropdownSubMenuTrigger
iconPos="right"
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
>
Roles
</DropdownSubMenuTrigger>
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
Apply Roles to Filter Users
</DropdownMenuLabel>
{projectRoles?.map(({ id, slug, name }) => (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
handleRoleToggle(slug);
}}
key={id}
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: "#bec2c8" }}
/>
{name}
</div>
</DropdownMenuItem>
))}
</DropdownSubMenuContent>
</DropdownSubMenu>
</DropdownMenuContent>
</DropdownMenu>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
@@ -358,7 +456,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
@@ -6,13 +6,27 @@ import { formatRelative } from "date-fns";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
Button,
ConfirmActionModal,
DeleteActionModal,
EmptyState,
PageHeader,
Spinner
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useAssumeProjectPrivileges,
useDeleteIdentityFromWorkspace,
useGetWorkspaceIdentityMembershipDetails
} from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection";
import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection";
@@ -35,8 +49,28 @@ const Page = () => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteIdentity",
"upgradePlan"
"assumePrivileges"
] as const);
const assumePrivileges = useAssumeProjectPrivileges();
const handleAssumePrivileges = async () => {
assumePrivileges.mutate(
{
actorId: identityId,
actorType: ActorType.IDENTITY,
projectId: workspaceId
},
{
onSuccess: () => {
createNotification({
type: "success",
text: "Identity privilege assumption has started"
});
window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`;
}
}
);
};
const onRemoveIdentitySubmit = async () => {
try {
@@ -100,6 +134,24 @@ const Page = () => {
>
Copy Membership ID
</Button>
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={ProjectPermissionSub.Identity}
renderTooltip
allowedLabel="Assume privileges of the user"
passThrough={false}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
onClick={() => handlePopUpOpen("assumePrivileges")}
>
Assume Privileges
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
@@ -137,6 +189,15 @@ const Page = () => {
deleteKey="remove"
onDeleteApproved={() => onRemoveIdentitySubmit()}
/>
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to assume privileges of this identity?"
subTitle="This will set your privileges to those of the identity for the next hour."
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
</>
) : (
<EmptyState title="Error: Unable to find the identity." className="py-12" />
@@ -32,7 +32,7 @@ import {
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -97,7 +97,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
});
const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
@@ -22,7 +22,12 @@ import {
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteIdentityProjectAdditionalPrivilege } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
@@ -83,7 +88,7 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
identityId={identityId}
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionIdentityActions.Edit,
subject(ProjectPermissionSub.Identity, {
identityId
})
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionIdentityActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -66,7 +67,7 @@ export const IdentityRoleModify = ({ identityProjectMembership }: Props) => {
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionIdentityActions.Edit,
ProjectPermissionSub.Identity
);
@@ -6,15 +6,28 @@ import { formatRelative } from "date-fns";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal, EmptyState, PageHeader, Spinner } from "@app/components/v2";
import {
Button,
ConfirmActionModal,
DeleteActionModal,
EmptyState,
PageHeader,
Spinner
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useOrganization,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteUserFromWorkspace, useGetWorkspaceUserDetails } from "@app/hooks/api";
import {
useAssumeProjectPrivileges,
useDeleteUserFromWorkspace,
useGetWorkspaceUserDetails
} from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
@@ -35,12 +48,34 @@ export const Page = () => {
const { mutateAsync: removeUserFromWorkspace, isPending: isRemovingUserFromWorkspace } =
useDeleteUserFromWorkspace();
const assumePrivileges = useAssumeProjectPrivileges();
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"removeMember",
"upgradePlan"
"upgradePlan",
"assumePrivileges"
] as const);
const handleAssumePrivileges = async () => {
const { userId } = popUp?.assumePrivileges?.data as { userId: string };
assumePrivileges.mutate(
{
actorId: userId,
actorType: ActorType.USER,
projectId: workspaceId
},
{
onSuccess: () => {
createNotification({
type: "success",
text: "User privilege assumption has started"
});
window.location.href = `/${currentWorkspace.type}/${currentWorkspace.id}/overview`;
}
}
);
};
const handleRemoveUser = async () => {
if (!currentOrg?.id || !currentWorkspace?.id || !membershipDetails?.user?.username) return;
@@ -91,7 +126,29 @@ export const Page = () => {
description={`User joined on ${membershipDetails?.createdAt && formatRelative(new Date(membershipDetails?.createdAt || ""), new Date())}`}
>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionMemberActions.AssumePrivileges}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Assume privileges of the user"
passThrough={false}
>
{(isAllowed) => (
<Button
variant="outline_bg"
size="xs"
isDisabled={!isAllowed}
isLoading={assumePrivileges.isPending}
onClick={() =>
handlePopUpOpen("assumePrivileges", { userId: membershipDetails?.user?.id })
}
>
Assume Privileges
</Button>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionMemberActions.Delete}
a={ProjectPermissionSub.Member}
renderTooltip
allowedLabel="Remove from project"
@@ -128,6 +185,15 @@ export const Page = () => {
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
onDeleteApproved={handleRemoveUser}
/>
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to assume privileges of this user?"
subTitle="This will set your privileges to those of the user for the next hour."
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
@@ -23,6 +23,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useUser
@@ -90,7 +91,7 @@ export const MemberProjectAdditionalPrivilegeSection = ({ membershipDetails }: P
privilegeId={(popUp?.modifyPrivilege?.data as { id: string })?.id}
isDisabled={
isOwnProjectMembershipDetails ||
permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member)
permission.cannot(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member)
}
/>
</motion.div>
@@ -31,7 +31,7 @@ import {
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
@@ -95,7 +95,7 @@ export const MembershipProjectAdditionalPrivilegeModifySection = ({
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member
);
@@ -27,6 +27,7 @@ import {
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
@@ -69,7 +70,7 @@ export const MemberRoleModify = ({ projectMember, onOpenUpgradeModal }: Props) =
const { data: projectRoles, isPending: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionMemberActions.Edit,
ProjectPermissionSub.Member
);
@@ -1,8 +1,9 @@
import { cloneElement } from "react";
import { cloneElement, useState } from "react";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import {
faChevronDown,
faChevronRight,
faGripVertical,
faInfoCircle,
faPlus,
faTrash
@@ -36,14 +37,44 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
isDisabled
}: Props<T>) => {
const { control, watch } = useFormContext<TFormSchema>();
const items = useFieldArray({
const { fields, remove, insert, move } = useFieldArray({
control,
name: `permissions.${subject}`
});
const [isOpen, setIsOpen] = useToggle();
// const [hideFullReadAccess, setHideFullReadAccess] = useState(false);
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
if (!items.fields.length) return <div />;
if (!fields.length) return <div />;
const handleDragStart = (_: React.DragEvent, index: number) => {
setDraggedItem(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverItem(index);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (draggedItem === null || dragOverItem === null || draggedItem === dragOverItem) {
setDraggedItem(null);
setDragOverItem(null);
return;
}
move(draggedItem, dragOverItem);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
return (
<div className="border border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md">
@@ -62,17 +93,17 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
<FontAwesomeIcon icon={isOpen ? faChevronDown : faChevronRight} />
</div>
<div className="flex-grow">{title}</div>
{items.fields.length > 1 && (
{fields.length > 1 && (
<div>
<Tag size="xs" className="px-2">
{items.fields.length} rules
{fields.length} rules
</Tag>
</div>
)}
</div>
{isOpen && (
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
{items.fields.map((el, rootIndex) => {
{fields.map((el, rootIndex) => {
let isFullReadAccessEnabled = false;
if (subject === ProjectPermissionSub.Secrets) {
@@ -82,47 +113,72 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
return (
<div
key={el.id}
className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md"
>
{isConditionalSubjects(subject) && (
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
<div className="w-1/4">Permission</div>
<div className="mr-4 w-1/4">
<Controller
defaultValue={false as any}
name={`permissions.${subject}.${rootIndex}.inverted`}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-full"
className="w-full"
isDisabled={isDisabled}
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>
</Select>
)}
/>
</div>
<div>
<Tooltip
asChild
content={
<>
<p>
Whether to allow or forbid the selected actions when the following
conditions (if any) are met.
</p>
<p className="mt-2">Forbid rules must come after allow rules.</p>
</>
}
>
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
</Tooltip>
</div>
</div>
className={twMerge(
"relative bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md",
dragOverItem === rootIndex ? "border-2 border-blue-400" : "",
draggedItem === rootIndex ? "opacity-50" : ""
)}
onDragOver={(e) => handleDragOver(e, rootIndex)}
onDrop={handleDrop}
>
{!isDisabled && (
<Tooltip position="left" content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, rootIndex)}
onDragEnd={handleDragEnd}
className="absolute right-3 top-2 cursor-move rounded-md bg-mineshaft-700 p-2 text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
)}
<div className="mb-4 flex items-center">
{isConditionalSubjects(subject) && (
<div className="flex w-full items-center text-gray-300">
<div className="w-1/4">Permission</div>
<div className="mr-4 w-1/4">
<Controller
defaultValue={false as any}
name={`permissions.${subject}.${rootIndex}.inverted`}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-full"
className="w-full"
isDisabled={isDisabled}
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>
</Select>
)}
/>
</div>
<div>
<Tooltip
asChild
content={
<>
<p>
Whether to allow or forbid the selected actions when the following
conditions (if any) are met.
</p>
<p className="mt-2">Forbid rules must come after allow rules.</p>
</>
}
>
<FontAwesomeIcon
icon={faInfoCircle}
size="sm"
className="text-gray-400"
/>
</Tooltip>
</div>
</div>
)}
</div>
<div className="flex text-gray-300">
<div className="w-1/4">Actions</div>
<div className="flex flex-grow flex-wrap justify-start gap-8">
@@ -179,7 +235,7 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
size="xs"
className="mt-2"
onClick={() => {
items.insert(rootIndex + 1, [
insert(rootIndex + 1, [
{ read: false, edit: false, create: false, delete: false } as any
]);
}}
@@ -194,7 +250,7 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
variant="outline_bg"
size="xs"
className="mt-2 hover:border-red"
onClick={() => items.remove(rootIndex)}
onClick={() => remove(rootIndex)}
isDisabled={isDisabled}
>
Remove policy
@@ -92,7 +92,8 @@ const MemberPolicyActionSchema = z.object({
[ProjectPermissionMemberActions.Create]: z.boolean().optional(),
[ProjectPermissionMemberActions.Edit]: z.boolean().optional(),
[ProjectPermissionMemberActions.Delete]: z.boolean().optional(),
[ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional()
[ProjectPermissionMemberActions.GrantPrivileges]: z.boolean().optional(),
[ProjectPermissionMemberActions.AssumePrivileges]: z.boolean().optional()
});
const IdentityPolicyActionSchema = z.object({
@@ -100,7 +101,8 @@ const IdentityPolicyActionSchema = z.object({
[ProjectPermissionIdentityActions.Create]: z.boolean().optional(),
[ProjectPermissionIdentityActions.Edit]: z.boolean().optional(),
[ProjectPermissionIdentityActions.Delete]: z.boolean().optional(),
[ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional()
[ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional(),
[ProjectPermissionIdentityActions.AssumePrivileges]: z.boolean().optional()
});
const GroupPolicyActionSchema = z.object({
@@ -577,6 +579,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canEdit = action.includes(ProjectPermissionMemberActions.Edit);
const canDelete = action.includes(ProjectPermissionMemberActions.Delete);
const canGrantPrivileges = action.includes(ProjectPermissionMemberActions.GrantPrivileges);
const canAssumePrivileges = action.includes(ProjectPermissionMemberActions.AssumePrivileges);
if (!formVal[subject]) formVal[subject] = [{}];
@@ -587,6 +590,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0][ProjectPermissionMemberActions.Delete] = true;
if (canGrantPrivileges)
formVal[subject]![0][ProjectPermissionMemberActions.GrantPrivileges] = true;
if (canAssumePrivileges)
formVal[subject]![0][ProjectPermissionMemberActions.AssumePrivileges] = true;
return;
}
@@ -596,6 +601,9 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canEdit = action.includes(ProjectPermissionIdentityActions.Edit);
const canDelete = action.includes(ProjectPermissionIdentityActions.Delete);
const canGrantPrivileges = action.includes(ProjectPermissionIdentityActions.GrantPrivileges);
const canAssumePrivileges = action.includes(
ProjectPermissionIdentityActions.AssumePrivileges
);
if (!formVal[subject]) formVal[subject] = [{ conditions: [] }];
@@ -606,6 +614,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0][ProjectPermissionIdentityActions.Delete] = true;
if (canGrantPrivileges)
formVal[subject]![0][ProjectPermissionIdentityActions.GrantPrivileges] = true;
if (canAssumePrivileges)
formVal[subject]![0][ProjectPermissionIdentityActions.AssumePrivileges] = true;
return;
}
@@ -904,7 +914,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Add", value: ProjectPermissionMemberActions.Create },
{ label: "Modify", value: ProjectPermissionMemberActions.Edit },
{ label: "Remove", value: ProjectPermissionMemberActions.Delete },
{ label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges }
{ label: "Grant Privileges", value: ProjectPermissionMemberActions.GrantPrivileges },
{ label: "Assume Privileges", value: ProjectPermissionMemberActions.AssumePrivileges }
]
},
[ProjectPermissionSub.Identity]: {
@@ -914,7 +925,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Add", value: ProjectPermissionIdentityActions.Create },
{ label: "Modify", value: ProjectPermissionIdentityActions.Edit },
{ label: "Remove", value: ProjectPermissionIdentityActions.Delete },
{ label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges }
{ label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges },
{ label: "Assume Privileges", value: ProjectPermissionIdentityActions.AssumePrivileges }
]
},
[ProjectPermissionSub.Groups]: {
@@ -9,6 +9,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
import { TeamCitySyncDestinationCol } from "./TeamCitySyncDestinationCol";
import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol";
import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol";
import { WindmillSyncDestinationCol } from "./WindmillSyncDestinationCol";
@@ -43,6 +44,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <VercelSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Windmill:
return <WindmillSyncDestinationCol secretSync={secretSync} />;
case SecretSync.TeamCity:
return <TeamCitySyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -0,0 +1,14 @@
import { TTeamCitySync } from "@app/hooks/api/secretSyncs/types/teamcity-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TTeamCitySync;
};
export const TeamCitySyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -94,6 +94,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.workspace;
secondaryText = destinationConfig.path;
break;
case SecretSync.TeamCity:
primaryText = destinationConfig.project;
secondaryText = destinationConfig.buildConfig;
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -135,7 +135,6 @@ export const SecretOverviewSecretRotationRow = ({
<img
src={`/images/integrations/${image}`}
style={{
height: "11px",
width: "11px"
}}
alt={`${rotationType} logo`}
@@ -4,6 +4,7 @@ import { subject } from "@casl/ability";
import {
faCheck,
faCopy,
faEyeSlash,
faProjectDiagram,
faTrash,
faXmark
@@ -25,7 +26,6 @@ import {
ModalTrigger,
Tooltip
} from "@app/components/v2";
import { Blur } from "@app/components/v2/Blur";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
@@ -149,7 +149,13 @@ export const SecretEditRow = ({
);
}
}
reset({ value });
if (secretValueHidden && !isOverride) {
setTimeout(() => {
reset({ value: defaultValue || null });
}, 50);
} else {
reset({ value });
}
};
const handleEditSecret = async ({ secretValue }: { secretValue: string }) => {
@@ -169,6 +175,16 @@ export const SecretEditRow = ({
ProjectPermissionSecretActions.ReadValue
);
const canEditSecretValue = permission.can(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: ["*"]
})
);
const handleDeleteSecret = useCallback(async () => {
setIsDeleting.on();
setIsModalOpen(false);
@@ -190,29 +206,32 @@ export const SecretEditRow = ({
deleteKey={secretName}
onDeleteApproved={handleDeleteSecret}
/>
{secretValueHidden && !isOverride && (
<Tooltip
content={`You do not have access to view the current value${canEditSecretValue && !isRotatedSecret ? ", but you can set a new one" : "."}`}
>
<FontAwesomeIcon className="pl-2" size="sm" icon={faEyeSlash} />
</Tooltip>
)}
<div className="flex-grow border-r border-r-mineshaft-600 pl-1 pr-2">
{secretValueHidden ? (
<Blur tooltipText="You do not have permission to read the value of this secret." />
) : (
<Controller
disabled={isImportedSecret && !defaultValue}
control={control}
name="value"
render={({ field }) => (
<InfisicalSecretInput
{...field}
isReadOnly={isImportedSecret || isRotatedSecret}
value={field.value as string}
key="secret-input"
isVisible={isVisible}
secretPath={secretPath}
environment={environment}
isImport={isImportedSecret}
/>
)}
/>
)}
<Controller
disabled={isImportedSecret && !defaultValue}
control={control}
name="value"
render={({ field }) => (
<InfisicalSecretInput
{...field}
isReadOnly={isImportedSecret || (isRotatedSecret && !isOverride)}
value={field.value as string}
key="secret-input"
isVisible={isVisible && !secretValueHidden}
secretPath={secretPath}
environment={environment}
isImport={isImportedSecret}
defaultValue={secretValueHidden ? "" : undefined}
/>
)}
/>
</div>
<div
@@ -1,3 +1,4 @@
import { subject } from "@casl/ability";
import { faCircle } from "@fortawesome/free-regular-svg-icons";
import {
faAngleDown,
@@ -14,6 +15,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
import { useProjectPermission } from "@app/context";
import {
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { useToggle } from "@app/hooks";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
@@ -73,6 +79,28 @@ export const SecretOverviewTableRow = ({
const totalCols = environments.length + 1; // secret key row
const [isSecretVisible, setIsSecretVisible] = useToggle();
const { permission } = useProjectPermission();
const getDefaultValue = (
secret: SecretV3RawSanitized | undefined,
importedSecret: { secret?: SecretV3RawSanitized } | undefined
) => {
const canEditSecretValue = permission.can(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment: secret?.env || "",
secretPath: secret?.path || "",
secretName: secret?.key || "",
secretTags: ["*"]
})
);
if (secret?.secretValueHidden && !secret?.valueOverride) {
return canEditSecretValue ? "******" : "";
}
return secret?.valueOverride || secret?.value || importedSecret?.secret?.value || "";
};
return (
<>
<Tr isHoverable isSelectable onClick={() => setIsFormExpanded.toggle()} className="group">
@@ -237,13 +265,7 @@ export const SecretOverviewTableRow = ({
isVisible={isSecretVisible}
secretName={secretKey}
secretValueHidden={secret?.secretValueHidden || false}
defaultValue={
secret?.secretValueHidden
? ""
: secret?.valueOverride ||
secret?.value ||
importedSecret?.secret?.value
}
defaultValue={getDefaultValue(secret, importedSecret)}
secretId={secret?.id}
isOverride={Boolean(secret?.valueOverride)}
isImportedSecret={isImportedSecret}
@@ -25,7 +25,7 @@ import {
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
@@ -289,7 +289,10 @@ export const AccessApprovalRequest = ({
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
@@ -22,7 +22,7 @@ import {
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionMemberActions,
ProjectPermissionSub,
useProjectPermission,
useUser,
@@ -171,7 +171,10 @@ export const SecretApprovalRequest = () => {
))}
</DropdownMenuContent>
</DropdownMenu>
{!!permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Member) && (
{!!permission.can(
ProjectPermissionMemberActions.Read,
ProjectPermissionSub.Member
) && (
<DropdownMenu>
<DropdownMenuTrigger>
<Button
@@ -6,10 +6,28 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetWorkspaceUsers } from "@app/hooks/api";
const ReminderFormSchema = z.object({
note: z.string().optional().nullable(),
recipients: z
.array(
z.object({
label: z.string(),
value: z.string().uuid()
})
)
.optional(),
days: z
.number()
.min(1, { message: "Must be at least 1 day" })
@@ -22,6 +40,7 @@ interface ReminderFormProps {
isOpen: boolean;
repeatDays?: number | null;
note?: string | null;
recipients?: string[] | null;
onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void;
}
@@ -29,12 +48,16 @@ export const CreateReminderForm = ({
isOpen,
onOpenChange,
repeatDays,
note
note,
recipients
}: ReminderFormProps) => {
const { currentWorkspace } = useWorkspace();
const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id);
const {
register,
control,
reset,
setValue,
handleSubmit,
formState: { isSubmitting }
@@ -51,13 +74,23 @@ export const CreateReminderForm = ({
};
useEffect(() => {
if (isOpen) {
reset({
days: repeatDays || undefined,
note: note || ""
});
// On initial load, filter the members to only include the recipients
if (members.length) {
const filteredMembers = members.filter((m) => recipients?.find((r) => r === m.user.id));
setValue(
"recipients",
filteredMembers.map((m) => ({
label: m.user.username || m.user.email,
value: m.user.id
}))
);
}
}, [isOpen]);
}, [members, isOpen, recipients]);
useEffect(() => {
if (repeatDays) setValue("days", repeatDays);
if (note) setValue("note", note);
}, [repeatDays, note]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -74,6 +107,7 @@ export const CreateReminderForm = ({
render={({ field, fieldState }) => (
<>
<FormControl
isRequired
className="mb-0"
label="Reminder Interval (in days)"
isError={Boolean(fieldState.error)}
@@ -83,6 +117,7 @@ export const CreateReminderForm = ({
onChange={(el) => setValue("days", parseInt(el.target.value, 10))}
type="number"
placeholder="31"
defaultValue={repeatDays || undefined}
value={field.value || undefined}
/>
</FormControl>
@@ -105,11 +140,43 @@ export const CreateReminderForm = ({
placeholder="Remember to rotate the AWS secret every month."
className="border border-mineshaft-600 text-sm"
rows={8}
defaultValue={note || ""}
reSize="none"
cols={30}
{...register("note")}
/>
</FormControl>
<Controller
control={control}
name="recipients"
render={({ field }) => (
<FormControl
tooltipText={
<div>
Select users to receive reminders.
<br />
<br /> If none are selected, all project members will receive the reminder.
</div>
}
label="Recipients"
className="mb-0"
>
<FilterableSelect
className="w-full"
placeholder="Select reminder recipients..."
isMulti
name="recipients"
options={members.map((member) => ({
label: member.user.username || member.user.email,
value: member.user.id
}))}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
)}
/>
</div>
<div className="mt-7 flex items-center space-x-4">
<Button
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons";
@@ -220,11 +221,12 @@ export const SecretDetailSidebar = ({
const handleReminderSubmit = async (
reminderRepeatDays: number | null | undefined,
reminderNote: string | null | undefined
reminderNote: string | null | undefined,
reminderRecipients: string[] | undefined
) => {
await onSaveSecret(
secret,
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true },
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true, reminderRecipients },
() => {}
);
};
@@ -233,6 +235,16 @@ export const SecretDetailSidebar = ({
const secretReminderRepeatDays = watch("reminderRepeatDays");
const secretReminderNote = watch("reminderNote");
const secretReminderRecipients = watch("reminderRecipients");
useEffect(() => {
setValue(
"reminderRecipients",
secret?.secretReminderRecipients?.map((el) => el.user.id),
{
shouldDirty: false
}
);
}, [secret?.secretReminderRecipients]);
const getModifiedByIcon = (userType: string | undefined | null) => {
switch (userType) {
@@ -294,14 +306,20 @@ export const SecretDetailSidebar = ({
<CreateReminderForm
repeatDays={secretReminderRepeatDays}
note={secretReminderNote}
recipients={secretReminderRecipients}
isOpen={createReminderFormOpen}
onOpenChange={(_, data) => {
setCreateReminderFormOpen.toggle();
if (data) {
const recipients = data.recipients?.length
? data.recipients.map((recipient) => recipient.value)
: undefined;
setValue("reminderRepeatDays", data.days, { shouldDirty: false });
setValue("reminderNote", data.note, { shouldDirty: false });
handleReminderSubmit(data.days, data.note);
setValue("reminderRecipients", recipients, { shouldDirty: false });
handleReminderSubmit(data.days, data.note, recipients);
}
}}
/>
@@ -47,10 +47,9 @@ import {
} from "@app/components/secrets/SecretReferenceDetails";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { Blur } from "@app/components/v2/Blur";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
import {
FontAwesomeSpriteName,
formSchema,
@@ -59,6 +58,8 @@ import {
} from "./SecretListView.utils";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
const hiddenValue = "******";
type Props = {
secret: SecretV3RawSanitized;
onSaveSecret: (
@@ -109,6 +110,23 @@ export const SecretItem = memo(
const { permission } = useProjectPermission();
const { isRotatedSecret } = secret;
const canEditSecretValue = permission.can(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secret.key,
secretTags: ["*"]
})
);
const getDefaultValue = () => {
if (secret.secretValueHidden) {
return canEditSecretValue ? hiddenValue : "";
}
return secret.valueOverride || secret.value || "";
};
const {
handleSubmit,
control,
@@ -122,11 +140,11 @@ export const SecretItem = memo(
} = useForm<TFormSchema>({
defaultValues: {
...secret,
value: secret.secretValueHidden ? "" : secret.value
value: getDefaultValue()
},
values: {
...secret,
value: secret.secretValueHidden ? "" : secret.value
value: getDefaultValue()
},
resolver: zodResolver(formSchema)
});
@@ -168,6 +186,7 @@ export const SecretItem = memo(
secretTags: selectedTagSlugs
})
);
const { secretValueHidden } = secret;
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
@@ -316,6 +335,13 @@ export const SecretItem = memo(
tabIndex={0}
role="button"
>
{secretValueHidden && !isOverriden && (
<Tooltip
content={`You do not have access to view the current value${canEditSecretValue && !isRotatedSecret ? ", but you can set a new one" : "."}`}
>
<FontAwesomeIcon className="pr-2" size="sm" icon={faEyeSlash} />
</Tooltip>
)}
{isOverriden ? (
<Controller
name="valueOverride"
@@ -331,8 +357,6 @@ export const SecretItem = memo(
/>
)}
/>
) : secretValueHidden ? (
<Blur tooltipText="You do not have permission to read the value of this secret." />
) : (
<Controller
name="value"
@@ -342,11 +366,11 @@ export const SecretItem = memo(
<InfisicalSecretInput
isReadOnly={isReadOnly || isRotatedSecret}
key="secret-value"
isVisible={isVisible}
isVisible={isVisible && !secretValueHidden}
environment={environment}
secretPath={secretPath}
{...field}
defaultValue={secretValueHidden ? "" : undefined}
defaultValue={secretValueHidden ? hiddenValue : undefined}
containerClassName="py-1.5 rounded-md transition-all"
/>
)}
@@ -87,6 +87,7 @@ export const SecretListView = ({
comment,
reminderRepeatDays,
reminderNote,
reminderRecipients,
tags,
skipMultilineEncoding,
newKey,
@@ -98,6 +99,7 @@ export const SecretListView = ({
comment: string;
reminderRepeatDays: number | null;
reminderNote: string | null;
reminderRecipients?: string[] | null;
tags: string[];
skipMultilineEncoding: boolean;
newKey: string;
@@ -133,6 +135,7 @@ export const SecretListView = ({
secretComment: comment,
secretReminderRepeatDays: reminderRepeatDays,
secretReminderNote: reminderNote,
secretReminderRecipients: reminderRecipients,
skipMultilineEncoding,
secretMetadata
});
@@ -174,6 +177,7 @@ export const SecretListView = ({
comment,
reminderRepeatDays,
reminderNote,
reminderRecipients,
secretMetadata,
isReminderEvent
} = modSecret;
@@ -182,6 +186,12 @@ export const SecretListView = ({
const tagIds = tags?.map(({ id }) => id);
const oldTagIds = (orgSecret?.tags || []).map(({ id }) => id);
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
const isSameRecipients =
!reminderRecipients?.some(
(newId) => !orgSecret.secretReminderRecipients?.find((oldId) => newId === oldId.user.id)
) && reminderRecipients?.length === orgSecret.secretReminderRecipients?.length;
const isSharedSecUnchanged =
(
[
@@ -191,9 +201,12 @@ export const SecretListView = ({
"skipMultilineEncoding",
"reminderRepeatDays",
"reminderNote",
"reminderRecipients",
"secretMetadata"
] as const
).every((el) => orgSecret[el] === modSecret[el]) && isSameTags;
).every((el) => orgSecret[el] === modSecret[el]) &&
isSameTags &&
isSameRecipients;
try {
// personal secret change
@@ -226,6 +239,7 @@ export const SecretListView = ({
comment,
reminderRepeatDays,
reminderNote,
reminderRecipients,
secretId: orgSecret.id,
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding,
@@ -47,6 +47,7 @@ export const formSchema = z.object({
.nullable()
.optional(),
reminderNote: z.string().trim().nullable().optional(),
reminderRecipients: z.array(z.string().uuid()).optional(),
secretMetadata: z
.object({
key: z.string().trim().min(1),
@@ -55,7 +55,6 @@ export const SecretRotationItem = ({
<img
src={`/images/integrations/${image}`}
style={{
height: "11px",
width: "11px"
}}
alt={`${rotationType} logo`}
@@ -19,6 +19,7 @@ import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSec
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
import { GitHubSyncDestinationSection } from "./GitHubSyncDestinationSection";
import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection";
import { TeamCitySyncDestinationSection } from "./TeamCitySyncDestinationSection";
import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection";
import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection";
import { WindmillSyncDestinationSection } from "./WindmillSyncDestinationSection";
@@ -73,6 +74,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.Windmill:
DestinationComponents = <WindmillSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.TeamCity:
DestinationComponents = <TeamCitySyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
@@ -0,0 +1,19 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TTeamCitySync } from "@app/hooks/api/secretSyncs/types/teamcity-sync";
type Props = {
secretSync: TTeamCitySync;
};
export const TeamCitySyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { project, buildConfig }
} = secretSync;
return (
<>
<GenericFieldLabel label="Project">{project}</GenericFieldLabel>
<GenericFieldLabel label="Build Configuration">{buildConfig}</GenericFieldLabel>
</>
);
};
@@ -52,6 +52,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.Camunda:
case SecretSync.Vercel:
case SecretSync.Windmill:
case SecretSync.TeamCity:
AdditionalSyncOptionsComponent = null;
break;
default:
@@ -1,11 +1,12 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
const tabs = [{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab }];
import { ProjectSshTab } from "./components/ProjectSshTab";
export const SettingsPage = () => {
const { t } = useTranslation();
@@ -17,19 +18,28 @@ export const SettingsPage = () => {
</Helmet>
<div className="w-full max-w-7xl">
<PageHeader title={t("settings.project.title")} />
<Tabs defaultValue={tabs[0].key}>
<Tabs defaultValue="tab-project-general">
<TabList>
{tabs.map((tab) => (
<Tab value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
<Tab value="tab-project-general">General</Tab>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => isAllowed && <Tab value="tab-project-ssh">SSH Settings</Tab>}
</ProjectPermissionCan>
</TabList>
{tabs.map(({ key, Component }) => (
<TabPanel value={key} key={key}>
<Component />
</TabPanel>
))}
<TabPanel value="tab-project-general">
<ProjectGeneralTab />
</TabPanel>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) =>
isAllowed && (
<TabPanel value="tab-project-ssh">
<ProjectSshTab />
</TabPanel>
)
}
</ProjectPermissionCan>
</Tabs>
</div>
</div>
@@ -0,0 +1,9 @@
import { ProjectSshConfigCasSection } from "./components";
export const ProjectSshTab = () => {
return (
<div>
<ProjectSshConfigCasSection />
</div>
);
};
@@ -0,0 +1,139 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Select, SelectItem } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import {
useGetProjectSshConfig,
useListWorkspaceSshCas,
useUpdateProjectSshConfig
} from "@app/hooks/api";
const schema = z
.object({
defaultUserSshCaId: z.string(),
defaultHostSshCaId: z.string()
})
.required();
export type FormData = z.infer<typeof schema>;
export const ProjectSshConfigCasSection = () => {
const { currentWorkspace } = useWorkspace();
const { data: sshConfig } = useGetProjectSshConfig(currentWorkspace.id);
const { data: sshCas } = useListWorkspaceSshCas(currentWorkspace.id);
const { mutate: updateProjectSshConfig } = useUpdateProjectSshConfig();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema)
});
useEffect(() => {
if (sshConfig) {
reset({
defaultUserSshCaId: sshConfig.defaultUserSshCaId || "",
defaultHostSshCaId: sshConfig.defaultHostSshCaId || ""
});
}
}, [sshConfig]);
const onFormSubmit = async ({ defaultUserSshCaId, defaultHostSshCaId }: FormData) => {
try {
await updateProjectSshConfig({
projectId: currentWorkspace.id,
defaultUserSshCaId: defaultUserSshCaId || undefined,
defaultHostSshCaId: defaultHostSshCaId || undefined
});
createNotification({
text: "Successfully updated SSH project settings",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update SSH project settings",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-8 text-xl font-semibold">Certificate Authorities</p>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="defaultUserSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default User CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="defaultHostSshCaId"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="Default Host CA"
isError={Boolean(error)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="min-w-[20rem]"
>
{sshCas?.map(({ id, friendlyName }) => (
<SelectItem value={String(id || "")} key={friendlyName}>
{friendlyName}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</form>
</div>
);
};
@@ -0,0 +1 @@
export { ProjectSshConfigCasSection } from "./ProjectSshConfigCasSection";
@@ -0,0 +1 @@
export { ProjectSshTab } from "./ProjectSshTab";
@@ -23,6 +23,7 @@ import {
useCreateSshHost,
useGetSshHostById,
useGetWorkspaceUsers,
useListWorkspaceSshHosts,
useUpdateSshHost
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -34,7 +35,8 @@ type Props = {
const schema = z
.object({
hostname: z.string(),
hostname: z.string().trim(),
alias: z.string().trim(),
userCertTtl: z
.string()
.trim()
@@ -58,6 +60,7 @@ export type FormData = z.infer<typeof schema>;
export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id || "";
const { data: sshHosts } = useListWorkspaceSshHosts(currentWorkspace.id);
const { data: members = [] } = useGetWorkspaceUsers(projectId);
const [expandedMappings, setExpandedMappings] = useState<Record<number, boolean>>({});
@@ -79,6 +82,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
resolver: zodResolver(schema),
defaultValues: {
hostname: "",
alias: "",
userCertTtl: "8h",
loginMappings: []
}
@@ -93,6 +97,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
if (sshHost) {
reset({
hostname: sshHost.hostname,
alias: sshHost.alias ?? "",
userCertTtl: sshHost.userCertTtl,
loginMappings: sshHost.loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
@@ -106,20 +111,51 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
} else {
reset({
hostname: "",
alias: "",
userCertTtl: "8h",
loginMappings: []
});
}
}, [sshHost]);
const onFormSubmit = async ({ hostname, userCertTtl, loginMappings }: FormData) => {
const onFormSubmit = async ({ hostname, alias, userCertTtl, loginMappings }: FormData) => {
try {
if (!projectId) return;
// check if there is already a different host with the same hostname
const existingHostnames =
sshHosts?.filter((h) => h.id !== sshHost?.id).map((h) => h.hostname) || [];
if (existingHostnames.includes(hostname.trim())) {
createNotification({
text: "A host with this hostname already exists.",
type: "error"
});
return;
}
const trimmedAlias = alias.trim();
// check if there is already a different host with the same non-null alias
if (trimmedAlias) {
const existingAliases =
sshHosts?.filter((h) => h.id !== sshHost?.id && h.alias !== null).map((h) => h.alias) ||
[];
if (existingAliases.includes(trimmedAlias)) {
createNotification({
text: "A host with this alias already exists.",
type: "error"
});
return;
}
}
if (sshHost) {
await updateMutateAsync({
sshHostId: sshHost.id,
hostname,
alias: trimmedAlias,
userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
@@ -132,6 +168,7 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
await createMutateAsync({
projectId,
hostname,
alias: trimmedAlias,
userCertTtl,
loginMappings: loginMappings.map(({ loginUser, allowedPrincipals }) => ({
loginUser,
@@ -195,6 +232,16 @@ export const SshHostModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="alias"
render={({ field, fieldState: { error } }) => (
<FormControl label="Alias" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="host" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
@@ -66,6 +66,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
<Table>
<THead>
<Tr>
<Th>Alias</Th>
<Th>Hostname</Th>
<Th>Login User - Authorized Principals Mapping</Th>
<Th />
@@ -83,6 +84,7 @@ export const SshHostsTable = ({ handlePopUpOpen }: Props) => {
className="h-10"
key={`ssh-host-${host.id}`}
>
<Td>{host.alias ?? "-"}</Td>
<Td>{host.hostname}</Td>
<Td>
{host.loginMappings.length === 0 ? (
@@ -1,6 +1,8 @@
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { createNotification } from "@app/components/notifications";
import {
DeleteActionModal,
EmptyState,
Table,
TableContainer,
@@ -9,59 +11,131 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useGetMySessions } from "@app/hooks/api";
import { Button } from "@app/components/v2/Button";
import { useGetMySessions, useRevokeMySessionById } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { timeAgo } from "@app/lib/fn/date";
import { formatSessionUserAgent } from "@app/lib/fn/string";
const formatLocalDateTime = (date: Date): string => {
return date.toLocaleString(undefined, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
};
export const SessionsTable = () => {
const { data, isPending } = useGetMySessions();
const { mutateAsync: revokeMySessionById } = useRevokeMySessionById();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSession"
] as const);
const formatDate = (dateToFormat: string) => {
const date = new Date(dateToFormat);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const handleSignOut = async (sessionId: string) => {
try {
await revokeMySessionById(sessionId);
createNotification({
text: "Session revoked successfully",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to revoke session",
type: "error"
});
}
const formattedDate = `${day}/${month}/${year}`;
return formattedDate;
handlePopUpClose("deleteSession");
};
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Created</Th>
<Th>Last active</Th>
<Th>IP address</Th>
<Th>Device</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="sesssions" />}
{!isPending &&
data &&
data.length > 0 &&
data.map(({ id, createdAt, lastUsed, ip, userAgent }) => {
return (
<Tr className="h-10" key={`session-${id}`}>
<Td>{formatDate(createdAt)}</Td>
<Td>{formatDate(lastUsed)}</Td>
<Td>{ip}</Td>
<Td>{userAgent}</Td>
</Tr>
);
})}
{!isPending && data && data?.length === 0 && (
<>
<DeleteActionModal
isOpen={popUp.deleteSession.isOpen}
title="Are you sure want to sign out of this session?"
onChange={(isOpen) => handlePopUpToggle("deleteSession", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
handleSignOut((popUp?.deleteSession?.data as { sessionId: string })?.sessionId)
}
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Td colSpan={4}>
<EmptyState title="No sessions on file" icon={faServer} />
</Td>
<Th>IP & Session ID</Th>
<Th>OS & Browser</Th>
<Th>Last accessed</Th>
<Th>Manage</Th>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="sessions" />}
{!isPending &&
data &&
data.length > 0 &&
data.map(({ id, createdAt, lastUsed, ip, userAgent }) => {
const { os, browser } = formatSessionUserAgent(userAgent);
const lastUsedDate = new Date(lastUsed);
const createdAtDate = new Date(createdAt);
return (
<Tr className="h-20" key={`session-${id}`}>
<Td>
<div className="flex flex-col">
<span className="font-medium">{ip}</span>
<span className="text-sm text-gray-500">ID: {id}</span>
</div>
</Td>
<Td>
<div className="flex flex-col">
<span className="font-medium">{os}</span>
<span className="text-sm text-gray-500">{browser}</span>
</div>
</Td>
<Td>
<div className="flex flex-col">
<Tooltip content={formatLocalDateTime(lastUsedDate)}>
<span className="font-medium">{timeAgo(lastUsedDate, new Date())}</span>
</Tooltip>
<Tooltip content={formatLocalDateTime(createdAtDate)}>
<span className="text-sm text-gray-500">
Created {timeAgo(createdAtDate, new Date())}
</span>
</Tooltip>
</div>
</Td>
<Td>
<Button
variant="plain"
colorSchema="danger"
onClick={() => handlePopUpOpen("deleteSession", { sessionId: id })}
>
Sign out
</Button>
</Td>
</Tr>
);
})}
{!isPending && data && data?.length === 0 && (
<Tr>
<Td colSpan={4}>
<EmptyState title="No sessions on file" icon={faServer} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
</>
);
};