mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Fix merge conflicts
This commit is contained in:
12
.github/workflows/release_build.yml
vendored
12
.github/workflows/release_build.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
# run only against tags
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -18,11 +18,16 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 🐋 Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- run: git fetch --force --tags
|
||||
- run: echo "Ref name ${{github.ref_name}}"
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: '>=1.19.3'
|
||||
go-version: ">=1.19.3"
|
||||
cache: true
|
||||
cache-dependency-path: cli/go.sum
|
||||
- name: libssl1.1 => libssl1.0-dev for OSXCross
|
||||
@@ -45,8 +50,7 @@ jobs:
|
||||
AUR_KEY: ${{ secrets.AUR_KEY }}
|
||||
- uses: actions/setup-python@v4
|
||||
- run: pip install --upgrade cloudsmith-cli
|
||||
- name: Publish to CloudSmith
|
||||
- name: Publish to CloudSmith
|
||||
run: sh cli/upload_to_cloudsmith.sh
|
||||
env:
|
||||
CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }}
|
||||
|
||||
|
||||
@@ -68,10 +68,10 @@ archives:
|
||||
|
||||
release:
|
||||
replace_existing_draft: true
|
||||
mode: 'replace'
|
||||
mode: "replace"
|
||||
|
||||
checksum:
|
||||
name_template: 'checksums.txt'
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-devel"
|
||||
@@ -80,8 +80,8 @@ changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- '^docs:'
|
||||
- '^test:'
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
# publishers:
|
||||
# - name: fury.io
|
||||
@@ -109,30 +109,30 @@ brews:
|
||||
man1.install "manpages/infisical.1.gz"
|
||||
|
||||
nfpms:
|
||||
- id: infisical
|
||||
package_name: infisical
|
||||
builds:
|
||||
- all-other-builds
|
||||
vendor: Infisical, Inc
|
||||
homepage: https://infisical.com/
|
||||
maintainer: Infisical, Inc
|
||||
description: The offical Infisical CLI
|
||||
license: MIT
|
||||
formats:
|
||||
- rpm
|
||||
- deb
|
||||
- apk
|
||||
- archlinux
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: ./completions/infisical.bash
|
||||
dst: /etc/bash_completion.d/infisical
|
||||
- src: ./completions/infisical.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/infisical.fish
|
||||
- src: ./completions/infisical.zsh
|
||||
dst: /usr/share/zsh/site-functions/_infisical
|
||||
- src: ./manpages/infisical.1.gz
|
||||
dst: /usr/share/man/man1/infisical.1.gz
|
||||
- id: infisical
|
||||
package_name: infisical
|
||||
builds:
|
||||
- all-other-builds
|
||||
vendor: Infisical, Inc
|
||||
homepage: https://infisical.com/
|
||||
maintainer: Infisical, Inc
|
||||
description: The offical Infisical CLI
|
||||
license: MIT
|
||||
formats:
|
||||
- rpm
|
||||
- deb
|
||||
- apk
|
||||
- archlinux
|
||||
bindir: /usr/bin
|
||||
contents:
|
||||
- src: ./completions/infisical.bash
|
||||
dst: /etc/bash_completion.d/infisical
|
||||
- src: ./completions/infisical.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/infisical.fish
|
||||
- src: ./completions/infisical.zsh
|
||||
dst: /usr/share/zsh/site-functions/_infisical
|
||||
- src: ./manpages/infisical.1.gz
|
||||
dst: /usr/share/man/man1/infisical.1.gz
|
||||
|
||||
scoop:
|
||||
bucket:
|
||||
@@ -146,15 +146,14 @@ scoop:
|
||||
license: MIT
|
||||
|
||||
aurs:
|
||||
-
|
||||
name: infisical-bin
|
||||
- name: infisical-bin
|
||||
homepage: "https://infisical.com"
|
||||
description: "The official Infisical CLI"
|
||||
maintainers:
|
||||
- Infisical, Inc <support@infisical.com>
|
||||
license: MIT
|
||||
private_key: '{{ .Env.AUR_KEY }}'
|
||||
git_url: 'ssh://aur@aur.archlinux.org/infisical-bin.git'
|
||||
private_key: "{{ .Env.AUR_KEY }}"
|
||||
git_url: "ssh://aur@aur.archlinux.org/infisical-bin.git"
|
||||
package: |-
|
||||
# bin
|
||||
install -Dm755 "./infisical" "${pkgdir}/usr/bin/infisical"
|
||||
@@ -169,19 +168,13 @@ aurs:
|
||||
install -Dm644 "./completions/infisical.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/infisical.fish"
|
||||
# man pages
|
||||
install -Dm644 "./manpages/infisical.1.gz" "${pkgdir}/usr/share/man/man1/infisical.1.gz"
|
||||
|
||||
# dockers:
|
||||
# - dockerfile: goreleaser.dockerfile
|
||||
# - dockerfile: cli/docker/Dockerfile
|
||||
# goos: linux
|
||||
# goarch: amd64
|
||||
# ids:
|
||||
# - infisical
|
||||
# image_templates:
|
||||
# - "infisical/cli:{{ .Version }}"
|
||||
# - "infisical/cli:{{ .Major }}.{{ .Minor }}"
|
||||
# - "infisical/cli:{{ .Major }}"
|
||||
# - "infisical/cli:{{ .Version }}"
|
||||
# - "infisical/cli:latest"
|
||||
# build_flag_templates:
|
||||
# - "--label=org.label-schema.schema-version=1.0"
|
||||
# - "--label=org.label-schema.version={{.Version}}"
|
||||
# - "--label=org.label-schema.name={{.ProjectName}}"
|
||||
# - "--platform=linux/amd64"
|
||||
@@ -1,5 +1,6 @@
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const EMAIL_TOKEN_LIFETIME = process.env.EMAIL_TOKEN_LIFETIME! || '86400';
|
||||
const EMAIL_TOKEN_LIFETIME = parseInt(process.env.EMAIL_TOKEN_LIFETIME! || '86400');
|
||||
const INVITE_ONLY_SIGNUP = process.env.INVITE_ONLY_SIGNUP == undefined ? false : process.env.INVITE_ONLY_SIGNUP
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!;
|
||||
const SALT_ROUNDS = parseInt(process.env.SALT_ROUNDS!) || 10;
|
||||
const JWT_AUTH_LIFETIME = process.env.JWT_AUTH_LIFETIME! || '10d';
|
||||
@@ -26,7 +27,7 @@ const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!;
|
||||
const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!;
|
||||
const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!;
|
||||
const CLIENT_SECRET_GITHUB = process.env.CLIENT_SECRET_GITHUB!;
|
||||
const CLIENT_SLUG_VERCEL= process.env.CLIENT_SLUG_VERCEL!;
|
||||
const CLIENT_SLUG_VERCEL = process.env.CLIENT_SLUG_VERCEL!;
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com';
|
||||
const POSTHOG_PROJECT_API_KEY =
|
||||
process.env.POSTHOG_PROJECT_API_KEY! ||
|
||||
@@ -52,6 +53,7 @@ const LICENSE_KEY = process.env.LICENSE_KEY!;
|
||||
export {
|
||||
PORT,
|
||||
EMAIL_TOKEN_LIFETIME,
|
||||
INVITE_ONLY_SIGNUP,
|
||||
ENCRYPTION_KEY,
|
||||
SALT_ROUNDS,
|
||||
JWT_AUTH_LIFETIME,
|
||||
|
||||
@@ -112,14 +112,14 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => {
|
||||
if (!membershipOrg) {
|
||||
throw new Error('Failed to validate organization membership');
|
||||
}
|
||||
|
||||
|
||||
invitee = await User.findOne({
|
||||
email: inviteeEmail
|
||||
}).select('+publicKey');
|
||||
|
||||
if (invitee) {
|
||||
// case: invitee is an existing user
|
||||
|
||||
|
||||
inviteeMembershipOrg = await MembershipOrg.findOne({
|
||||
user: invitee._id,
|
||||
organization: organizationId
|
||||
@@ -236,7 +236,7 @@ export const verifyUserToOrganization = async (req: Request, res: Response) => {
|
||||
message: 'Successfully verified email',
|
||||
user,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// initialize user account
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
MembershipOrg,
|
||||
Organization,
|
||||
Workspace,
|
||||
IncidentContactOrg
|
||||
IncidentContactOrg,
|
||||
IMembershipOrg
|
||||
} from '../../models';
|
||||
import { createOrganization as create } from '../../helpers/organization';
|
||||
import { addMembershipsOrg } from '../../helpers/membershipOrg';
|
||||
import { OWNER, ACCEPTED } from '../../variables';
|
||||
import _ from 'lodash';
|
||||
|
||||
export const getOrganizations = async (req: Request, res: Response) => {
|
||||
let organizations;
|
||||
@@ -382,3 +384,44 @@ export const getOrganizationSubscriptions = async (
|
||||
subscriptions
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Given a org id, return the projects each member of the org belongs to
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const getOrganizationMembersAndTheirWorkspaces = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => {
|
||||
const { organizationId } = req.params;
|
||||
|
||||
const workspacesSet = (
|
||||
await Workspace.find(
|
||||
{
|
||||
organization: organizationId
|
||||
},
|
||||
'_id'
|
||||
)
|
||||
).map((w) => w._id.toString());
|
||||
|
||||
const memberships = (
|
||||
await Membership.find({
|
||||
workspace: { $in: workspacesSet }
|
||||
}).populate('workspace')
|
||||
);
|
||||
const userToWorkspaceIds: any = {};
|
||||
|
||||
memberships.forEach(membership => {
|
||||
const user = membership.user.toString();
|
||||
if (userToWorkspaceIds[user]) {
|
||||
userToWorkspaceIds[user].push(membership.workspace);
|
||||
} else {
|
||||
userToWorkspaceIds[user] = [membership.workspace];
|
||||
}
|
||||
});
|
||||
|
||||
return res.json(userToWorkspaceIds);
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET } from '../../config';
|
||||
import { User } from '../../models';
|
||||
import { JWT_SIGNUP_LIFETIME, JWT_SIGNUP_SECRET, INVITE_ONLY_SIGNUP } from '../../config';
|
||||
import {
|
||||
sendEmailVerification,
|
||||
checkEmailVerification,
|
||||
} from '../../helpers/signup';
|
||||
import { createToken } from '../../helpers/auth';
|
||||
import { BadRequestError } from '../../utils/errors';
|
||||
|
||||
/**
|
||||
* Signup step 1: Initialize account for user under email [email] and send a verification code
|
||||
@@ -20,6 +21,14 @@ export const beginEmailSignup = async (req: Request, res: Response) => {
|
||||
try {
|
||||
email = req.body.email;
|
||||
|
||||
if (INVITE_ONLY_SIGNUP) {
|
||||
// Only one user can create an account without being invited. The rest need to be invited in order to make an account
|
||||
const userCount = await User.countDocuments({})
|
||||
if (userCount != 0) {
|
||||
throw BadRequestError({ message: "New user sign ups are not allowed at this time. You must be invited to sign up." })
|
||||
}
|
||||
}
|
||||
|
||||
const user = await User.findOne({ email }).select('+publicKey');
|
||||
if (user && user?.publicKey) {
|
||||
// case: user has already completed account
|
||||
@@ -98,4 +107,4 @@ export const verifyEmailSignup = async (req: Request, res: Response) => {
|
||||
user,
|
||||
token
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -246,13 +246,14 @@ export const getAllAccessibleEnvironmentsOfWorkspace = async (
|
||||
relatedWorkspace.environments.forEach(environment => {
|
||||
const isReadBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_READ })
|
||||
const isWriteBlocked = _.some(deniedPermission, { environmentSlug: environment.slug, ability: ABILITY_WRITE })
|
||||
if (isReadBlocked) {
|
||||
if (isReadBlocked && isWriteBlocked) {
|
||||
return
|
||||
} else {
|
||||
accessibleEnvironments.push({
|
||||
name: environment.name,
|
||||
slug: environment.slug,
|
||||
isWriteDenied: isWriteBlocked
|
||||
isWriteDenied: isWriteBlocked,
|
||||
isReadDenied: isReadBlocked
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { EESecretService, EELogService } from '../../ee/services';
|
||||
import { postHogClient } from '../../services';
|
||||
import { getChannelFromUserAgent } from '../../utils/posthog';
|
||||
import { ABILITY_READ, ABILITY_WRITE } from '../../variables/organization';
|
||||
import { userHasWorkspaceAccess } from '../../ee/helpers/checkMembershipPermissions';
|
||||
import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions';
|
||||
|
||||
/**
|
||||
* Create secret(s) for workspace with id [workspaceId] and environment [environment]
|
||||
@@ -298,27 +298,42 @@ export const getSecrets = async (req: Request, res: Response) => {
|
||||
userEmail = req.serviceTokenData.user.email;
|
||||
}
|
||||
|
||||
// none service token case as service tokens are already scoped
|
||||
// none service token case as service tokens are already scoped to env and project
|
||||
let hasWriteOnlyAccess
|
||||
if (!req.serviceTokenData) {
|
||||
const hasAccess = await userHasWorkspaceAccess(userId, workspaceId, environment, ABILITY_READ)
|
||||
if (!hasAccess) {
|
||||
hasWriteOnlyAccess = await userHasWriteOnlyAbility(userId, workspaceId, environment)
|
||||
const hasNoAccess = await userHasNoAbility(userId, workspaceId, environment)
|
||||
if (hasNoAccess) {
|
||||
throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" })
|
||||
}
|
||||
}
|
||||
|
||||
const [err, secrets] = await to(Secret.find(
|
||||
{
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
$or: [
|
||||
{ user: userId },
|
||||
{ user: { $exists: false } }
|
||||
],
|
||||
type: { $in: [SECRET_SHARED, SECRET_PERSONAL] }
|
||||
}
|
||||
).populate("tags").then())
|
||||
|
||||
if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack });
|
||||
let secrets: any
|
||||
if (hasWriteOnlyAccess) {
|
||||
secrets = await Secret.find(
|
||||
{
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
$or: [
|
||||
{ user: userId },
|
||||
{ user: { $exists: false } }
|
||||
],
|
||||
type: { $in: [SECRET_SHARED, SECRET_PERSONAL] }
|
||||
}
|
||||
)
|
||||
.select("secretKeyCiphertext secretKeyIV secretKeyTag")
|
||||
} else {
|
||||
secrets = await Secret.find(
|
||||
{
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
$or: [
|
||||
{ user: userId },
|
||||
{ user: { $exists: false } }
|
||||
],
|
||||
type: { $in: [SECRET_SHARED, SECRET_PERSONAL] }
|
||||
}
|
||||
).populate("tags")
|
||||
}
|
||||
|
||||
const channel = getChannelFromUserAgent(req.headers['user-agent'])
|
||||
|
||||
@@ -356,6 +371,59 @@ export const getSecrets = async (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export const getOnlySecretKeys = async (req: Request, res: Response) => {
|
||||
const { workspaceId, environment } = req.query;
|
||||
|
||||
let userId = "" // used for getting personal secrets for user
|
||||
let userEmail = "" // used for posthog
|
||||
if (req.user) {
|
||||
userId = req.user._id;
|
||||
userEmail = req.user.email;
|
||||
}
|
||||
|
||||
if (req.serviceTokenData) {
|
||||
userId = req.serviceTokenData.user._id
|
||||
userEmail = req.serviceTokenData.user.email;
|
||||
}
|
||||
|
||||
// none service token case as service tokens are already scoped
|
||||
if (!req.serviceTokenData) {
|
||||
const hasAccess = await userHasWorkspaceAccess(userId, workspaceId, environment, ABILITY_READ)
|
||||
if (!hasAccess) {
|
||||
throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" })
|
||||
}
|
||||
}
|
||||
|
||||
const [err, secretKeys] = await to(Secret.find(
|
||||
{
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
$or: [
|
||||
{ user: userId },
|
||||
{ user: { $exists: false } }
|
||||
],
|
||||
type: { $in: [SECRET_SHARED, SECRET_PERSONAL] }
|
||||
}
|
||||
)
|
||||
.select("secretKeyIV secretKeyTag secretKeyCiphertext")
|
||||
.then())
|
||||
|
||||
if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack });
|
||||
|
||||
// readAction && await EELogService.createLog({
|
||||
// userId: new Types.ObjectId(userId),
|
||||
// workspaceId: new Types.ObjectId(workspaceId as string),
|
||||
// actions: [readAction],
|
||||
// channel,
|
||||
// ipAddress: req.ip
|
||||
// });
|
||||
|
||||
return res.status(200).send({
|
||||
secretKeys
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update secret(s)
|
||||
* @param req
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import _ from "lodash";
|
||||
import { Membership } from "../../models";
|
||||
import { ABILITY_READ, ABILITY_WRITE } from "../../variables/organization";
|
||||
|
||||
export const userHasWorkspaceAccess = async (userId: any, workspaceId: any, environment: any, action: any) => {
|
||||
const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId })
|
||||
@@ -15,4 +16,39 @@ export const userHasWorkspaceAccess = async (userId: any, workspaceId: any, envi
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const userHasWriteOnlyAbility = async (userId: any, workspaceId: any, environment: any) => {
|
||||
const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId })
|
||||
if (!membershipForWorkspace) {
|
||||
return false
|
||||
}
|
||||
|
||||
const deniedMembershipPermissions = membershipForWorkspace.deniedPermissions;
|
||||
const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_WRITE });
|
||||
const isReadDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_READ });
|
||||
|
||||
// case: you have write only if read is blocked and write is not
|
||||
if (isReadDisallowed && !isWriteDisallowed) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const userHasNoAbility = async (userId: any, workspaceId: any, environment: any) => {
|
||||
const membershipForWorkspace = await Membership.findOne({ workspace: workspaceId, user: userId })
|
||||
if (!membershipForWorkspace) {
|
||||
return true
|
||||
}
|
||||
|
||||
const deniedMembershipPermissions = membershipForWorkspace.deniedPermissions;
|
||||
const isWriteDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_WRITE });
|
||||
const isReadBlocked = _.some(deniedMembershipPermissions, { environmentSlug: environment, ability: ABILITY_READ });
|
||||
|
||||
if (isReadBlocked && isWriteDisallowed) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
84
backend/src/models/secretApprovalRequest.ts
Normal file
84
backend/src/models/secretApprovalRequest.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import mongoose, { Schema, model } from 'mongoose';
|
||||
import Secret, { ISecret } from './secret';
|
||||
|
||||
interface ISecretApprovalRequest {
|
||||
secret: mongoose.Types.ObjectId;
|
||||
requestedChanges: ISecret;
|
||||
requestedBy: mongoose.Types.ObjectId;
|
||||
approvers: IApprover[];
|
||||
status: ApprovalStatus;
|
||||
timestamp: Date;
|
||||
requestType: RequestType;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
interface IApprover {
|
||||
userId: mongoose.Types.ObjectId;
|
||||
status: ApprovalStatus;
|
||||
}
|
||||
|
||||
enum ApprovalStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected'
|
||||
}
|
||||
|
||||
enum RequestType {
|
||||
UPDATE = 'update',
|
||||
DELETE = 'delete',
|
||||
CREATE = 'create'
|
||||
}
|
||||
|
||||
const approverSchema = new mongoose.Schema({
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
enum: [ApprovalStatus],
|
||||
default: ApprovalStatus.PENDING
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const secretApprovalRequestSchema = new Schema<ISecretApprovalRequest>(
|
||||
{
|
||||
secret: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Secret'
|
||||
},
|
||||
requestedChanges: Secret,
|
||||
requestedBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
approvers: [approverSchema],
|
||||
status: {
|
||||
type: String,
|
||||
enum: ApprovalStatus,
|
||||
default: ApprovalStatus.PENDING
|
||||
},
|
||||
timestamp: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
requestType: {
|
||||
type: String,
|
||||
enum: RequestType,
|
||||
required: true
|
||||
},
|
||||
requestId: {
|
||||
type: String,
|
||||
required: false
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
const SecretApprovalRequest = model<ISecretApprovalRequest>('SecretApprovalRequest', secretApprovalRequestSchema);
|
||||
|
||||
export default SecretApprovalRequest;
|
||||
33
backend/src/models/token.ts
Normal file
33
backend/src/models/token.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Schema, model } from 'mongoose';
|
||||
import { EMAIL_TOKEN_LIFETIME } from '../config';
|
||||
|
||||
export interface IToken {
|
||||
email: string;
|
||||
token: string;
|
||||
createdAt: Date;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
const tokenSchema = new Schema<IToken>({
|
||||
email: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
token: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now
|
||||
},
|
||||
ttl: {
|
||||
type: Number,
|
||||
}
|
||||
});
|
||||
|
||||
tokenSchema.index({ email: 1 });
|
||||
|
||||
const Token = model<IToken>('Token', tokenSchema);
|
||||
|
||||
export default Token;
|
||||
@@ -156,4 +156,19 @@ router.get(
|
||||
organizationController.getOrganizationSubscriptions
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:organizationId/workspace-memberships',
|
||||
requireAuth({
|
||||
acceptedAuthModes: ['jwt']
|
||||
}),
|
||||
requireOrganizationAuth({
|
||||
acceptedRoles: [OWNER, ADMIN, MEMBER],
|
||||
acceptedStatuses: [ACCEPTED]
|
||||
}),
|
||||
param('organizationId').exists().trim(),
|
||||
validateRequest,
|
||||
organizationController.getOrganizationMembersAndTheirWorkspaces
|
||||
);
|
||||
|
||||
|
||||
export default router;
|
||||
|
||||
4
cli/docker/Dockerfile
Normal file
4
cli/docker/Dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM alpine
|
||||
RUN apk add --no-cache tini
|
||||
COPY infisical /bin/infisical
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/bin/infisical"]
|
||||
@@ -37,5 +37,6 @@ Configuring Infisical requires setting some environment variables. There is a fi
|
||||
| `CLIENT_SECRET_VERCEL` | OAuth2 client secret for Vercel integration | `None` |
|
||||
| `CLIENT_SECRET_NETLIFY` | OAuth2 client secret for Netlify integration | `None` |
|
||||
| `CLIENT_SECRET_GITHUB` | OAuth2 client secret for GitHub integration | `None` |
|
||||
| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` |
|
||||
| `CLIENT_SLUG_VERCEL` | OAuth2 slug for Netlify integration | `None` |
|
||||
| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` |
|
||||
| `INVITE_ONLY_SIGNUP` | If true, users can only sign up if they are invited | `false` |
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Tag {
|
||||
export interface SecretDataProps {
|
||||
pos: number;
|
||||
key: string;
|
||||
value: string;
|
||||
value: string | undefined;
|
||||
valueOverride: string | undefined;
|
||||
id: string;
|
||||
comment: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { faX } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from '@fortawesome/free-solid-svg-icons';
|
||||
import { plans } from 'public/data/frequentConstants';
|
||||
|
||||
import { useNotificationContext } from '@app/components/context/Notifications/NotificationProvider';
|
||||
@@ -106,6 +106,11 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
|
||||
ability: "read",
|
||||
environmentSlug: slug
|
||||
}];
|
||||
} else if (val === "Add Only") {
|
||||
denials = [{
|
||||
ability: "read",
|
||||
environmentSlug: slug
|
||||
}];
|
||||
} else {
|
||||
denials = [];
|
||||
}
|
||||
@@ -185,21 +190,21 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
|
||||
|
||||
return (
|
||||
<div className="table-container bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1 min-w-max">
|
||||
<div className="absolute rounded-t-md w-full h-[3.25rem] bg-white/5" />
|
||||
<div className="absolute rounded-t-md w-full h-[3.1rem] bg-white/5" />
|
||||
<UpgradePlanModal
|
||||
isOpen={isUpgradeModalOpen}
|
||||
onClose={closeUpgradeModal}
|
||||
text="You can change user permissions if you switch to Infisical's Professional plan."
|
||||
/>
|
||||
<table className="w-full my-0.5">
|
||||
<thead className="text-gray-400 text-sm font-light">
|
||||
<thead className="text-gray-400 text-xs font-light">
|
||||
<tr>
|
||||
<th className="text-left pl-4 py-3.5">NAME</th>
|
||||
<th className="text-left pl-4 py-3.5">EMAIL</th>
|
||||
<th className="text-left pl-6 pr-10 py-3.5">ROLE</th>
|
||||
{workspaceEnvs.map(env => (
|
||||
<th key={guidGenerator()} className="text-left pl-8 py-1 max-w-min break-normal">
|
||||
<span>{env.name.toUpperCase()}<br/></span>
|
||||
<th key={guidGenerator()} className="text-left pl-2 py-1 max-w-min break-normal">
|
||||
<span>{env.slug.toUpperCase()}<br/></span>
|
||||
{/* <span>PERMISSION</span> */}
|
||||
</th>
|
||||
))}
|
||||
@@ -221,7 +226,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
|
||||
user.email?.toLowerCase().includes(filter)
|
||||
)
|
||||
.map((row, index) => (
|
||||
<tr key={guidGenerator()} className="bg-bunker-800 hover:bg-bunker-700">
|
||||
<tr key={guidGenerator()} className="bg-bunker-600 text-sm hover:bg-bunker-500">
|
||||
<td className="pl-4 py-2 border-mineshaft-700 border-t text-gray-300">
|
||||
{row.firstName} {row.lastName}
|
||||
</td>
|
||||
@@ -231,7 +236,8 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
|
||||
<td className="pl-6 pr-10 py-2 border-mineshaft-700 border-t text-gray-300">
|
||||
<div className="justify-start h-full flex flex-row items-center">
|
||||
<Select
|
||||
className="w-36"
|
||||
className="w-36 bg-mineshaft-700"
|
||||
dropdownContainerClassName="bg-mineshaft-700"
|
||||
// open={isOpen}
|
||||
onValueChange={(e) => handleRoleUpdate(index, e)}
|
||||
value={row.role}
|
||||
@@ -253,23 +259,36 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{workspaceEnvs.map((env) => <td key={guidGenerator()} className="pl-8 py-2 border-mineshaft-700 border-t text-gray-300">
|
||||
{workspaceEnvs.map((env) => <td key={guidGenerator()} className="pl-2 py-2 border-mineshaft-700 border-t text-gray-300">
|
||||
<Select
|
||||
className="w-36"
|
||||
className="w-16 bg-mineshaft-700"
|
||||
dropdownContainerClassName="bg-mineshaft-700"
|
||||
position="item-aligned"
|
||||
// open={isOpen}
|
||||
onValueChange={(val) => handlePermissionUpdate(index, val, row.membershipId, env.slug)}
|
||||
value={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
(row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read"))
|
||||
? "No Access"
|
||||
: (row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") ? "Read Only" : "Read & Write")
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
: (row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? "Read Only"
|
||||
: !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? "Add Only" : "Read & Write")
|
||||
}
|
||||
icon={
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
(row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read"))
|
||||
? faEyeSlash
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
: (row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? faEye
|
||||
: !row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("write") && row.deniedPermissions.filter((perm: any) => perm.environmentSlug === env.slug).map((perm: {ability: string}) => perm.ability).includes("read") ? faPlus : faPenToSquare)
|
||||
}
|
||||
disabled={myRole !== 'admin'}
|
||||
// onOpenChange={(open) => setIsOpen(open)}
|
||||
>
|
||||
<SelectItem value="No Access">No Access</SelectItem>
|
||||
<SelectItem value="Read Only">Read Only</SelectItem>
|
||||
<SelectItem value="Read & Write">Read & Write</SelectItem>
|
||||
<SelectItem value="No Access" customIcon={faEyeSlash}>No Access</SelectItem>
|
||||
<SelectItem value="Read Only" customIcon={faEye}>Read Only</SelectItem>
|
||||
<SelectItem value="Add Only" customIcon={faPlus}>Add Only</SelectItem>
|
||||
<SelectItem value="Read & Write" customIcon={faPenToSquare}>Read & Write</SelectItem>
|
||||
</Select>
|
||||
</td>)}
|
||||
<td className="flex flex-row justify-end pl-8 pr-8 py-2 border-t border-0.5 border-mineshaft-700">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { faX } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import changeUserRoleInOrganization from '@app/pages/api/organization/changeUserRoleInOrganization';
|
||||
import deleteUserFromOrganization from '@app/pages/api/organization/deleteUserFromOrganization';
|
||||
import getOrganizationProjectMemberships from '@app/pages/api/organization/GetOrgProjectMemberships';
|
||||
import deleteUserFromWorkspace from '@app/pages/api/workspace/deleteUserFromWorkspace';
|
||||
import getLatestFileKey from '@app/pages/api/workspace/getLatestFileKey';
|
||||
import uploadKeys from '@app/pages/api/workspace/uploadKeys';
|
||||
@@ -36,6 +37,8 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
|
||||
);
|
||||
const router = useRouter();
|
||||
const [myRole, setMyRole] = useState('member');
|
||||
const [userProjectMemberships, setUserProjectMemberships] = useState<any[]>([]);
|
||||
console.log(123, userData)
|
||||
|
||||
const workspaceId = router.query.id as string;
|
||||
// Delete the row in the table (e.g. a user)
|
||||
@@ -79,6 +82,10 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
|
||||
|
||||
useEffect(() => {
|
||||
setMyRole(userData.filter((user) => user.email === myUser)[0]?.role);
|
||||
(async () => {
|
||||
const result = await getOrganizationProjectMemberships({ orgId: String(localStorage.getItem("orgData.id"))})
|
||||
setUserProjectMemberships(result);
|
||||
})();
|
||||
}, [userData, myUser]);
|
||||
|
||||
const grantAccess = async (id: string, publicKey: string) => {
|
||||
@@ -110,7 +117,7 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-container bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1 min-w-max">
|
||||
<div className="table-container bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1 min-w-max w-full">
|
||||
<div className="absolute rounded-t-md w-full h-[3.25rem] bg-white/5" />
|
||||
<table className="w-full my-0.5">
|
||||
<thead className="text-gray-400 text-sm font-light">
|
||||
@@ -118,6 +125,7 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
|
||||
<th className="text-left pl-4 py-3.5">NAME</th>
|
||||
<th className="text-left pl-4 py-3.5">EMAIL</th>
|
||||
<th className="text-left pl-6 pr-10 py-3.5">ROLE</th>
|
||||
<th className="text-left pl-6 pr-10 py-3.5">PROJECTS</th>
|
||||
<th aria-label="buttons" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -189,6 +197,17 @@ const UserTable = ({ userData, changeData, myUser, filter, resendInvite, isOrg }
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="pl-4 py-2 border-mineshaft-700 border-t text-gray-300">
|
||||
<td className="flex items-center max-h-16 overflow-x-auto w-full max-w-xl">
|
||||
{userProjectMemberships[row.userId]
|
||||
? userProjectMemberships[row.userId]?.map((project: any) => (
|
||||
<div key={project.id} className='mx-1 min-w-max px-1.5 bg-mineshaft-500 rounded-sm text-sm text-bunker-200 flex items-center'>
|
||||
<span className='mb-0.5 cursor-default'>{project.name}</span>
|
||||
</div>
|
||||
))
|
||||
: <span className='ml-1 text-bunker-100 rounded-sm px-1 py-0.5 text-sm bg-red/80'>This user isn't part of any projects yet.</span>}
|
||||
</td>
|
||||
</td>
|
||||
<td className="flex flex-row justify-end pl-8 pr-8 py-2 border-t border-0.5 border-mineshaft-700">
|
||||
{myUser !== row.email &&
|
||||
// row.role !== "admin" &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { faX } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
type NotificationType = 'success' | 'error' | 'info';
|
||||
@@ -36,7 +36,7 @@ const Notification = ({ notification, clearNotification }: NotificationProps) =>
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full flex items-center justify-between px-4 py-4 rounded-md border border-bunker-500 pointer-events-auto bg-bunker-500"
|
||||
className="relative w-full flex items-center justify-between px-6 py-6 rounded-md border border-bunker-500 pointer-events-auto bg-mineshaft-700 mb-3 right-3"
|
||||
role="alert"
|
||||
>
|
||||
{notification.type === 'error' && (
|
||||
@@ -48,13 +48,13 @@ const Notification = ({ notification, clearNotification }: NotificationProps) =>
|
||||
{notification.type === 'info' && (
|
||||
<div className="absolute w-full h-1 bg-yellow top-0 left-0 rounded-t-md" />
|
||||
)}
|
||||
<p className="text-bunker-200 text-sm font-semibold mt-0.5">{notification.text}</p>
|
||||
<p className="text-bunker-200 text-md font-base mt-0.5">{notification.text}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg"
|
||||
onClick={() => clearNotification(notification.text)}
|
||||
>
|
||||
<FontAwesomeIcon className="text-white pl-2 w-4 h-3 hover:text-red" icon={faX} />
|
||||
<FontAwesomeIcon className="absolute right-2 top-3 text-bunker-300 pl-2 w-4 h-4 hover:text-white" icon={faXmark} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { memo, SyntheticEvent, useRef } from 'react';
|
||||
import { faCircle, faExclamationCircle, faEye, faLayerGroup } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCircle, faCodeBranch, faExclamationCircle, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import guidGenerator from '../utilities/randomId';
|
||||
import { HoverObject } from '../v2/HoverCard';
|
||||
import { PopoverObject } from '../v2/Popover/Popover';
|
||||
|
||||
const REGEX = /([$]{.*?})/g;
|
||||
|
||||
@@ -112,7 +113,7 @@ const DashboardInputField = ({
|
||||
}}>
|
||||
<HoverObject
|
||||
text={overrideEnabled ? 'This secret is overriden with your personal value' : 'You can override this secret with a personal value'}
|
||||
icon={faLayerGroup}
|
||||
icon={faCodeBranch}
|
||||
color={overrideEnabled ? 'primary' : 'bunker-400'}
|
||||
/>
|
||||
</button>
|
||||
@@ -125,24 +126,24 @@ const DashboardInputField = ({
|
||||
const error = startsWithNumber || isDuplicate;
|
||||
|
||||
return (
|
||||
<div title={value} className={`relative flex-col w-full h-10 ${
|
||||
isSideBarOpen && 'bg-mineshaft-700 duration-200'
|
||||
}`}>
|
||||
<div
|
||||
className={`group relative flex flex-col justify-center items-center ${
|
||||
error ? 'w-max' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
onChange={(e) => onChangeHandler(e.target.value, position)}
|
||||
type={type}
|
||||
value={value}
|
||||
className='z-10 peer ph-no-capture bg-transparent py-2.5 caret-bunker-200 text-sm px-2 w-full min-w-16 outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200'
|
||||
spellCheck="false"
|
||||
placeholder='–'
|
||||
/>
|
||||
<PopoverObject text={value || ''} onChangeHandler={onChangeHandler} position={position}>
|
||||
<div title={value} className={`relative flex-col w-full h-10 overflow-hidden ${
|
||||
isSideBarOpen && 'bg-mineshaft-700 duration-200'
|
||||
}`}>
|
||||
<div
|
||||
className={`group relative flex flex-col justify-center items-center h-full ${
|
||||
error ? 'w-max' : 'w-full'
|
||||
}`}
|
||||
>
|
||||
{value?.split("\n")[0] ? <span className='ph-no-capture truncate break-all bg-transparent leading-tight text-xs px-2 w-full min-w-16 outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200'>
|
||||
{value?.split("\n")[0]}
|
||||
</span> : <span className='text-bunker-400'>-</span> }
|
||||
{value?.split("\n")[1] && <span className='ph-no-capture truncate break-all bg-transparent leading-tight text-xs px-2 w-full min-w-16 outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200'>
|
||||
{value?.split("\n")[1]}
|
||||
</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverObject>
|
||||
);
|
||||
}
|
||||
if (type === 'value') {
|
||||
@@ -215,7 +216,7 @@ const DashboardInputField = ({
|
||||
))}
|
||||
{value?.split('').length === 0 && <span className='text-bunker-400/80'>EMPTY</span>}
|
||||
</div>
|
||||
<div className='invisible group-hover:visible cursor-pointer'><FontAwesomeIcon icon={faEye} /></div>
|
||||
<div className='invisible group-hover:visible cursor-default z-[100]'><FontAwesomeIcon icon={faEye} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ const KeyPair = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/12 border-r border-mineshaft-600">
|
||||
<div className="w-[calc(10%)] border-r border-mineshaft-600">
|
||||
<div className="flex items-center max-h-16">
|
||||
<DashboardInputField
|
||||
onChangeHandler={modifyComment}
|
||||
@@ -171,12 +171,26 @@ const KeyPair = ({
|
||||
<FontAwesomeIcon className="text-bunker-300 hover:text-primary text-lg" icon={faEllipsis} />
|
||||
</div>
|
||||
<div className={`group-hover:bg-mineshaft-700 z-50 ${isSnapshot ?? 'invisible'}`}>
|
||||
<DeleteActionButton
|
||||
{keyPair.key || keyPair.value
|
||||
? <DeleteActionButton
|
||||
onSubmit={() => { if (deleteRow) {
|
||||
deleteRow({ ids: [keyPair.id], secretName: keyPair?.key })
|
||||
}}}
|
||||
isPlain
|
||||
/>
|
||||
: <div className='cursor-pointer w-[1.5rem] h-[2.35rem] mr-2 flex items-center justfy-center'>
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => { if (deleteRow) {
|
||||
deleteRow({ ids: [keyPair.id], secretName: keyPair?.key })
|
||||
}}}
|
||||
className="invisible group-hover:visible"
|
||||
>
|
||||
<FontAwesomeIcon className="text-bunker-300 hover:text-red pl-2 pr-6 text-lg mt-0.5" icon={faXmark} />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import GenerateSecretMenu from './GenerateSecretMenu';
|
||||
|
||||
interface SecretProps {
|
||||
key: string;
|
||||
value: string;
|
||||
value: string | undefined;
|
||||
valueOverride: string | undefined;
|
||||
pos: number;
|
||||
id: string;
|
||||
@@ -80,9 +80,9 @@ const SideBar = ({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="absolute border-l border-mineshaft-500 bg-bunker h-full w-[28rem] sticky top-0 right-0 z-[70] shadow-xl flex flex-col justify-between">
|
||||
<div className="absolute border-l border-mineshaft-500 bg-bunker h-full w-full min-w-sm max-w-sm sticky top-0 right-0 z-[70] shadow-xl flex flex-col justify-between">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<Image
|
||||
src="/images/loading/loading.gif"
|
||||
height={60}
|
||||
@@ -91,7 +91,7 @@ const SideBar = ({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-min overflow-y-auto">
|
||||
<div className="h-min overflow-y-auto w-full">
|
||||
<div className="flex flex-row px-4 py-3 border-b border-mineshaft-500 justify-between items-center">
|
||||
<p className="font-semibold text-lg text-bunker-200">{t('dashboard:sidebar.secret')}</p>
|
||||
<div
|
||||
@@ -186,7 +186,7 @@ const SideBar = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-full mt-4 mb-4 flex max-w-sm flex-col justify-start space-y-2 px-4">
|
||||
<div className="mt-full w-96 mt-4 mb-4 flex max-w-sm flex-col justify-start space-y-2 px-4">
|
||||
<div>
|
||||
<Button
|
||||
text="Compare secret across environments"
|
||||
@@ -197,7 +197,7 @@ const SideBar = ({
|
||||
<CompareSecretsModal
|
||||
compareModal={compareModal}
|
||||
setCompareModal={setCompareModal}
|
||||
currentSecret={{ key: data[0]?.key, value: data[0]?.value }}
|
||||
currentSecret={{ key: data[0]?.key, value: data[0]?.value ?? '' }}
|
||||
workspaceEnvs={workspaceEnvs}
|
||||
selectedEnv={selectedEnv}
|
||||
workspaceId={workspaceId}
|
||||
|
||||
@@ -76,7 +76,7 @@ const encryptSecrets = async ({
|
||||
iv: secretValueIV,
|
||||
tag: secretValueTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: secret.value,
|
||||
plaintext: secret.value ?? '',
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ interface EncryptedSecretProps {
|
||||
|
||||
interface SecretProps {
|
||||
key: string;
|
||||
value: string;
|
||||
value: string | undefined;
|
||||
type: 'personal' | 'shared';
|
||||
comment: string;
|
||||
id: string;
|
||||
@@ -87,12 +87,17 @@ const getSecretsForProject = async ({
|
||||
key
|
||||
});
|
||||
|
||||
const plainTextValue = decryptSymmetric({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
let plainTextValue;
|
||||
if (secret.secretValueCiphertext !== undefined) {
|
||||
plainTextValue = decryptSymmetric({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
} else {
|
||||
plainTextValue = undefined;
|
||||
}
|
||||
|
||||
let plainTextComment;
|
||||
if (secret.secretCommentCiphertext) {
|
||||
|
||||
49
frontend/src/components/v2/Popover/Popover.tsx
Normal file
49
frontend/src/components/v2/Popover/Popover.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
|
||||
type Props = {
|
||||
children: any;
|
||||
text: string;
|
||||
onChangeHandler: (value: string, position: number) => void;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export type PopoverProps = Props;
|
||||
|
||||
export const PopoverObject = ({children, text, onChangeHandler, position}: Props) => (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild className='data-[state=open]:outline data-[state=open]:outline-primary data-[state=closed]:hover:outline data-[state=closed]:hover:outline-mineshaft-400'>
|
||||
{children}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="rounded z-[100] p-3 w-[460px] min-h-fit border border-chicago-700 bg-mineshaft-600 shadow-[0_10px_38px_-10px_hsla(206,22%,7%,.35),0_10px_20px_-15px_hsla(206,22%,7%,.2)] focus:shadow-[0_10px_38px_-10px_hsla(206,22%,7%,.35),0_10px_20px_-15px_hsla(206,22%,7%,.2),0_0_0_2px_theme(colors.violet7)] will-change-[transform,opacity] data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade"
|
||||
sideOffset={5}
|
||||
hideWhenDetached
|
||||
side="left"
|
||||
>
|
||||
<div className="flex flex-col pt-2 dark">
|
||||
<p className="text-bunker-200 text-[15px] leading-[0px] font-medium mb-5">Comment</p>
|
||||
<textarea
|
||||
onChange={(e) => onChangeHandler(e.target.value, position)}
|
||||
// type={type}
|
||||
value={text}
|
||||
className='z-10 dark:[color-scheme:dark] peer h-[20rem] ph-no-capture bg-bunker-600 border border-mineshaft-500 rounded-md py-2.5 caret-bunker-200 text-sm px-2 w-full outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200'
|
||||
spellCheck="false"
|
||||
placeholder='–'
|
||||
/>
|
||||
</div>
|
||||
<Popover.Close
|
||||
className="rounded-full h-[25px] w-[25px] inline-flex items-center justify-center text-bunker-300 hover:text-white absolute top-[5px] right-[5px] hover:bg-violet4 focus:shadow-[0_0_0_2px] focus:shadow-violet7 outline-none cursor-default"
|
||||
aria-label="Close"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</Popover.Close>
|
||||
<Popover.Arrow className="fill-chicago-700" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
|
||||
PopoverObject.displayName = 'Popover';
|
||||
2
frontend/src/components/v2/Popover/index.tsx
Normal file
2
frontend/src/components/v2/Popover/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { PopoverProps } from './Popover';
|
||||
export { PopoverObject } from './Popover';
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, ReactNode } from 'react';
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faCheck, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
@@ -13,6 +14,7 @@ type Props = {
|
||||
dropdownContainerClassName?: string;
|
||||
isLoading?: boolean;
|
||||
position?: 'item-aligned' | 'popper';
|
||||
icon?: IconProp;
|
||||
};
|
||||
|
||||
export type SelectProps = SelectPrimitive.SelectProps & Props;
|
||||
@@ -32,7 +34,9 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
<SelectPrimitive.Value placeholder={placeholder}>
|
||||
{props.icon ? <FontAwesomeIcon icon={props.icon} /> : placeholder}
|
||||
</SelectPrimitive.Value>
|
||||
{!props.disabled && (
|
||||
<SelectPrimitive.Icon className="ml-3">
|
||||
<FontAwesomeIcon icon={faChevronDown} size="sm" />
|
||||
@@ -42,7 +46,7 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className={twMerge(
|
||||
'relative left-4 top-1 overflow-hidden rounded-md bg-bunker-800 font-inter text-bunker-100 shadow-md z-[100]',
|
||||
'relative left-4 top-1 overflow-hidden rounded-md bg-bunker-800 border border-mineshaft-500 drop-shadow-xl font-inter text-bunker-100 shadow-md z-[100]',
|
||||
dropdownContainerClassName
|
||||
)}
|
||||
position={position}
|
||||
@@ -76,6 +80,7 @@ Select.displayName = 'Select';
|
||||
export type SelectItemProps = Omit<SelectPrimitive.SelectItemProps, 'disabled'> & {
|
||||
isDisabled?: boolean;
|
||||
isSelected?: boolean;
|
||||
customIcon?: IconProp;
|
||||
};
|
||||
|
||||
export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
@@ -88,13 +93,13 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
select-none items-center rounded-md py-2 pl-10 pr-4 mb-0.5 text-sm
|
||||
outline-none transition-all hover:bg-mineshaft-500`,
|
||||
isSelected && 'bg-primary',
|
||||
isDisabled && 'cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-gray-600',
|
||||
isDisabled && 'cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600',
|
||||
className
|
||||
)}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator className="absolute left-3.5 text-primary">
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
<FontAwesomeIcon icon={props.customIcon ? props.customIcon : faCheck} />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
<SelectPrimitive.ItemText className="">{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
|
||||
@@ -159,9 +159,9 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }:
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute border-l border-mineshaft-500 ${
|
||||
className={`absolute border-l border-mineshaft-500 w-full min-w-sm max-w-sm ${
|
||||
isLoading ? 'bg-bunker-800' : 'bg-bunker'
|
||||
} fixed h-full w-[28rem] right-0 z-[70] shadow-xl flex flex-col justify-between sticky top-0`}
|
||||
} fixed h-full right-0 z-[70] shadow-xl flex flex-col justify-between sticky top-0`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full mb-8">
|
||||
@@ -186,7 +186,8 @@ const PITRecoverySidebar = ({ toggleSidebar, setSnapshotData, chosenSnapshot }:
|
||||
<FontAwesomeIcon icon={faXmark} className="w-4 h-4 text-bunker-300 cursor-pointer" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col px-2 py-2 overflow-y-auto h-[92vh]">
|
||||
<div className="flex flex-col w-96 px-2 py-2 overflow-y-auto bg-bunker border-l border-mineshaft-600 h-[calc(100vh-115px)]">
|
||||
<span className='px-2 text-bunker-200 pb-2 text-sm'>Note: This will recover secrets for all enviroments in this project.</span>
|
||||
{secretSnapshotsMetadata?.map((snapshot: SnaphotProps, id: number) => (
|
||||
<div
|
||||
onKeyDown={() => null}
|
||||
|
||||
@@ -76,9 +76,9 @@ const SecretVersionList = ({ secretId }: { secretId: string }) => {
|
||||
}, [secretId]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-52 px-4 mt-4 text-sm text-bunker-300 overflow-x-none">
|
||||
<div className="w-full min-w-40 h-[12.4rem] px-4 mt-4 text-sm text-bunker-300 overflow-x-none">
|
||||
<p className="">{t('dashboard:sidebar.version-history')}</p>
|
||||
<div className="p-1 rounded-md bg-bunker-800 border border-mineshaft-500 overflow-x-none h-full">
|
||||
<div className="pl-1 py-0.5 rounded-md bg-bunker-800 border border-mineshaft-500 overflow-x-none h-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Image
|
||||
@@ -99,10 +99,10 @@ const SecretVersionList = ({ secretId }: { secretId: string }) => {
|
||||
<div className="p-1">
|
||||
<FontAwesomeIcon icon={index === 0 ? faDotCircle : faCircle} />
|
||||
</div>
|
||||
<div className="w-0 h-full border-l mt-1" />
|
||||
<div className="w-0 h-full border-l border-bunker-300 mt-1" />
|
||||
</div>
|
||||
<div className="flex flex-col w-full max-w-[calc(100%-2.3rem)]">
|
||||
<div className="pr-2 pt-1">
|
||||
<div className="pr-2 pt-1 text-bunker-300/90">
|
||||
{new Date(version.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
@@ -114,10 +114,10 @@ const SecretVersionList = ({ secretId }: { secretId: string }) => {
|
||||
</div>
|
||||
<div className="">
|
||||
<p className="break-words ph-no-capture">
|
||||
<span className="py-0.5 px-1 rounded-md bg-primary-200/10 mr-1.5">
|
||||
<span className="py-0.5 px-1 rounded-sm bg-primary-500/30 mr-1.5">
|
||||
Value:
|
||||
</span>
|
||||
{version.value}
|
||||
<span className='font-mono'>{version.value}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import SecurityClient from '@app/components/utilities/SecurityClient';
|
||||
|
||||
/**
|
||||
* This route lets us get all the project memebrships of users in an org.
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
* @returns
|
||||
*/
|
||||
|
||||
const getOrganizationProjectMemberships = (req: { orgId: string }) =>
|
||||
SecurityClient.fetchCall(`/api/v1/organization/${req.orgId}/workspace-memberships`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (res) => {
|
||||
if (res && res.status === 200) {
|
||||
return res.json();
|
||||
}
|
||||
console.log('Failed to get project memberships for users in an org');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
export default getOrganizationProjectMemberships;
|
||||
@@ -53,12 +53,13 @@ type WorkspaceEnv = {
|
||||
name: string;
|
||||
slug: string;
|
||||
isWriteDenied: boolean;
|
||||
isReadDenied: boolean;
|
||||
};
|
||||
|
||||
interface SecretDataProps {
|
||||
pos: number;
|
||||
key: string;
|
||||
value: string;
|
||||
value: string | undefined;
|
||||
valueOverride: string | undefined;
|
||||
id: string;
|
||||
idOverride: string | undefined;
|
||||
@@ -268,6 +269,8 @@ export default function Dashboard() {
|
||||
});
|
||||
setInitialData(dataToSort);
|
||||
reorderRows(dataToSort);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error', error);
|
||||
@@ -318,7 +321,7 @@ export default function Dashboard() {
|
||||
setButtonReady(true);
|
||||
toggleSidebar('None');
|
||||
createNotification({
|
||||
text: `${secretName} has been deleted. Remember to save changes.`,
|
||||
text: `${secretName || 'Secret'} has been deleted. Remember to save changes.`,
|
||||
type: 'error'
|
||||
});
|
||||
sortValuesHandler(
|
||||
@@ -526,7 +529,7 @@ export default function Dashboard() {
|
||||
});
|
||||
if (secrets) await addSecrets({ secrets, env: selectedEnv.slug, workspaceId });
|
||||
}
|
||||
if (selectedEnv && secretsToBeUpdated.concat(overridesToBeUpdated).length > 0) {
|
||||
if (selectedEnv && !selectedEnv.isReadDenied && secretsToBeUpdated.concat(overridesToBeUpdated).length > 0) {
|
||||
const secrets = await encryptSecrets({
|
||||
secretsToEncrypt: secretsToBeUpdated.concat(overridesToBeUpdated),
|
||||
workspaceId,
|
||||
@@ -642,6 +645,11 @@ export default function Dashboard() {
|
||||
{new Date(snapshotData.createdAt).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{selectedEnv?.isReadDenied && (
|
||||
<span className="bg-primary-500 text-black text-sm ml-4 mt-1 px-1.5 rounded-md">
|
||||
Add Only Mode
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!snapshotData && data?.length === 0 && selectedEnv && (
|
||||
<ListBox
|
||||
@@ -652,7 +660,8 @@ export default function Dashboard() {
|
||||
workspaceEnvs.find(({ name }) => envName === name) || {
|
||||
name: 'unknown',
|
||||
slug: 'unknown',
|
||||
isWriteDenied: false
|
||||
isWriteDenied: false,
|
||||
isReadDenied: false
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -661,7 +670,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<div className="flex justify-start max-w-sm mt-1 mr-2">
|
||||
<Button
|
||||
{!selectedEnv?.isReadDenied && <Button
|
||||
text={String(`${numSnapshots} ${t('Commits')}`)}
|
||||
onButtonPressed={() => {
|
||||
toggleSidebar('None');
|
||||
@@ -670,7 +679,7 @@ export default function Dashboard() {
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
icon={faClockRotateLeft}
|
||||
/>
|
||||
/>}
|
||||
</div>
|
||||
{(data?.length !== 0 || buttonReady) && !snapshotData && (
|
||||
<div className="flex justify-start max-w-sm mt-1">
|
||||
@@ -738,7 +747,8 @@ export default function Dashboard() {
|
||||
workspaceEnvs.find(({ name }) => envName === name) || {
|
||||
name: 'unknown',
|
||||
slug: 'unknown',
|
||||
isWriteDenied: false
|
||||
isWriteDenied: false,
|
||||
isReadDenied: false
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -752,7 +762,8 @@ export default function Dashboard() {
|
||||
workspaceEnvs.find(({ name }) => envName === name) || {
|
||||
name: 'unknown',
|
||||
slug: 'unknown',
|
||||
isWriteDenied: false
|
||||
isWriteDenied: false,
|
||||
isReadDenied: false
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -770,7 +781,7 @@ export default function Dashboard() {
|
||||
placeholder={String(t('dashboard:search-keys'))}
|
||||
/>
|
||||
</div>
|
||||
{!snapshotData && (
|
||||
{!snapshotData && !selectedEnv.isReadDenied && (
|
||||
<div className="ml-2 min-w-max flex flex-row items-start justify-start">
|
||||
<DownloadSecretMenu data={data} env={selectedEnv.slug} />
|
||||
</div>
|
||||
@@ -825,7 +836,7 @@ export default function Dashboard() {
|
||||
>
|
||||
<div className="relative flex flex-row justify-between w-full mr-auto max-h-14 items-center">
|
||||
<div className="w-1/5 border-r border-mineshaft-600 flex flex-row items-center">
|
||||
<div className='text-transparent text-xs flex items-center justify-center w-14 h-10 cursor-default'>0</div>
|
||||
<div className='text-transparent text-xs flex items-center justify-center w-12 h-10 cursor-default'>0</div>
|
||||
<span className='px-2 text-bunker-300 font-semibold'>Key</span>
|
||||
{!snapshotData && <IconButton
|
||||
ariaLabel="copy icon"
|
||||
@@ -843,7 +854,7 @@ export default function Dashboard() {
|
||||
<div className='text-bunker-300 px-2 font-semibold h-10 flex items-center w-7/12'>Value</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2/12 border-r border-mineshaft-600">
|
||||
<div className="w-[calc(10%)] border-r border-mineshaft-600">
|
||||
<div className="flex items-center max-h-16">
|
||||
<div className='text-bunker-300 px-2 font-semibold h-10 flex items-center w-3/12'>Comment</div>
|
||||
</div>
|
||||
@@ -876,6 +887,7 @@ export default function Dashboard() {
|
||||
|| row.tags?.map(tag => tag.name).join(" ")?.toUpperCase().includes(searchKeys.toUpperCase())
|
||||
|| row.comment?.toUpperCase().includes(searchKeys.toUpperCase()))
|
||||
.filter((row) => !sharedToHide.includes(row.id))
|
||||
.filter((row) => row.value !== undefined)
|
||||
.map((keyPair) => (
|
||||
<KeyPair
|
||||
isCapitalized={autoCapitalization}
|
||||
@@ -995,7 +1007,7 @@ export default function Dashboard() {
|
||||
toggleSidebar={toggleSidebar}
|
||||
data={data.filter(
|
||||
(row: SecretDataProps) =>
|
||||
row.id === sidebarSecretId
|
||||
row.id === sidebarSecretId && row.value !== undefined
|
||||
)}
|
||||
modifyKey={listenChangeKey}
|
||||
modifyValue={listenChangeValue}
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function SettingsOrg() {
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
<div className="flex flex-row">
|
||||
<div className="w-full max-h-screen pb-2 overflow-y-auto">
|
||||
<div className="w-full max-h-screen pb-2">
|
||||
<NavHeader pageName={t('settings-org:title')} />
|
||||
<AddIncidentContactDialog
|
||||
isOpen={isAddIncidentContactOpen}
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function PersonalSettings() {
|
||||
setApiKeys={setApiKeys}
|
||||
/>
|
||||
<div className="flex flex-row">
|
||||
<div className="w-full max-h-screen pb-2 overflow-y-auto">
|
||||
<div className="w-full max-h-screen pb-2">
|
||||
<NavHeader pageName={t('settings-personal:title')} isProjectRelated={false} />
|
||||
<div className="flex flex-row justify-between items-center ml-6 mt-8 mb-6 text-xl max-w-5xl">
|
||||
<div className="flex flex-col justify-start items-start text-3xl">
|
||||
|
||||
Reference in New Issue
Block a user