Compare commits
51 Commits
infisical/
...
infisical/
Author | SHA1 | Date | |
---|---|---|---|
cb66386e13 | |||
ae53f03f71 | |||
7ae024724d | |||
0b2bc1d345 | |||
da5eca3e68 | |||
d140e4f3c9 | |||
80623c03f4 | |||
ed6c6e8d1e | |||
7e044ad9ff | |||
8f2b54514c | |||
5f5f46eddf | |||
3174896d37 | |||
919e184305 | |||
c7d08745fc | |||
d6d780a7b4 | |||
03e965ec5a | |||
cd0df2d617 | |||
e72e6dd6ee | |||
7987a1ea2b | |||
e6036175c1 | |||
171a70ddc1 | |||
a845f4ee5c | |||
71cd4425b4 | |||
deb22bf8ad | |||
1b1a95ab78 | |||
cf4f26ab90 | |||
84249f535b | |||
c7bbe82f4a | |||
d8d2741868 | |||
f45074a2dd | |||
564b6b8ef6 | |||
fafd963a8a | |||
9e38076d45 | |||
d3a6da187b | |||
7a90fa472d | |||
756c1e5098 | |||
0dd34eae60 | |||
846e2f21cc | |||
68296c1b99 | |||
2192985291 | |||
16acace648 | |||
e3e4a98cd6 | |||
4afb20ad0d | |||
60134cf8ac | |||
22d5f97793 | |||
d12c4b7580 | |||
5feb942d79 | |||
ae2706542c | |||
d5861493bf | |||
53044f3d39 | |||
93268f5767 |
@ -4,7 +4,7 @@
|
||||
ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218
|
||||
|
||||
# Required
|
||||
DB_CONNECTION_URI=postgres://infisical:infisical@db:5432/infisical
|
||||
DB_CONNECTION_URI=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
|
||||
# JWT
|
||||
# Required secrets to sign JWT tokens
|
||||
|
190
.github/resources/changelog-generator.py
vendored
Normal file
@ -0,0 +1,190 @@
|
||||
# inspired by https://www.photoroom.com/inside-photoroom/how-we-automated-our-changelog-thanks-to-chatgpt
|
||||
import os
|
||||
import requests
|
||||
import re
|
||||
from openai import OpenAI
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
|
||||
import uuid
|
||||
|
||||
# Constants
|
||||
REPO_OWNER = "infisical"
|
||||
REPO_NAME = "infisical"
|
||||
TOKEN = os.environ["GITHUB_TOKEN"]
|
||||
SLACK_WEBHOOK_URL = os.environ["SLACK_WEBHOOK_URL"]
|
||||
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
|
||||
SLACK_MSG_COLOR = "#36a64f"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
|
||||
def set_multiline_output(name, value):
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as fh:
|
||||
delimiter = uuid.uuid1()
|
||||
print(f'{name}<<{delimiter}', file=fh)
|
||||
print(value, file=fh)
|
||||
print(delimiter, file=fh)
|
||||
|
||||
def post_changelog_to_slack(changelog, tag):
|
||||
slack_payload = {
|
||||
"text": "Hey team, it's changelog time! :wave:",
|
||||
"attachments": [
|
||||
{
|
||||
"color": SLACK_MSG_COLOR,
|
||||
"title": f"🗓️Infisical Changelog - {tag}",
|
||||
"text": changelog,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
response = requests.post(SLACK_WEBHOOK_URL, json=slack_payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception("Failed to post changelog to Slack.")
|
||||
|
||||
def find_previous_release_tag(release_tag:str):
|
||||
previous_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0", f"{release_tag}^"]).decode("utf-8").strip()
|
||||
while not(previous_tag.startswith("infisical/")):
|
||||
previous_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0", f"{previous_tag}^"]).decode("utf-8").strip()
|
||||
return previous_tag
|
||||
|
||||
def get_tag_creation_date(tag_name):
|
||||
url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/git/refs/tags/{tag_name}"
|
||||
response = requests.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
commit_sha = response.json()['object']['sha']
|
||||
|
||||
commit_url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/commits/{commit_sha}"
|
||||
commit_response = requests.get(commit_url, headers=headers)
|
||||
commit_response.raise_for_status()
|
||||
creation_date = commit_response.json()['commit']['author']['date']
|
||||
|
||||
return datetime.strptime(creation_date, '%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
|
||||
def fetch_prs_between_tags(previous_tag_date:datetime, release_tag_date:datetime):
|
||||
# Use GitHub API to fetch PRs merged between the commits
|
||||
url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/pulls?state=closed&merged=true"
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception("Error fetching PRs from GitHub API!")
|
||||
|
||||
prs = []
|
||||
for pr in response.json():
|
||||
# the idea is as tags happen recently we get last 100 closed PRs and then filter by tag creation date
|
||||
if pr["merged_at"] and datetime.strptime(pr["merged_at"],'%Y-%m-%dT%H:%M:%SZ') < release_tag_date and datetime.strptime(pr["merged_at"],'%Y-%m-%dT%H:%M:%SZ') > previous_tag_date:
|
||||
prs.append(pr)
|
||||
|
||||
return prs
|
||||
|
||||
|
||||
def extract_commit_details_from_prs(prs):
|
||||
commit_details = []
|
||||
for pr in prs:
|
||||
commit_message = pr["title"]
|
||||
commit_url = pr["html_url"]
|
||||
pr_number = pr["number"]
|
||||
branch_name = pr["head"]["ref"]
|
||||
issue_numbers = re.findall(r"(www-\d+|web-\d+)", branch_name)
|
||||
|
||||
# If no issue numbers are found, add the PR details without issue numbers and URLs
|
||||
if not issue_numbers:
|
||||
commit_details.append(
|
||||
{
|
||||
"message": commit_message,
|
||||
"pr_number": pr_number,
|
||||
"pr_url": commit_url,
|
||||
"issue_number": None,
|
||||
"issue_url": None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
for issue in issue_numbers:
|
||||
commit_details.append(
|
||||
{
|
||||
"message": commit_message,
|
||||
"pr_number": pr_number,
|
||||
"pr_url": commit_url,
|
||||
"issue_number": issue,
|
||||
}
|
||||
)
|
||||
|
||||
return commit_details
|
||||
|
||||
# Function to generate changelog using OpenAI
|
||||
def generate_changelog_with_openai(commit_details):
|
||||
commit_messages = []
|
||||
for details in commit_details:
|
||||
base_message = f"{details['pr_url']} - {details['message']}"
|
||||
# Add the issue URL if available
|
||||
# if details["issue_url"]:
|
||||
# base_message += f" (Linear Issue: {details['issue_url']})"
|
||||
commit_messages.append(base_message)
|
||||
|
||||
commit_list = "\n".join(commit_messages)
|
||||
prompt = """
|
||||
Generate a changelog for Infisical, opensource secretops
|
||||
The changelog should:
|
||||
1. Be Informative: Using the provided list of GitHub commits, break them down into categories such as Features, Fixes & Improvements, and Technical Updates. Summarize each commit concisely, ensuring the key points are highlighted.
|
||||
2. Have a Professional yet Friendly tone: The tone should be balanced, not too corporate or too informal.
|
||||
3. Celebratory Introduction and Conclusion: Start the changelog with a celebratory note acknowledging the team's hard work and progress. End with a shoutout to the team and wishes for a pleasant weekend.
|
||||
4. Formatting: you cannot use Markdown formatting, and you can only use emojis for the introductory paragraph or the conclusion paragraph, nowhere else.
|
||||
5. Links: the syntax to create links is the following: `<http://www.example.com|This message is a link>`.
|
||||
6. Linear Links: note that the Linear link is optional, include it only if provided.
|
||||
7. Do not wrap your answer in a codeblock. Just output the text, nothing else
|
||||
Here's a good example to follow, please try to match the formatting as closely as possible, only changing the content of the changelog and have some liberty with the introduction. Notice the importance of the formatting of a changelog item:
|
||||
- <https://github.com/facebook/react/pull/27304/%7C#27304>: We optimize our ci to strip comments and minify production builds. (<https://linear.app/example/issue/WEB-1234/%7CWEB-1234>))
|
||||
And here's an example of the full changelog:
|
||||
|
||||
*Features*
|
||||
• <https://github.com/facebook/react/pull/27304/%7C#27304>: We optimize our ci to strip comments and minify production builds. (<https://linear.app/example/issue/WEB-1234/%7CWEB-1234>)
|
||||
*Fixes & Improvements*
|
||||
• <https://github.com/facebook/react/pull/27304/%7C#27304>: We optimize our ci to strip comments and minify production builds. (<https://linear.app/example/issue/WEB-1234/%7CWEB-1234>)
|
||||
*Technical Updates*
|
||||
• <https://github.com/facebook/react/pull/27304/%7C#27304>: We optimize our ci to strip comments and minify production builds. (<https://linear.app/example/issue/WEB-1234/%7CWEB-1234>)
|
||||
|
||||
Stay tuned for more exciting updates coming soon!
|
||||
And here are the commits:
|
||||
{}
|
||||
""".format(
|
||||
commit_list
|
||||
)
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
response = client.chat.completions.create(model="gpt-3.5-turbo", messages=messages)
|
||||
|
||||
if "error" in response.choices[0].message:
|
||||
raise Exception("Error generating changelog with OpenAI!")
|
||||
|
||||
return response.choices[0].message.content.strip()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# Get the latest and previous release tags
|
||||
latest_tag = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"]).decode("utf-8").strip()
|
||||
previous_tag = find_previous_release_tag(latest_tag)
|
||||
|
||||
latest_tag_date = get_tag_creation_date(latest_tag)
|
||||
previous_tag_date = get_tag_creation_date(previous_tag)
|
||||
|
||||
prs = fetch_prs_between_tags(previous_tag_date,latest_tag_date)
|
||||
pr_details = extract_commit_details_from_prs(prs)
|
||||
|
||||
# Generate changelog
|
||||
changelog = generate_changelog_with_openai(pr_details)
|
||||
|
||||
post_changelog_to_slack(changelog,latest_tag)
|
||||
# Print or post changelog to Slack
|
||||
# set_multiline_output("changelog", changelog)
|
||||
|
||||
except Exception as e:
|
||||
print(str(e))
|
34
.github/workflows/generate-release-changelog.yml
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
name: Generate Changelog
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "infisical/v*.*.*-postgres"
|
||||
|
||||
jobs:
|
||||
generate_changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12.0"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests openai
|
||||
- name: Generate Changelog and Post to Slack
|
||||
id: gen-changelog
|
||||
run: python .github/resources/changelog-generator.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
@ -9,7 +9,12 @@ export async function seed(knex: Knex): Promise<void> {
|
||||
await knex(TableName.Users).del();
|
||||
await knex(TableName.UserEncryptionKey).del();
|
||||
await knex(TableName.SuperAdmin).del();
|
||||
await knex(TableName.SuperAdmin).insert([{ initialized: true, allowSignUp: true }]);
|
||||
|
||||
await knex(TableName.SuperAdmin).insert([
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
{ id: "00000000-0000-0000-0000-000000000000", initialized: true, allowSignUp: true }
|
||||
]);
|
||||
// Inserts seed entries
|
||||
const [user] = await knex(TableName.Users)
|
||||
.insert([
|
||||
|
@ -27,6 +27,7 @@ type TSAMLConfig = {
|
||||
cert: string;
|
||||
audience: string;
|
||||
wantAuthnResponseSigned?: boolean;
|
||||
wantAssertionsSigned?: boolean;
|
||||
disableRequestedAuthnContext?: boolean;
|
||||
};
|
||||
|
||||
@ -82,6 +83,10 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
samlConfig.audience = `spn:${ssoConfig.issuer}`;
|
||||
}
|
||||
}
|
||||
if (ssoConfig.authProvider === SamlProviders.GOOGLE_SAML) {
|
||||
samlConfig.wantAssertionsSigned = false;
|
||||
}
|
||||
|
||||
(req as unknown as FastifyRequest).ssoConfig = ssoConfig;
|
||||
done(null, samlConfig);
|
||||
} catch (error) {
|
||||
|
@ -47,7 +47,7 @@ export type TLicenseServiceFactory = ReturnType<typeof licenseServiceFactory>;
|
||||
const LICENSE_SERVER_CLOUD_LOGIN = "/api/auth/v1/license-server-login";
|
||||
const LICENSE_SERVER_ON_PREM_LOGIN = "/api/auth/v1/license-login";
|
||||
|
||||
const LICENSE_SERVER_CLOUD_PLAN_TTL = 60; // 60s
|
||||
const LICENSE_SERVER_CLOUD_PLAN_TTL = 30; // 30 second
|
||||
const FEATURE_CACHE_KEY = (orgId: string) => `infisical-cloud-plan-${orgId}`;
|
||||
|
||||
export const licenseServiceFactory = ({
|
||||
|
@ -4,7 +4,8 @@ import { ActorType } from "@app/services/auth/auth-type";
|
||||
export enum SamlProviders {
|
||||
OKTA_SAML = "okta-saml",
|
||||
AZURE_SAML = "azure-saml",
|
||||
JUMPCLOUD_SAML = "jumpcloud-saml"
|
||||
JUMPCLOUD_SAML = "jumpcloud-saml",
|
||||
GOOGLE_SAML = "google-saml"
|
||||
}
|
||||
|
||||
export type TCreateSamlCfgDTO = {
|
||||
|
@ -263,6 +263,8 @@ export const registerRoutes = async (
|
||||
incidentContactDAL,
|
||||
tokenService,
|
||||
projectDAL,
|
||||
projectMembershipDAL,
|
||||
projectKeyDAL,
|
||||
smtpService,
|
||||
userDAL,
|
||||
orgBotDAL
|
||||
|
@ -87,11 +87,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
params: z.object({ organizationId: z.string().trim() }),
|
||||
body: z.object({
|
||||
name: z.string().trim().optional(),
|
||||
name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
||||
.max(64, { message: "Slug must be 64 or fewer characters" })
|
||||
.regex(/^[a-zA-Z0-9-]+$/, "Slug must only contain alphanumeric characters or hyphens")
|
||||
.optional(),
|
||||
authEnforced: z.boolean().optional(),
|
||||
scimEnabled: z.boolean().optional()
|
||||
|
@ -222,7 +222,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
workspaceId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
name: z.string().trim().optional(),
|
||||
name: z.string().trim().max(64, { message: "Name must be 64 or fewer characters" }).optional(),
|
||||
autoCapitalization: z.boolean().optional()
|
||||
}),
|
||||
response: {
|
||||
|
@ -441,16 +441,19 @@ const syncSecretsAWSParameterStore = async ({
|
||||
}) => {
|
||||
if (!accessId) return;
|
||||
|
||||
AWS.config.update({
|
||||
const config = new AWS.Config({
|
||||
region: integration.region as string,
|
||||
accessKeyId: accessId,
|
||||
secretAccessKey: accessToken
|
||||
credentials: {
|
||||
accessKeyId: accessId,
|
||||
secretAccessKey: accessToken
|
||||
}
|
||||
});
|
||||
|
||||
const ssm = new AWS.SSM({
|
||||
apiVersion: "2014-11-06",
|
||||
region: integration.region as string
|
||||
});
|
||||
ssm.config.update(config);
|
||||
|
||||
const params = {
|
||||
Path: integration.path as string,
|
||||
@ -514,12 +517,6 @@ const syncSecretsAWSParameterStore = async ({
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
AWS.config.update({
|
||||
region: undefined,
|
||||
accessKeyId: undefined,
|
||||
secretAccessKey: undefined
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@ -541,12 +538,6 @@ const syncSecretsAWSSecretManager = async ({
|
||||
try {
|
||||
if (!accessId) return;
|
||||
|
||||
AWS.config.update({
|
||||
region: integration.region as string,
|
||||
accessKeyId: accessId,
|
||||
secretAccessKey: accessToken
|
||||
});
|
||||
|
||||
secretsManager = new SecretsManagerClient({
|
||||
region: integration.region as string,
|
||||
credentials: {
|
||||
@ -575,12 +566,6 @@ const syncSecretsAWSSecretManager = async ({
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
AWS.config.update({
|
||||
region: undefined,
|
||||
accessKeyId: undefined,
|
||||
secretAccessKey: undefined
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundException && secretsManager) {
|
||||
await secretsManager.send(
|
||||
@ -590,11 +575,6 @@ const syncSecretsAWSSecretManager = async ({
|
||||
})
|
||||
);
|
||||
}
|
||||
AWS.config.update({
|
||||
region: undefined,
|
||||
accessKeyId: undefined,
|
||||
secretAccessKey: undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -22,6 +22,8 @@ import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
|
||||
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||
import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TIncidentContactsDALFactory } from "./incident-contacts-dal";
|
||||
@ -44,6 +46,8 @@ type TOrgServiceFactoryDep = {
|
||||
orgRoleDAL: TOrgRoleDALFactory;
|
||||
userDAL: TUserDALFactory;
|
||||
projectDAL: TProjectDALFactory;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserId" | "delete">;
|
||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete">;
|
||||
incidentContactDAL: TIncidentContactsDALFactory;
|
||||
samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne" | "findEnforceableSamlCfg">;
|
||||
smtpService: TSmtpService;
|
||||
@ -65,6 +69,8 @@ export const orgServiceFactory = ({
|
||||
permissionService,
|
||||
smtpService,
|
||||
projectDAL,
|
||||
projectMembershipDAL,
|
||||
projectKeyDAL,
|
||||
tokenService,
|
||||
orgBotDAL,
|
||||
licenseService,
|
||||
@ -503,10 +509,50 @@ export const orgServiceFactory = ({
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member);
|
||||
|
||||
const membership = await orgDAL.deleteMembershipById(membershipId, orgId);
|
||||
const deletedMembership = await orgDAL.transaction(async (tx) => {
|
||||
const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx);
|
||||
|
||||
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
||||
return membership;
|
||||
if (!orgMembership.userId) {
|
||||
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
||||
return orgMembership;
|
||||
}
|
||||
|
||||
// Get all the project memberships of the user in the organization
|
||||
const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId);
|
||||
|
||||
// Delete all the project memberships of the user in the organization
|
||||
await projectMembershipDAL.delete(
|
||||
{
|
||||
$in: {
|
||||
id: projectMemberships.map((membership) => membership.id)
|
||||
}
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
// Get all the project keys of the user in the organization
|
||||
const projectKeys = await projectKeyDAL.find({
|
||||
$in: {
|
||||
projectId: projectMemberships.map((membership) => membership.projectId)
|
||||
},
|
||||
receiverId: orgMembership.userId
|
||||
});
|
||||
|
||||
// Delete all the project keys of the user in the organization
|
||||
await projectKeyDAL.delete(
|
||||
{
|
||||
$in: {
|
||||
id: projectKeys.map((key) => key.id)
|
||||
}
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
||||
return orgMembership;
|
||||
});
|
||||
|
||||
return deletedMembership;
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -82,5 +82,25 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByEmail };
|
||||
const findProjectMembershipsByUserId = async (orgId: string, userId: string) => {
|
||||
try {
|
||||
const memberships = await db(TableName.ProjectMembership)
|
||||
.where({ userId })
|
||||
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
|
||||
.where({ [`${TableName.Project}.orgId` as "orgId"]: orgId })
|
||||
.select(selectAllTableCols(TableName.ProjectMembership));
|
||||
|
||||
return memberships;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find project memberships by user id" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...projectMemberOrm,
|
||||
findAllProjectMembers,
|
||||
findProjectGhostUser,
|
||||
findMembershipsByEmail,
|
||||
findProjectMembershipsByUserId
|
||||
};
|
||||
};
|
||||
|
@ -375,6 +375,10 @@ export const secretServiceFactory = ({
|
||||
|
||||
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||
|
||||
if (inputSecret.newSecretName === "") {
|
||||
throw new BadRequestError({ message: "New secret name cannot be empty" });
|
||||
}
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
||||
const folderId = folder.id;
|
||||
|
@ -145,6 +145,25 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo
|
||||
return loginTwoV2Response, nil
|
||||
}
|
||||
|
||||
func CallGetAllOrganizations(httpClient *resty.Client) (GetOrganizationsResponse, error) {
|
||||
var orgResponse GetOrganizationsResponse
|
||||
response, err := httpClient.
|
||||
R().
|
||||
SetResult(&orgResponse).
|
||||
SetHeader("User-Agent", USER_AGENT).
|
||||
Get(fmt.Sprintf("%v/v1/organization", config.INFISICAL_URL))
|
||||
|
||||
if err != nil {
|
||||
return GetOrganizationsResponse{}, err
|
||||
}
|
||||
|
||||
if response.IsError() {
|
||||
return GetOrganizationsResponse{}, fmt.Errorf("CallGetAllOrganizations: Unsuccessful response: [response=%v]", response)
|
||||
}
|
||||
|
||||
return orgResponse, nil
|
||||
}
|
||||
|
||||
func CallGetAllWorkSpacesUserBelongsTo(httpClient *resty.Client) (GetWorkSpacesResponse, error) {
|
||||
var workSpacesResponse GetWorkSpacesResponse
|
||||
response, err := httpClient.
|
||||
|
@ -120,14 +120,21 @@ type PullSecretsByInfisicalTokenResponse struct {
|
||||
|
||||
type GetWorkSpacesResponse struct {
|
||||
Workspaces []struct {
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
V int `json:"__v"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
V int `json:"__v"`
|
||||
OrganizationId string `json:"orgId"`
|
||||
} `json:"workspaces"`
|
||||
}
|
||||
|
||||
type GetOrganizationsResponse struct {
|
||||
Organizations []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"organizations"`
|
||||
}
|
||||
|
||||
type Secret struct {
|
||||
SecretKeyCiphertext string `json:"secretKeyCiphertext,omitempty"`
|
||||
SecretKeyIV string `json:"secretKeyIV,omitempty"`
|
||||
@ -505,5 +512,5 @@ type GetRawSecretsV3Response struct {
|
||||
SecretComment string `json:"secretComment"`
|
||||
} `json:"secrets"`
|
||||
Imports []any `json:"imports"`
|
||||
ETag string
|
||||
ETag string
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Infisical/infisical-merge/packages/api"
|
||||
"github.com/Infisical/infisical-merge/packages/models"
|
||||
@ -52,25 +51,19 @@ var initCmd = &cobra.Command{
|
||||
|
||||
httpClient := resty.New()
|
||||
httpClient.SetAuthToken(userCreds.UserCredentials.JTWToken)
|
||||
workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient)
|
||||
|
||||
organizationResponse, err := api.CallGetAllOrganizations(httpClient)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to pull projects that belong to you")
|
||||
util.HandleError(err, "Unable to pull organizations that belong to you")
|
||||
}
|
||||
|
||||
workspaces := workspaceResponse.Workspaces
|
||||
if len(workspaces) == 0 {
|
||||
message := fmt.Sprintf("You don't have any projects created in Infisical. You must first create a project at %s", util.INFISICAL_TOKEN_NAME)
|
||||
util.PrintErrorMessageAndExit(message)
|
||||
}
|
||||
organizations := organizationResponse.Organizations
|
||||
|
||||
var workspaceNames []string
|
||||
for _, workspace := range workspaces {
|
||||
workspaceNames = append(workspaceNames, workspace.Name)
|
||||
}
|
||||
organizationNames := util.GetOrganizationsNameList(organizationResponse)
|
||||
|
||||
prompt := promptui.Select{
|
||||
Label: "Which of your Infisical projects would you like to connect this project to?",
|
||||
Items: workspaceNames,
|
||||
Label: "Which Infisical organization would you like to select a project from?",
|
||||
Items: organizationNames,
|
||||
Size: 7,
|
||||
}
|
||||
|
||||
@ -79,7 +72,27 @@ var initCmd = &cobra.Command{
|
||||
util.HandleError(err)
|
||||
}
|
||||
|
||||
err = writeWorkspaceFile(workspaces[index])
|
||||
selectedOrganization := organizations[index]
|
||||
|
||||
workspaceResponse, err := api.CallGetAllWorkSpacesUserBelongsTo(httpClient)
|
||||
if err != nil {
|
||||
util.HandleError(err, "Unable to pull projects that belong to you")
|
||||
}
|
||||
|
||||
filteredWorkspaces, workspaceNames := util.GetWorkspacesInOrganization(workspaceResponse, selectedOrganization.ID)
|
||||
|
||||
prompt = promptui.Select{
|
||||
Label: "Which of your Infisical projects would you like to connect this project to?",
|
||||
Items: workspaceNames,
|
||||
Size: 7,
|
||||
}
|
||||
|
||||
index, _, err = prompt.Run()
|
||||
if err != nil {
|
||||
util.HandleError(err)
|
||||
}
|
||||
|
||||
err = writeWorkspaceFile(filteredWorkspaces[index])
|
||||
if err != nil {
|
||||
util.HandleError(err)
|
||||
}
|
||||
|
@ -45,11 +45,11 @@ type SingleFolder struct {
|
||||
}
|
||||
|
||||
type Workspace struct {
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
V int `json:"__v"`
|
||||
Organization string `json:"organization,omitempty"`
|
||||
ID string `json:"_id"`
|
||||
Name string `json:"name"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
V int `json:"__v"`
|
||||
OrganizationId string `json:"orgId"`
|
||||
}
|
||||
|
||||
type WorkspaceConfigFile struct {
|
||||
|
45
cli/packages/util/init.go
Normal file
@ -0,0 +1,45 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Infisical/infisical-merge/packages/api"
|
||||
"github.com/Infisical/infisical-merge/packages/models"
|
||||
)
|
||||
|
||||
func GetOrganizationsNameList(organizationResponse api.GetOrganizationsResponse) []string {
|
||||
organizations := organizationResponse.Organizations
|
||||
|
||||
if len(organizations) == 0 {
|
||||
message := fmt.Sprintf("You don't have any organization created in Infisical. You must first create a organization at %s", INFISICAL_DEFAULT_URL)
|
||||
PrintErrorMessageAndExit(message)
|
||||
}
|
||||
|
||||
var organizationNames []string
|
||||
for _, workspace := range organizations {
|
||||
organizationNames = append(organizationNames, workspace.Name)
|
||||
}
|
||||
|
||||
return organizationNames
|
||||
}
|
||||
|
||||
func GetWorkspacesInOrganization(workspaceResponse api.GetWorkSpacesResponse, orgId string) ([]models.Workspace, []string) {
|
||||
workspaces := workspaceResponse.Workspaces
|
||||
|
||||
var filteredWorkspaces []models.Workspace
|
||||
var workspaceNames []string
|
||||
|
||||
for _, workspace := range workspaces {
|
||||
if workspace.OrganizationId == orgId {
|
||||
filteredWorkspaces = append(filteredWorkspaces, workspace)
|
||||
workspaceNames = append(workspaceNames, workspace.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filteredWorkspaces) == 0 {
|
||||
message := fmt.Sprintf("You don't have any projects created in Infisical organization. You must first create a project at %s", INFISICAL_DEFAULT_URL)
|
||||
PrintErrorMessageAndExit(message)
|
||||
}
|
||||
|
||||
return filteredWorkspaces, workspaceNames
|
||||
}
|
@ -2,7 +2,6 @@
|
||||
title: "Enhancing Security and Usability: Project Upgrades"
|
||||
---
|
||||
|
||||
|
||||
At Infisical, we're constantly striving to elevate the security and usability standards of our platform to better serve our users.
|
||||
With this commitment in mind, we're excited to introduce our latest addition, non-E2EE projects, aimed at addressing two significant issues while enhancing how clients interact with Infisical programmatically.
|
||||
|
||||
@ -11,11 +10,11 @@ Additionally, our API lacked the capability to interact with projects without de
|
||||
These obstacles made API driven automation and collaboration a painful experience for a majority of our users.
|
||||
|
||||
To overcome these limitations, our upgrade focuses on disabling end-to-end encryption (E2EE) for projects.
|
||||
While this may raise eyebrows, it's important to understand that this decision is a strategic move to make Infisical easer to use and interact with.
|
||||
While this may raise eyebrows, it's important to understand that this decision is a strategic move to make Infisical easier to use and interact with.
|
||||
|
||||
But what does this mean for our users? Essentially nothing, there are no changes required on your end.
|
||||
But what does this mean for our users? Essentially nothing, there are no changes required on your end.
|
||||
Rest assured, all sensitive data remains encrypted at rest according to the latest industry standards.
|
||||
Our commitment to security remains unwavering, and this upgrade is a testament to our dedication to delivering on our promises in both security and usability when it comes to secrets management.
|
||||
|
||||
To increase consistency with existing and future integrations, all projects created on Infisical from now on will have end-to-end encryption (E2EE) disabled by default.
|
||||
To increase consistency with existing and future integrations, all projects created on Infisical from now on will have end-to-end encryption (E2EE) disabled by default.
|
||||
This will not only reduce confusion for end users, but will also make the Infisical API seamless to use.
|
||||
|
95
docs/documentation/platform/sso/google-saml.mdx
Normal file
@ -0,0 +1,95 @@
|
||||
---
|
||||
title: "Google SAML"
|
||||
description: "Configure Google SAML for Infisical SSO"
|
||||
---
|
||||
|
||||
<Info>
|
||||
Google SAML SSO feature is a paid feature.
|
||||
|
||||
If you're using Infisical Cloud, then it is available under the **Pro Tier**. If you're self-hosting Infisical,
|
||||
then you should contact team@infisical.com to purchase an enterprise license to use it.
|
||||
</Info>
|
||||
|
||||
<Steps>
|
||||
<Step title="Prepare the SAML SSO configuration in Infisical">
|
||||
In Infisical, head to your Organization Settings > Authentication > SAML SSO Configuration and select **Set up SAML SSO**.
|
||||
|
||||
Next, note the **ACS URL** and **SP Entity ID** to use when configuring the Google SAML application.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Create a SAML application in Google">
|
||||
2.1. In your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps and
|
||||
create a **custom SAML app**.
|
||||
|
||||

|
||||
|
||||
2.2. In the **App details** tab, give the application a unique name like Infisical.
|
||||
|
||||

|
||||
|
||||
2.3. In the **Google Identity Provider details** tab, copy the **SSO URL**, **Entity ID** and **Certificate**.
|
||||
|
||||

|
||||
|
||||
2.4. Back in Infisical, set **SSO URL**, **IdP Entity ID**, and **Certificate** to the corresponding items from step 2.3.
|
||||
|
||||

|
||||
|
||||
2.5. Back in the Google Admin console, in the **Service provider details** tab, set the **ACS URL** and **Entity ID** to the corresponding items from step 1.
|
||||
|
||||
Also, check the **Signed response** checkbox.
|
||||
|
||||

|
||||
|
||||
2.6. In the **Attribute mapping** tab, configure the following map:
|
||||
|
||||
- **First name** -> **firstName**
|
||||
- **Last name** -> **lastName**
|
||||
- **Primary email** -> **email**
|
||||
|
||||

|
||||
|
||||
Click **Finish**.
|
||||
</Step>
|
||||
<Step title="Assign users in Google Workspace to the application">
|
||||
Back in your [Google Admin console](https://support.google.com/a/answer/182076), head to Menu > Apps > Web and mobile apps > your SAML app
|
||||
and press on **User access**.
|
||||
|
||||

|
||||
|
||||
To assign everyone in your organization to the application, click **On for everyone** or **Off for everyone** and then click **Save**.
|
||||
|
||||
You can also assign an organizational unit or set of users to an application; you can learn more about that [here](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app).
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Enable SAML SSO in Infisical">
|
||||
Enabling SAML SSO allows members in your organization to log into Infisical via Google Workspace.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Enforce SAML SSO in Infisical">
|
||||
Enforcing SAML SSO ensures that members in your organization can only access Infisical
|
||||
by logging into the organization via Google.
|
||||
|
||||
To enforce SAML SSO, you're required to test out the SAML connection by successfully authenticating at least one Google user with Infisical;
|
||||
Once you've completed this requirement, you can toggle the **Enforce SAML SSO** button to enforce SAML SSO.
|
||||
|
||||
<Warning>
|
||||
We recommend ensuring that your account is provisioned the application in Google
|
||||
prior to enforcing SAML SSO to prevent any unintended issues.
|
||||
</Warning>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
If you're configuring SAML SSO on a self-hosted instance of Infisical, make sure to
|
||||
set the `AUTH_SECRET` and `SITE_URL` environment variable for it to work:
|
||||
|
||||
- `AUTH_SECRET`: A secret key used for signing and verifying JWT. This can be a random 32-byte base64 string generated with `openssl rand -base64 32`.
|
||||
- `SITE_URL`: The URL of your self-hosted instance of Infisical - should be an absolute URL including the protocol (e.g. https://app.infisical.com)
|
||||
</Note>
|
||||
|
||||
References:
|
||||
- Google's guide to [set up your own custom SAML app](https://support.google.com/a/answer/6087519?hl=en#add_custom_saml&turn_on&verify_sso&&zippy=%2Cstep-add-the-custom-saml-app%2Cstep-turn-on-your-saml-app%2Cstep-verify-that-sso-is-working-with-your-custom-app).
|
@ -22,3 +22,4 @@ your IdP cannot and will not have access to the decryption key needed to decrypt
|
||||
- [Okta SAML](/documentation/platform/sso/okta)
|
||||
- [Azure SAML](/documentation/platform/sso/azure)
|
||||
- [JumpCloud SAML](/documentation/platform/sso/jumpcloud)
|
||||
- [Google SAML](/documentation/platform/sso/google-saml)
|
||||
|
BIN
docs/images/sso/google-saml/attribute-mapping.png
Normal file
After Width: | Height: | Size: 236 KiB |
BIN
docs/images/sso/google-saml/create-custom-saml-app.png
Normal file
After Width: | Height: | Size: 258 KiB |
BIN
docs/images/sso/google-saml/custom-saml-app-config-2.png
Normal file
After Width: | Height: | Size: 221 KiB |
BIN
docs/images/sso/google-saml/custom-saml-app-config.png
Normal file
After Width: | Height: | Size: 279 KiB |
BIN
docs/images/sso/google-saml/enable-saml.png
Normal file
After Width: | Height: | Size: 584 KiB |
BIN
docs/images/sso/google-saml/infisical-config.png
Normal file
After Width: | Height: | Size: 605 KiB |
BIN
docs/images/sso/google-saml/init-config.png
Normal file
After Width: | Height: | Size: 524 KiB |
BIN
docs/images/sso/google-saml/name-custom-saml-app.png
Normal file
After Width: | Height: | Size: 205 KiB |
BIN
docs/images/sso/google-saml/user-access-assign.png
Normal file
After Width: | Height: | Size: 324 KiB |
BIN
docs/images/sso/google-saml/user-access.png
Normal file
After Width: | Height: | Size: 371 KiB |
@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Infisical Agent"
|
||||
title: "Overview"
|
||||
---
|
||||
|
||||
Infisical Agent is a client daemon that simplifies the adoption of Infisical by providing a more scalable and user-friendly approach for applications to interact with Infisical.
|
||||
@ -51,6 +51,9 @@ While specifying an authentication method is mandatory to start the agent, confi
|
||||
| `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. |
|
||||
| `templates[].source-path` | The path to the template file that should be used to render secrets. |
|
||||
| `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. |
|
||||
| `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `60s` (optional) |
|
||||
| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) |
|
||||
| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) |
|
||||
|
||||
|
||||
## Quick start Infisical Agent
|
||||
@ -76,6 +79,11 @@ sinks:
|
||||
templates:
|
||||
- source-path: my-dot-ev-secret-template
|
||||
destination-path: /some/path/.env
|
||||
config:
|
||||
polling-interval: 60s
|
||||
execute:
|
||||
timeout: 30
|
||||
command: ./reload-app.sh
|
||||
```
|
||||
|
||||
Above is an example agent configuration file that defines the token authentication method, one sink location (where to deposit access tokens after renewal) and a secret template.
|
||||
|
@ -9,9 +9,7 @@ The Infisical API (sometimes referred to as the **backend**) contains the core p
|
||||
|
||||
## Storage backend
|
||||
|
||||
Infisical relies on a storage backend to store data including users and secrets.
|
||||
|
||||
Currently, the only supported storage backend is [MongoDB](https://www.mongodb.com) but we plan to add support for other options including PostgreSQL in Q1 2024.
|
||||
Infisical relies on a storage backend to store data including users and secrets. Infisical's storage backend is Postgres.
|
||||
|
||||
## Redis
|
||||
|
||||
@ -27,4 +25,4 @@ Clients are any application or infrastructure that connecting to the Infisical A
|
||||
- Public API: Making API requests directly to the Infisical API.
|
||||
- Client SDK: A platform-specific library with method abstractions for working with secrets. Currently, there are three official SDKs: [Node SDK](https://infisical.com/docs/sdks/languages/node), [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java).
|
||||
- CLI: A terminal-based interface for interacting with the Infisical API.
|
||||
- Kubernetes Operator: This operator retrieves secrets from Infisical and securely store
|
||||
- Kubernetes Operator: This operator retrieves secrets from Infisical and securely store
|
||||
|
@ -146,7 +146,8 @@
|
||||
"documentation/platform/sso/gitlab",
|
||||
"documentation/platform/sso/okta",
|
||||
"documentation/platform/sso/azure",
|
||||
"documentation/platform/sso/jumpcloud"
|
||||
"documentation/platform/sso/jumpcloud",
|
||||
"documentation/platform/sso/google-saml"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -30,7 +30,7 @@ export const Checkbox = ({
|
||||
<div className="flex items-center font-inter text-bunker-300">
|
||||
<CheckboxPrimitive.Root
|
||||
className={twMerge(
|
||||
"flex items-center justify-center w-4 h-4 transition-all rounded shadow border border-mineshaft-400 hover:bg-mineshaft-500 bg-mineshaft-600",
|
||||
"flex items-center flex-shrink-0 justify-center w-4 h-4 transition-all rounded shadow border border-mineshaft-400 hover:bg-mineshaft-500 bg-mineshaft-600",
|
||||
isDisabled && "bg-bunker-400 hover:bg-bunker-400",
|
||||
isChecked && "bg-primary hover:bg-primary",
|
||||
Boolean(children) && "mr-3",
|
||||
@ -46,7 +46,7 @@ export const Checkbox = ({
|
||||
<FontAwesomeIcon icon={faCheck} size="sm" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
<label className="text-sm whitespace-nowrap" htmlFor={id}>
|
||||
<label className="text-sm whitespace-nowrap truncate" htmlFor={id}>
|
||||
{children}
|
||||
{isRequired && <span className="pl-1 text-red">*</span>}
|
||||
</label>
|
||||
|
@ -42,27 +42,27 @@ export const MenuItem = <T extends ElementType = "button">({
|
||||
const iconRef = useRef();
|
||||
|
||||
return (
|
||||
<a onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
|
||||
<div onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
|
||||
<li
|
||||
className={twMerge(
|
||||
"group px-1 py-2 mt-0.5 font-inter flex flex-col text-sm text-bunker-100 transition-all rounded cursor-pointer hover:bg-mineshaft-700 duration-50",
|
||||
"duration-50 group mt-0.5 flex cursor-pointer flex-col rounded px-1 py-2 font-inter text-sm text-bunker-100 transition-all hover:bg-mineshaft-700",
|
||||
isSelected && "bg-mineshaft-600 hover:bg-mineshaft-600",
|
||||
isDisabled && "hover:bg-transparent cursor-not-allowed",
|
||||
isDisabled && "cursor-not-allowed hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<motion.span className="w-full flex flex-row items-center justify-start rounded-sm">
|
||||
<motion.span className="flex w-full flex-row items-center justify-start rounded-sm">
|
||||
<Item
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex items-center relative"
|
||||
className="relative flex items-center"
|
||||
ref={inputRef}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
isSelected ? "visisble" : "invisible"
|
||||
} -left-[0.28rem] absolute w-[0.07rem] rounded-md h-5 bg-primary`}
|
||||
} absolute -left-[0.28rem] h-5 w-[0.07rem] rounded-md bg-primary`}
|
||||
/>
|
||||
{/* {icon && <span className="mr-3 ml-4 w-5 block group-hover:hidden">{icon}</span>} */}
|
||||
{icon && (
|
||||
@ -81,7 +81,7 @@ export const MenuItem = <T extends ElementType = "button">({
|
||||
{description && <span className="mt-2 text-xs">{description}</span>}
|
||||
</motion.span>
|
||||
</li>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -103,16 +103,16 @@ export const SubMenuItem = <T extends ElementType = "button">({
|
||||
<a onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
|
||||
<li
|
||||
className={twMerge(
|
||||
"group px-1 py-1 mt-0.5 font-inter flex flex-col text-sm text-mineshaft-300 hover:text-mineshaft-100 transition-all rounded cursor-pointer hover:bg-mineshaft-700 duration-50",
|
||||
isDisabled && "hover:bg-transparent cursor-not-allowed",
|
||||
"duration-50 group mt-0.5 flex cursor-pointer flex-col rounded px-1 py-1 font-inter text-sm text-mineshaft-300 transition-all hover:bg-mineshaft-700 hover:text-mineshaft-100",
|
||||
isDisabled && "cursor-not-allowed hover:bg-transparent",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<motion.span className="w-full flex flex-row items-center justify-start rounded-sm pl-6">
|
||||
<motion.span className="flex w-full flex-row items-center justify-start rounded-sm pl-6">
|
||||
<Item
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex items-center relative"
|
||||
className="relative flex items-center"
|
||||
ref={inputRef}
|
||||
{...props}
|
||||
>
|
||||
|
@ -103,7 +103,7 @@ export const UpgradeProjectAlert = ({ project }: UpgradeProjectAlertProps): JSX.
|
||||
Upgrade your project version to continue receiving the latest improvements and
|
||||
patches.
|
||||
</p>
|
||||
<Link href="/docs/documentation/platform/project-upgrade">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/project-upgrade">
|
||||
<a target="_blank" className="text-primary-400">
|
||||
Learn more
|
||||
</a>
|
||||
@ -117,7 +117,7 @@ export const UpgradeProjectAlert = ({ project }: UpgradeProjectAlertProps): JSX.
|
||||
Upgrading the project version is required to continue receiving the latest
|
||||
improvements and patches.
|
||||
</p>
|
||||
<Link href="/docs/documentation/platform/project-upgrade">
|
||||
<Link href="https://infisical.com/docs/documentation/platform/project-upgrade">
|
||||
<a target="_blank" className="text-primary-400">
|
||||
Learn more
|
||||
</a>
|
||||
|
@ -81,7 +81,7 @@ export const CreateSecretImportForm = ({
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const axiosError = err as AxiosError
|
||||
const axiosError = err as AxiosError;
|
||||
if (axiosError?.response?.status === 401) {
|
||||
createNotification({
|
||||
text: "You do not have access to the selected environment/path",
|
||||
|
@ -1,24 +1,4 @@
|
||||
/* eslint-disable simple-import-sort/imports */
|
||||
import { memo, useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faClock,
|
||||
faClose,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsis,
|
||||
faKey,
|
||||
faTag,
|
||||
faTags
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
@ -48,9 +28,29 @@ import {
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
import { WsTag } from "@app/hooks/api/types";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faClock,
|
||||
faClose,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsis,
|
||||
faKey,
|
||||
faTag,
|
||||
faTags
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { memo, useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
import { CreateReminderForm } from "./CreateReminderForm";
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
|
||||
type Props = {
|
||||
secret: DecryptedSecret;
|
||||
@ -104,7 +104,8 @@ export const SecretItem = memo(
|
||||
setValue,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { isDirty, isSubmitting }
|
||||
trigger,
|
||||
formState: { isDirty, isSubmitting, errors }
|
||||
} = useForm<TFormSchema>({
|
||||
defaultValues: secret,
|
||||
values: secret,
|
||||
@ -235,15 +236,18 @@ export const SecretItem = memo(
|
||||
<Controller
|
||||
name="key"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
isReadOnly={isReadOnly}
|
||||
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
variant="plain"
|
||||
isDisabled={isOverriden}
|
||||
placeholder={error?.message}
|
||||
isError={Boolean(error)}
|
||||
onKeyUp={() => trigger("key")}
|
||||
{...field}
|
||||
className="w-full px-0 focus:text-bunker-100 focus:ring-transparent"
|
||||
className="w-full px-0 placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -497,7 +501,7 @@ export const SecretItem = memo(
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -10, opacity: 0 }}
|
||||
>
|
||||
<Tooltip content="Save">
|
||||
<Tooltip content={errors.key ? errors.key?.message : "Save"}>
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
@ -507,12 +511,16 @@ export const SecretItem = memo(
|
||||
"p-0 text-primary opacity-0 group-hover:opacity-100",
|
||||
isDirty && "opacity-100"
|
||||
)}
|
||||
isDisabled={isSubmitting}
|
||||
isDisabled={isSubmitting || Boolean(errors.key)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner className="m-0 h-4 w-4 p-0" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} size="lg" className="text-primary" />
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
size="lg"
|
||||
className={twMerge("text-primary", errors.key && "text-mineshaft-300")}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
@ -206,7 +206,7 @@ export const SecretListView = ({
|
||||
reminderRepeatDays,
|
||||
reminderNote
|
||||
} = modSecret;
|
||||
const hasKeyChanged = oldKey !== key;
|
||||
const hasKeyChanged = oldKey !== key && key;
|
||||
|
||||
const tagIds = tags?.map(({ id }) => id);
|
||||
const oldTagIds = (orgSecret?.tags || []).map(({ id }) => id);
|
||||
|
@ -8,7 +8,7 @@ export enum SecretActionType {
|
||||
}
|
||||
|
||||
export const formSchema = z.object({
|
||||
key: z.string().trim(),
|
||||
key: z.string().trim().min(1, { message: "Secret key is required" }),
|
||||
value: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
|
||||
idOverride: z.string().trim().optional(),
|
||||
valueOverride: z
|
||||
|
@ -2,22 +2,31 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faAngleDown,
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faFolderBlank,
|
||||
faMagnifyingGlass
|
||||
faFolderPlus,
|
||||
faMagnifyingGlass,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import { PermissionDeniedBanner } from "@app/components/permissions";
|
||||
import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@ -30,7 +39,13 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { UpgradeProjectAlert } from "@app/components/v2/UpgradeProjectAlert";
|
||||
import { useOrganization, useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useOrganization,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useCreateSecretV3,
|
||||
@ -42,6 +57,8 @@ import {
|
||||
} from "@app/hooks/api";
|
||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm";
|
||||
import { CreateSecretForm } from "./components/CreateSecretForm";
|
||||
import { FolderBreadCrumbs } from "./components/FolderBreadCrumbs";
|
||||
import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSection";
|
||||
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
|
||||
@ -110,6 +127,40 @@ export const SecretOverviewPage = () => {
|
||||
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3();
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"addSecretsInAllEnvs",
|
||||
"addFolder",
|
||||
"misc"
|
||||
] as const);
|
||||
|
||||
const handleFolderCreate = async (folderName: string) => {
|
||||
const promises = userAvailableEnvs.map((env) => {
|
||||
const environment = env.slug;
|
||||
return createFolder({
|
||||
name: folderName,
|
||||
path: secretPath,
|
||||
environment,
|
||||
projectId: workspaceId
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const isFoldersAdded = results.some((result) => result.status === "fulfilled");
|
||||
|
||||
if (isFoldersAdded) {
|
||||
handlePopUpClose("addFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created folder"
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretCreate = async (env: string, key: string, value: string) => {
|
||||
try {
|
||||
// create folder if not existing
|
||||
@ -269,210 +320,286 @@ export const SecretOverviewPage = () => {
|
||||
filteredFolderNames?.length === 0;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<ProjectIndexSecretsSection decryptFileKey={latestFileKey!} />
|
||||
<div className="relative right-5 ml-4">
|
||||
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="mt-6">
|
||||
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
|
||||
<p className="text-md text-bunker-300">
|
||||
Inject your secrets using
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical CLI
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical API
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical SDKs
|
||||
</a>
|
||||
, and
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/introduction"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<>
|
||||
<div className="container mx-auto px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<ProjectIndexSecretsSection decryptFileKey={latestFileKey!} />
|
||||
<div className="relative right-5 ml-4">
|
||||
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="mt-6">
|
||||
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
|
||||
<p className="text-md text-bunker-300">
|
||||
Inject your secrets using
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical CLI
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical API
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical SDKs
|
||||
</a>
|
||||
, and
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/introduction"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{currentWorkspace?.version === ProjectVersion.V1 && (
|
||||
<UpgradeProjectAlert project={currentWorkspace} />
|
||||
)}
|
||||
{currentWorkspace?.version === ProjectVersion.V1 && (
|
||||
<UpgradeProjectAlert project={currentWorkspace} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
|
||||
<div className="w-80">
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by secret/folder name..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
|
||||
<div className="flex flex-row items-center justify-center space-x-2">
|
||||
<div className="w-80">
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by secret/folder name..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
|
||||
className="h-10 rounded-r-none"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="add-folder-or-import"
|
||||
variant="outline_bg"
|
||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="flex flex-col space-y-1 p-1.5">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
|
||||
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr className="sticky top-0 z-20 border-0">
|
||||
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
|
||||
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-3.5 pb-3">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
|
||||
>
|
||||
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
{userAvailableEnvs?.map(({ name, slug }, index) => {
|
||||
const envSecKeyCount = getEnvSecretKeyCount(slug);
|
||||
const missingKeyCount = secKeys.length - envSecKeyCount;
|
||||
return (
|
||||
<Th
|
||||
className="min-table-row min-w-[11rem] border-b-0 p-0 text-center"
|
||||
key={`secret-overview-${name}-${index + 1}`}
|
||||
>
|
||||
<div className="flex items-center justify-center border-b border-mineshaft-600 px-5 pt-3.5 pb-[0.83rem]">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium duration-100 hover:text-mineshaft-100"
|
||||
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
|
||||
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr className="sticky top-0 z-20 border-0">
|
||||
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
|
||||
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-3.5 pb-3">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className="ml-2"
|
||||
ariaLabel="sort"
|
||||
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
|
||||
>
|
||||
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
{userAvailableEnvs?.map(({ name, slug }, index) => {
|
||||
const envSecKeyCount = getEnvSecretKeyCount(slug);
|
||||
const missingKeyCount = secKeys.length - envSecKeyCount;
|
||||
return (
|
||||
<Th
|
||||
className="min-table-row min-w-[11rem] border-b-0 p-0 text-center"
|
||||
key={`secret-overview-${name}-${index + 1}`}
|
||||
>
|
||||
<div className="flex items-center justify-center border-b border-mineshaft-600 px-5 pt-3.5 pb-[0.83rem]">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-medium duration-100 hover:text-mineshaft-100"
|
||||
onClick={() => handleExploreEnvClick(slug)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{missingKeyCount > 0 && (
|
||||
<Tooltip
|
||||
className="max-w-none lowercase"
|
||||
content={`${missingKeyCount} secrets missing\n compared to other environments`}
|
||||
>
|
||||
<div className="ml-2 flex h-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red-600 p-1 text-xs font-medium text-bunker-100">
|
||||
<span className="text-bunker-100">{missingKeyCount}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Th>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{canViewOverviewPage && isTableLoading && (
|
||||
<TableSkeleton
|
||||
columns={userAvailableEnvs.length + 1}
|
||||
innerKey="secret-overview-loading"
|
||||
rows={5}
|
||||
className="bg-mineshaft-700"
|
||||
/>
|
||||
)}
|
||||
{isTableEmpty && !isTableLoading && (
|
||||
<Tr>
|
||||
<Td colSpan={userAvailableEnvs.length + 1}>
|
||||
<EmptyState title="Let's add some secrets" icon={faFolderBlank} iconSize="3x">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/project/[id]/secrets/[env]",
|
||||
query: { id: workspaceId, env: userAvailableEnvs?.[0]?.slug }
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline_bg"
|
||||
colorSchema="primary"
|
||||
size="md"
|
||||
>
|
||||
Go to {userAvailableEnvs?.[0]?.name}
|
||||
</Button>
|
||||
</Link>
|
||||
</EmptyState>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isTableLoading &&
|
||||
filteredFolderNames.map((folderName, index) => (
|
||||
<SecretOverviewFolderRow
|
||||
folderName={folderName}
|
||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||
environments={userAvailableEnvs}
|
||||
key={`overview-${folderName}-${index + 1}`}
|
||||
onClick={handleFolderClick}
|
||||
/>
|
||||
))}
|
||||
{!isTableLoading &&
|
||||
(userAvailableEnvs?.length > 0 ? (
|
||||
filteredSecretNames.map((key, index) => (
|
||||
<SecretOverviewTableRow
|
||||
secretPath={secretPath}
|
||||
onSecretCreate={handleSecretCreate}
|
||||
onSecretDelete={handleSecretDelete}
|
||||
onSecretUpdate={handleSecretUpdate}
|
||||
key={`overview-${key}-${index + 1}`}
|
||||
environments={userAvailableEnvs}
|
||||
secretKey={key}
|
||||
getSecretByKey={getSecretByKey}
|
||||
expandableColWidth={expandableTableWidth}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<PermissionDeniedBanner />
|
||||
))}
|
||||
</TBody>
|
||||
<TFoot>
|
||||
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
|
||||
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 p-0">
|
||||
<div
|
||||
className="w-full border-t border-r border-mineshaft-600"
|
||||
style={{ height: "45px" }}
|
||||
/>
|
||||
</Td>
|
||||
{userAvailableEnvs.map(({ name, slug }) => (
|
||||
<Td key={`explore-${name}-btn`} className="border-0 border-mineshaft-600 p-0">
|
||||
<div className="flex w-full items-center justify-center border-r border-t border-mineshaft-600 px-5 py-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
onClick={() => handleExploreEnvClick(slug)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{missingKeyCount > 0 && (
|
||||
<Tooltip
|
||||
className="max-w-none lowercase"
|
||||
content={`${missingKeyCount} secrets missing\n compared to other environments`}
|
||||
>
|
||||
<div className="ml-2 flex h-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red-600 p-1 text-xs font-medium text-bunker-100">
|
||||
<span className="text-bunker-100">{missingKeyCount}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Th>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{canViewOverviewPage && isTableLoading && (
|
||||
<TableSkeleton
|
||||
columns={userAvailableEnvs.length + 1}
|
||||
innerKey="secret-overview-loading"
|
||||
rows={5}
|
||||
className="bg-mineshaft-700"
|
||||
/>
|
||||
)}
|
||||
{isTableEmpty && !isTableLoading && (
|
||||
<Tr>
|
||||
<Td colSpan={userAvailableEnvs.length + 1}>
|
||||
<EmptyState title="Let's add some secrets" icon={faFolderBlank} iconSize="3x">
|
||||
<Link
|
||||
href={{
|
||||
pathname: "/project/[id]/secrets/[env]",
|
||||
query: { id: workspaceId, env: userAvailableEnvs?.[0]?.slug }
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="outline_bg"
|
||||
colorSchema="primary"
|
||||
size="md"
|
||||
>
|
||||
Go to {userAvailableEnvs?.[0]?.name}
|
||||
Explore
|
||||
</Button>
|
||||
</Link>
|
||||
</EmptyState>
|
||||
</Td>
|
||||
</div>
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
)}
|
||||
{!isTableLoading &&
|
||||
filteredFolderNames.map((folderName, index) => (
|
||||
<SecretOverviewFolderRow
|
||||
folderName={folderName}
|
||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||
environments={userAvailableEnvs}
|
||||
key={`overview-${folderName}-${index + 1}`}
|
||||
onClick={handleFolderClick}
|
||||
/>
|
||||
))}
|
||||
{!isTableLoading &&
|
||||
(userAvailableEnvs?.length > 0 ? (
|
||||
filteredSecretNames.map((key, index) => (
|
||||
<SecretOverviewTableRow
|
||||
secretPath={secretPath}
|
||||
onSecretCreate={handleSecretCreate}
|
||||
onSecretDelete={handleSecretDelete}
|
||||
onSecretUpdate={handleSecretUpdate}
|
||||
key={`overview-${key}-${index + 1}`}
|
||||
environments={userAvailableEnvs}
|
||||
secretKey={key}
|
||||
getSecretByKey={getSecretByKey}
|
||||
expandableColWidth={expandableTableWidth}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<PermissionDeniedBanner />
|
||||
))}
|
||||
</TBody>
|
||||
<TFoot>
|
||||
<Tr className="sticky bottom-0 z-10 border-0 bg-mineshaft-800">
|
||||
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 p-0">
|
||||
<div
|
||||
className="w-full border-t border-r border-mineshaft-600"
|
||||
style={{ height: "45px" }}
|
||||
/>
|
||||
</Td>
|
||||
{userAvailableEnvs.map(({ name, slug }) => (
|
||||
<Td key={`explore-${name}-btn`} className="border-0 border-mineshaft-600 p-0">
|
||||
<div className="flex w-full items-center justify-center border-r border-t border-mineshaft-600 px-5 py-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline_bg"
|
||||
isFullWidth
|
||||
onClick={() => handleExploreEnvClick(slug)}
|
||||
>
|
||||
Explore
|
||||
</Button>
|
||||
</div>
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
</TFoot>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TFoot>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateSecretForm
|
||||
secretPath={secretPath}
|
||||
isOpen={popUp.addSecretsInAllEnvs.isOpen}
|
||||
getSecretByKey={getSecretByKey}
|
||||
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretsInAllEnvs", isOpen)}
|
||||
onClose={() => handlePopUpClose("addSecretsInAllEnvs")}
|
||||
decryptFileKey={latestFileKey!}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.addFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||
>
|
||||
<ModalContent title="Create Folder">
|
||||
<FolderForm onCreateFolder={handleFolderCreate} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,222 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
SecretInput,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useCreateFolder, useCreateSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
|
||||
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
const typeSchema = z
|
||||
.object({
|
||||
key: z.string().min(1, "Key is required"),
|
||||
value: z.string().optional(),
|
||||
environments: z.record(z.boolean().optional())
|
||||
})
|
||||
.refine((data) => data.key !== undefined, {
|
||||
message: "Please enter secret name"
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
secretPath?: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
|
||||
// modal props
|
||||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
onTogglePopUp: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const CreateSecretForm = ({
|
||||
secretPath = "/",
|
||||
decryptFileKey,
|
||||
isOpen,
|
||||
getSecretByKey,
|
||||
onClose,
|
||||
onTogglePopUp
|
||||
}: Props) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
formState: { isSubmitting, errors }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
const newSecretKey = watch("key");
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
|
||||
const handleFormSubmit = async ({ key, value, environments: selectedEnv }: TFormSchema) => {
|
||||
const environmentsSelected = environments.filter(({ slug }) => selectedEnv[slug]);
|
||||
const isEnvironmentsSelected = environmentsSelected.length;
|
||||
|
||||
if (!isEnvironmentsSelected) {
|
||||
createNotification({ type: "error", text: "Select atleast one environment" });
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = environmentsSelected.map(async (env) => {
|
||||
const environment = env.slug;
|
||||
// create folder if not existing
|
||||
if (secretPath !== "/") {
|
||||
// /hello/world -> [hello","world"]
|
||||
const pathSegment = secretPath.split("/").filter(Boolean);
|
||||
const parentPath = `/${pathSegment.slice(0, -1).join("/")}`;
|
||||
const folderName = pathSegment.at(-1);
|
||||
if (folderName && parentPath) {
|
||||
await createFolder({
|
||||
projectId: workspaceId,
|
||||
path: parentPath,
|
||||
environment,
|
||||
name: folderName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isEdit = getSecretByKey(environment, key) !== undefined;
|
||||
if (isEdit) {
|
||||
return updateSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value || "",
|
||||
type: "shared",
|
||||
latestFileKey: decryptFileKey
|
||||
});
|
||||
}
|
||||
|
||||
return createSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value || "",
|
||||
secretComment: "",
|
||||
type: "shared",
|
||||
latestFileKey: decryptFileKey
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const isSecretsAdded = results.some((result) => result.status === "fulfilled");
|
||||
|
||||
if (isSecretsAdded) {
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Secrets created successfully"
|
||||
});
|
||||
onClose();
|
||||
reset();
|
||||
} else {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||
<ModalContent
|
||||
className="max-h-[80vh] overflow-y-auto"
|
||||
title="Bulk Create & Update"
|
||||
subTitle="Create & update a secret across many environments"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<FormControl label="Key" isError={Boolean(errors?.key)} errorText={errors?.key?.message}>
|
||||
<Input
|
||||
{...register("key")}
|
||||
placeholder="Type your secret name"
|
||||
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
/>
|
||||
</FormControl>
|
||||
<Controller
|
||||
control={control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Value"
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormLabel label="Environments" className="mb-2" />
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto ">
|
||||
{environments.map((env) => {
|
||||
return (
|
||||
<Controller
|
||||
name={`environments.${env.slug}`}
|
||||
key={`secret-input-${env.slug}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`secret-input-${env.slug}`}
|
||||
>
|
||||
{env.name}
|
||||
{getSecretByKey(env.slug, newSecretKey) && (
|
||||
<Tooltip content="Secret exists. Will be overwritten">
|
||||
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="layout-create-project-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Create Secret
|
||||
</Button>
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { CreateSecretForm } from "./CreateSecretForm"
|
@ -19,7 +19,8 @@ import { SSOModal } from "./SSOModal";
|
||||
const ssoAuthProviderMap: { [key: string]: string } = {
|
||||
"okta-saml": "Okta SAML",
|
||||
"azure-saml": "Azure SAML",
|
||||
"jumpcloud-saml": "JumpCloud SAML"
|
||||
"jumpcloud-saml": "JumpCloud SAML",
|
||||
"google-saml": "Google SAML"
|
||||
};
|
||||
|
||||
export const OrgSSOSection = (): JSX.Element => {
|
||||
|
@ -21,13 +21,15 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
enum AuthProvider {
|
||||
OKTA_SAML = "okta-saml",
|
||||
AZURE_SAML = "azure-saml",
|
||||
JUMPCLOUD_SAML = "jumpcloud-saml"
|
||||
JUMPCLOUD_SAML = "jumpcloud-saml",
|
||||
GOOGLE_SAML = "google-saml"
|
||||
}
|
||||
|
||||
const ssoAuthProviders = [
|
||||
{ label: "Okta SAML", value: AuthProvider.OKTA_SAML },
|
||||
{ label: "Azure SAML", value: AuthProvider.AZURE_SAML },
|
||||
{ label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML }
|
||||
{ label: "JumpCloud SAML", value: AuthProvider.JUMPCLOUD_SAML },
|
||||
{ label: "Google SAML", value: AuthProvider.GOOGLE_SAML }
|
||||
];
|
||||
|
||||
const schema = yup
|
||||
@ -140,7 +142,15 @@ export const SSOModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props)
|
||||
issuer: "IdP Entity ID",
|
||||
issuerPlaceholder: "xxx"
|
||||
};
|
||||
|
||||
case AuthProvider.GOOGLE_SAML:
|
||||
return {
|
||||
acsUrl: "ACS URL",
|
||||
entityId: "SP Entity ID",
|
||||
entryPoint: "SSO URL",
|
||||
entryPointPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx",
|
||||
issuer: "IdP Entity ID",
|
||||
issuerPlaceholder: "https://accounts.google.com/o/saml2/idp?idpid=xxx"
|
||||
};
|
||||
default:
|
||||
return {
|
||||
acsUrl: "ACS URL",
|
||||
|