Merge pull request #193 from Infisical/activity-logs

Activity logs
This commit is contained in:
BlackMagiq
2023-01-05 19:50:32 +07:00
committed by GitHub
82 changed files with 5131 additions and 2843 deletions

View File

@ -13,7 +13,9 @@ import { apiLimiter } from './helpers/rateLimiter';
import {
workspace as eeWorkspaceRouter,
secret as eeSecretRouter
secret as eeSecretRouter,
secretSnapshot as eeSecretSnapshotRouter,
action as eeActionRouter
} from './ee/routes/v1';
import {
signup as v1SignupRouter,
@ -70,7 +72,9 @@ if (NODE_ENV === 'production') {
// (EE) routes
app.use('/api/v1/secret', eeSecretRouter);
app.use('/api/v1/secret-snapshot', eeSecretSnapshotRouter);
app.use('/api/v1/workspace', eeWorkspaceRouter);
app.use('/api/v1/action', eeActionRouter);
// v1 routes
app.use('/api/v1/signup', v1SignupRouter);

View File

@ -123,7 +123,9 @@ export const pullSecrets = async (req: Request, res: Response) => {
secrets = await pull({
userId: req.user._id.toString(),
workspaceId,
environment
environment,
channel: channel ? channel : 'cli',
ipAddress: req.ip
});
key = await Key.findOne({
@ -188,7 +190,9 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => {
secrets = await pull({
userId: req.serviceToken.user._id.toString(),
workspaceId,
environment
environment,
channel: 'cli',
ipAddress: req.ip
});
key = {

View File

@ -2,7 +2,7 @@ import to from "await-to-js";
import { Request, Response } from "express";
import mongoose, { Types } from "mongoose";
import Secret, { ISecret } from "../../models/secret";
import { CreateSecretRequestBody, ModifySecretRequestBody, SanitizedSecretForCreate, SanitizedSecretModify } from "../../types/secret/types";
import { CreateSecretRequestBody, ModifySecretRequestBody, SanitizedSecretForCreate, SanitizedSecretModify } from "../../types/secret";
const { ValidationError } = mongoose.Error;
import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors';
import { AnyBulkWriteOperation } from 'mongodb';

View File

@ -11,10 +11,6 @@ import {
ServiceToken,
ServiceTokenData
} from '../../models';
import {
createWorkspace as create,
deleteWorkspace as deleteWork
} from '../../helpers/workspace';
import {
v2PushSecrets as push,
pullSecrets as pull,
@ -50,7 +46,6 @@ interface V2PushSecret {
*/
export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
// upload (encrypted) secrets to workspace with id [workspaceId]
try {
let { secrets }: { secrets: V2PushSecret[] } = req.body;
const { keys, environment, channel } = req.body;
@ -70,7 +65,9 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => {
userId: req.user._id,
workspaceId,
environment,
secrets
secrets,
channel: channel ? channel : 'cli',
ipAddress: req.ip
});
await pushKeys({
@ -136,7 +133,9 @@ export const pullSecrets = async (req: Request, res: Response) => {
secrets = await pull({
userId,
workspaceId,
environment
environment,
channel: channel ? channel : 'cli',
ipAddress: req.ip
});
if (channel !== 'cli') {
@ -196,7 +195,7 @@ export const getWorkspaceServiceTokenData = async (
) => {
let serviceTokenData;
try {
const { workspaceId } = req.query;
const { workspaceId } = req.params;
serviceTokenData = await ServiceTokenData
.find({

View File

@ -0,0 +1,31 @@
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import { Action, SecretVersion } from '../../models';
import { ActionNotFoundError } from '../../../utils/errors';
export const getAction = async (req: Request, res: Response) => {
let action;
try {
const { actionId } = req.params;
action = await Action
.findById(actionId)
.populate([
'payload.secretVersions.oldSecretVersion',
'payload.secretVersions.newSecretVersion'
]);
if (!action) throw ActionNotFoundError({
message: 'Failed to find action'
});
} catch (err) {
throw ActionNotFoundError({
message: 'Failed to find action'
});
}
return res.status(200).send({
action
});
}

View File

@ -1,9 +1,13 @@
import * as stripeController from './stripeController';
import * as secretController from './secretController';
import * as secretSnapshotController from './secretSnapshotController';
import * as workspaceController from './workspaceController';
import * as actionController from './actionController';
export {
stripeController,
secretController,
workspaceController
secretSnapshotController,
workspaceController,
actionController
}

View File

@ -18,6 +18,7 @@ import { SecretVersion } from '../../models';
secretVersions = await SecretVersion.find({
secret: secretId
})
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit);

View File

@ -0,0 +1,27 @@
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import { SecretSnapshot } from '../../models';
export const getSecretSnapshot = async (req: Request, res: Response) => {
let secretSnapshot;
try {
const { secretSnapshotId } = req.params;
secretSnapshot = await SecretSnapshot
.findById(secretSnapshotId)
.populate('secretVersions');
if (!secretSnapshot) throw new Error('Failed to find secret snapshot');
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get secret snapshot'
});
}
return res.status(200).send({
secretSnapshot
});
}

View File

@ -1,6 +1,9 @@
import { Request, Response } from 'express';
import e, { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
import { SecretSnapshot } from '../../models';
import {
SecretSnapshot,
Log
} from '../../models';
/**
* Return secret snapshots for workspace with id [workspaceId]
@ -18,6 +21,7 @@ import { SecretSnapshot } from '../../models';
secretSnapshots = await SecretSnapshot.find({
workspace: workspaceId
})
.sort({ createdAt: -1 })
.skip(offset)
.limit(limit);
@ -32,4 +36,77 @@ import { SecretSnapshot } from '../../models';
return res.status(200).send({
secretSnapshots
});
}
/**
* Return count of secret snapshots for workspace with id [workspaceId]
* @param req
* @param res
*/
export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => {
let count;
try {
const { workspaceId } = req.params;
count = await SecretSnapshot.countDocuments({
workspace: workspaceId
});
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to count number of secret snapshots'
});
}
return res.status(200).send({
count
});
}
/**
* Return (audit) logs for workspace with id [workspaceId]
* @param req
* @param res
* @returns
*/
export const getWorkspaceLogs = async (req: Request, res: Response) => {
let logs
try {
const { workspaceId } = req.params;
const offset: number = parseInt(req.query.offset as string);
const limit: number = parseInt(req.query.limit as string);
const sortBy: string = req.query.sortBy as string;
const userId: string = req.query.userId as string;
const actionNames: string = req.query.actionNames as string;
logs = await Log.find({
workspace: workspaceId,
...( userId ? { user: userId } : {}),
...(
actionNames
? {
actionNames: {
$in: actionNames.split(',')
}
} : {}
)
})
.sort({ createdAt: sortBy === 'recent' ? -1 : 1 })
.skip(offset)
.limit(limit)
.populate('actions')
.populate('user');
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get workspace logs'
});
}
return res.status(200).send({
logs
});
}

View File

@ -0,0 +1,112 @@
import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import { Secret } from '../../models';
import { SecretVersion, Action } from '../models';
import { ACTION_UPDATE_SECRETS } from '../../variables';
/**
* Create an (audit) action for secrets including
* add, delete, update, and read actions.
* @param {Object} obj
* @param {String} obj.name - name of action
* @param {ObjectId[]} obj.secretIds - ids of relevant secrets
* @returns {Action} action - new action
*/
const createActionSecretHelper = async ({
name,
userId,
workspaceId,
secretIds
}: {
name: string;
userId: string;
workspaceId: string;
secretIds: Types.ObjectId[];
}) => {
let action;
let latestSecretVersions;
try {
if (name === ACTION_UPDATE_SECRETS) {
// case: action is updating secrets
// -> add old and new secret versions
// TODO: make query more efficient
latestSecretVersions = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds,
},
},
},
{
$sort: { version: -1 },
},
{
$group: {
_id: "$secret",
versions: { $push: "$$ROOT" },
},
},
{
$project: {
_id: 0,
secret: "$_id",
versions: { $slice: ["$versions", 2] },
},
}
]))
.map((s) => ({
oldSecretVersion: s.versions[0]._id,
newSecretVersion: s.versions[1]._id
}));
} else {
// case: action is adding, deleting, or reading secrets
// -> add new secret versions
latestSecretVersions = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds
}
}
},
{
$group: {
_id: '$secret',
version: { $max: '$version' },
versionId: { $max: '$_id' } // secret version id
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => ({
newSecretVersion: s.versionId
}));
}
action = await new Action({
name,
user: userId,
workspace: workspaceId,
payload: {
secretVersions: latestSecretVersions
}
}).save();
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to create action');
}
return action;
}
export { createActionSecretHelper };

View File

@ -0,0 +1,41 @@
import * as Sentry from '@sentry/node';
import {
Log,
IAction
} from '../models';
const createLogHelper = async ({
userId,
workspaceId,
actions,
channel,
ipAddress
}: {
userId: string;
workspaceId: string;
actions: IAction[];
channel: string;
ipAddress: string;
}) => {
let log;
try {
log = await new Log({
user: userId,
workspace: workspaceId,
actionNames: actions.map((a) => a.name),
actions,
channel,
ipAddress
}).save();
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to create log');
}
return log;
}
export {
createLogHelper
}

View File

@ -1,6 +1,8 @@
import { Types } from 'mongoose';
import * as Sentry from '@sentry/node';
import {
Secret
Secret,
ISecret
} from '../../models';
import {
SecretSnapshot,
@ -9,66 +11,159 @@ import {
} from '../models';
/**
* Save a copy of the current state of secrets in workspace with id
* Save a secret snapshot that is a copy of the current state of secrets in workspace with id
* [workspaceId] under a new snapshot with incremented version under the
* secretsnapshots collection.
* @param {Object} obj
* @param {String} obj.workspaceId
* @returns {SecretSnapshot} secretSnapshot - new secret snapshot
*/
const takeSecretSnapshotHelper = async ({
workspaceId
}: {
workspaceId: string;
}) => {
let secretSnapshot;
try {
const secrets = await Secret.find({
const secretIds = (await Secret.find({
workspace: workspaceId
});
}, '_id')).map((s) => s._id);
const latestSecretVersions = (await SecretVersion.aggregate([
{
$match: {
secret: {
$in: secretIds
}
}
},
{
$group: {
_id: '$secret',
version: { $max: '$version' },
versionId: { $max: '$_id' } // secret version id
}
},
{
$sort: { version: -1 }
}
])
.exec())
.map((s) => s.versionId);
const latestSecretSnapshot = await SecretSnapshot.findOne({
workspace: workspaceId
}).sort({ version: -1 });
if (!latestSecretSnapshot) {
// case: no snapshots exist for workspace -> create first snapshot
await new SecretSnapshot({
workspace: workspaceId,
version: 1,
secrets
}).save();
return;
}
// case: snapshots exist for workspace
await new SecretSnapshot({
secretSnapshot = await new SecretSnapshot({
workspace: workspaceId,
version: latestSecretSnapshot.version + 1,
secrets
version: latestSecretSnapshot ? latestSecretSnapshot.version + 1 : 1,
secretVersions: latestSecretVersions
}).save();
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to take a secret snapshot');
}
return secretSnapshot;
}
/**
* Add secret versions [secretVersions] to the SecretVersion collection.
* @param {Object} obj
* @param {Object[]} obj.secretVersions
* @returns {SecretVersion[]} newSecretVersions - new secret versions
*/
const addSecretVersionsHelper = async ({
secretVersions
}: {
secretVersions: ISecretVersion[]
}) => {
let newSecretVersions;
try {
await SecretVersion.insertMany(secretVersions);
newSecretVersions = await SecretVersion.insertMany(secretVersions);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to add secret versions');
}
return newSecretVersions;
}
const markDeletedSecretVersionsHelper = async ({
secretIds
}: {
secretIds: Types.ObjectId[];
}) => {
try {
await SecretVersion.updateMany({
secret: { $in: secretIds }
}, {
isDeleted: true
}, {
new: true
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to mark secret versions as deleted');
}
}
/**
* Initialize secret versioning by setting previously unversioned
* secrets to version 1 and begin populating secret versions.
*/
const initSecretVersioningHelper = async () => {
try {
await Secret.updateMany(
{ version: { $exists: false } },
{ $set: { version: 1 } }
);
const unversionedSecrets: ISecret[] = await Secret.aggregate([
{
$lookup: {
from: 'secretversions',
localField: '_id',
foreignField: 'secret',
as: 'versions',
},
},
{
$match: {
versions: { $size: 0 },
},
},
]);
if (unversionedSecrets.length > 0) {
await addSecretVersionsHelper({
secretVersions: unversionedSecrets.map((s, idx) => ({
...s,
secret: s._id,
version: s.version ? s.version : 1,
isDeleted: false,
workspace: s.workspace,
environment: s.environment
}))
});
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to ensure that secrets are versioned');
}
}
export {
takeSecretSnapshotHelper,
addSecretVersionsHelper
addSecretVersionsHelper,
markDeletedSecretVersionsHelper,
initSecretVersioningHelper
}

View File

@ -0,0 +1,7 @@
import requireLicenseAuth from './requireLicenseAuth';
import requireSecretSnapshotAuth from './requireSecretSnapshotAuth';
export {
requireLicenseAuth,
requireSecretSnapshotAuth
}

View File

@ -0,0 +1,47 @@
import { Request, Response, NextFunction } from 'express';
import { UnauthorizedRequestError, SecretSnapshotNotFoundError } from '../../utils/errors';
import { SecretSnapshot } from '../models';
import {
validateMembership
} from '../../helpers/membership';
/**
* Validate if user on request has proper membership for secret snapshot
* @param {Object} obj
* @param {String[]} obj.acceptedRoles - accepted workspace roles
* @param {String[]} obj.acceptedStatuses - accepted workspace statuses
* @param {String[]} obj.location - location of [workspaceId] on request (e.g. params, body) for parsing
*/
const requireSecretSnapshotAuth = ({
acceptedRoles,
}: {
acceptedRoles: string[];
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const { secretSnapshotId } = req.params;
const secretSnapshot = await SecretSnapshot.findById(secretSnapshotId);
if (!secretSnapshot) {
return next(SecretSnapshotNotFoundError({
message: 'Failed to find secret snapshot'
}));
}
await validateMembership({
userId: req.user._id.toString(),
workspaceId: secretSnapshot.workspace.toString(),
acceptedRoles
});
req.secretSnapshot = secretSnapshot as any;
next();
} catch (err) {
return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret snapshot' }));
}
}
}
export default requireSecretSnapshotAuth;

View File

@ -0,0 +1,46 @@
import { Schema, model, Types } from 'mongoose';
export interface IAction {
name: string;
user?: Types.ObjectId,
workspace?: Types.ObjectId,
payload: {
secretVersions?: Types.ObjectId[]
}
}
const actionSchema = new Schema<IAction>(
{
name: {
type: String,
required: true
},
user: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace'
},
payload: {
secretVersions: [{
oldSecretVersion: {
type: Schema.Types.ObjectId,
ref: 'SecretVersion'
},
newSecretVersion: {
type: Schema.Types.ObjectId,
ref: 'SecretVersion'
}
}]
}
}, {
timestamps: true
}
);
const Action = model<IAction>('Action', actionSchema);
export default Action;

View File

@ -1,9 +1,15 @@
import SecretSnapshot, { ISecretSnapshot } from "./secretSnapshot";
import SecretVersion, { ISecretVersion } from "./secretVersion";
import SecretSnapshot, { ISecretSnapshot } from './secretSnapshot';
import SecretVersion, { ISecretVersion } from './secretVersion';
import Log, { ILog } from './log';
import Action, { IAction } from './action';
export {
SecretSnapshot,
ISecretSnapshot,
SecretVersion,
ISecretVersion
ISecretVersion,
Log,
ILog,
Action,
IAction
}

View File

@ -0,0 +1,59 @@
import { Schema, model, Types } from 'mongoose';
import {
ACTION_ADD_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_READ_SECRETS,
ACTION_DELETE_SECRETS
} from '../../variables';
export interface ILog {
_id: Types.ObjectId;
user?: Types.ObjectId;
workspace?: Types.ObjectId;
actionNames: string[];
actions: Types.ObjectId[];
channel: string;
ipAddress?: string;
}
const logSchema = new Schema<ILog>(
{
user: {
type: Schema.Types.ObjectId,
ref: 'User'
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace'
},
actionNames: {
type: [String],
enum: [
ACTION_ADD_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_READ_SECRETS,
ACTION_DELETE_SECRETS
],
required: true
},
actions: [{
type: Schema.Types.ObjectId,
ref: 'Action',
required: true
}],
channel: {
type: String,
enum: ['web', 'cli', 'auto'],
required: true
},
ipAddress: {
type: String
}
}, {
timestamps: true
}
);
const Log = model<ILog>('Log', logSchema);
export default Log;

View File

@ -1,31 +1,9 @@
import { Schema, model, Types } from 'mongoose';
import {
SECRET_SHARED,
SECRET_PERSONAL,
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD
} from '../../variables';
export interface ISecretSnapshot {
workspace: Types.ObjectId;
version: number;
secrets: {
version: number;
workspace: Types.ObjectId;
type: string;
user: Types.ObjectId;
environment: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
secretKeyHash: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretValueHash: string;
}[]
secretVersions: Types.ObjectId[];
}
const secretSnapshotSchema = new Schema<ISecretSnapshot>(
@ -39,64 +17,10 @@ const secretSnapshotSchema = new Schema<ISecretSnapshot>(
type: Number,
required: true
},
secrets: [{
version: {
type: Number,
default: 1,
required: true
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
},
type: {
type: String,
enum: [SECRET_SHARED, SECRET_PERSONAL],
required: true
},
user: {
// user associated with the personal secret
type: Schema.Types.ObjectId,
ref: 'User'
},
environment: {
type: String,
enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD],
required: true
},
secretKeyCiphertext: {
type: String,
required: true
},
secretKeyIV: {
type: String, // symmetric
required: true
},
secretKeyTag: {
type: String, // symmetric
required: true
},
secretKeyHash: {
type: String,
required: true
},
secretValueCiphertext: {
type: String,
required: true
},
secretValueIV: {
type: String, // symmetric
required: true
},
secretValueTag: {
type: String, // symmetric
required: true
},
secretValueHash: {
type: String,
required: true
}
secretVersions: [{
type: Schema.Types.ObjectId,
ref: 'SecretVersion',
required: true
}]
},
{

View File

@ -1,9 +1,30 @@
import { Schema, model, Types } from 'mongoose';
import {
SECRET_SHARED,
SECRET_PERSONAL,
ENV_DEV,
ENV_TESTING,
ENV_STAGING,
ENV_PROD
} from '../../variables';
/**
* TODO:
* 1. Modify SecretVersion to also contain XX
* - type
* - user
* - environment
* 2. Modify SecretSnapshot to point to arrays of SecretVersion
*/
export interface ISecretVersion {
_id?: Types.ObjectId;
secret: Types.ObjectId;
version: number;
workspace: Types.ObjectId; // new
type: string; // new
user: Types.ObjectId; // new
environment: string; // new
isDeleted: boolean;
secretKeyCiphertext: string;
secretKeyIV: string;
@ -27,6 +48,26 @@ const secretVersionSchema = new Schema<ISecretVersion>(
default: 1,
required: true
},
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
},
type: {
type: String,
enum: [SECRET_SHARED, SECRET_PERSONAL],
required: true
},
user: {
// user associated with the personal secret
type: Schema.Types.ObjectId,
ref: 'User'
},
environment: {
type: String,
enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD],
required: true
},
isDeleted: {
type: Boolean,
default: false,

View File

@ -0,0 +1,17 @@
import express from 'express';
const router = express.Router();
import {
validateRequest
} from '../../../middleware';
import { param } from 'express-validator';
import { actionController } from '../../controllers/v1';
// TODO: put into action controller
router.get(
'/:actionId',
param('actionId').exists().trim(),
validateRequest,
actionController.getAction
);
export default router;

View File

@ -1,7 +1,11 @@
import secret from './secret';
import secretSnapshot from './secretSnapshot';
import workspace from './workspace';
import action from './action';
export {
secret,
workspace
secretSnapshot,
workspace,
action
}

View File

@ -0,0 +1,27 @@
import express from 'express';
const router = express.Router();
import {
requireSecretSnapshotAuth
} from '../../middleware';
import {
requireAuth,
validateRequest
} from '../../../middleware';
import { param } from 'express-validator';
import { ADMIN, MEMBER } from '../../../variables';
import { secretSnapshotController } from '../../controllers/v1';
router.get(
'/:secretSnapshotId',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireSecretSnapshotAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
param('secretSnapshotId').exists().trim(),
validateRequest,
secretSnapshotController.getSecretSnapshot
);
export default router;

View File

@ -24,4 +24,35 @@ router.get(
workspaceController.getWorkspaceSecretSnapshots
);
router.get(
'/:workspaceId/secret-snapshots/count',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
param('workspaceId').exists().trim(),
validateRequest,
workspaceController.getWorkspaceSecretSnapshotsCount
);
router.get(
'/:workspaceId/logs',
requireAuth({
acceptedAuthModes: ['jwt']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
param('workspaceId').exists().trim(),
query('offset').exists().isInt(),
query('limit').exists().isInt(),
query('sortBy'),
query('userId'),
query('actionNames'),
validateRequest,
workspaceController.getWorkspaceLogs
);
export default router;

View File

@ -0,0 +1,81 @@
import { Types } from 'mongoose';
import {
Log,
Action,
IAction
} from '../models';
import {
createLogHelper
} from '../helpers/log';
import {
createActionSecretHelper
} from '../helpers/action';
import EELicenseService from './EELicenseService';
/**
* Class to handle Enterprise Edition log actions
*/
class EELogService {
/**
* Create an (audit) log
* @param {Object} obj
* @param {String} obj.userId - id of user associated with the log
* @param {String} obj.workspaceId - id of workspace associated with the log
* @param {Action} obj.actions - actions to include in log
* @param {String} obj.channel - channel (web/cli/auto) associated with the log
* @param {String} obj.ipAddress - ip address associated with the log
* @returns {Log} log - new audit log
*/
static async createLog({
userId,
workspaceId,
actions,
channel,
ipAddress
}: {
userId: string;
workspaceId: string;
actions: IAction[];
channel: string;
ipAddress: string;
}) {
if (!EELicenseService.isLicenseValid) return null;
return await createLogHelper({
userId,
workspaceId,
actions,
channel,
ipAddress
})
}
/**
* Create an (audit) action for secrets including
* add, delete, update, and read actions.
* @param {Object} obj
* @param {String} obj.name - name of action
* @param {ObjectId[]} obj.secretIds - secret ids
* @returns {Action} action - new action
*/
static async createActionSecret({
name,
userId,
workspaceId,
secretIds
}: {
name: string;
userId: string;
workspaceId: string;
secretIds: Types.ObjectId[];
}) {
if (!EELicenseService.isLicenseValid) return null;
return await createActionSecretHelper({
name,
userId,
workspaceId,
secretIds
});
}
}
export default EELogService;

View File

@ -1,7 +1,10 @@
import { Types } from 'mongoose';
import { ISecretVersion } from '../models';
import {
takeSecretSnapshotHelper,
addSecretVersionsHelper
addSecretVersionsHelper,
markDeletedSecretVersionsHelper,
initSecretVersioningHelper
} from '../helpers/secret';
import EELicenseService from './EELicenseService';
@ -11,12 +14,13 @@ import EELicenseService from './EELicenseService';
class EESecretService {
/**
* Save a copy of the current state of secrets in workspace with id
* Save a secret snapshot that is a copy of the current state of secrets in workspace with id
* [workspaceId] under a new snapshot with incremented version under the
* SecretSnapshot collection.
* Requires a valid license key [licenseKey]
* @param {Object} obj
* @param {String} obj.workspaceId
* @returns {SecretSnapshot} secretSnapshot - new secret snpashot
*/
static async takeSecretSnapshot({
workspaceId
@ -24,13 +28,14 @@ class EESecretService {
workspaceId: string;
}) {
if (!EELicenseService.isLicenseValid) return;
await takeSecretSnapshotHelper({ workspaceId });
return await takeSecretSnapshotHelper({ workspaceId });
}
/**
* Adds secret versions [secretVersions] to the SecretVersion collection.
* Add secret versions [secretVersions] to the SecretVersion collection.
* @param {Object} obj
* @param {SecretVersion} obj.secretVersions
* @param {Object[]} obj.secretVersions
* @returns {SecretVersion[]} newSecretVersions - new secret versions
*/
static async addSecretVersions({
secretVersions
@ -38,10 +43,36 @@ class EESecretService {
secretVersions: ISecretVersion[];
}) {
if (!EELicenseService.isLicenseValid) return;
await addSecretVersionsHelper({
return await addSecretVersionsHelper({
secretVersions
});
}
/**
* Mark secret versions associated with secrets with ids [secretIds]
* as deleted.
* @param {Object} obj
* @param {ObjectId[]} obj.secretIds - secret ids
*/
static async markDeletedSecretVersions({
secretIds
}: {
secretIds: Types.ObjectId[];
}) {
if (!EELicenseService.isLicenseValid) return;
await markDeletedSecretVersionsHelper({
secretIds
});
}
/**
* Initialize secret versioning by setting previously unversioned
* secrets to version 1 and begin populating secret versions.
*/
static async initSecretVersioning() {
if (!EELicenseService.isLicenseValid) return;
await initSecretVersioningHelper();
}
}
export default EESecretService;

View File

@ -1,7 +1,9 @@
import EELicenseService from "./EELicenseService";
import EESecretService from "./EESecretService";
import EELogService from "./EELogService";
export {
EELicenseService,
EESecretService
EESecretService,
EELogService
}

View File

@ -1,4 +1,7 @@
import { EVENT_PUSH_SECRETS } from '../variables';
import {
EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS
} from '../variables';
interface PushSecret {
ciphertextKey: string;
@ -19,7 +22,7 @@ interface PushSecret {
* @returns
*/
const eventPushSecrets = ({
workspaceId,
workspaceId
}: {
workspaceId: string;
}) => {
@ -32,6 +35,26 @@ const eventPushSecrets = ({
});
}
/**
* Return event for pulling secrets
* @param {Object} obj
* @param {String} obj.workspaceId - id of workspace to pull secrets from
* @returns
*/
const eventPullSecrets = ({
workspaceId,
}: {
workspaceId: string;
}) => {
return ({
name: EVENT_PULL_SECRETS,
workspaceId,
payload: {
}
});
}
export {
eventPushSecrets
}

View File

@ -0,0 +1,31 @@
import mongoose from 'mongoose';
import { ISecret, Secret } from '../models';
import { EESecretService } from '../ee/services';
import { getLogger } from '../utils/logger';
/**
* Initialize database connection
* @param {Object} obj
* @param {String} obj.mongoURL - mongo connection string
* @returns
*/
const initDatabaseHelper = async ({
mongoURL
}: {
mongoURL: string;
}) => {
try {
await mongoose.connect(mongoURL);
getLogger("database").info("Database connection established");
await EESecretService.initSecretVersioning();
} catch (err) {
getLogger("database").error(`Unable to establish Database connection due to the error.\n${err}`);
}
return mongoose.connection;
}
export {
initDatabaseHelper
}

View File

@ -1,19 +1,24 @@
import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import {
Secret,
ISecret,
} from '../models';
import {
EESecretService
EESecretService,
EELogService
} from '../ee/services';
import {
SecretVersion
IAction
} from '../ee/models';
import {
takeSecretSnapshotHelper
} from '../ee/helpers/secret';
import { decryptSymmetric } from '../utils/crypto';
import { SECRET_SHARED, SECRET_PERSONAL } from '../variables';
import {
SECRET_SHARED,
SECRET_PERSONAL,
ACTION_ADD_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_DELETE_SECRETS,
ACTION_READ_SECRETS
} from '../variables';
interface V1PushSecret {
ciphertextKey: string;
@ -51,8 +56,6 @@ interface Update {
[index: string]: any;
}
type DecryptSecretType = 'text' | 'object' | 'expanded';
/**
* Push secrets for user with id [userId] to workspace
* with id [workspaceId] with environment [environment]. Follow steps:
@ -68,7 +71,7 @@ const v1PushSecrets = async ({
userId,
workspaceId,
environment,
secrets
secrets,
}: {
userId: string;
workspaceId: string;
@ -78,7 +81,7 @@ const v1PushSecrets = async ({
// TODO: clean up function and fix up types
try {
// construct useful data structures
const oldSecrets = await pullSecrets({
const oldSecrets = await getSecrets({
userId,
workspaceId,
environment
@ -101,11 +104,9 @@ const v1PushSecrets = async ({
await Secret.deleteMany({
_id: { $in: toDelete }
});
await SecretVersion.updateMany({
secret: { $in: toDelete }
}, {
isDeleted: true
await EESecretService.markDeletedSecretVersions({
secretIds: toDelete
});
}
@ -188,6 +189,10 @@ const v1PushSecrets = async ({
return ({
secret: _id,
version: version ? version + 1 : 1,
workspace: new Types.ObjectId(workspaceId),
type: newSecret.type,
user: new Types.ObjectId(userId),
environment,
isDeleted: false,
secretKeyCiphertext: newSecret.ciphertextKey,
secretKeyIV: newSecret.ivKey,
@ -239,6 +244,11 @@ const v1PushSecrets = async ({
EESecretService.addSecretVersions({
secretVersions: newSecrets.map(({
_id,
version,
workspace,
type,
user,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
@ -249,7 +259,11 @@ const v1PushSecrets = async ({
secretValueHash
}) => ({
secret: _id,
version: 1,
version,
workspace,
type,
user,
environment,
isDeleted: false,
secretKeyCiphertext,
secretKeyIV,
@ -284,22 +298,30 @@ const v1PushSecrets = async ({
* @param {String} obj.workspaceId - id of workspace to push to
* @param {String} obj.environment - environment for secrets
* @param {Object[]} obj.secrets - secrets to push
* @param {String} obj.channel - channel (web/cli/auto)
* @param {String} obj.ipAddress - ip address of request to push secrets
*/
const v2PushSecrets = async ({
userId,
workspaceId,
environment,
secrets
secrets,
channel,
ipAddress
}: {
userId: string;
workspaceId: string;
environment: string;
secrets: V2PushSecret[];
channel: string;
ipAddress: string;
}): Promise<void> => {
// TODO: clean up function and fix up types
try {
const actions: IAction[] = [];
// construct useful data structures
const oldSecrets = await pullSecrets({
const oldSecrets = await getSecrets({
userId,
workspaceId,
environment
@ -322,12 +344,19 @@ const v1PushSecrets = async ({
await Secret.deleteMany({
_id: { $in: toDelete }
});
await SecretVersion.updateMany({
secret: { $in: toDelete }
}, {
isDeleted: true
await EESecretService.markDeletedSecretVersions({
secretIds: toDelete
});
const deleteAction = await EELogService.createActionSecret({
name: ACTION_DELETE_SECRETS,
userId,
workspaceId,
secretIds: toDelete
});
deleteAction && actions.push(deleteAction);
}
const toUpdate = oldSecrets
@ -348,118 +377,10 @@ const v1PushSecrets = async ({
return false;
});
const operations = toUpdate
.map((s) => {
const {
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash,
} = newSecretsObj[`${s.type}-${s.secretKeyHash}`];
const update: Update = {
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash,
}
if (!s.version) {
// case: (legacy) secret was not versioned
update.version = 1;
} else {
update['$inc'] = {
version: 1
}
}
if (s.type === SECRET_PERSONAL) {
// attach user associated with the personal secret
update['user'] = userId;
}
return {
updateOne: {
filter: {
_id: oldSecretsObj[`${s.type}-${s.secretKeyHash}`]._id
},
update
}
};
});
await Secret.bulkWrite(operations as any);
// (EE) add secret versions for updated secrets
await EESecretService.addSecretVersions({
secretVersions: toUpdate.map((s) => {
const {
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash,
} = newSecretsObj[`${s.type}-${s.secretKeyHash}`];
return ({
secret: s._id,
version: s.version ? s.version + 1 : 1,
isDeleted: false,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
})
})
});
// handle adding new secrets
const toAdd = secrets.filter((s) => !(`${s.type}-${s.secretKeyHash}` in oldSecretsObj));
if (toAdd.length > 0) {
// add secrets
const newSecrets = await Secret.insertMany(
toAdd.map(({
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash,
}, idx) => {
const obj: any = {
version: 1,
workspace: workspaceId,
type: toAdd[idx].type,
environment,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
if (toUpdate.length > 0) {
const operations = toUpdate
.map((s) => {
const {
secretValueCiphertext,
secretValueIV,
secretValueTag,
@ -467,49 +388,120 @@ const v1PushSecrets = async ({
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash
};
secretCommentHash,
} = newSecretsObj[`${s.type}-${s.secretKeyHash}`];
if (toAdd[idx].type === 'personal') {
obj['user' as keyof typeof obj] = userId;
const update: Update = {
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash,
secretCommentCiphertext,
secretCommentIV,
secretCommentTag,
secretCommentHash,
}
return obj;
})
if (!s.version) {
// case: (legacy) secret was not versioned
update.version = 1;
} else {
update['$inc'] = {
version: 1
}
}
if (s.type === SECRET_PERSONAL) {
// attach user associated with the personal secret
update['user'] = userId;
}
return {
updateOne: {
filter: {
_id: oldSecretsObj[`${s.type}-${s.secretKeyHash}`]._id
},
update
}
};
});
await Secret.bulkWrite(operations as any);
// (EE) add secret versions for updated secrets
await EESecretService.addSecretVersions({
secretVersions: toUpdate.map((s) => {
return ({
...newSecretsObj[`${s.type}-${s.secretKeyHash}`],
secret: s._id,
version: s.version ? s.version + 1 : 1,
workspace: new Types.ObjectId(workspaceId),
user: s.user,
environment: s.environment,
isDeleted: false
})
})
});
const updateAction = await EELogService.createActionSecret({
name: ACTION_UPDATE_SECRETS,
userId,
workspaceId,
secretIds: toUpdate.map((u) => u._id)
});
updateAction && actions.push(updateAction);
}
// handle adding new secrets
const toAdd = secrets.filter((s) => !(`${s.type}-${s.secretKeyHash}` in oldSecretsObj));
if (toAdd.length > 0) {
// add secrets
const newSecrets = await Secret.insertMany(
toAdd.map((s, idx) => ({
...s,
version: 1,
workspace: workspaceId,
type: toAdd[idx].type,
environment,
...( toAdd[idx].type === 'personal' ? { user: userId } : {})
}))
);
// (EE) add secret versions for new secrets
EESecretService.addSecretVersions({
secretVersions: newSecrets.map(({
_id,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}) => ({
secret: _id,
version: 1,
isDeleted: false,
secretKeyCiphertext,
secretKeyIV,
secretKeyTag,
secretKeyHash,
secretValueCiphertext,
secretValueIV,
secretValueTag,
secretValueHash
}))
secretVersions: newSecrets.map((secretDocument) => {
return {
...secretDocument.toObject(),
secret: secretDocument._id,
isDeleted: false
}})
});
const addAction = await EELogService.createActionSecret({
name: ACTION_ADD_SECRETS,
userId,
workspaceId,
secretIds: newSecrets.map((n) => n._id)
});
addAction && actions.push(addAction);
}
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId
})
// (EE) create (audit) log
if (actions.length > 0) {
await EELogService.createLog({
userId,
workspaceId,
actions,
channel,
ipAddress
});
}
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
@ -518,15 +510,14 @@ const v1PushSecrets = async ({
};
/**
* Pull secrets for user with id [userId] for workspace
* Get secrets for user with id [userId] for workspace
* with id [workspaceId] with environment [environment]
* @param {Object} obj
* @param {String} obj.userId -id of user to pull secrets for
* @param {String} obj.workspaceId - id of workspace to pull from
* @param {String} obj.environment - environment for secrets
*
*/
const pullSecrets = async ({
const getSecrets = async ({
userId,
workspaceId,
environment
@ -563,9 +554,64 @@ const pullSecrets = async ({
return secrets;
};
/**
* Pull secrets for user with id [userId] for workspace
* with id [workspaceId] with environment [environment]
* @param {Object} obj
* @param {String} obj.userId -id of user to pull secrets for
* @param {String} obj.workspaceId - id of workspace to pull from
* @param {String} obj.environment - environment for secrets
* @param {String} obj.channel - channel (web/cli/auto)
* @param {String} obj.ipAddress - ip address of request to push secrets
*/
const pullSecrets = async ({
userId,
workspaceId,
environment,
channel,
ipAddress
}: {
userId: string;
workspaceId: string;
environment: string;
channel: string;
ipAddress: string;
}): Promise<ISecret[]> => {
let secrets: any;
try {
secrets = await getSecrets({
userId,
workspaceId,
environment
})
const readAction = await EELogService.createActionSecret({
name: ACTION_READ_SECRETS,
userId,
workspaceId,
secretIds: secrets.map((n: any) => n._id)
});
readAction && await EELogService.createLog({
userId,
workspaceId,
actions: [readAction],
channel,
ipAddress
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to pull shared and personal secrets');
}
return secrets;
};
/**
* Reformat output of pullSecrets() to be compatible with how existing
* clients handle secrets
* web client handle secrets
* @param {Object} obj
* @param {Object} obj.secrets
*/

View File

@ -4,12 +4,12 @@ dotenv.config();
import * as Sentry from '@sentry/node';
import { SENTRY_DSN, NODE_ENV, MONGO_URL } from './config';
import { server } from './app';
import { initDatabase } from './services/database';
import { DatabaseService } from './services';
import { setUpHealthEndpoint } from './services/health';
import { initSmtp } from './services/smtp';
import { setTransporter } from './helpers/nodemailer';
initDatabase(MONGO_URL);
DatabaseService.initDatabase(MONGO_URL);
setUpHealthEndpoint(server);

View File

@ -11,7 +11,7 @@ export interface IUser {
tag?: string;
salt?: string;
verifier?: string;
refreshVersion?: Number;
refreshVersion?: number;
}
const userSchema = new Schema<IUser>(
@ -52,7 +52,8 @@ const userSchema = new Schema<IUser>(
},
refreshVersion: {
type: Number,
default: 0
default: 0,
select: false
}
},
{

View File

@ -4,6 +4,7 @@ import { requireAuth, validateRequest } from '../../middleware';
import { body, query } from 'express-validator';
import { userActionController } from '../../controllers/v1';
// note: [userAction] will be deprecated in /v2 in favor of [action]
router.post(
'/',
requireAuth({

View File

@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
import { requireAuth, requireWorkspaceAuth, validateRequest } from '../../middleware';
import { body, param, query } from 'express-validator';
import { ADMIN, MEMBER } from '../../variables';
import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret/types';
import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret';
import { secretController } from '../../controllers/v2';
import { fetchAllSecrets } from '../../controllers/v2/secretController';

View File

@ -0,0 +1,16 @@
import mongoose from 'mongoose';
import { getLogger } from '../utils/logger';
import { initDatabaseHelper } from '../helpers/database';
/**
* Class to handle database actions
*/
class DatabaseService {
static async initDatabase(MONGO_URL: string) {
return await initDatabaseHelper({
mongoURL: MONGO_URL
});
}
}
export default DatabaseService;

View File

@ -1,10 +0,0 @@
import mongoose from 'mongoose';
import { getLogger } from '../utils/logger';
export const initDatabase = (MONGO_URL: string) => {
mongoose
.connect(MONGO_URL)
.then(() => getLogger("database").info("Database connection established"))
.catch((e) => getLogger("database").error(`Unable to establish Database connection due to the error.\n${e}`));
return mongoose.connection;
};

View File

@ -1,9 +1,11 @@
import DatabaseService from './DatabaseService';
import postHogClient from './PostHogClient';
import BotService from './BotService';
import EventService from './EventService';
import IntegrationService from './IntegrationService';
export {
DatabaseService,
postHogClient,
BotService,
EventService,

View File

@ -13,6 +13,7 @@ declare global {
integrationAuth: any;
bot: any;
secret: any;
secretSnapshot: any;
serviceToken: any;
accessToken: any;
serviceTokenData: any;

View File

@ -123,6 +123,16 @@ export const SecretNotFoundError = (error?: Partial<RequestErrorContext>) => new
stack: error?.stack
});
//* ----->[SECRET SNAPSHOT ERRORS]<-----
export const SecretSnapshotNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,
statusCode: error?.statusCode ?? 404,
type: error?.type ?? 'secret_snapshot_not_found_error',
message: error?.message ?? 'The requested secret snapshot was not found',
context: error?.context,
stack: error?.stack
});
//* ----->[ACTION ERRORS]<-----
export const ActionNotFoundError = (error?: Partial<RequestErrorContext>) => new RequestError({
logLevel: error?.logLevel ?? LogLevel.ERROR,

View File

@ -0,0 +1,11 @@
const ACTION_ADD_SECRETS = 'addSecrets';
const ACTION_DELETE_SECRETS = 'deleteSecrets';
const ACTION_UPDATE_SECRETS = 'updateSecrets';
const ACTION_READ_SECRETS = 'readSecrets';
export {
ACTION_ADD_SECRETS,
ACTION_DELETE_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_READ_SECRETS
}

View File

@ -31,6 +31,12 @@ import {
} from './organization';
import { SECRET_SHARED, SECRET_PERSONAL } from './secret';
import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS } from './event';
import {
ACTION_ADD_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_DELETE_SECRETS,
ACTION_READ_SECRETS
} from './action';
import { SMTP_HOST_SENDGRID, SMTP_HOST_MAILGUN } from './smtp';
import { PLAN_STARTER, PLAN_PRO } from './stripe';
@ -63,6 +69,10 @@ export {
INTEGRATION_GITHUB_API_URL,
EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS,
ACTION_ADD_SECRETS,
ACTION_UPDATE_SECRETS,
ACTION_DELETE_SECRETS,
ACTION_READ_SECRETS,
INTEGRATION_OPTIONS,
SMTP_HOST_SENDGRID,
SMTP_HOST_MAILGUN,

View File

@ -0,0 +1,9 @@
---
title: "Activity Logs"
---
Activity logs record all actions going through Infisical including CRUD operations applied to environment variables. They help answer questions like:
- Who added or updated environment variables recently?
- Did Bob read environment variables last week (if at all)?
- What IP address was used for that action?

View File

@ -4,11 +4,10 @@ title: "Integrations"
Integrations allow environment variables to be synced across your entire infrastructure from local development to CI/CD and production.
We're still early with integrations, but expect more soon.
We're still early with integrations, but expect more soon.
<Card title="View integrations documentation" icon="link" href="/integrations/overview">
View all available integrations and their guide
<Card title="View integrations" icon="link" href="/integrations/overview">
View all available integrations and their guides
</Card>
![integrations](../../images/project-integrations.png)

View File

@ -0,0 +1,5 @@
---
title: "Point-in-Time Recovery"
---
Point-in-time (PIT) recovery allows environment variables to be rolled back to any point in time. It's powered by snapshots that get captured after mutations to environment variables.

View File

@ -0,0 +1,5 @@
---
title: "Secret Versioning"
---
Secret versioning allows an individual environment variable to be rolled back without touching other project environment variables.

View File

@ -4,14 +4,14 @@ title: "Features"
This is a non-exhaustive list of features that Infisical offers:
## Web UI
## Platform
The Web UI is used to manage teams and environment variables.
- Provision access to organizations and projects.
- Add/delete/update, scope, search, sort, hide-unhide environment variables.
- Separate environment variables by environment.
- Import environment variables via drag-and-drop, export them as a .env file.
- Provision members access to organizations and projects.
- Manage secrets by adding, deleting, updating them across environments; search, sort, hide/un-hide, export/import them.
- Sync secrets to platforms via integrations to platforms like GitHub, Vercel, and Netlify.
- Rollback secrets to any point in time.
- Rollback each secrets to any version.
- Track actions through activity logs.
## CLI

View File

@ -80,6 +80,9 @@
"getting-started/dashboard/organization",
"getting-started/dashboard/project",
"getting-started/dashboard/integrations",
"getting-started/dashboard/pit-recovery",
"getting-started/dashboard/versioning",
"getting-started/dashboard/audit-logs",
"getting-started/dashboard/token"
]
},

View File

@ -0,0 +1,106 @@
import React from 'react';
import { Fragment } from 'react';
import { useTranslation } from "next-i18next";
import {
faAngleDown,
faEye,
faPlus,
faShuffle,
faTrash,
faX
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Listbox, Transition } from '@headlessui/react';
interface ListBoxProps {
selected: string;
select: (event: string) => void;
}
const eventOptions = [
{
name: 'addSecrets',
icon: faPlus
},
{
name: 'readSecrets',
icon: faEye
},
{
name: 'updateSecrets',
icon: faShuffle
},
{
name: 'deleteSecrets',
icon: faTrash
}
];
/**
* This is the component that we use for the event picker in the activity logs tab.
* @param {object} obj
* @param {string} obj.selected - the event that is currently selected
* @param {function} obj.select - an action that happens when an item is selected
*/
export default function EventFilter({
selected,
select
}: ListBoxProps): JSX.Element {
const { t } = useTranslation();
return (
<Listbox value={t("activity:event." + selected)} onChange={select}>
<div className="relative">
<Listbox.Button className="bg-mineshaft-800 hover:bg-mineshaft-700 duration-200 cursor-pointer rounded-md h-10 flex items-center justify-between pl-4 pr-2 w-52 text-bunker-200 text-sm">
{selected != '' ? (
<p className="select-none text-bunker-100">{t("activity:event." + selected)}</p>
) : (
<p className="select-none">Select an event</p>
)}
{selected != '' ? (
<FontAwesomeIcon
icon={faX}
className="pl-2 w-2 p-2"
onClick={() => select('')}
/>
) : (
<FontAwesomeIcon icon={faAngleDown} className="pl-4 pr-2" />
)}
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="border border-mineshaft-700 z-50 w-52 p-1 absolute mt-1 max-h-60 overflow-auto rounded-md bg-bunker text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
{eventOptions.map((event, id) => {
return (
<Listbox.Option
key={id}
className={`px-4 h-10 flex items-center text-sm cursor-pointer hover:bg-mineshaft-700 text-bunker-200 rounded-md ${
selected == t("activity:event." + event.name) && 'bg-mineshaft-700'
}`}
value={event.name}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? 'font-semibold' : 'font-normal'
}`}
>
<FontAwesomeIcon icon={event.icon} className="pr-4" />{' '}
{t("activity:event." + event.name)}
</span>
</>
)}
</Listbox.Option>
);
})}
</Listbox.Options>
</Transition>
</div>
</Listbox>
);
}

View File

@ -6,10 +6,12 @@ import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import {
faBookOpen,
faFileLines,
faGear,
faKey,
faMobile,
faPlug,
faTimeline,
faUser,
} from "@fortawesome/free-solid-svg-icons";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
@ -119,7 +121,7 @@ export default function Layout({ children }: LayoutProps) {
}
});
}
router.push("/dashboard/" + newWorkspaceId + "?Development");
router.push("/dashboard/" + newWorkspaceId);
setIsOpen(false);
setNewWorkspaceName("");
} else {
@ -139,8 +141,7 @@ export default function Layout({ children }: LayoutProps) {
{
href:
"/dashboard/" +
workspaceMapping[workspaceSelected as any] +
"?Development",
workspaceMapping[workspaceSelected as any],
title: t("nav:menu.secrets"),
emoji: <FontAwesomeIcon icon={faKey} />,
},
@ -154,6 +155,11 @@ export default function Layout({ children }: LayoutProps) {
title: t("nav:menu.integrations"),
emoji: <FontAwesomeIcon icon={faPlug} />,
},
{
href: '/activity/' + workspaceMapping[workspaceSelected as any],
title: 'Activity Logs',
emoji: <FontAwesomeIcon icon={faFileLines} />
},
{
href: "/settings/project/" + workspaceMapping[workspaceSelected as any],
title: t("nav:menu.project-settings"),
@ -192,7 +198,7 @@ export default function Layout({ children }: LayoutProps) {
.map((workspace: { _id: string }) => workspace._id)
.includes(intendedWorkspaceId)
) {
router.push("/dashboard/" + userWorkspaces[0]._id + "?Development");
router.push("/dashboard/" + userWorkspaces[0]._id);
} else {
setWorkspaceList(
userWorkspaces.map((workspace: any) => workspace.name)
@ -235,8 +241,7 @@ export default function Layout({ children }: LayoutProps) {
) {
router.push(
"/dashboard/" +
workspaceMapping[workspaceSelected as any] +
"?Development"
workspaceMapping[workspaceSelected as any]
);
localStorage.setItem(
"projectData.id",

View File

@ -1,12 +1,12 @@
import React from "react";
import { Fragment } from "react";
import React from 'react';
import { Fragment } from 'react';
import {
faAngleDown,
faCheck,
faPlus,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Listbox, Transition } from "@headlessui/react";
faPlus
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Listbox, Transition } from '@headlessui/react';
interface ListBoxProps {
selected: string;
@ -34,20 +34,20 @@ export default function ListBox({
data,
text,
buttonAction,
isFull,
isFull
}: ListBoxProps): JSX.Element {
return (
<Listbox value={selected} onChange={onChange}>
<div className="relative">
<Listbox.Button
className={`text-gray-400 relative ${
isFull ? "w-full" : "w-52"
isFull ? 'w-full' : 'w-52'
} cursor-default rounded-md bg-white/[0.07] hover:bg-white/[0.11] duration-200 py-2.5 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm`}
>
<div className="flex flex-row">
{text}
<span className="ml-1 cursor-pointer block truncate font-semibold text-gray-300">
{" "}
{' '}
{selected}
</span>
</div>
@ -70,11 +70,11 @@ export default function ListBox({
key={personIdx}
className={({ active, selected }) =>
`my-0.5 relative cursor-default select-none py-2 pl-10 pr-4 rounded-md ${
selected ? "bg-white/10 text-gray-400 font-bold" : ""
selected ? 'bg-white/10 text-gray-400 font-bold' : ''
} ${
active && !selected
? "bg-white/5 text-mineshaft-200 cursor-pointer"
: "text-gray-400"
? 'bg-white/5 text-mineshaft-200 cursor-pointer'
: 'text-gray-400'
} `
}
value={person}
@ -83,7 +83,7 @@ export default function ListBox({
<>
<span
className={`block truncate text-primary${
selected ? "font-medium" : "font-normal"
selected ? 'font-medium' : 'font-normal'
}`}
>
{person}

View File

@ -115,7 +115,7 @@ export default function Button(props: ButtonProps): JSX.Element {
<FontAwesomeIcon
icon={props.icon}
className={`flex my-auto font-extrabold ${
props.size == "icon-sm" ? "text-sm" : "text-md"
props.size == "icon-sm" ? "text-sm" : "text-sm"
} ${(props.text || props.textDisabled) && "mr-2"}`}
/>
)}

View File

@ -12,6 +12,7 @@ import { envMapping } from "../../../public/data/frequentConstants";
import {
decryptAssymmetric,
encryptAssymmetric,
encryptSymmetric,
} from "../../utilities/cryptography/crypto";
import Button from "../buttons/Button";
import InputField from "../InputField";
@ -25,11 +26,15 @@ const expiryMapping = {
"12 months": 31104000,
};
const crypto = require('crypto');
const AddServiceTokenDialog = ({
isOpen,
closeModal,
workspaceId,
workspaceName,
serviceTokens,
setServiceTokens
}) => {
const [serviceToken, setServiceToken] = useState("");
const [serviceTokenName, setServiceTokenName] = useState("");
@ -48,16 +53,14 @@ const AddServiceTokenDialog = ({
privateKey: localStorage.getItem("PRIVATE_KEY"),
});
// generate new public/private key pair
const pair = nacl.box.keyPair();
const publicKey = nacl.util.encodeBase64(pair.publicKey);
const privateKey = nacl.util.encodeBase64(pair.secretKey);
// encrypt workspace key under newly-generated public key
const { ciphertext: encryptedKey, nonce } = encryptAssymmetric({
const randomBytes = crypto.randomBytes(16).toString('hex');
const {
ciphertext,
iv,
tag,
} = encryptSymmetric({
plaintext: key,
publicKey,
privateKey,
key: randomBytes,
});
let newServiceToken = await addServiceToken({
@ -65,13 +68,15 @@ const AddServiceTokenDialog = ({
workspaceId,
environment: envMapping[serviceTokenEnv],
expiresIn: expiryMapping[serviceTokenExpiresIn],
publicKey,
encryptedKey,
nonce,
encryptedKey: ciphertext,
iv,
tag
});
console.log('newServiceToken', newServiceToken);
const serviceToken = newServiceToken + "," + privateKey;
setServiceToken(serviceToken);
setServiceTokens(serviceTokens.concat([newServiceToken.serviceTokenData]));
setServiceToken(newServiceToken.serviceToken + "." + randomBytes);
};
function copyToClipboard() {
@ -161,7 +166,7 @@ const AddServiceTokenDialog = ({
"Production",
"Testing",
]}
width="full"
isFull={true}
text={`${t("common:environment")}: `}
/>
</div>
@ -176,7 +181,7 @@ const AddServiceTokenDialog = ({
"6 months",
"12 months",
]}
width="full"
isFull={true}
text={`${t("common:expired-in")}: `}
/>
</div>
@ -211,7 +216,7 @@ const AddServiceTokenDialog = ({
</div>
</div>
<div className="w-full">
<div className="flex justify-end items-center bg-white/[0.07] text-base mt-2 mr-2 rounded-md text-gray-400 w-full h-44">
<div className="flex justify-end items-center bg-white/[0.07] text-base mt-2 mr-2 rounded-md text-gray-400 w-full h-20">
<input
type="text"
value={serviceToken}
@ -236,7 +241,7 @@ const AddServiceTokenDialog = ({
)}
</button>
<span className="absolute hidden group-hover:flex group-hover:animate-popup duration-300 w-28 -left-8 -top-20 translate-y-full px-3 py-2 bg-chicago-900 rounded-md text-center text-gray-400 text-sm">
{t("common.click-to-copy")}
{t("common:click-to-copy")}
</span>
</div>
</div>

View File

@ -1,36 +1,53 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { faX } from '@fortawesome/free-solid-svg-icons';
import { useNotificationContext } from '~/components/context/Notifications/NotificationProvider';
import deleteServiceToken from "../../../pages/api/serviceToken/deleteServiceToken";
import { reverseEnvMapping } from '../../../public/data/frequentConstants';
import guidGenerator from '../../utilities/randomId';
import Button from '../buttons/Button';
interface TokenProps {
_id: string;
name: string;
environment: string;
expiresAt: string;
}
interface ServiceTokensProps {
data: TokenProps[];
workspaceName: string;
setServiceTokens: (value: TokenProps[]) => void;
}
/**
* This is the component that we utilize for the user table - in future, can reuse it for some other purposes too.
* This is the component that we utilize for the service token table
* #TODO: add the possibility of choosing and doing operations on multiple users.
* @param {*} props
* @param {object} obj
* @param {any[]} obj.data - current state of the service token table
* @param {string} obj.workspaceName - name of the current project
* @param {function} obj.setServiceTokens - updating the state of the service token table
* @returns
*/
const ServiceTokenTable = ({ data, workspaceName }) => {
const router = useRouter();
const ServiceTokenTable = ({ data, workspaceName, setServiceTokens }: ServiceTokensProps) => {
const { createNotification } = useNotificationContext();
return (
<div className="table-container w-full bg-bunker rounded-md mb-6 border border-mineshaft-700 relative mt-1">
<div className="absolute rounded-t-md w-full h-12 bg-white/5"></div>
<table className="w-full my-1">
<thead className="text-bunker-300">
<thead className="text-bunker-300 text-sm font-light">
<tr>
<th className="text-left pl-6 pt-2.5 pb-2">Token name</th>
<th className="text-left pl-6 pt-2.5 pb-2">Project</th>
<th className="text-left pl-6 pt-2.5 pb-2">Environment</th>
<th className="text-left pl-6 pt-2.5 pb-2">Valid until</th>
<th className="text-left pl-6 pt-2.5 pb-2">TOKEN NAME</th>
<th className="text-left pl-6 pt-2.5 pb-2">PROJECT</th>
<th className="text-left pl-6 pt-2.5 pb-2">ENVIRONMENT</th>
<th className="text-left pl-6 pt-2.5 pb-2">VAILD UNTIL</th>
<th></th>
</tr>
</thead>
<tbody>
{data?.length > 0 ? (
data.map((row, index) => {
data?.map((row) => {
return (
<tr
key={guidGenerator()}
@ -51,7 +68,14 @@ const ServiceTokenTable = ({ data, workspaceName }) => {
<td className="py-2 border-mineshaft-700 border-t">
<div className="opacity-50 hover:opacity-100 duration-200 flex items-center">
<Button
onButtonPressed={() => {}}
onButtonPressed={() => {
deleteServiceToken({ serviceTokenId: row._id} );
setServiceTokens(data.filter(token => token._id != row._id));
createNotification({
text: `'${row.name}' token has been revoked.`,
type: 'error'
});
}}
color="red"
size="icon-sm"
icon={faX}
@ -63,7 +87,7 @@ const ServiceTokenTable = ({ data, workspaceName }) => {
})
) : (
<tr>
<td colSpan="4" className="text-center pt-7 pb-4 text-bunker-400">
<td colSpan={4} className="text-center pt-7 pb-5 text-bunker-300 text-sm">
No service tokens yet
</td>
</tr>

View File

@ -36,7 +36,7 @@ const Notification = ({
return (
<div
className="relative w-full flex items-center justify-between px-4 py-6 rounded-md border border-bunker-500 pointer-events-auto bg-bunker-500"
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"
role="alert"
>
{notification.type === 'error' && (
@ -56,7 +56,7 @@ const Notification = ({
onClick={() => clearNotification(notification.text)}
>
<FontAwesomeIcon
className="text-white w-4 h-3 hover:text-red"
className="text-white pl-2 w-4 h-3 hover:text-red"
icon={faX}
/>
</button>

View File

@ -38,7 +38,7 @@ const NotificationProvider = ({ children }: NotificationProviderProps) => {
const createNotification = ({
text,
type = 'success',
timeoutMs = 5000
timeoutMs = 4000
}: Notification) => {
const doesNotifExist = notifications.some((notif) => notif.text === text);

View File

@ -53,7 +53,7 @@ const DashboardInputField = ({
return (
<div className="flex-col w-full">
<div
className={`group relative flex flex-col justify-center w-full max-w-2xl border ${
className={`group relative flex flex-col justify-center w-full border ${
error ? 'border-red' : 'border-mineshaft-500'
} rounded-md`}
>
@ -85,7 +85,7 @@ const DashboardInputField = ({
return (
<div className="flex-col w-full">
<div
className={`group relative whitespace-pre flex flex-col justify-center w-full max-w-2xl border border-mineshaft-500 rounded-md`}
className={`group relative whitespace-pre flex flex-col justify-center w-full border border-mineshaft-500 rounded-md`}
>
{override == true && <div className='bg-primary-300 absolute top-[0.1rem] right-[0.1rem] z-10 w-min text-xxs px-1 text-black opacity-80 rounded-md'>Override enabled</div>}
<input
@ -108,9 +108,9 @@ const DashboardInputField = ({
} ${
override ? 'text-primary-300' : 'text-gray-400'
}
absolute flex flex-row whitespace-pre font-mono z-0 ph-no-capture max-w-2xl overflow-x-scroll bg-bunker-800 h-9 rounded-md text-md px-2 py-1.5 w-full min-w-16 outline-none focus:ring-2 focus:ring-primary/50 duration-100 no-scrollbar no-scrollbar::-webkit-scrollbar`}
absolute flex flex-row whitespace-pre font-mono z-0 ph-no-capture overflow-x-scroll bg-bunker-800 h-9 rounded-md text-md px-2 py-1.5 w-full min-w-16 outline-none focus:ring-2 focus:ring-primary/50 duration-100 no-scrollbar no-scrollbar::-webkit-scrollbar`}
>
{value.split(REGEX).map((word, id) => {
{value?.split(REGEX).map((word, id) => {
if (word.match(REGEX) !== null) {
return (
<span className="ph-no-capture text-yellow" key={id}>
@ -139,7 +139,7 @@ const DashboardInputField = ({
})}
</div>
{blurred && (
<div className="absolute flex flex-row items-center z-20 peer pr-2 bg-bunker-800 group-hover:hidden peer-hover:hidden peer-focus:hidden peer-active:invisible h-9 w-full max-w-2xl rounded-md text-gray-400/50 text-clip">
<div className="absolute flex flex-row items-center z-20 peer pr-2 bg-bunker-800 group-hover:hidden peer-hover:hidden peer-focus:hidden peer-active:invisible h-9 w-full rounded-md text-gray-400/50 text-clip">
<div className="px-2 flex flex-row items-center overflow-x-scroll no-scrollbar no-scrollbar::-webkit-scrollbar">
{value.split('').map(() => (
<FontAwesomeIcon

View File

@ -15,40 +15,40 @@ interface SecretDataProps {
interface KeyPairProps {
keyPair: SecretDataProps;
deleteRow: (id: string) => void;
modifyKey: (value: string, position: number) => void;
modifyValue: (value: string, position: number) => void;
isBlurred: boolean;
isDuplicate: boolean;
toggleSidebar: (id: string) => void;
sidebarSecretId: string;
isSnapshot: boolean;
}
/**
* This component represent a single row for an environemnt variable on the dashboard
* @param {object} obj
* @param {String[]} obj.keyPair - data related to the environment variable (id, pos, key, value, public/private)
* @param {function} obj.deleteRow - a function to delete a certain keyPair
* @param {function} obj.modifyKey - modify the key of a certain environment variable
* @param {function} obj.modifyValue - modify the value of a certain environment variable
* @param {boolean} obj.isBlurred - if the blurring setting is turned on
* @param {boolean} obj.isDuplicate - list of all the duplicates secret names on the dashboard
* @param {function} obj.toggleSidebar - open/close/switch sidebar
* @param {string} obj.sidebarSecretId - the id of a secret for the side bar is displayed
* @param {boolean} obj.isSnapshot - whether this keyPair is in a snapshot. If so, it won't have some features like sidebar
* @returns
*/
const KeyPair = ({
keyPair,
deleteRow,
modifyKey,
modifyValue,
isBlurred,
isDuplicate,
toggleSidebar,
sidebarSecretId
sidebarSecretId,
isSnapshot
}: KeyPairProps) => {
return (
<div className={`mx-1 flex flex-col items-center ml-1 ${keyPair.id == sidebarSecretId && "bg-mineshaft-500 duration-200"} rounded-md`}>
<div className={`mx-1 flex flex-col items-center ml-1 ${isSnapshot && "pointer-events-none"} ${keyPair.id == sidebarSecretId && "bg-mineshaft-500 duration-200"} rounded-md`}>
<div className="relative flex flex-row justify-between w-full max-w-5xl mr-auto max-h-14 my-1 items-start px-1">
{keyPair.type == "personal" && <div className="group font-normal group absolute top-[1rem] left-[0.2rem] z-40 inline-block text-gray-300 underline hover:text-primary duration-200">
<div className='w-1 h-1 rounded-full bg-primary z-40'></div>
@ -57,7 +57,7 @@ const KeyPair = ({
</span>
</div>}
<div className="min-w-xl w-96">
<div className="flex pr-1 items-center rounded-lg mt-4 md:mt-0 max-h-16">
<div className="flex pr-1.5 items-center rounded-lg mt-4 md:mt-0 max-h-16">
<DashboardInputField
onChangeHandler={modifyKey}
type="varName"
@ -67,8 +67,8 @@ const KeyPair = ({
/>
</div>
</div>
<div className="w-full min-w-5xl">
<div className="flex min-w-7xl items-center pl-1 pr-1.5 rounded-lg mt-4 md:mt-0 max-h-10 ">
<div className="w-full min-w-xl">
<div className={`flex min-w-xl items-center ${!isSnapshot && "pr-1.5"} rounded-lg mt-4 md:mt-0 max-h-10`}>
<DashboardInputField
onChangeHandler={modifyValue}
type="value"
@ -79,24 +79,15 @@ const KeyPair = ({
/>
</div>
</div>
<div onClick={() => toggleSidebar(keyPair.id)} className="cursor-pointer w-9 h-9 bg-mineshaft-700 hover:bg-chicago-700 rounded-md flex flex-row justify-center items-center duration-200">
{!isSnapshot && <div onClick={() => toggleSidebar(keyPair.id)} className="cursor-pointer w-[2.35rem] h-[2.35rem] bg-mineshaft-700 hover:bg-chicago-700 rounded-md flex flex-row justify-center items-center duration-200">
<FontAwesomeIcon
className="text-gray-300 px-2.5 text-lg mt-0.5"
icon={faEllipsis}
/>
</div>
<div className="w-2"></div>
<div className="bg-[#9B3535] hover:bg-red rounded-md duration-200">
<Button
onButtonPressed={() => deleteRow(keyPair.id)}
color="none"
size="icon-sm"
icon={faX}
/>
</div>
</div>}
</div>
</div>
);
};
export default React.memo(KeyPair);
export default KeyPair;

View File

@ -1,4 +1,5 @@
import { useState } from 'react';
import Image from 'next/image';
import { useTranslation } from "next-i18next";
import { faX } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -40,6 +41,7 @@ interface SideBarProps {
savePush: () => void;
sharedToHide: string[];
setSharedToHide: (values: string[]) => void;
deleteRow: any;
}
/**
@ -54,6 +56,7 @@ interface SideBarProps {
* @param {function} obj.savePush - save changes andp ush secrets
* @param {string[]} obj.sharedToHide - an array of shared secrets that we want to hide visually because they are overriden.
* @param {function} obj.setSharedToHide - a function that updates the array of secrets that we want to hide visually
* @param {function} obj.deleteRow - a function to delete a certain keyPair
* @returns the sidebar with 'secret's settings'
*/
const SideBar = ({
@ -67,93 +70,97 @@ const SideBar = ({
buttonReady,
savePush,
sharedToHide,
setSharedToHide
setSharedToHide,
deleteRow
}: SideBarProps) => {
const [isLoading, setIsLoading] = useState(false);
const [overrideEnabled, setOverrideEnabled] = useState(data.map(secret => secret.type).includes("personal"));
const { t } = useTranslation();
return <div className='absolute border-l border-mineshaft-500 bg-bunker fixed h-full w-96 top-14 right-0 z-50 shadow-xl flex flex-col justify-between'>
<div className='h-min overflow-y-auto'>
<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 className='p-1' onClick={() => toggleSidebar("None")}>
<FontAwesomeIcon icon={faX} className='w-4 h-4 text-bunker-300 cursor-pointer'/>
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Image
src="/images/loading/loading.gif"
height={60}
width={100}
alt="infisical loading indicator"
></Image>
</div>
) : (
<div className='h-min overflow-y-auto'>
<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 className='p-1' onClick={() => toggleSidebar("None")}>
<FontAwesomeIcon icon={faX} className='w-4 h-4 text-bunker-300 cursor-pointer'/>
</div>
</div>
</div>
<div className='mt-4 px-4 pointer-events-none'>
<p className='text-sm text-bunker-300'>{t("dashboard:sidebar.key")}</p>
<DashboardInputField
onChangeHandler={modifyKey}
type="varName"
position={data[0]?.pos}
value={data[0]?.key}
isDuplicate={false}
blurred={false}
/>
</div>
{data.filter(secret => secret.type == "shared")[0]?.value
? <div className={`relative mt-2 px-4 ${overrideEnabled && "opacity-40 pointer-events-none"} duration-200`}>
<p className='text-sm text-bunker-300'>{t("dashboard:sidebar.value")}</p>
<DashboardInputField
onChangeHandler={modifyValue}
type="value"
position={data.filter(secret => secret.type == "shared")[0]?.pos}
value={data.filter(secret => secret.type == "shared")[0]?.value}
isDuplicate={false}
blurred={true}
/>
<div className='absolute bg-bunker-800 right-[1.07rem] top-[1.6rem] z-50'>
<GenerateSecretMenu modifyValue={modifyValue} position={data.filter(secret => secret.type == "shared")[0]?.pos} />
</div>
</div>
: <div className='px-4 text-sm text-bunker-300 pt-4'>
<span className='py-0.5 px-1 rounded-md bg-primary-200/10 mr-1'>{t("common:note")}:</span>
{t("dashboard:sidebar.personal-explanation")}
</div>}
<div className='mt-4 px-4'>
{data.filter(secret => secret.type == "shared")[0]?.value &&
<div className='flex flex-row items-center justify-between my-2 pl-1 pr-2'>
<p className='text-sm text-bunker-300'>{t("dashboard:sidebar.override")}</p>
<Toggle
enabled={overrideEnabled}
setEnabled={setOverrideEnabled}
addOverride={addOverride}
keyName={data[0]?.key}
value={data[0]?.value}
pos={data[0]?.pos}
id={data[0]?.id}
comment={data[0]?.comment}
deleteOverride={deleteOverride}
sharedToHide={sharedToHide}
setSharedToHide={setSharedToHide}
<div className='mt-4 px-4 pointer-events-none'>
<p className='text-sm text-bunker-300'>{t("dashboard:sidebar.key")}</p>
<DashboardInputField
onChangeHandler={modifyKey}
type="varName"
position={data[0]?.pos}
value={data[0]?.key}
isDuplicate={false}
blurred={false}
/>
</div>}
<div className={`relative ${!overrideEnabled && "opacity-40 pointer-events-none"} duration-200`}>
</div>
{data.filter(secret => secret.type == "shared")[0]?.value
? <div className={`relative mt-2 px-4 ${overrideEnabled && "opacity-40 pointer-events-none"} duration-200`}>
<p className='text-sm text-bunker-300'>{t("dashboard:sidebar.value")}</p>
<DashboardInputField
onChangeHandler={modifyValue}
type="value"
position={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.pos : data[0]?.pos}
value={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.value : data[0]?.value}
position={data.filter(secret => secret.type == "shared")[0]?.pos}
value={data.filter(secret => secret.type == "shared")[0]?.value}
isDuplicate={false}
blurred={true}
blurred={true}
/>
<div className='absolute right-[0.57rem] top-[0.3rem] z-50'>
<GenerateSecretMenu modifyValue={modifyValue} position={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.pos : data[0]?.pos} />
<div className='absolute bg-bunker-800 right-[1.07rem] top-[1.6rem] z-50'>
<GenerateSecretMenu modifyValue={modifyValue} position={data.filter(secret => secret.type == "shared")[0]?.pos} />
</div>
</div>
: <div className='px-4 text-sm text-bunker-300 pt-4'>
<span className='py-0.5 px-1 rounded-md bg-primary-200/10 mr-1'>{t("common:note")}:</span>
{t("dashboard:sidebar.personal-explanation")}
</div>}
<div className='mt-4 px-4'>
{data.filter(secret => secret.type == "shared")[0]?.value &&
<div className='flex flex-row items-center justify-between my-2 pl-1 pr-2'>
<p className='text-sm text-bunker-300'>{t("dashboard:sidebar.override")}</p>
<Toggle
enabled={overrideEnabled}
setEnabled={setOverrideEnabled}
addOverride={addOverride}
keyName={data[0]?.key}
value={data[0]?.value}
pos={data[0]?.pos}
id={data[0]?.id}
comment={data[0]?.comment}
deleteOverride={deleteOverride}
sharedToHide={sharedToHide}
setSharedToHide={setSharedToHide}
/>
</div>}
<div className={`relative ${!overrideEnabled && "opacity-40 pointer-events-none"} duration-200`}>
<DashboardInputField
onChangeHandler={modifyValue}
type="value"
position={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.pos : data[0]?.pos}
value={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.value : data[0]?.value}
isDuplicate={false}
blurred={true}
/>
<div className='absolute right-[0.57rem] top-[0.3rem] z-50'>
<GenerateSecretMenu modifyValue={modifyValue} position={overrideEnabled ? data.filter(secret => secret.type == "personal")[0]?.pos : data[0]?.pos} />
</div>
</div>
</div>
<SecretVersionList secretId={data[0]?.id} />
<CommentField comment={data.filter(secret => secret.type == "shared")[0]?.comment} modifyComment={modifyComment} position={data[0]?.pos} />
</div>
{/* <div className={`relative mt-4 px-4 opacity-80 duration-200`}>
<p className='text-sm text-bunker-200'>Group</p>
<ListBox
selected={"Database Secrets"}
onChange={() => {}}
data={["Group1"]}
isFull={true}
/>
</div> */}
<SecretVersionList secretId={data[0]?.id} />
<CommentField comment={data.filter(secret => secret.type == "shared")[0]?.comment} modifyComment={modifyComment} position={data[0]?.pos} />
</div>
)}
<div className={`flex justify-start max-w-sm mt-4 px-4 mt-full mb-[4.7rem]`}>
<Button
text={String(t("common:save-changes"))}
@ -163,6 +170,14 @@ const SideBar = ({
active={buttonReady}
textDisabled="Saved"
/>
<div className="bg-[#9B3535] opacity-70 hover:opacity-100 w-[4.5rem] h-[2.5rem] rounded-md duration-200 ml-2">
<Button
text={String(t("Delete"))}
onButtonPressed={() => deleteRow({ ids: overrideEnabled ? data.map(secret => secret.id) : [data.filter(secret => secret.type == "shared")[0]?.id], secretName: data[0]?.key })}
color="red"
size="md"
/>
</div>
</div>
</div>
};

View File

@ -0,0 +1,32 @@
import SecurityClient from '~/utilities/SecurityClient';
interface workspaceProps {
actionId: string;
}
/**
* This function fetches the data for a certain action performed by a user
* @param {object} obj
* @param {string} obj.actionId - id of an action for which we are trying to get data
* @returns
*/
const getActionData = async ({ actionId }: workspaceProps) => {
return SecurityClient.fetchCall(
'/api/v1/action/' + actionId, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
).then(async (res) => {
console.log(188, res)
if (res && res.status == 200) {
return (await res.json()).action;
} else {
console.log('Failed to get the info about an action');
}
});
};
export default getActionData;

View File

@ -0,0 +1,72 @@
import SecurityClient from '~/utilities/SecurityClient';
interface workspaceProps {
workspaceId: string;
offset: number;
limit: number;
userId: string;
actionNames: string;
}
/**
* This function fetches the activity logs for a certain project
* @param {object} obj
* @param {string} obj.workspaceId - workspace id for which we are trying to get project log
* @param {object} obj.offset - teh starting point of logs that we want to pull
* @param {object} obj.limit - how many logs will we output
* @param {object} obj.userId - optional userId filter - will only query logs for that user
* @param {string} obj.actionNames - optional actionNames filter - will only query logs for those actions
* @returns
*/
const getProjectLogs = async ({ workspaceId, offset, limit, userId, actionNames }: workspaceProps) => {
let payload;
if (userId != "" && actionNames != '') {
payload = {
offset: String(offset),
limit: String(limit),
sortBy: 'recent',
userId: JSON.stringify(userId),
actionNames: actionNames
}
} else if (userId != "") {
payload = {
offset: String(offset),
limit: String(limit),
sortBy: 'recent',
userId: JSON.stringify(userId)
}
} else if (actionNames != "") {
payload = {
offset: String(offset),
limit: String(limit),
sortBy: 'recent',
actionNames: actionNames
}
} else {
payload = {
offset: String(offset),
limit: String(limit),
sortBy: 'recent'
}
}
return SecurityClient.fetchCall(
'/api/v1/workspace/' + workspaceId + '/logs?' +
new URLSearchParams(payload),
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
).then(async (res) => {
if (res && res.status == 200) {
return (await res.json()).logs;
} else {
console.log('Failed to get project logs');
}
});
};
export default getProjectLogs;

View File

@ -0,0 +1,39 @@
import SecurityClient from '~/utilities/SecurityClient';
interface workspaceProps {
workspaceId: string;
offset: number;
limit: number;
}
/**
* This function fetches the secret snapshots for a certain project
* @param {object} obj
* @param {string} obj.workspaceId - project id for which we are trying to get project secret snapshots
* @param {object} obj.offset - teh starting point of snapshots that we want to pull
* @param {object} obj.limit - how many snapshots will we output
* @returns
*/
const getProjectSecretShanpshots = async ({ workspaceId, offset, limit }: workspaceProps) => {
return SecurityClient.fetchCall(
'/api/v1/workspace/' + workspaceId + '/secret-snapshots?' +
new URLSearchParams({
offset: String(offset),
limit: String(limit)
}), {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
).then(async (res) => {
if (res && res.status == 200) {
return (await res.json()).secretSnapshots;
} else {
console.log('Failed to get project secret snapshots');
}
});
};
export default getProjectSecretShanpshots;

View File

@ -0,0 +1,31 @@
import SecurityClient from '~/utilities/SecurityClient';
interface workspaceProps {
workspaceId: string;
}
/**
* This function fetches the count of secret snapshots for a certain project
* @param {object} obj
* @param {string} obj.workspaceId - project id for which we are trying to get project secret snapshots
* @returns
*/
const getProjectSercetSnapshotsCount = async ({ workspaceId }: workspaceProps) => {
return SecurityClient.fetchCall(
'/api/v1/workspace/' + workspaceId + '/secret-snapshots/count', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
).then(async (res) => {
if (res && res.status == 200) {
return (await res.json()).count;
} else {
console.log('Failed to get the count of project secret snapshots');
}
});
};
export default getProjectSercetSnapshotsCount;

View File

@ -0,0 +1,31 @@
import SecurityClient from '~/utilities/SecurityClient';
interface SnapshotProps {
secretSnapshotId: string;
}
/**
* This function fetches the secrets for a certain secret snapshot
* @param {object} obj
* @param {string} obj.secretSnapshotId - snapshot id for which we are trying to get secrets
* @returns
*/
const getSecretSnapshotData = async ({ secretSnapshotId }: SnapshotProps) => {
return SecurityClient.fetchCall(
'/api/v1/secret-snapshot/' + secretSnapshotId, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
}
).then(async (res) => {
if (res && res.status == 200) {
return (await res.json()).secretSnapshot;
} else {
console.log('Failed to get the secrets of a certain snapshot');
}
});
};
export default getSecretSnapshotData;

View File

@ -17,7 +17,7 @@ interface secretVersionProps {
*/
const getSecretVersions = async ({ secretId, offset, limit }: secretVersionProps) => {
return SecurityClient.fetchCall(
'/api/v1/secret/' + secretId + '/secret-versions?'+
'/api/v1/secret/' + secretId + '/secret-versions?' +
new URLSearchParams({
offset: String(offset),
limit: String(limit)
@ -32,7 +32,7 @@ const getSecretVersions = async ({ secretId, offset, limit }: secretVersionProps
if (res && res.status == 200) {
return await res.json();
} else {
console.log('Failed to get project secrets');
console.log('Failed to get secret version history');
}
});
};

View File

@ -0,0 +1,185 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { faX } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import getActionData from "ee/api/secrets/GetActionData";
import patienceDiff from 'ee/utilities/findTextDifferences';
import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey";
import DashboardInputField from '../../components/dashboard/DashboardInputField';
const {
decryptAssymmetric,
decryptSymmetric
} = require('../../components/utilities/cryptography/crypto');
const nacl = require('tweetnacl');
nacl.util = require('tweetnacl-util');
interface SideBarProps {
toggleSidebar: (value: string) => void;
currentAction: string;
}
interface SecretProps {
secret: string;
secretKeyCiphertext: string;
secretKeyHash: string;
secretKeyIV: string;
secretKeyTag: string;
secretValueCiphertext: string;
secretValueHash: string;
secretValueIV: string;
secretValueTag: string;
}
interface DecryptedSecretProps {
newSecretVersion: {
key: string;
value: string;
}
oldSecretVersion: {
key: string;
value: string;
}
}
interface ActionProps {
name: string;
}
/**
* @param {object} obj
* @param {function} obj.toggleSidebar - function that opens or closes the sidebar
* @param {string} obj.currentAction - the action id for which a sidebar is being displayed
* @returns the sidebar with the payload of user activity logs
*/
const ActivitySideBar = ({
toggleSidebar,
currentAction
}: SideBarProps) => {
const { t } = useTranslation();
const router = useRouter();
const [actionData, setActionData] = useState<DecryptedSecretProps[]>();
const [actionMetaData, setActionMetaData] = useState<ActionProps>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const getLogData = async () => {
setIsLoading(true);
const tempActionData = await getActionData({ actionId: currentAction });
const latestKey = await getLatestFileKey({ workspaceId: String(router.query.id) })
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY');
// #TODO: make this a separate function and reuse across the app
let decryptedLatestKey: string;
if (latestKey) {
// assymmetrically decrypt symmetric key with local private key
decryptedLatestKey = decryptAssymmetric({
ciphertext: latestKey.latestKey.encryptedKey,
nonce: latestKey.latestKey.nonce,
publicKey: latestKey.latestKey.sender.publicKey,
privateKey: String(PRIVATE_KEY)
});
}
const decryptedSecretVersions = tempActionData.payload.secretVersions.map((encryptedSecretVersion: {
newSecretVersion?: SecretProps;
oldSecretVersion?: SecretProps;
}) => {
return {
newSecretVersion: {
key: decryptSymmetric({
ciphertext: encryptedSecretVersion.newSecretVersion!.secretKeyCiphertext,
iv: encryptedSecretVersion.newSecretVersion!.secretKeyIV,
tag: encryptedSecretVersion.newSecretVersion!.secretKeyTag,
key: decryptedLatestKey
}),
value: decryptSymmetric({
ciphertext: encryptedSecretVersion.newSecretVersion!.secretValueCiphertext,
iv: encryptedSecretVersion.newSecretVersion!.secretValueIV,
tag: encryptedSecretVersion.newSecretVersion!.secretValueTag,
key: decryptedLatestKey
})
},
oldSecretVersion: {
key: encryptedSecretVersion.oldSecretVersion?.secretKeyCiphertext
? decryptSymmetric({
ciphertext: encryptedSecretVersion.oldSecretVersion?.secretKeyCiphertext,
iv: encryptedSecretVersion.oldSecretVersion?.secretKeyIV,
tag: encryptedSecretVersion.oldSecretVersion?.secretKeyTag,
key: decryptedLatestKey
}): undefined,
value: encryptedSecretVersion.oldSecretVersion?.secretValueCiphertext
? decryptSymmetric({
ciphertext: encryptedSecretVersion.oldSecretVersion?.secretValueCiphertext,
iv: encryptedSecretVersion.oldSecretVersion?.secretValueIV,
tag: encryptedSecretVersion.oldSecretVersion?.secretValueTag,
key: decryptedLatestKey
}): undefined
}
}
})
setActionData(decryptedSecretVersions);
setActionMetaData({name: tempActionData.name});
setIsLoading(false);
}
getLogData();
}, [currentAction]);
return <div className={`absolute border-l border-mineshaft-500 ${isLoading ? "bg-bunker-800" : "bg-bunker"} fixed h-full w-96 top-14 right-0 z-50 shadow-xl flex flex-col justify-between`}>
{isLoading ? (
<div className="flex items-center justify-center h-full mb-8">
<Image
src="/images/loading/loading.gif"
height={60}
width={100}
alt="infisical loading indicator"
></Image>
</div>
) : (
<div className='h-min overflow-y-auto'>
<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("activity:event." + actionMetaData?.name)}</p>
<div className='p-1' onClick={() => toggleSidebar("")}>
<FontAwesomeIcon icon={faX} className='w-4 h-4 text-bunker-300 cursor-pointer'/>
</div>
</div>
<div className='flex flex-col px-4'>
{(actionMetaData?.name == 'readSecrets'
|| actionMetaData?.name == 'addSecrets'
|| actionMetaData?.name == 'deleteSecrets') && actionData?.map((item, id) =>
<div key={id}>
<div className='text-xs text-bunker-200 mt-4 pl-1'>{item.newSecretVersion.key}</div>
<DashboardInputField
key={id}
onChangeHandler={() => {}}
type="value"
position={1}
value={item.newSecretVersion.value}
isDuplicate={false}
blurred={false}
/>
</div>
)}
{actionMetaData?.name == 'updateSecrets' && actionData?.map((item, id) =>
<>
<div className='text-xs text-bunker-200 mt-4 pl-1'>{item.newSecretVersion.key}</div>
<div className='text-bunker-100 font-mono rounded-md overflow-hidden'>
<div className='bg-red/30 px-2'>- {patienceDiff(item.oldSecretVersion.value.split(''), item.newSecretVersion.value.split(''), false).lines.map((character, id) => character.bIndex != -1 && <span key={id} className={`${character.aIndex == -1 && "bg-red-700/80"}`}>{character.line}</span>)}</div>
<div className='bg-green-500/30 px-2'>+ {patienceDiff(item.oldSecretVersion.value.split(''), item.newSecretVersion.value.split(''), false).lines.map((character, id) => character.aIndex != -1 && <span key={id} className={`${character.bIndex == -1 && "bg-green-700/80"}`}>{character.line}</span>)}</div>
</div>
</>
)}
</div>
</div>
)}
</div>
};
export default ActivitySideBar;

View File

@ -0,0 +1,129 @@
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { useTranslation } from "next-i18next";
import {
faAngleDown,
faAngleRight,
faUpRightFromSquare
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import timeSince from 'ee/utilities/timeSince';
import guidGenerator from '../../components/utilities/randomId';
interface PayloadProps {
_id: string;
name: string;
secretVersions: string[];
}
interface logData {
_id: string;
channel: string;
createdAt: string;
ipAddress: string;
user: string;
payload: PayloadProps[];
}
/**
* This is a single row of the activity table
* @param obj
* @param {logData} obj.row - data for a certain event
* @param {function} obj.toggleSidebar - open and close sidebar that displays data for a specific event
* @returns
*/
const ActivityLogsRow = ({ row, toggleSidebar }: { row: logData, toggleSidebar: (value: string) => void; }) => {
const [payloadOpened, setPayloadOpened] = useState(false);
const { t } = useTranslation();
return (
<>
<tr key={guidGenerator()} className="bg-bunker-800 duration-100 w-full text-sm">
<td
onClick={() => setPayloadOpened(!payloadOpened)}
className="border-mineshaft-700 border-t text-gray-300 flex items-center cursor-pointer"
>
<FontAwesomeIcon
icon={payloadOpened ? faAngleDown : faAngleRight}
className={`mt-2.5 ml-6 text-bunker-100 hover:bg-mineshaft-700 ${
payloadOpened && 'bg-mineshaft-500'
} p-1 duration-100 h-4 w-4 rounded-md`}
/>
</td>
<td className="py-3 border-mineshaft-700 border-t text-gray-300">
{row.payload?.map(action => String(action.secretVersions.length) + " " + t("activity:event." + action.name)).join(" and ")}
</td>
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
{row.user}
</td>
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
{row.channel}
</td>
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
{timeSince(new Date(row.createdAt))}
</td>
</tr>
{payloadOpened &&
<tr className='h-9 text-bunker-200 border-mineshaft-700 border-t text-sm'>
<td></td>
<td>Timestamp</td>
<td>{row.createdAt}</td>
</tr>}
{payloadOpened &&
row.payload?.map((action, index) =>
<tr key={index} className="h-9 text-bunker-200 border-mineshaft-700 border-t text-sm">
<td></td>
<td className="">{t("activity:event." + action.name)}</td>
<td className="text-primary-300 cursor-pointer hover:text-primary duration-200" onClick={() => toggleSidebar(action._id)}>
{action.secretVersions.length + (action.secretVersions.length != 1 ? " secrets" : " secret")}
<FontAwesomeIcon icon={faUpRightFromSquare} className="ml-2 mb-0.5 font-light w-3 h-3"/>
</td>
</tr>)}
{payloadOpened &&
<tr className='h-9 text-bunker-200 border-mineshaft-700 border-t text-sm'>
<td></td>
<td>IP Address</td>
<td>{row.ipAddress}</td>
</tr>}
</>
);
};
/**
* This is the table for activity logs (one of the tabs)
* @param {object} obj
* @param {logData} obj.data - data for user activity logs
* @param {function} obj.toggleSidebar - function that opens or closes the sidebar
* @returns
*/
const ActivityTable = ({ data, toggleSidebar }: { data: logData[], toggleSidebar: (value: string) => void; }) => {
return (
<div className="w-full px-6 mt-8">
<div className="table-container w-full bg-bunker rounded-md mb-6 border border-mineshaft-700 relative">
<div className="absolute rounded-t-md w-full h-[3rem] bg-white/5"></div>
<table className="w-full my-1">
<thead className="text-bunker-300">
<tr className='text-sm'>
<th className="text-left pl-6 pt-2.5 pb-3"></th>
<th className="text-left font-semibold pt-2.5 pb-3">EVENT</th>
<th className="text-left font-semibold pl-6 pt-2.5 pb-3">USER</th>
<th className="text-left font-semibold pl-6 pt-2.5 pb-3">SOURCE</th>
<th className="text-left font-semibold pl-6 pt-2.5 pb-3">TIME</th>
<th></th>
</tr>
</thead>
<tbody>
{data?.map((row, index) => {
return <ActivityLogsRow key={index} row={row} toggleSidebar={toggleSidebar} />;
})}
</tbody>
</table>
</div>
</div>
);
};
export default ActivityTable;

View File

@ -0,0 +1,158 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { faX } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import getProjectSecretShanpshots from "ee/api/secrets/GetProjectSercetShanpshots";
import getSecretSnapshotData from "ee/api/secrets/GetSecretSnapshotData";
import timeSince from "ee/utilities/timeSince";
import Button from "~/components/basic/buttons/Button";
import { decryptAssymmetric, decryptSymmetric } from "~/components/utilities/cryptography/crypto";
import getLatestFileKey from "~/pages/api/workspace/getLatestFileKey";
interface SideBarProps {
toggleSidebar: (value: boolean) => void;
setSnapshotData: (value: any) => void;
chosenSnapshot: string;
}
interface SnaphotProps {
_id: string;
createdAt: string;
secretVersions: string[];
}
interface EncrypetedSecretVersionListProps {
_id: string;
createdAt: string;
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
secretKeyCiphertext: string;
secretKeyIV: string;
secretKeyTag: string;
environment: string;
type: "personal" | "shared";
}
/**
* @param {object} obj
* @param {function} obj.toggleSidebar - function that opens or closes the sidebar
* @param {function} obj.setSnapshotData - state manager for snapshot data
* @param {string} obj.chosenSnaphshot - the snapshot id which is currently selected
*
*
* @returns the sidebar with the options for point-in-time recovery (commits)
*/
const PITRecoverySidebar = ({
toggleSidebar,
setSnapshotData,
chosenSnapshot
}: SideBarProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [secretSnapshotsMetadata, setSecretSnapshotsMetadata] = useState<SnaphotProps[]>([]);
const [currentOffset, setCurrentOffset] = useState(0);
const currentLimit = 15;
const loadMoreSnapshots = () => {
setCurrentOffset(currentOffset + currentLimit);
}
useEffect(() => {
const getLogData = async () => {
setIsLoading(true);
const results = await getProjectSecretShanpshots({ workspaceId: String(router.query.id), limit: currentLimit, offset: currentOffset })
setSecretSnapshotsMetadata(secretSnapshotsMetadata.concat(results));
setIsLoading(false);
}
getLogData();
}, [currentOffset]);
const exploreSnapshot = async ({ snapshotId }: { snapshotId: string; }) => {
const secretSnapshotData = await getSecretSnapshotData({ secretSnapshotId: snapshotId });
const latestKey = await getLatestFileKey({ workspaceId: String(router.query.id) })
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY');
let decryptedLatestKey: string;
if (latestKey) {
// assymmetrically decrypt symmetric key with local private key
decryptedLatestKey = decryptAssymmetric({
ciphertext: latestKey.latestKey.encryptedKey,
nonce: latestKey.latestKey.nonce,
publicKey: latestKey.latestKey.sender.publicKey,
privateKey: String(PRIVATE_KEY)
});
}
const decryptedSecretVersions = secretSnapshotData.secretVersions.map((encryptedSecretVersion: EncrypetedSecretVersionListProps, pos: number) => {
return {
id: encryptedSecretVersion._id,
pos: pos,
type: encryptedSecretVersion.type,
environment: encryptedSecretVersion.environment,
key: decryptSymmetric({
ciphertext: encryptedSecretVersion.secretKeyCiphertext,
iv: encryptedSecretVersion.secretKeyIV,
tag: encryptedSecretVersion.secretKeyTag,
key: decryptedLatestKey
}),
value: decryptSymmetric({
ciphertext: encryptedSecretVersion.secretValueCiphertext,
iv: encryptedSecretVersion.secretValueIV,
tag: encryptedSecretVersion.secretValueTag,
key: decryptedLatestKey
})
}
})
setSnapshotData({ id: secretSnapshotData._id, createdAt: secretSnapshotData.createdAt, secretVersions: decryptedSecretVersions })
}
return <div className={`absolute border-l border-mineshaft-500 ${isLoading ? "bg-bunker-800" : "bg-bunker"} fixed h-full w-96 top-14 right-0 z-50 shadow-xl flex flex-col justify-between`}>
{isLoading ? (
<div className="flex items-center justify-center h-full mb-8">
<Image
src="/images/loading/loading.gif"
height={60}
width={100}
alt="infisical loading indicator"
></Image>
</div>
) : (
<div className='h-min overflow-y-auto'>
<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("Point-in-time Recovery")}</p>
<div className='p-1' onClick={() => toggleSidebar(false)}>
<FontAwesomeIcon icon={faX} className='w-4 h-4 text-bunker-300 cursor-pointer'/>
</div>
</div>
<div className='flex flex-col px-2 py-2'>
{secretSnapshotsMetadata?.map((snapshot: SnaphotProps, id: number) => <div key={snapshot._id} className={`${chosenSnapshot == snapshot._id || (id == 0 && chosenSnapshot === "") ? "bg-primary text-black" : "bg-mineshaft-700"} py-3 px-4 mb-2 rounded-md flex flex-row justify-between items-center`}>
<div className="flex flex-row items-start">
<div className={`${chosenSnapshot == snapshot._id || (id == 0 && chosenSnapshot === "") ? "text-bunker-800" : "text-bunker-200"} text-sm mr-1.5`}>{timeSince(new Date(snapshot.createdAt))}</div>
<div className={`${chosenSnapshot == snapshot._id || (id == 0 && chosenSnapshot === "") ? "text-bunker-900" : "text-bunker-300"} text-sm `}>{" - " + snapshot.secretVersions.length + " Secrets"}</div>
</div>
<div
onClick={() => exploreSnapshot({ snapshotId: snapshot._id })}
className={`${chosenSnapshot == snapshot._id || (id == 0 && chosenSnapshot === "") ? "text-bunker-800 pointer-events-none" : "text-bunker-200 hover:text-primary duration-200 cursor-pointer"} text-sm`}>
{id == 0 ? "Current Version" : chosenSnapshot == snapshot._id ? "Currently Viewing" : "Explore"}
</div>
</div>)}
<div className='flex justify-center w-full mb-14'>
<div className='items-center w-40'>
<Button text="View More" textDisabled="End of History" active={secretSnapshotsMetadata.length % 15 == 0 ? true : false} onButtonPressed={loadMoreSnapshots} size="md" color="mineshaft"/>
</div>
</div>
</div>
</div>
)}
</div>
};
export default PITRecoverySidebar;

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useTranslation } from "next-i18next";
import { faCircle, faDotCircle } from '@fortawesome/free-solid-svg-icons';
@ -22,15 +23,18 @@ interface EncrypetedSecretVersionListProps {
/**
* @param {string} secretId - the id of a secret for which are querying version history
* @returns a list of versions for a specific secret
*/
const SecretVersionList = ({ secretId }: { secretId: string; }) => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation();
const [secretVersions, setSecretVersions] = useState<DecryptedSecretVersionListProps[]>([{createdAt: "123", value: "124"}]);
const [secretVersions, setSecretVersions] = useState<DecryptedSecretVersionListProps[]>([]);
useEffect(() => {
const getSecretVersionHistory = async () => {
setIsLoading(true);
try {
const encryptedSecretVersions = await getSecretVersions({ secretId, offset: 0, limit: 10});
const latestKey = await getLatestFileKey({ workspaceId: String(router.query.id) })
@ -61,43 +65,54 @@ const SecretVersionList = ({ secretId }: { secretId: string; }) => {
})
setSecretVersions(decryptedSecretVersions);
setIsLoading(false);
} catch (error) {
console.log(error)
}
};
getSecretVersionHistory();
}, []);
}, [secretId]);
return <div className='w-full h-52 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'>
<div className='h-48 overflow-y-auto overflow-x-none'>
{secretVersions?.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((version: DecryptedSecretVersionListProps, index: number) =>
<div key={index} className='flex flex-row'>
<div className='pr-1 flex flex-col items-center'>
<div className='p-1'><FontAwesomeIcon icon={index == 0 ? faDotCircle : faCircle} /></div>
<div className='w-0 h-full border-l mt-1'></div>
</div>
<div className='flex flex-col w-full max-w-[calc(100%-2.3rem)]'>
<div className='pr-2 pt-1'>
{(new Date(version.createdAt)).toLocaleDateString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
<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'>
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Image
src="/images/loading/loading.gif"
height={60}
width={100}
alt="infisical loading indicator"
></Image>
</div>
) : (
<div className='h-48 overflow-y-auto overflow-x-none'>
{secretVersions?.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.map((version: DecryptedSecretVersionListProps, index: number) =>
<div key={index} className='flex flex-row'>
<div className='pr-1 flex flex-col items-center'>
<div className='p-1'><FontAwesomeIcon icon={index == 0 ? faDotCircle : faCircle} /></div>
<div className='w-0 h-full border-l mt-1'></div>
</div>
<div className='flex flex-col w-full max-w-[calc(100%-2.3rem)]'>
<div className='pr-2 pt-1'>
{(new Date(version.createdAt)).toLocaleDateString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</div>
<div className=''><p className='break-words'><span className='py-0.5 px-1 rounded-md bg-primary-200/10 mr-1.5'>Value:</span>{version.value}</p></div>
</div>
</div>
<div className=''><p className='break-words'><span className='py-0.5 px-1 rounded-md bg-primary-200/10 mr-1.5'>Value:</span>{version.value}</p></div>
{/* <div className=''><p className='break-words'><span className='py-0.5 px-1 rounded-md bg-primary-200/10 mr-1.5'>Updated by:</span>{version.user}</p></div> */}
</div>
)}
</div>
)}
</div>
</div>
</div>
};
export default SecretVersionList;

View File

@ -0,0 +1,346 @@
/**
*
* @param textOld - old secret
* @param textNew - new (updated) secret
* @param diffPlusFlag - a flag for whether we want to detect moving segments
* - doesn't work in some examples (e.g., when we have a full reverse ordering of the text)
* @returns
*/
function patienceDiff(textOld: string[], textNew: string[], diffPlusFlag?: boolean) {
/**
* findUnique finds all unique values in arr[lo..hi], inclusive. This
* function is used in preparation for determining the longest common
* subsequence. Specifically, it first reduces the array range in question
* to unique values.
* @param chars - an array of characters
* @param lo
* @param hi
* @returns - an ordered Map, with the arr[i] value as the Map key and the
* array index i as the Map value.
*/
function findUnique(chars: string[], lo: number, hi: number) {
const characterMap = new Map();
for (let i=lo; i<=hi; i++) {
const character = chars[i];
if (characterMap.has(character)) {
characterMap.get(character).count++;
characterMap.get(character).index = i;
} else {
characterMap.set(character, { count: 1, index: i });
}
}
characterMap.forEach((val, key, map) => {
if (val.count !== 1) {
map.delete(key);
} else {
map.set(key, val.index);
}
});
return characterMap;
}
/**
* @param aArray
* @param aLo
* @param aHi
* @param bArray
* @param bLo
* @param bHi
* @returns an ordered Map, with the Map key as the common line between aArray
* and bArray, with the Map value as an object containing the array indexes of
* the matching unique lines.
*
*/
function uniqueCommon(aArray: string[], aLo: number, aHi: number, bArray: string[], bLo: number, bHi: number) {
const ma = findUnique(aArray, aLo, aHi);
const mb = findUnique(bArray, bLo, bHi);
ma.forEach((val, key, map) => {
if (mb.has(key)) {
map.set(key, {
indexA: val,
indexB: mb.get(key)
});
} else {
map.delete(key);
}
});
return ma;
}
/**
* longestCommonSubsequence takes an ordered Map from the function uniqueCommon
* and determines the Longest Common Subsequence (LCS).
* @param abMap
* @returns an ordered array of objects containing the array indexes of the
* matching lines for a LCS.
*/
function longestCommonSubsequence(abMap: Map<number, { indexA: number, indexB: number, prev?: number }>) {
const ja: any = [];
// First, walk the list creating the jagged array.
abMap.forEach((val, key, map) => {
let i = 0;
while (ja[i] && ja[i][ja[i].length - 1].indexB < val.indexB) {
i++;
}
if (!ja[i]) {
ja[i] = [];
}
if (0 < i) {
val.prev = ja[i-1][ja[i - 1].length - 1];
}
ja[i].push(val);
});
// Now, pull out the longest common subsequence.
let lcs: any[] = [];
if (0 < ja.length) {
const n = ja.length - 1;
lcs = [ja[n][ja[n].length - 1]];
while (lcs[lcs.length - 1].prev) {
lcs.push(lcs[lcs.length - 1].prev);
}
}
return lcs.reverse();
}
// "result" is the array used to accumulate the textOld that are deleted, the
// lines that are shared between textOld and textNew, and the textNew that were
// inserted.
const result: any[] = [];
let deleted = 0;
let inserted = 0;
// aMove and bMove will contain the lines that don't match, and will be returned
// for possible searching of lines that moved.
const aMove: any[] = [];
const aMoveIndex: any[] = [];
const bMove: any[] = [];
const bMoveIndex: any[] = [];
/**
* addToResult simply pushes the latest value onto the "result" array. This
* array captures the diff of the line, aIndex, and bIndex from the textOld
* and textNew array.
* @param aIndex
* @param bIndex
*/
function addToResult(aIndex: number, bIndex: number) {
if (bIndex < 0) {
aMove.push(textOld[aIndex]);
aMoveIndex.push(result.length);
deleted++;
} else if (aIndex < 0) {
bMove.push(textNew[bIndex]);
bMoveIndex.push(result.length);
inserted++;
}
result.push({
line: 0 <= aIndex ? textOld[aIndex] : textNew[bIndex],
aIndex: aIndex,
bIndex: bIndex,
});
}
/**
* addSubMatch handles the lines between a pair of entries in the LCS. Thus,
* this function might recursively call recurseLCS to further match the lines
* between textOld and textNew.
* @param aLo
* @param aHi
* @param bLo
* @param bHi
*/
function addSubMatch(aLo: number, aHi: number, bLo: number, bHi: number) {
// Match any lines at the beginning of textOld and textNew.
while (aLo <= aHi && bLo <= bHi && textOld[aLo] === textNew[bLo]) {
addToResult(aLo++, bLo++);
}
// Match any lines at the end of textOld and textNew, but don't place them
// in the "result" array just yet, as the lines between these matches at
// the beginning and the end need to be analyzed first.
const aHiTemp = aHi;
while (aLo <= aHi && bLo <= bHi && textOld[aHi] === textNew[bHi]) {
aHi--;
bHi--;
}
// Now, check to determine with the remaining lines in the subsequence
// whether there are any unique common lines between textOld and textNew.
//
// If not, add the subsequence to the result (all textOld having been
// deleted, and all textNew having been inserted).
//
// If there are unique common lines between textOld and textNew, then let's
// recursively perform the patience diff on the subsequence.
const uniqueCommonMap = uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi);
if (uniqueCommonMap.size === 0) {
while (aLo <= aHi) {
addToResult(aLo++, -1);
}
while (bLo <= bHi) {
addToResult(-1, bLo++);
}
} else {
recurseLCS(aLo, aHi, bLo, bHi, uniqueCommonMap);
}
// Finally, let's add the matches at the end to the result.
while (aHi < aHiTemp) {
addToResult(++aHi, ++bHi);
}
}
/**
* recurseLCS finds the longest common subsequence (LCS) between the arrays
* textOld[aLo..aHi] and textNew[bLo..bHi] inclusive. Then for each subsequence
* recursively performs another LCS search (via addSubMatch), until there are
* none found, at which point the subsequence is dumped to the result.
* @param aLo
* @param aHi
* @param bLo
* @param bHi
* @param uniqueCommonMap
*/
function recurseLCS(aLo: number, aHi: number, bLo: number, bHi: number, uniqueCommonMap?: any) {
const x = longestCommonSubsequence(uniqueCommonMap || uniqueCommon(textOld, aLo, aHi, textNew, bLo, bHi));
if (x.length === 0) {
addSubMatch(aLo, aHi, bLo, bHi);
} else {
if (aLo < x[0].indexA || bLo < x[0].indexB) {
addSubMatch(aLo, x[0].indexA - 1, bLo, x[0].indexB - 1);
}
let i;
for (i = 0; i < x.length - 1; i++) {
addSubMatch(x[i].indexA, x[i+1].indexA - 1, x[i].indexB, x[i+1].indexB - 1);
}
if (x[i].indexA <= aHi || x[i].indexB <= bHi) {
addSubMatch(x[i].indexA, aHi, x[i].indexB, bHi);
}
}
}
recurseLCS(0, textOld.length - 1, 0, textNew.length - 1);
if (diffPlusFlag) {
return {
lines: result,
lineCountDeleted: deleted,
lineCountInserted: inserted,
lineCountMoved: 0,
aMove: aMove,
aMoveIndex: aMoveIndex,
bMove: bMove,
bMoveIndex: bMoveIndex,
};
}
return {
lines: result,
lineCountDeleted: deleted,
lineCountInserted: inserted,
lineCountMoved: 0,
};
}
/**
* use: patienceDiffPlus( textOld[], textNew[] )
*
* where:
* textOld[] contains the original text lines.
* textNew[] contains the new text lines.
*
* returns an object with the following properties:
* lines[] with properties of:
* line containing the line of text from textOld or textNew.
* aIndex referencing the index in aLine[].
* bIndex referencing the index in textNew[].
* (Note: The line is text from either textOld or textNew, with aIndex and bIndex
* referencing the original index. If aIndex === -1 then the line is new from textNew,
* and if bIndex === -1 then the line is old from textOld.)
* moved is true if the line was moved from elsewhere in textOld[] or textNew[].
* lineCountDeleted is the number of lines from textOld[] not appearing in textNew[].
* lineCountInserted is the number of lines from textNew[] not appearing in textOld[].
* lineCountMoved is the number of lines that moved.
*/
function patienceDiffPlus(textOld: string[], textNew: string[]) {
const difference = patienceDiff(textOld, textNew, true);
let aMoveNext = difference.aMove;
let aMoveIndexNext = difference.aMoveIndex;
let bMoveNext = difference.bMove;
let bMoveIndexNext = difference.bMoveIndex;
delete difference.aMove;
delete difference.aMoveIndex;
delete difference.bMove;
delete difference.bMoveIndex;
let lastLineCountMoved;
do {
const aMove = aMoveNext;
const aMoveIndex = aMoveIndexNext;
const bMove = bMoveNext;
const bMoveIndex = bMoveIndexNext;
aMoveNext = [];
aMoveIndexNext = [];
bMoveNext = [];
bMoveIndexNext = [];
const subDiff = patienceDiff(aMove!, bMove!);
lastLineCountMoved = difference.lineCountMoved;
subDiff.lines.forEach((v, i) => {
if (0 <= v.aIndex && 0 <= v.bIndex) {
difference.lines[aMoveIndex![v.aIndex]].moved = true;
difference.lines[bMoveIndex![v.bIndex]].aIndex = aMoveIndex![v.aIndex];
difference.lines[bMoveIndex![v.bIndex]].moved = true;
difference.lineCountInserted--;
difference.lineCountDeleted--;
difference.lineCountMoved++;
} else if (v.bIndex < 0) {
aMoveNext!.push(aMove![v.aIndex]);
aMoveIndexNext!.push(aMoveIndex![v.aIndex]);
} else {
bMoveNext!.push(bMove![v.bIndex]);
bMoveIndexNext!.push(bMoveIndex![v.bIndex]);
}
});
} while (0 < difference.lineCountMoved - lastLineCountMoved);
return difference;
}
export default patienceDiff;

View File

@ -0,0 +1,35 @@
/**
* Time since a certain date
* @param {Date} date - the timestamp got which we want to understand how long ago it happened
* @returns {String} text - how much time has passed since a certain timestamp
*/
function timeSince(date: Date) {
const seconds = Math.floor(
((new Date() as any) - (date as any)) / 1000
) as number;
let interval = seconds / 31536000;
if (interval > 1) {
return Math.floor(interval) + ' years ago';
}
interval = seconds / 2592000;
if (interval > 1) {
return Math.floor(interval) + ' months ago';
}
interval = seconds / 86400;
if (interval > 1) {
return Math.floor(interval) + ' days ago';
}
interval = seconds / 3600;
if (interval > 1) {
return Math.floor(interval) + ' hours ago';
}
interval = seconds / 60;
if (interval > 1) {
return Math.floor(interval) + ' minutes ago';
}
return Math.floor(seconds) + ' seconds ago';
}
export default timeSince;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { useTranslation } from "next-i18next";
import ActivitySideBar from 'ee/components/ActivitySideBar';
import Button from '~/components/basic/buttons/Button';
import EventFilter from '~/components/basic/EventFilter';
import NavHeader from '~/components/navigation/NavHeader';
import { getTranslatedServerSideProps } from '~/components/utilities/withTranslateProps';
import getProjectLogs from '../../ee/api/secrets/GetProjectLogs';
import ActivityTable from '../../ee/components/ActivityTable';
interface logData {
_id: string;
channel: string;
createdAt: string;
ipAddress: string;
user: {
email: string;
};
actions: {
_id: string;
name: string;
payload: {
secretVersions: string[];
}
}[]
}
interface PayloadProps {
_id: string;
name: string;
secretVersions: string[];
}
interface logDataPoint {
_id: string;
channel: string;
createdAt: string;
ipAddress: string;
user: string;
payload: PayloadProps[];
}
/**
* This is the tab that includes all of the user activity logs
*/
export default function Activity() {
const router = useRouter();
const [eventChosen, setEventChosen] = useState('');
const [logsData, setLogsData] = useState<logDataPoint[]>([]);
const [currentOffset, setCurrentOffset] = useState(0);
const currentLimit = 10;
const [currentSidebarAction, toggleSidebar] = useState<string>()
const { t } = useTranslation();
// this use effect updates the data in case of a new filter being added
useEffect(() => {
setCurrentOffset(0);
const getLogData = async () => {
const tempLogsData = await getProjectLogs({ workspaceId: String(router.query.id), offset: 0, limit: currentLimit, userId: "", actionNames: eventChosen })
setLogsData(tempLogsData.map((log: logData) => {
return {
_id: log._id,
channel: log.channel,
createdAt: log.createdAt,
ipAddress: log.ipAddress,
user: log.user.email,
payload: log.actions.map(action => {
return {
_id: action._id,
name: action.name,
secretVersions: action.payload.secretVersions
}
})
}
}))
}
getLogData();
}, [eventChosen]);
// this use effect adds more data in case 'View More' button is clicked
useEffect(() => {
const getLogData = async () => {
const tempLogsData = await getProjectLogs({ workspaceId: String(router.query.id), offset: currentOffset, limit: currentLimit, userId: "", actionNames: eventChosen })
setLogsData(logsData.concat(tempLogsData.map((log: logData) => {
return {
_id: log._id,
channel: log.channel,
createdAt: log.createdAt,
ipAddress: log.ipAddress,
user: log.user.email,
payload: log.actions.map(action => {
return {
_id: action._id,
name: action.name,
secretVersions: action.payload.secretVersions
}
})
}
})))
}
getLogData();
}, [currentLimit, currentOffset]);
const loadMoreLogs = () => {
setCurrentOffset(currentOffset + currentLimit);
}
return (
<div className="mx-6 lg:mx-0 w-full overflow-y-scroll h-screen">
<NavHeader pageName="Project Activity" isProjectRelated={true} />
{currentSidebarAction && <ActivitySideBar toggleSidebar={toggleSidebar} currentAction={currentSidebarAction} />}
<div className="flex flex-col justify-between items-start mx-4 mt-6 mb-4 text-xl max-w-5xl px-2">
<div className="flex flex-row justify-start items-center text-3xl">
<p className="font-semibold mr-4 text-bunker-100">Activity Logs</p>
</div>
<p className="mr-4 text-base text-gray-400">
Event history for this Infisical project.
</p>
</div>
<div className="px-6 h-8 mt-2">
<EventFilter
selected={eventChosen}
select={setEventChosen}
/>
</div>
<ActivityTable
data={logsData}
toggleSidebar={toggleSidebar}
/>
<div className='flex justify-center w-full mb-6'>
<div className='items-center w-60'>
<Button text="View More" textDisabled="End of History" active={logsData.length % 10 == 0 ? true : false} onButtonPressed={loadMoreLogs} size="md" color="mineshaft"/>
</div>
</div>
</div>
);
}
Activity.requireAuth = true;
export const getServerSideProps = getTranslatedServerSideProps(["activity"]);

View File

@ -5,14 +5,21 @@ interface Props {
workspaceId: string;
environment: string;
expiresIn: number;
publicKey: string;
encryptedKey: string;
nonce: string;
iv: string;
tag: string;
}
/**
* This route gets service tokens for a specific user in a project
* @param {*} param0
* @param {object} obj
* @param {string} obj.name - name of the service token
* @param {string} obj.workspaceId - workspace for which we are issuing the token
* @param {string} obj.environment - environment for which we are issuing the token
* @param {string} obj.expiresIn - how soon the service token expires in ms
* @param {string} obj.encryptedKey - encrypted project key through random symmetric encryption
* @param {string} obj.iv - obtained through symmetric encryption
* @param {string} obj.tag - obtained through symmetric encryption
* @returns
*/
const addServiceToken = ({
@ -20,11 +27,11 @@ const addServiceToken = ({
workspaceId,
environment,
expiresIn,
publicKey,
encryptedKey,
nonce
iv,
tag
}: Props) => {
return SecurityClient.fetchCall('/api/v1/service-token/', {
return SecurityClient.fetchCall('/api/v2/service-token/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@ -34,13 +41,13 @@ const addServiceToken = ({
workspaceId,
environment,
expiresIn,
publicKey,
encryptedKey,
nonce
iv,
tag
})
}).then(async (res) => {
if (res && res.status == 200) {
return (await res.json()).token;
return (await res.json());
} else {
console.log('Failed to add service tokens');
}

View File

@ -0,0 +1,30 @@
import SecurityClient from '~/utilities/SecurityClient';
interface Props {
serviceTokenId: string;
}
/**
* This route revokes a specific service token
* @param {object} obj
* @param {string} obj.serviceTokenId - id of a cervice token that we want to delete
* @returns
*/
const deleteServiceToken = ({
serviceTokenId,
}: Props) => {
return SecurityClient.fetchCall('/api/v2/service-token/' + serviceTokenId, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
}).then(async (res) => {
if (res && res.status == 200) {
return (await res.json());
} else {
console.log('Failed to delete a service token');
}
});
};
export default deleteServiceToken;

View File

@ -7,7 +7,7 @@ import SecurityClient from '~/utilities/SecurityClient';
*/
const getServiceTokens = ({ workspaceId }: { workspaceId: string }) => {
return SecurityClient.fetchCall(
'/api/v1/workspace/' + workspaceId + '/service-tokens',
'/api/v2/workspace/' + workspaceId + '/service-token-data',
{
method: 'GET',
headers: {
@ -16,7 +16,7 @@ const getServiceTokens = ({ workspaceId }: { workspaceId: string }) => {
}
).then(async (res) => {
if (res && res.status == 200) {
return (await res.json()).serviceTokens;
return (await res.json()).serviceTokenData;
} else {
console.log('Failed to get service tokens');
}

View File

@ -6,8 +6,9 @@ import { useTranslation } from "next-i18next";
import {
faArrowDownAZ,
faArrowDownZA,
faArrowLeft,
faCheck,
faCopy,
faClockRotateLeft,
faDownload,
faEye,
faEyeSlash,
@ -16,6 +17,8 @@ import {
faPlus,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import getProjectSercetSnapshotsCount from 'ee/api/secrets/GetProjectSercetSnapshotsCount';
import PITRecoverySidebar from 'ee/components/PITRecoverySidebar';
import Button from '~/components/basic/buttons/Button';
import ListBox from '~/components/basic/Listbox';
@ -30,12 +33,13 @@ import pushKeys from '~/components/utilities/secrets/pushKeys';
import { getTranslatedServerSideProps } from '~/components/utilities/withTranslateProps';
import guidGenerator from '~/utilities/randomId';
import { envMapping } from '../../public/data/frequentConstants';
import { envMapping, reverseEnvMapping } from '../../public/data/frequentConstants';
import getUser from '../api/user/getUser';
import checkUserAction from '../api/userActions/checkUserAction';
import registerUserAction from '../api/userActions/registerUserAction';
import getWorkspaces from '../api/workspace/getWorkspaces';
const queryString = require("query-string");
interface SecretDataProps {
type: 'personal' | 'shared';
@ -46,6 +50,19 @@ interface SecretDataProps {
comment: string;
}
interface SnapshotProps {
id: string;
createdAt: string;
secretVersions: {
id: string;
pos: number;
type: "personal" | "shared";
environment: string;
key: string;
value: string;
}[];
}
/**
* this function finds the teh duplicates in an array
* @param arr - array of anything (e.g., with secret keys and types (personal/shared))
@ -76,22 +93,20 @@ export default function Dashboard() {
const [workspaceId, setWorkspaceId] = useState('');
const [blurred, setBlurred] = useState(true);
const [isKeyAvailable, setIsKeyAvailable] = useState(true);
const [env, setEnv] = useState(
router.asPath.split('?').length == 1
? 'Development'
: Object.keys(envMapping).includes(router.asPath.split('?')[1])
? router.asPath.split('?')[1]
: 'Development'
);
const [env, setEnv] = useState('Development');
const [snapshotEnv, setSnapshotEnv] = useState('Development');
const [isNew, setIsNew] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [searchKeys, setSearchKeys] = useState('');
const [errorDragAndDrop, setErrorDragAndDrop] = useState(false);
const [projectIdCopied, setProjectIdCopied] = useState(false);
const [sortMethod, setSortMethod] = useState('alphabetical');
const [checkDocsPopUpVisible, setCheckDocsPopUpVisible] = useState(false);
const [hasUserEverPushed, setHasUserEverPushed] = useState(false);
const [sidebarSecretId, toggleSidebar] = useState("None");
const [PITSidebarOpen, togglePITSidebar] = useState(false);
const [sharedToHide, setSharedToHide] = useState<string[]>([]);
const [snapshotData, setSnapshotData] = useState<SnapshotProps>();
const [numSnapshots, setNumSnapshots] = useState<number>();
const { t } = useTranslation();
const { createNotification } = useNotificationContext();
@ -142,17 +157,39 @@ export default function Dashboard() {
useEffect(() => {
(async () => {
try {
console.log(1, 'reloaded')
const tempNumSnapshots = await getProjectSercetSnapshotsCount({ workspaceId: String(router.query.id) })
setNumSnapshots(tempNumSnapshots);
const userWorkspaces = await getWorkspaces();
const listWorkspaces = userWorkspaces.map((workspace) => workspace._id);
if (
!listWorkspaces.includes(router.asPath.split('/')[2].split('?')[0])
!listWorkspaces.includes(router.asPath.split('/')[2])
) {
router.push('/dashboard/' + listWorkspaces[0]);
}
if (env != router.asPath.split('?')[1]) {
router.push(router.asPath.split('?')[0] + '?' + env);
}
const user = await getUser();
setIsNew(
(Date.parse(String(new Date())) - Date.parse(user.createdAt)) / 60000 < 3
? true
: false
);
const userAction = await checkUserAction({
action: 'first_time_secrets_pushed'
});
setHasUserEverPushed(userAction ? true : false);
} catch (error) {
console.log('Error', error);
setData(undefined);
}
})();
}, []);
useEffect(() => {
(async () => {
try {
setIsLoading(true);
setBlurred(true);
setWorkspaceId(String(router.query.id));
@ -174,18 +211,7 @@ export default function Dashboard() {
dataToSort?.map((item) => item.key).indexOf(item)
).includes(row.key) && row.type == 'shared'))?.map((item) => item.id)
)
const user = await getUser();
setIsNew(
(Date.parse(String(new Date())) - Date.parse(user.createdAt)) / 60000 < 3
? true
: false
);
const userAction = await checkUserAction({
action: 'first_time_secrets_pushed'
});
setHasUserEverPushed(userAction ? true : false);
setIsLoading(false);
} catch (error) {
console.log('Error', error);
setData(undefined);
@ -241,9 +267,14 @@ export default function Dashboard() {
sortValuesHandler(tempdata, sortMethod == "alhpabetical" ? "-alphabetical" : "alphabetical");
};
const deleteRow = (id: string) => {
const deleteRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => {
setButtonReady(true);
setData(data!.filter((row: SecretDataProps) => row.id !== id));
toggleSidebar("None");
createNotification({
text: `${secretName} has been deleted. Remember to save changes.`,
type: 'error'
});
setData(data!.filter((row: SecretDataProps) => !ids.includes(row.id)));
};
/**
@ -317,12 +348,21 @@ export default function Dashboard() {
/**
* Save the changes of environment variables and push them to the database
*/
const savePush = async () => {
// Format the new object with environment variables
const obj = Object.assign(
{},
...data!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.comment] }))
);
const savePush = async (dataToPush?: any[], envToPush?: string) => {
let obj;
// dataToPush is mostly used for rollbacks, otherwise we always take the current state data
if ((dataToPush ?? [])?.length > 0) {
obj = Object.assign(
{},
...dataToPush!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.comment ?? ''] }))
);
} else {
// Format the new object with environment variables
obj = Object.assign(
{},
...data!.map((row: SecretDataProps) => ({ [row.type.charAt(0) + row.key]: [row.value, row.comment ?? ''] }))
);
}
// Checking if any of the secret keys start with a number - if so, don't do anything
const nameErrors = !Object.keys(obj)
@ -346,13 +386,17 @@ export default function Dashboard() {
// Once "Save changed is clicked", disable that button
setButtonReady(false);
pushKeys({ obj, workspaceId: String(router.query.id), env });
console.log(envToPush ? envToPush : env, env, envToPush)
pushKeys({ obj, workspaceId: String(router.query.id), env: envToPush ? envToPush : env });
// If this user has never saved environment variables before, show them a prompt to read docs
if (!hasUserEverPushed) {
setCheckDocsPopUpVisible(true);
await registerUserAction({ action: 'first_time_secrets_pushed' });
}
// increasing the number of project commits
setNumSnapshots(numSnapshots ?? 0 + 1);
};
const addData = (newData: SecretDataProps[]) => {
@ -395,27 +439,10 @@ export default function Dashboard() {
alink.click();
};
const deleteCertainRow = (id: string) => {
deleteRow(id);
const deleteCertainRow = ({ ids, secretName }: { ids: string[]; secretName: string; }) => {
deleteRow({ids, secretName});
};
/**
* This function copies the project id to the clipboard
*/
function copyToClipboard() {
const copyText = document.getElementById('myInput') as HTMLInputElement;
if (copyText) {
copyText.select();
copyText.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(copyText.value);
setProjectIdCopied(true);
setTimeout(() => setProjectIdCopied(false), 2000);
}
}
return data ? (
<div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white">
<Head>
@ -438,6 +465,12 @@ export default function Dashboard() {
savePush={savePush}
sharedToHide={sharedToHide}
setSharedToHide={setSharedToHide}
deleteRow={deleteCertainRow}
/>}
{PITSidebarOpen && <PITRecoverySidebar
toggleSidebar={togglePITSidebar}
chosenSnapshot={String(snapshotData?.id ? snapshotData.id : "")}
setSnapshotData={setSnapshotData}
/>}
<div className="w-full max-h-96 pb-2">
<NavHeader pageName={t("dashboard:title")} isProjectRelated={true} />
@ -453,9 +486,22 @@ export default function Dashboard() {
/>
)}
<div className="flex flex-row justify-between items-center mx-6 mt-6 mb-3 text-xl max-w-5xl">
{snapshotData &&
<div className={`flex justify-start max-w-sm mt-1 mr-2`}>
<Button
text={String(t("Go back to current"))}
onButtonPressed={() => setSnapshotData(undefined)}
color="mineshaft"
size="md"
icon={faArrowLeft}
/>
</div>}
<div className="flex flex-row justify-start items-center text-3xl">
<p className="font-semibold mr-4 mt-1">{t("dashboard:title")}</p>
{data?.length == 0 && (
<div className="font-semibold mr-4 mt-1 flex flex-row items-center">
<p>{snapshotData ? "Secret Snapshot" : t("dashboard:title")}</p>
{snapshotData && <span className='bg-primary-800 text-sm ml-4 mt-1 px-1.5 rounded-md'>{new Date(snapshotData.createdAt).toLocaleString()}</span>}
</div>
{!snapshotData && data?.length == 0 && (
<ListBox
selected={env}
data={['Development', 'Staging', 'Production', 'Testing']}
@ -464,35 +510,17 @@ export default function Dashboard() {
)}
</div>
<div className="flex flex-row">
<div className="flex justify-end items-center bg-white/[0.07] text-base mt-2 mr-2 rounded-md text-gray-400">
<p className="mr-2 font-bold pl-4">{`${t(
"common:project-id"
)}:`}</p>
<input
type="text"
value={workspaceId}
id="myInput"
className="bg-white/0 text-gray-400 py-2 w-60 px-2 min-w-md outline-none"
disabled
></input>
<div className="group font-normal group relative inline-block text-gray-400 underline hover:text-primary duration-200">
<button
onClick={copyToClipboard}
className="pl-4 pr-4 border-l border-white/20 py-2 hover:bg-white/[0.12] duration-200"
>
{projectIdCopied ? (
<FontAwesomeIcon icon={faCheck} className="pr-0.5" />
) : (
<FontAwesomeIcon icon={faCopy} />
)}
</button>
<span className="absolute hidden group-hover:flex group-hover:animate-popup duration-300 w-28 -left-8 -top-20 translate-y-full pl-3 py-2 bg-white/10 rounded-md text-center text-gray-400 text-sm">
{t("common:click-to-copy")}
</span>
</div>
<div className={`flex justify-start max-w-sm mt-1 mr-2`}>
<Button
text={String(numSnapshots + " " + t("Commits"))}
onButtonPressed={() => togglePITSidebar(true)}
color="mineshaft"
size="md"
icon={faClockRotateLeft}
/>
</div>
{(data?.length !== 0 || buttonReady) && (
<div className={`flex justify-start max-w-sm mt-2`}>
{(data?.length !== 0 || buttonReady) && !snapshotData && (
<div className={`flex justify-start max-w-sm mt-1`}>
<Button
text={String(t("common:save-changes"))}
onButtonPressed={savePush}
@ -504,18 +532,64 @@ export default function Dashboard() {
/>
</div>
)}
{snapshotData && <div className={`flex justify-start max-w-sm mt-1`}>
<Button
text={String(t("Rollback to this snapshot"))}
onButtonPressed={async () => {
const envsToRollback = snapshotData.secretVersions.map(sv => sv.environment).filter((v, i, a) => a.indexOf(v) === i);
// Update secrets in the state only for the current environment
setData(
snapshotData.secretVersions
.filter(row => reverseEnvMapping[row.environment] == env)
.map((sv, position) => {
return {
id: sv.id, pos: position, type: sv.type, key: sv.key, value: sv.value, comment: ''
}
})
);
// Rollback each of the environments in the snapshot
// #TODO: clean up other environments
envsToRollback.map(async (envToRollback) => {
await savePush(
snapshotData.secretVersions
.filter(row => row.environment == envToRollback)
.map((sv, position) => {
return {id: sv.id, pos: position, type: sv.type, key: sv.key, value: sv.value, comment: ''}
}),
reverseEnvMapping[envToRollback]
);
});
setSnapshotData(undefined);
createNotification({
text: `Rollback has been performed successfully.`,
type: 'success'
});
}}
color="primary"
size="md"
active={buttonReady}
/>
</div>}
</div>
</div>
<div className="mx-6 w-full pr-12">
<div className="flex flex-col max-w-5xl pb-1">
<div className="w-full flex flex-row items-start">
{data?.length !== 0 && (
{(!snapshotData || data?.length !== 0) && (
<>
<ListBox
{!snapshotData
? <ListBox
selected={env}
data={['Development', 'Staging', 'Production', 'Testing']}
onChange={setEnv}
/>
: <ListBox
selected={snapshotEnv}
data={['Development', 'Staging', 'Production', 'Testing']}
onChange={setSnapshotEnv}
/>}
<div className="h-10 w-full bg-white/5 hover:bg-white/10 ml-2 flex items-center rounded-md flex flex-row items-center">
<FontAwesomeIcon
className="bg-white/5 rounded-l-md py-3 pl-4 pr-2 text-gray-400"
@ -528,7 +602,7 @@ export default function Dashboard() {
placeholder={String(t("dashboard:search-keys"))}
/>
</div>
<div className="ml-2 min-w-max flex flex-row items-start justify-start">
{!snapshotData && <div className="ml-2 min-w-max flex flex-row items-start justify-start">
<Button
onButtonPressed={() => reorderRows(1)}
color="mineshaft"
@ -539,15 +613,15 @@ export default function Dashboard() {
: faArrowDownZA
}
/>
</div>
<div className="ml-2 min-w-max flex flex-row items-start justify-start">
</div>}
{!snapshotData && <div className="ml-2 min-w-max flex flex-row items-start justify-start">
<Button
onButtonPressed={download}
color="mineshaft"
size="icon-md"
icon={faDownload}
/>
</div>
</div>}
<div className="ml-2 min-w-max flex flex-row items-start justify-start">
<Button
onButtonPressed={changeBlurred}
@ -556,7 +630,7 @@ export default function Dashboard() {
icon={blurred ? faEye : faEyeSlash}
/>
</div>
<div className="relative ml-2 min-w-max flex flex-row items-start justify-end">
{!snapshotData && <div className="relative ml-2 min-w-max flex flex-row items-start justify-end">
<Button
text={String(t("dashboard:add-key"))}
onButtonPressed={addRow}
@ -570,32 +644,65 @@ export default function Dashboard() {
<span className="relative inline-flex rounded-full h-3 w-3 bg-primary"></span>
</span>
)}
</div>
</div>}
</>
)}
</div>
</div>
{data?.length !== 0 ? (
{isLoading ? (
<div className="flex items-center justify-center h-full my-48">
<Image
src="/images/loading/loading.gif"
height={60}
width={100}
alt="infisical loading indicator"
></Image>
</div>
) : (
data?.length !== 0 ? (
<div className="flex flex-col w-full mt-1 mb-2">
<div
className={`max-w-5xl mt-1 max-h-[calc(100vh-280px)] overflow-hidden overflow-y-scroll no-scrollbar no-scrollbar::-webkit-scrollbar`}
>
<div className="px-1 pt-2 bg-mineshaft-800 rounded-md p-2">
{data?.filter(row => !(sharedToHide.includes(row.id) && row.type == 'shared')).map((keyPair) => (
{!snapshotData && data?.filter(row => row.key.toUpperCase().includes(searchKeys.toUpperCase()))
.filter(row => !(sharedToHide.includes(row.id) && row.type == 'shared')).map((keyPair) => (
<KeyPair
key={keyPair.id}
keyPair={keyPair}
deleteRow={deleteCertainRow}
modifyValue={listenChangeValue}
modifyKey={listenChangeKey}
isBlurred={blurred}
isDuplicate={findDuplicates(data?.map((item) => item.key + item.type))?.includes(keyPair.key + keyPair.type)}
toggleSidebar={toggleSidebar}
sidebarSecretId={sidebarSecretId}
isSnapshot={false}
/>
))}
{snapshotData && snapshotData.secretVersions?.sort((a, b) => a.key.localeCompare(b.key))
.filter(row => reverseEnvMapping[row.environment] == snapshotEnv)
.filter(row => row.key.toUpperCase().includes(searchKeys.toUpperCase()))
.filter(row => !(snapshotData.secretVersions?.filter(row => (snapshotData.secretVersions
?.map((item) => item.key)
.filter(
(item, index) =>
index !==
snapshotData.secretVersions?.map((item) => item.key).indexOf(item)
).includes(row.key) && row.type == 'shared'))?.map((item) => item.id).includes(row.id) && row.type == 'shared')).map((keyPair) => (
<KeyPair
key={keyPair.id}
keyPair={keyPair}
modifyValue={listenChangeValue}
modifyKey={listenChangeKey}
isBlurred={blurred}
isDuplicate={findDuplicates(data?.map((item) => item.key + item.type))?.includes(keyPair.key + keyPair.type)}
toggleSidebar={toggleSidebar}
sidebarSecretId={sidebarSecretId}
isSnapshot={true}
/>
))}
</div>
<div className="w-full max-w-5xl px-2 pt-3">
{!snapshotData && <div className="w-full max-w-5xl px-2 pt-3">
<DropZone
setData={addData}
setErrorDragAndDrop={setErrorDragAndDrop}
@ -605,12 +712,12 @@ export default function Dashboard() {
keysExist={true}
numCurrentRows={data.length}
/>
</div>
</div>}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-xl text-gray-400 max-w-5xl mt-28">
{isKeyAvailable && (
{isKeyAvailable && !snapshotData && (
<DropZone
setData={setData}
setErrorDragAndDrop={setErrorDragAndDrop}
@ -639,7 +746,7 @@ export default function Dashboard() {
</>
))}
</div>
)}
))}
</div>
</div>
</div>

View File

@ -2,12 +2,13 @@ import { useEffect, useRef, useState } from "react";
import Head from "next/head";
import { useRouter } from "next/router";
import { useTranslation } from "next-i18next";
import { faCheck, faPlus } from "@fortawesome/free-solid-svg-icons";
import { faCheck, faCopy, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Button from "~/components/basic/buttons/Button";
import AddServiceTokenDialog from "~/components/basic/dialog/AddServiceTokenDialog";
import InputField from "~/components/basic/InputField";
import ServiceTokenTable from "~/components/basic/table/ServiceTokenTable";
import ServiceTokenTable from "~/components/basic/table/ServiceTokenTable.tsx";
import NavHeader from "~/components/navigation/NavHeader";
import { getTranslatedServerSideProps } from "~/utilities/withTranslateProps";
@ -16,6 +17,7 @@ import deleteWorkspace from "../../api/workspace/deleteWorkspace";
import getWorkspaces from "../../api/workspace/getWorkspaces";
import renameWorkspace from "../../api/workspace/renameWorkspace";
export default function SettingsBasic() {
const [buttonReady, setButtonReady] = useState(false);
const router = useRouter();
@ -26,9 +28,28 @@ export default function SettingsBasic() {
const [isAddOpen, setIsAddOpen] = useState(false);
let [isAddServiceTokenDialogOpen, setIsAddServiceTokenDialogOpen] =
useState(false);
const [projectIdCopied, setProjectIdCopied] = useState(false);
const { t } = useTranslation();
/**
* This function copies the project id to the clipboard
*/
function copyToClipboard() {
// const copyText = document.getElementById('myInput') as HTMLInputElement;
const copyText = document.getElementById('myInput')
if (copyText) {
copyText.select();
copyText.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(copyText.value);
setProjectIdCopied(true);
setTimeout(() => setProjectIdCopied(false), 2000);
}
}
useEffect(async () => {
let userWorkspaces = await getWorkspaces();
userWorkspaces.map((userWorkspace) => {
@ -103,6 +124,8 @@ export default function SettingsBasic() {
workspaceId={router.query.id}
closeModal={closeAddServiceTokenModal}
workspaceName={workspaceName}
serviceTokens={serviceTokens}
setServiceTokens={setServiceTokens}
/>
<div className="flex flex-row mr-6 max-w-5xl">
<div className="w-full max-h-screen pb-2 overflow-y-auto">
@ -124,7 +147,7 @@ export default function SettingsBasic() {
<div className="flex flex-col">
<div className="min-w-md mt-2 flex flex-col items-start">
<div className="bg-white/5 rounded-md px-6 pt-6 pb-4 flex flex-col items-start flex flex-col items-start w-full mb-6 pt-2">
<p className="text-xl font-semibold mb-4">
<p className="text-xl font-semibold mb-4 mt-2">
{t("common:display-name")}
</p>
<div className="max-h-28 w-full max-w-md mr-auto">
@ -150,7 +173,7 @@ export default function SettingsBasic() {
</div>
</div>
</div>
<div className="bg-white/5 rounded-md px-6 pt-6 pb-2 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4">
<div className="bg-white/5 rounded-md px-6 pt-4 pb-2 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4">
<p className="text-xl font-semibold self-start">
{t("common:project-id")}
</p>
@ -169,18 +192,36 @@ export default function SettingsBasic() {
{t("settings-project:docs")}
</a>
</p>
<div className="max-h-28 w-ful">
<InputField
type="varName"
value={router.query.id}
placeholder=""
isRequired
static
text={t("settings-project:auto-generated")}
/>
<p className="mt-4 text-xs text-bunker-300">{t("settings-project:auto-generated")}</p>
<div className="flex justify-end items-center bg-white/[0.07] text-base mt-2 mb-3 mr-2 rounded-md text-gray-400">
<p className="mr-2 font-bold pl-4">{`${t(
"common:project-id"
)}:`}</p>
<input
type="text"
value={workspaceId}
id="myInput"
className="bg-white/0 text-gray-400 py-2 w-60 px-2 min-w-md outline-none"
disabled
></input>
<div className="group font-normal group relative inline-block text-gray-400 underline hover:text-primary duration-200">
<button
onClick={copyToClipboard}
className="pl-4 pr-4 border-l border-white/20 py-2 hover:bg-white/[0.12] duration-200"
>
{projectIdCopied ? (
<FontAwesomeIcon icon={faCheck} className="pr-0.5" />
) : (
<FontAwesomeIcon icon={faCopy} />
)}
</button>
<span className="absolute hidden group-hover:flex group-hover:animate-popup duration-300 w-28 -left-8 -top-20 translate-y-full pl-3 py-2 bg-bunker-800 rounded-md text-center text-gray-400 text-sm">
{t("common:click-to-copy")}
</span>
</div>
</div>
</div>
<div className="bg-white/5 rounded-md px-6 pt-6 flex flex-col items-start flex flex-col items-start w-full mt-4 mb-4 pt-2">
<div className="bg-white/5 rounded-md px-6 pt-4 flex flex-col items-start flex flex-col items-start w-full mt-4 mb-4 pt-2">
<div className="flex flex-row justify-between w-full">
<div className="flex flex-col w-full">
<p className="text-xl font-semibold mb-3">
@ -205,47 +246,12 @@ export default function SettingsBasic() {
<ServiceTokenTable
data={serviceTokens}
workspaceName={workspaceName}
setServiceTokens={setServiceTokens}
/>
</div>
{/* <div className="bg-white/5 rounded-md px-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pb-6 pt-6">
<p className="text-xl font-semibold self-start">
Project Environments
</p>
<p className="text-md mr-1 text-gray-400 mt-2 self-start">
Choose which environments will show up
in your Dashboard. Some common ones
include Development, Staging, and
Production. Often, teams choose to add
Testing.
</p>
<p className="text-sm mr-1 text-gray-500 self-start">
Note: the text in brackets shows how
these environmant should be accessed in
CLI.
</p>
<div className="rounded-md h-10 w-full mr-auto mt-4 flex flex-row">
{envOptions.map((env) => (
<div className="bg-white/5 hover:bg-white/10 duration-200 h-full w-max px-3 flex flex-row items-center justify-between rounded-md mr-1 text-sm">
{env}
<XIcon
className="h-5 w-5 ml-2 mt-0.5 text-white cursor-pointer"
aria-hidden="true"
/>
</div>
))}
<div className="group bg-white/5 hover:bg-primary hover:text-black duration-200 h-full w-max py-1 px-3 flex flex-row items-center justify-between rounded-md mr-1 cursor-pointer text-sm font-semibold">
<PlusIcon
className="h-5 w-5 text-white mr-2 group-hover:text-black"
aria-hidden="true"
/>
Add
</div>
</div>
</div> */}
</div>
</div>
<div className="bg-white/5 rounded-md px-6 pt-6 pb-6 border-l border-red pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pb-4 pt-2">
<div className="bg-white/5 rounded-md px-6 pt-4 pb-6 border-l border-red pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pb-4 pt-2">
<p className="text-xl font-bold text-red">
{t("settings-project:danger-zone")}
</p>

View File

@ -141,16 +141,16 @@ export default function SignupInvite() {
// Step 4 of the sign up process (download the emergency kit pdf)
const stepConfirmEmail = (
<div className="bg-bunker flex flex-col items-center w-full max-w-xs md:max-w-lg mx-auto h-7/12 py-8 px-4 md:px-6 mx-1 mb-36 md:mb-16 rounded-xl drop-shadow-xl">
<p className="text-4xl text-center font-semibold mb-8 flex justify-center text-transparent bg-clip-text bg-gradient-to-br from-sky-400 to-primary">
<p className="text-4xl text-center font-semibold mb-6 flex justify-center text-primary-100">
Confirm your email
</p>
<Image
src="/images/envelope.svg"
src="/images/dragon-signupinvite.svg"
height={262}
width={410}
alt="verify email"
></Image>
<div className="flex max-w-max flex-col items-center justify-center md:p-2 max-h-24 max-w-md mx-auto text-lg px-4 mt-4 mb-2">
<div className="flex max-w-max flex-col items-center justify-center md:p-2 max-h-24 max-w-md mx-auto text-lg px-4 mt-10 mb-2">
<Button
text="Confirm Email"
onButtonPressed={async () => {

View File

@ -0,0 +1,129 @@
<svg width="378" height="600" viewBox="0 0 378 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_118_2691)">
<path d="M52.51 256.81C52.51 256.81 45.39 236.73 45.92 222.75C46.45 208.77 47.9 204.03 47.9 204.03L41.62 196.12C41.62 196.12 43.11 195.5 44.7 196.12C46.29 196.74 47.26 197.14 47.26 197.14C47.26 197.14 48.2 188.87 50.89 181.48C53.58 174.09 56.94 166.32 56.94 166.32C56.94 166.32 31.36 134.25 29.44 127.61C27.52 120.97 32.24 107.9 32.44 88.67C32.64 69.44 24.52 46.3 19.6 36.25C14.68 26.2 8.47 10.37 8.47 10.37C8.47 10.37 31.4 26.51 49.98 53.59C68.56 80.67 76.51 104.73 78.01 112.64C79.51 120.55 80.36 129.13 80.36 129.13C80.36 129.13 86.49 122.44 89.65 119.25C92.81 116.06 95.75 112.27 95.75 112.27C95.75 112.27 93.02 119.98 91.98 125.61C90.94 131.24 90.47 138.92 90.47 138.92C90.47 138.92 93.96 135.92 98.65 134.78C103.34 133.64 107.2 133.01 107.2 133.01C107.2 133.01 118.91 115.02 128.26 103C137.61 90.98 149.09 78.96 149.09 78.96C149.09 78.96 145.35 97.39 142.55 107.54C139.75 117.69 138.01 132.13 138.14 131.32C138.27 130.51 144.61 131.91 146.56 131.98C148.51 132.05 150.83 133.13 150.83 133.13C150.83 133.13 156.58 125.51 166.72 122.13C176.86 118.75 183.15 119.04 183.15 119.04C183.15 119.04 178.13 123.87 175.61 128.51C173.09 133.15 169.23 137.98 169.23 137.98C169.23 137.98 175.99 139.72 181.12 142.23C186.25 144.74 192.68 149.28 192.68 149.28C192.68 149.28 195.75 128.67 194.94 117.87C194.13 107.07 196.2 105.02 204.7 99.8C213.2 94.58 241.29 69.28 252.49 57.33C263.69 45.38 275.77 27.16 279.69 20.35C279.69 20.35 282.11 42.2 269.42 78.26C256.73 114.32 246.34 131.77 233.08 148.45C219.82 165.13 215.3 167.94 215.3 167.94C215.3 167.94 232.52 189.29 241.54 205.71C250.56 222.13 277.83 281.26 287.88 312.34C297.93 343.42 325.15 440.06 327.84 463.85C330.53 487.64 341.96 544.7 344.13 558.2C344.13 558.2 326.64 478.31 311.18 439.78C295.72 401.25 274.22 330.09 249.81 282.67C225.4 235.25 215.01 221.3 215.01 221.3C215.01 221.3 213.39 229.6 210.77 236.81C208.15 244.02 205.45 248.19 205.45 248.19C205.45 248.19 212.37 246.63 214.01 245.65C215.65 244.67 213.57 251.78 210.77 255.93C207.97 260.08 202.92 265.49 202.92 265.49C202.92 265.49 202.92 283.01 187.01 293.81C171.1 304.61 164.96 305.77 162.12 309.72C159.28 313.67 157.58 317.93 159.05 336.27C160.52 354.61 167.2 382.9 167.2 382.9L156.41 383.08C156.41 383.08 154.86 390.33 148.66 396.81C142.46 403.29 137.62 406.57 137.62 406.57C137.62 406.57 139.99 413.05 143.18 417.88C146.37 422.71 146.83 423.9 146.83 423.9C146.83 423.9 138.28 422.08 132.52 417.15C126.76 412.22 125.58 410.58 125.58 410.58C125.58 410.58 115 412.77 106.88 408.94C98.76 405.11 89.55 396.81 89.55 396.81C89.55 396.81 89.09 403.83 85.9 406.57C82.71 409.31 77.23 411.95 77.23 411.95C77.23 411.95 77.23 417.88 79.33 422.17C81.43 426.46 84.17 429.1 84.17 429.1C84.17 429.1 75.23 425.27 71.49 420.71C67.75 416.15 65.56 412.77 65.56 412.77C65.56 412.77 49.69 410.31 44.58 398.81C39.47 387.31 39.84 378.28 39.84 378.28C39.84 378.28 30.72 366.15 35.28 356.57C39.84 346.99 49.79 345.71 53.62 346.99C57.45 348.27 63.84 351.28 63.84 351.28C63.84 351.28 69.59 344.62 75.61 331.12C81.63 317.62 84.7 305.49 83.44 302.25C82.18 299.01 75.96 289.63 71.08 287.37C66.2 285.11 58.52 281.89 51.34 271.18C44.16 260.47 43.04 251.11 43.04 251.11C43.04 251.11 46.42 255.46 48.7 256.13C50.98 256.8 52.54 256.77 52.54 256.77L52.51 256.81Z" fill="#ADD63D"/>
<path d="M167.2 382.94C167.2 382.94 160.59 349.3 159.05 336.31C157.51 323.32 157.81 309.31 165.49 305.87C173.17 302.43 192.85 293.32 197.51 285.21C202.17 277.1 202.91 265.52 202.91 265.52C202.91 265.52 208.03 261.43 210.76 255.96C213.49 250.49 214 245.68 214 245.68L205.44 248.22C205.44 248.22 210.36 238.57 212.34 231.98C214.32 225.39 214.99 221.32 214.99 221.32C214.99 221.32 238.33 257.8 250.6 284.29C262.87 310.78 286.79 367.25 301.95 411.75C317.11 456.25 332.34 507.48 338.22 532.85C344.1 558.22 346.81 568.69 348.34 579.62C349.87 590.55 350.82 596.9 350.82 596.9H146.4C146.4 596.9 148.45 585.23 149.26 577.29C150.07 569.35 150.86 558.22 150.86 558.22C150.86 558.22 242.6 557.01 248.61 557.01C254.62 557.01 260.4 556.22 260.66 544.93C260.92 533.64 260.6 396.04 260.6 392.23C260.6 388.42 258.38 382.99 249.48 382.95C240.58 382.91 167.19 382.95 167.19 382.95L167.2 382.94Z" fill="#E5F234"/>
<path d="M139.9 266.29C139.9 266.29 138.7 255.81 138.7 252.56V245.57C138.7 245.57 152.81 240.57 161.66 231.8C170.51 223.03 178.7 209.54 178.7 209.54C178.7 209.54 183.64 215.68 183.4 224.96C183.16 234.24 181.35 248.69 170.03 254.48C158.71 260.27 150.27 260.07 146.9 261.97C143.53 263.87 141.84 266.32 141.48 267.21C141.12 268.1 139.91 266.29 139.91 266.29H139.9Z" fill="white"/>
<path d="M55.84 221.33C55.84 221.33 54.71 235.46 59.38 247.24C64.05 259.02 69.99 261.56 76.21 263.4C82.43 265.24 86.53 265.95 87.95 268.07C89.37 270.19 90.5 273.44 90.5 273.44C90.5 273.44 91.63 262.41 91.35 251.38C91.07 240.35 90.5 239.22 90.5 239.22C90.5 239.22 81.31 247.2 70.98 240.17C60.65 233.14 60.23 227.34 59.38 225.93C58.53 224.52 55.84 221.34 55.84 221.34V221.33Z" fill="white"/>
<path d="M239.77 202.63C239.77 202.63 252.91 204.91 259.85 208.5C266.79 212.09 274.59 218.86 278.26 226.09C281.93 233.32 282.43 236.9 282.43 236.9C282.43 236.9 276.92 227.04 268.65 225.5C260.38 223.96 256.91 223.83 255.31 224.5C253.71 225.17 252.51 226.09 252.51 226.09L239.77 202.64V202.63Z" fill="#30993A"/>
<path d="M303.74 356.09C303.74 356.09 313.8 365.8 324.37 376.87C334.94 387.94 339.07 401.17 340.23 409.12C341.39 417.07 341.08 419.63 341.08 419.63C341.08 419.63 338.82 405.78 329.49 396.74C320.16 387.7 310.97 385.45 310.97 385.45L303.74 356.09V356.09Z" fill="#30993A"/>
<path d="M339.29 519.97C339.29 519.97 352.44 527.4 361.59 544.85C370.74 562.3 370.17 575.88 370.17 575.88C370.17 575.88 367.02 561.87 359.02 554.58C351.02 547.29 342.72 545.29 342.72 545.29L339.29 519.98V519.97Z" fill="#30993A"/>
<path d="M38.44 383.12H10.8C6.94001 383.12 3.82001 386.25 3.82001 390.1V548.14C3.82001 552.7 7.52001 556.4 12.08 556.4H250.98C255.57 556.4 259.31 552.7 259.35 548.1L260.63 390.73C260.66 386.54 257.27 383.12 253.08 383.12H156.42C156.42 383.12 155.19 391.65 146.41 399.13L137.63 406.61C137.63 406.61 140.2 415.72 143.52 419.83C146.84 423.94 148.03 426.34 148.03 426.34C148.03 426.34 137.22 421.39 132.54 417.19C127.86 412.99 125.6 410.62 125.6 410.62C125.6 410.62 109.78 414.06 100.47 406.14C91.16 398.22 89.57 396.85 89.57 396.85C89.57 396.85 87.97 408.97 82.61 410.48L77.25 411.99C77.25 411.99 77.09 417.58 79.35 422.21C81.61 426.84 84.19 429.14 84.19 429.14C84.19 429.14 77.44 428.68 71.51 420.75L65.58 412.81C65.58 412.81 50.74 411.15 45.9 401.8C41.06 392.45 38.48 383.12 38.48 383.12H38.44Z" fill="#E6FFFF"/>
<path d="M133.29 514.89C154.509 514.89 171.71 497.689 171.71 476.47C171.71 455.251 154.509 438.05 133.29 438.05C112.071 438.05 94.87 455.251 94.87 476.47C94.87 497.689 112.071 514.89 133.29 514.89Z" fill="#63ED4A"/>
<path d="M209.1 173.6C209.1 173.6 234.52 202.63 257.89 254.45C277.4 297.7 296.34 361.95 305.17 398.01C314 434.07 315.41 443.46 315.41 443.46L336.65 522.52C336.65 522.52 317.94 400.97 304.96 361.03C291.98 321.09 275.55 269.28 260.87 241.86C246.19 214.44 228.76 184.08 222.6 175.98C216.44 167.88 215.74 167.3 215.74 167.3L209.09 173.58L209.1 173.6Z" fill="#30993A"/>
<path d="M191.01 157.59C191.01 157.59 187.12 153.53 179.33 149.44C172.09 145.63 166.19 143.41 166.19 143.41L170.37 138.29C170.37 138.29 182.39 142.28 187.53 144.93C192.67 147.58 193.3 148.46 193.3 148.46L191.01 157.59V157.59Z" fill="#30993A"/>
<path d="M91.35 145.17C95.13 143.08 99.36 141.26 104.15 139.96C105.28 137.55 106.63 135 107.83 132.81C106.01 133.12 104.37 133.46 102.99 133.81C98.77 134.88 94.99 136.16 91.14 137.91L91.35 145.17V145.17Z" fill="#30993A"/>
<path d="M149.82 133.39C149.93 133.27 150.07 133.16 150.22 133.04C146.52 132.2 142.45 131.66 138.27 131.36C138.06 133.31 137.83 135.53 137.62 137.53C140.48 137.7 143.29 137.97 146.04 138.32C147.04 136.79 148.19 135.14 149.81 133.39H149.82Z" fill="#30993A"/>
<path d="M47.9 198.94C48.54 199.3 49.3 199.81 50.12 200.42C50.92 196.53 51.92 192.74 53.14 189.3C55.42 182.9 57.94 177.69 61.06 173.01C60.72 172.08 60.42 171.48 60.01 170.88L57.46 167.33C54.76 172.02 52.9 176.24 51.92 178.74C50.28 182.92 48.65 190.29 47.52 198.59L47.91 198.94H47.9Z" fill="#30993A"/>
<path d="M123.93 231.98C123.93 231.98 128.46 238.41 138.69 235.48C148.92 232.55 155.47 224.74 164.65 213.8C173.83 202.86 180.66 196.81 185.64 193.78C190.62 190.75 197.09 188.75 197.09 188.75C197.09 188.75 186.32 197.52 178.51 208.49C170.7 219.46 161.21 233.36 153.75 239.01C146.29 244.66 141.89 245.82 136.52 244.65C131.15 243.48 127.25 241.33 125.69 238.5C124.13 235.67 123.93 231.97 123.93 231.97V231.98Z" fill="#30993A"/>
<path d="M93.48 234.12C93.48 234.12 87.99 237.47 81.25 236.5C74.51 235.53 70.71 229.96 67.63 224.67C63.67 217.87 57.37 205.97 50.11 200.42C45.74 197.08 41.61 196.12 41.61 196.12C41.61 196.12 56.62 213.06 61.05 226.07C65.48 239.08 70.42 244.22 77.2 243.96C83.98 243.7 87.28 242.2 89.18 240.15C91.08 238.1 93.48 234.12 93.48 234.12V234.12Z" fill="#30993A"/>
<path d="M138.75 276.15C138.75 276.15 134.81 270.58 131.81 262.76C128.81 254.94 127.81 244.85 128.17 242.03C128.53 239.21 137.26 245.22 137.63 245.07C138 244.92 138.16 255.04 139.34 261.05C140.52 267.06 138.75 276.15 138.75 276.15V276.15Z" fill="#30993A"/>
<path d="M183.7 239.82C187.57 231.26 189.28 224.74 190.46 216.85C191.64 208.96 192.75 192.59 192.75 192.59C192.75 192.59 184.83 199.42 184.38 205.26C183.93 211.1 185.64 218.4 185.64 223.65C185.64 228.9 183.7 239.82 183.7 239.82V239.82Z" fill="#30993A"/>
<path d="M195.35 253.07C195.35 253.07 201.93 244.14 207.07 231.22C212.21 218.3 213.83 208.67 215.01 201.46C215.01 201.46 216.19 212.57 214.04 223.79C211.89 235.01 205.78 249.96 205.78 249.96L195.35 253.06V253.07Z" fill="#30993A"/>
<path d="M140.77 294.62C140.77 294.62 144.8 292.03 155.8 292.1C166.8 292.17 176.34 292.87 184.33 288.98C192.32 285.09 196.62 280.43 200.4 272.28C204.18 264.13 190.34 280.74 181.48 283.05C172.62 285.36 162.45 285.57 157.06 286.42C151.67 287.27 147.78 287.94 145.21 289.72C142.64 291.5 140.76 294.63 140.76 294.63L140.77 294.62Z" fill="#30993A"/>
<path d="M81.25 287.84C85.27 289.15 87.63 292.02 87.63 292.02L84.44 302.59C84.44 302.59 80.47 293.77 75.32 290.7C70.17 287.63 68.38 286.29 68.38 286.29C68.38 286.29 72.28 286.73 75.01 286.99C77.74 287.25 81.24 287.85 81.24 287.85L81.25 287.84Z" fill="#30993A"/>
<path d="M60.62 255.5C55.52 248.94 53.05 242.72 50.95 232.58C48.85 222.44 48.48 206.68 48.48 206.68L56.6 218.56C56.6 218.56 53.78 227.38 55.72 236.15C57.66 244.92 60.62 255.51 60.62 255.51V255.5Z" fill="#30993A"/>
<path d="M147.91 180.56C147.91 180.56 156.73 167.42 164.38 159.05C172.03 150.68 176.25 147.71 179.33 149.44C182.41 151.17 182.02 145.09 176.3 143.41C170.58 141.73 167.52 141.79 167.52 141.79C167.52 141.79 161.64 150.37 158.47 157.59C155.3 164.81 147.92 180.55 147.92 180.55L147.91 180.56Z" fill="#30993A"/>
<path d="M120.72 214.72C120.72 214.72 127.62 209.07 132.93 196.12C137.77 184.32 139.04 175.66 140.78 163.37C142.52 151.08 142.27 143.24 146.04 138.32C149.81 133.4 138.69 132.81 138.69 132.81C138.69 132.81 136.23 152.3 133.77 167.83C131.31 183.36 130.07 194.06 127.59 200.14C125.11 206.22 120.72 214.72 120.72 214.72V214.72Z" fill="#30993A"/>
<path d="M91.42 147.31C92.23 151.71 94.89 157.79 94.89 157.79L103.05 138.32L90.46 141.18L91.42 147.31V147.31Z" fill="#30993A"/>
<path d="M94.44 211.75C94.44 211.75 89.48 202.56 88.29 190.76C87.1 178.96 88.64 169.71 89.65 164.02C90.66 158.33 93.49 150.86 93.49 150.86L97.45 152.47C97.45 152.47 91.59 170.63 91.59 181.75C91.59 192.87 91.46 199.91 92.03 202.72C92.6 205.53 94.43 211.74 94.43 211.74L94.44 211.75Z" fill="#30993A"/>
<path d="M209.1 173.6C206.93 175.84 204.99 177.24 204.99 177.24C204.99 177.24 223.54 154.02 232.28 142.04C241.65 129.21 253.55 107.54 258.71 94.01C263.87 80.48 278.32 28.42 278.32 28.42C278.32 28.42 277.89 48.41 270.49 75.13C263.09 101.85 250.74 122.23 243.22 134.54C235.7 146.85 226.36 156.15 221.99 161.56C217.62 166.97 213.41 170.12 212.15 171.35C210.89 172.58 209.11 173.59 209.11 173.59L209.1 173.6Z" fill="#30993A"/>
<path d="M221.09 137.53C221.09 137.53 229.91 122.95 239.19 108.44C248.47 93.93 259.17 78.28 259.17 78.28C259.17 78.28 251.55 100.57 244.63 112.18C237.71 123.79 229.6 129.78 227.56 132.23C225.52 134.68 221.09 137.53 221.09 137.53V137.53Z" fill="#30993A"/>
<path d="M12.63 15.82C12.63 15.82 35.12 38.31 49.73 64.03C64.34 89.75 68.68 107.83 70.94 120.17C73.2 132.51 73.2 146.07 73.2 146.07L81.02 129.13C81.02 129.13 76.35 94.98 60.87 71.1C45.39 47.22 31.18 30.09 25.1 24.49C19.02 18.89 12.63 15.82 12.63 15.82Z" fill="#30993A"/>
<path d="M123.93 284.38C123.93 284.38 123.94 302.84 125.56 316.38C127.18 329.92 131.07 339.68 133.92 344.73C133.92 344.73 123.82 345.04 122.47 334.79C121.12 324.54 122.63 309.74 122.47 301.25C122.31 292.76 123.93 284.38 123.93 284.38Z" fill="#30993A"/>
<path d="M151.64 301.25C151.64 301.25 150.56 306.94 150.72 317.29C150.72 317.29 151.67 310.74 155.9 307.19C160.13 303.64 165.62 301.88 169.57 300.32C173.52 298.76 165.85 306.48 162.7 310.7C159.55 314.92 158.01 317.2 158.42 328.92C158.83 340.64 161.77 358.55 163.3 368.55C164.83 378.55 167.2 382.93 167.2 382.93L154.62 383.11C154.62 383.11 159.59 373.2 156.4 362.66C153.21 352.12 149.72 345.16 146.39 343.13C143.06 341.1 144.48 334.91 145.35 325.82C146.22 316.73 146.84 310.36 148.1 307.56C149.36 304.76 151.63 301.24 151.63 301.24L151.64 301.25Z" fill="#30993A"/>
<path d="M151.64 301.25C151.64 301.25 145.65 305.2 142.16 314.55C138.67 323.9 136.28 338.05 136.28 338.05C136.28 338.05 138.29 338.55 140.76 340.05C143.23 341.55 144.07 341.85 144.07 341.85C144.07 341.85 144.29 319.6 146.2 313.58C148.11 307.56 149.83 303.89 149.83 303.89L151.64 301.24V301.25Z" fill="#30993A"/>
<path d="M136.29 338.05C136.29 338.05 146.8 343.39 150.34 358.27C153.66 372.2 150.78 385.28 141.09 394.37C132.34 402.58 118.75 405.25 109.59 403.11C100.43 400.97 92.16 396.99 85.44 386.71C85.44 386.71 90.43 399.86 102.37 406.5C114.31 413.14 127.38 408.13 127.38 408.13C127.38 408.13 132.66 407.66 134.88 406.19C137.1 404.72 150.67 398.12 153.31 390.61C155.95 383.1 159.22 374.68 156.79 364.09C154.36 353.5 150.97 349.72 146.69 344.73C142.41 339.74 140.16 338.97 138.81 338.49C137.46 338.01 136.29 338.06 136.29 338.06V338.05Z" fill="#30993A"/>
<path d="M128.15 410.8C128.15 410.8 132.71 410.8 136.61 408.29C140.51 405.78 137.23 403.62 134.56 403.93C131.89 404.24 124.72 406.75 124.72 406.75L128.15 410.8Z" fill="#30993A"/>
<path d="M46.08 383.12C46.08 383.12 45.4 386.83 47.29 393.57C49.18 400.31 55.54 405.38 62.82 407.13C70.1 408.88 77.36 407.58 81.3 402.96C85.24 398.34 86.1 393.12 85.44 386.72C85.44 386.72 89.91 392.72 87.36 400.92C84.81 409.12 77.31 413.81 75.03 413.99C72.75 414.17 66.57 414.3 66.35 413.99C66.13 413.68 48.66 408.26 45.23 400.55C41.8 392.84 38.85 384.78 39.06 381.68C39.27 378.58 40.56 378.66 40.56 378.66L46.77 382.75" fill="#30993A"/>
<path d="M60.91 386.77C60.91 386.77 50.31 388.69 44.99 387.23C39.67 385.77 39.12 380.98 39.12 380.98L42.11 379.68C42.11 379.68 50.31 384.35 53.31 385.41C56.31 386.47 60.9 386.77 60.9 386.77H60.91Z" fill="#30993A"/>
<path d="M122.59 383.12C122.59 383.12 130.98 382.75 138.75 377.45C145.36 372.94 147.39 366.74 147.39 361.28C147.39 354.19 143.91 348.55 137.95 344.73C131.99 340.91 131.23 345.14 131.23 345.14C131.23 345.14 141.67 347.09 142.77 354.52C143.87 361.95 143.09 365.91 139.21 370.41C135.33 374.91 131.78 378.16 129.56 379.42C127.34 380.68 122.57 383.12 122.57 383.12H122.59Z" fill="#30993A"/>
<path d="M132.11 362.67C134.1 369.3 126.98 371.3 120.85 372.66C114.72 374.02 110.28 376.08 110.28 376.08C110.28 376.08 115.03 371.23 121.28 367.35C127.53 363.47 132.11 362.67 132.11 362.67Z" fill="#30993A"/>
<path d="M61.25 376.88C61.25 376.88 57.8 375.75 53.01 375.13C48.22 374.51 45.06 374.21 43.36 372.06C41.85 370.16 42.02 367.42 43.36 365.81C44.7 364.2 47.82 366.54 53.12 370.92C58.42 375.3 61.25 376.88 61.25 376.88Z" fill="#30993A"/>
<path d="M190.68 159.35C190.68 159.35 197.27 149.45 200.57 135.96C203.87 122.47 203.73 114.64 214.99 104.35C226.25 94.06 236 86.09 250.28 69.89C264.56 53.69 272.89 39 278.33 28.42C283.77 17.84 266.7 43.24 252.98 56.83C239.26 70.42 209.45 95.59 203.15 100.79C196.85 105.99 195.39 110.24 195.06 120.42C194.73 130.6 193.98 136.96 193.47 143.42C192.96 149.88 190.69 159.36 190.69 159.36L190.68 159.35Z" fill="#EBFFAB"/>
<path d="M94.44 211.75C94.44 211.75 94.38 180.96 104.45 155.22C114.52 129.48 146.83 83.26 146.83 83.26C146.83 83.26 128.29 97.73 115.02 121.34C101.75 144.95 92.5 163.02 92.04 175.67C91.58 188.32 92.78 198.15 92.65 201.47C92.52 204.79 94.43 211.75 94.43 211.75H94.44Z" fill="#EBFFAB"/>
<path d="M147.91 180.56C147.91 180.56 150.99 157.98 158.23 144.58C165.47 131.18 174.6 123.46 179.04 120.79C183.48 118.12 174.07 128.83 168.3 138.61C162.53 148.39 158.52 155.28 155.98 161.18C153.44 167.08 147.91 180.56 147.91 180.56Z" fill="#EBFFAB"/>
<path d="M91.94 118.93C91.94 118.93 85.2 128.78 79.57 146.98C73.94 165.18 75.08 178.72 75.56 183.87C76.04 189.02 77.98 198.6 77.98 198.6C77.98 198.6 69.99 181.45 70.59 166.01C71.19 150.57 73.71 141.41 79.32 132.8C84.93 124.19 91.94 116.36 91.94 116.36V118.92V118.93Z" fill="#EBFFAB"/>
<path d="M92.6 238.71C92.6 238.71 97.22 252.29 94.47 280.54C91.72 308.79 81.58 337.66 77.8 347.39C74.92 354.79 72.59 359.67 72.59 359.67C72.59 359.67 72.28 359.2 69.44 357.2C66.6 355.2 62.74 351.67 62.74 351.67C62.74 351.67 69.32 347.67 73.55 339.07C77.78 330.47 85.32 305.49 87.63 294.95C89.94 284.41 92.93 264.11 92.65 255.49C92.37 246.87 92.6 238.7 92.6 238.7V238.71Z" fill="#EBFFAB"/>
<path d="M103.71 359.12C99.5 362.43 95.34 366.85 95.34 366.85C95.34 366.85 109.25 356.96 119.87 352.43C129.08 348.5 136.82 347.78 139.94 352.26C142.73 356.26 143.1 351 139.94 348.51C136.78 346.02 130.02 341.66 121.63 345.56C113.24 349.46 108.05 354.08 106.42 356.1C104.79 358.12 103.71 359.13 103.71 359.13V359.12Z" fill="#EBFFAB"/>
<path d="M46.52 380.38C41.94 376.81 38.33 372.56 37.92 365.8C37.51 359.04 40.8 354.83 45.66 352.33C50.52 349.83 58.18 349.83 64.23 353.61C68.87 356.51 58.18 346.1 51.02 346.63C43.86 347.16 38.88 350.19 36.04 355.15C33.2 360.11 32.46 363.89 34.28 369.08C36.1 374.27 37.94 376.47 40.89 378.64C43.84 380.81 46.52 380.37 46.52 380.37V380.38Z" fill="#EBFFAB"/>
<path d="M45.15 255.5C45.15 255.5 48.12 263.94 57.64 271.67C67.16 279.4 75.62 280.92 81.26 281.61C81.26 281.61 68.65 284.92 60.63 279.34C52.61 273.76 48.39 267.23 46.61 262.71C44.83 258.19 45.16 255.5 45.16 255.5H45.15Z" fill="#EBFFAB"/>
<path d="M161.04 283.66C172.02 281.37 181.06 276.6 191.01 269.27C201.55 261.5 211.74 248.51 211.74 248.51C211.74 248.51 208.44 259.19 199.4 268.61C190.36 278.03 180.47 283.16 172.66 284.37C164.85 285.58 161.04 283.65 161.04 283.65V283.66Z" fill="#EBFFAB"/>
<path d="M132.71 221.33C135.69 214.65 141.01 206.72 148.75 200.75C156.49 194.78 163.2 192.17 163.2 192.17C163.2 192.17 152.17 201.37 146.82 206.75C141.47 212.13 132.7 221.33 132.7 221.33H132.71Z" fill="#EBFFAB"/>
<path d="M52.76 157.49C46.61 146.55 38.16 132.68 37.97 126.06C37.78 119.44 40.58 107.91 39.67 92.96C37.78 61.75 23.88 32.63 12.62 15.82C12.62 15.82 28.63 53.87 30.84 71.99C33.05 90.11 31.22 110.21 29.16 120.23C27.1 130.25 29.31 129.31 35.81 138.32C42.31 147.33 52.75 157.49 52.75 157.49H52.76Z" fill="#EBFFAB"/>
<path d="M115.47 396.75C112.39 396.75 104.91 396.73 99.08 392.58C92.55 387.92 89.89 382.72 88.14 378.19C88.14 378.19 94.01 385.08 101.51 390.17C107.69 394.36 115.47 396.75 115.47 396.75Z" fill="#EBFFAB"/>
<path d="M213.09 235.8C213.09 235.8 234.88 269.47 252.19 309.4C269.5 349.33 289.02 397.96 301.26 436.08C313.5 474.2 334.73 544.97 340.53 596.9H350.85C350.85 596.9 329.25 489.6 312.87 444.82C296.49 400.04 272.59 334.57 257.9 302.3C243.21 270.03 216.46 222.33 216.46 222.33C216.46 222.33 213.04 229.81 212.36 232C211.68 234.19 213.09 235.82 213.09 235.82V235.8Z" fill="#B1B511"/>
<path d="M159.81 320.37C159.81 320.37 169.7 321.92 193.18 321.3C216.66 320.68 243.87 314.47 248.01 313.13C252.15 311.79 252.16 309.25 250.41 305.36C248.66 301.47 253.19 304.77 255.67 310.71C258.15 316.65 257.96 319.73 237.88 323.26C217.8 326.79 188.91 327.27 177.64 327.82C166.37 328.37 159.05 326.27 159.05 326.27L159.81 320.37V320.37Z" fill="#B1B511"/>
<path d="M261.44 405.91C265.67 405.76 276.13 405.33 281.51 404.52C286.89 403.71 290.12 403.73 288.79 400.14C287.46 396.55 290.68 396.87 292.07 401.06C293.46 405.25 296.81 407.43 289.88 408.67C282.95 409.91 260.65 411.73 260.65 411.73L261.43 405.9L261.44 405.91Z" fill="#B1B511"/>
<path d="M260.78 508.93C260.78 508.93 272.96 508.5 291.41 506.82C309.86 505.14 316.92 503.72 318.52 503.4C320.12 503.08 320.82 501.9 319.93 498.67C319.04 495.44 323.37 499.92 323.89 503.8C324.41 507.68 323.94 509.27 316.4 510.36C308.86 511.45 260.11 515.24 260.11 515.24L260.78 509.57V508.93Z" fill="#B1B511"/>
<path d="M177.64 382.94C177.44 377.3 175.61 356.17 176.29 340.06C176.97 323.95 177.44 316.6 190.08 305.79C202.72 294.98 207.18 286.55 209.09 278.35C211 270.15 210.77 266.65 210.77 266.65C210.77 266.65 217.42 260.1 222.14 250.7C226.86 241.3 216.64 227.28 216.64 227.28L215.32 224.83L205.33 249.23L215.32 245.87C215.32 245.87 207.99 259.32 206.55 261.43C205.11 263.54 203.9 265.29 203.52 269.11C203.14 272.93 198.97 286.78 185.68 295.49C172.39 304.2 165.38 307.52 161.84 311.88C158.3 316.24 157.11 323.98 159.13 337.12C161.15 350.26 167.18 382.97 167.18 382.97H177.62L177.64 382.94Z" fill="#B1B511"/>
<path d="M150.28 565.92H257.19C263.95 565.92 269.42 560.44 269.42 553.69V401.39C269.42 396.59 265.53 392.7 260.73 392.7H260.6V549.45C260.61 553.28 257.5 556.37 253.67 556.35L151.3 555.73L150.26 565.92H150.28Z" fill="#B1B511"/>
<path d="M140.35 247.23C140.35 247.23 153.1 247.48 162.17 242.28C170.41 237.56 173.39 232.31 174.83 230.31C177 227.27 182.38 233.27 177.65 242.98C172.92 252.69 185.03 241.37 183.33 226.81C181.63 212.25 179.21 208.49 179.21 208.49C179.21 208.49 170.05 226.57 159.47 234.59C148.89 242.61 138.71 244.26 138.71 244.26L140.37 247.24L140.35 247.23Z" fill="#DBDBDB"/>
<path d="M63.05 253C61.76 250.78 60.35 246.57 60.62 243.22C60.89 239.87 62.62 238.15 63.82 237.84C65.02 237.53 55.94 224.81 55.94 224.81C55.94 224.81 53.7 231.54 56.95 241.4C60.2 251.26 63.32 256.16 63.18 254.58L63.05 253V253Z" fill="#DBDBDB"/>
<path d="M68.39 244.4C71.68 246.87 78.84 248.86 84.17 248.86C89.5 248.86 92.04 247.39 92.04 247.39L91.38 239.86C91.38 239.86 84.49 245.6 78.01 245.36C71.53 245.12 68.39 244.41 68.39 244.41V244.4Z" fill="#DBDBDB"/>
<path d="M171.36 471.24L254.54 394.37L169.56 463.75L171.36 471.24Z" fill="#B6D8D6"/>
<path d="M11.55 395.26L95.03 473.03L97.24 465.84L11.55 395.26Z" fill="#B6D8D6"/>
<path d="M5.95 550.56H251.05C252.98 550.56 254.54 549 254.54 547.07V384.54C254.54 384.54 260.49 383.66 260.61 392.71C260.73 401.76 260.62 549.46 260.62 549.46C260.62 549.46 260.49 556.48 246.84 557.43C233.19 558.38 9.93 556.4 9.93 556.4C9.93 556.4 5.95 556.76 5.95 553.66V550.56V550.56Z" fill="#B6D8D6"/>
<path d="M86.87 431.77C86.87 431.77 85.48 422.85 85.44 420.3L85.39 417.74C85.39 417.74 88.17 417.18 92.51 414.63C96.85 412.08 100.07 407.36 100.07 407.36L90.51 397.69C90.51 397.69 91.18 405.94 83.73 409.12C76.28 412.3 76.03 412.29 76.17 412.96C76.31 413.63 76.75 418.57 79.83 423.18C82.91 427.79 86.88 431.77 86.88 431.77H86.87Z" fill="#B6D8D6"/>
<path d="M96.67 408.63C96.67 408.63 100.73 413.29 113.11 415.68C124.14 417.81 131.5 415.97 131.5 415.97L126.27 411.33C126.27 411.33 112.77 412.87 107.17 409.95C101.57 407.03 96.67 405.1 96.67 405.1V408.63V408.63Z" fill="#B6D8D6"/>
<path d="M152.24 428.63C152.24 428.63 149.92 420.26 148.77 415.31L147.62 410.35C147.62 410.35 156.21 405.95 161.9 398.81C167.59 391.67 169.58 384.51 169.58 384.51H156.43C156.43 384.51 152.39 395.01 147.41 399.87C142.43 404.73 137.64 406.61 137.64 406.61C137.64 406.61 141.01 415.42 144.32 419.64C147.63 423.86 152.25 428.63 152.25 428.63H152.24Z" fill="#B6D8D6"/>
<path d="M11.55 539.42V416.1C11.55 415.02 12.4 414.13 13.48 414.08C14.63 414.03 15.59 414.95 15.59 416.1V539.42C15.59 540.54 14.69 541.44 13.57 541.44C12.45 541.44 11.55 540.54 11.55 539.42Z" fill="white"/>
<path d="M123.93 231.98C127.22 240.34 134.75 242.94 142.9 240.24C154.4 236.91 161.76 225.18 169.49 216.12C177.79 206.1 185.95 195.02 197.52 188.51C188.13 197.83 182.14 209.25 174.85 220.09C169.83 227.53 165.18 235.33 157.73 240.99C152.88 244.67 146.85 246.9 140.78 247.21C131.84 247.78 123.19 241.46 123.94 231.99L123.93 231.98Z" fill="#0E1016"/>
<path d="M140.5 244.41C139.88 252.6 140.98 261.04 142.12 269.17L138.16 268.34C139.94 265.98 141.69 263.78 143.95 261.83C144.78 261.12 145.89 260.42 146.99 260.2C150.78 259.37 154.13 259.16 157.75 258.19C171.83 255.13 181.28 242.36 181.39 228.24C181.34 221.76 180.13 214.85 176.47 209.44C175.14 207.09 178.29 207.99 179.97 206.88C181.75 205.71 182.15 202.65 183.72 205.08C186.96 212.44 187.48 220.7 186.71 228.6C185.67 238.21 180.89 247.25 173.71 253.65C169.38 257.38 163.98 259.79 158.53 261.19C154.98 262.23 150.86 262.57 147.48 263.64C143.52 266.37 141.19 272.12 138.77 276.15C137.28 265.51 135.64 254.71 136.6 243.95C136.96 241.33 140.78 241.8 140.52 244.41H140.5Z" fill="#0E1016"/>
<path d="M153.41 239.26C153.41 239.26 154.37 243.22 158.19 246.71C162.01 250.2 164.6 251.1 164.6 251.1C164.6 251.1 165.84 245.7 165.39 239.85C164.94 234 163.21 229.92 163.21 229.92C163.21 229.92 160.44 233.46 157.91 235.64C155.38 237.82 153.4 239.27 153.4 239.27L153.41 239.26Z" fill="#0E1016"/>
<path d="M140.77 294.62C144.36 285.43 159.13 284.22 167.6 282.43C190.27 278.44 204.78 263.89 214.21 243.56C214.21 243.51 216.98 245.8 216.99 245.79C210.33 249.47 203.07 252.88 195.35 253.08C198.89 251.63 202.19 250 205.46 248.24C210.09 245.72 214.92 242.65 219.38 239.84C213.39 261.52 197.4 281.72 174.32 285.77C172.35 286.15 170.15 286.43 168.15 286.58C159.95 287.43 145.65 287.08 140.77 294.63V294.62Z" fill="#0E1016"/>
<path d="M201.59 250.35C205.75 243.1 209.71 235.32 211.86 227.21C212.93 223.12 213.61 218.84 214.13 214.54C214.61 210.23 214.94 205.87 215.01 201.47C215.97 205.78 216.45 210.2 216.59 214.64C216.96 223.62 215.55 232.82 211.84 241.03C210.66 243.69 209.37 246.27 207.86 248.75C207.03 250.13 200.99 251.3 201.59 250.35V250.35Z" fill="#0E1016"/>
<path d="M206.56 264.2C206.28 273.72 202.08 282.8 196.03 290.03C189.86 297.04 180.9 301.27 172.41 304.17C168.79 305.62 164.34 306.26 162.12 309.76C160.08 313.3 160.21 317.65 160.09 321.81C160.09 324.74 160.08 327.72 160.23 330.58C161.43 348.08 164.88 365.64 169.69 382.48C170.08 383.78 169.03 383.58 167.72 383.96C166.38 384.36 165.28 385.12 164.96 383.76C163.55 377.94 162.29 372.1 161.21 366.24C158.57 351.58 156.11 336.72 156.44 321.76C156.66 308.69 157.93 304.5 170.77 299.88C181.65 295.59 191.46 290.51 197.03 279.88C199.71 275.05 201.7 269.63 202.04 264.12C202.21 261.24 206.48 261.27 206.56 264.2V264.2Z" fill="#0E1016"/>
<path d="M93.48 234.12C93.73 240.88 87.12 247.03 80.29 247.16C69.72 247.96 62.98 241.65 59.08 232.57C53.73 220 49.86 206.15 39.65 196.52L37.97 194.82C39.9 194.78 42.07 194.65 43.97 194.93C45.31 195.12 46.55 195.35 47.79 196.08L45.87 196.91C47.36 185.87 50.93 175.28 55.66 165.22C56.66 163.22 58.31 167.64 58.09 168.11C57.18 170.08 56.32 172.11 55.47 174.13C52.3 181.59 50.09 189.43 48.33 197.33L47.94 199.09L46.42 198.16C44.68 197.08 42.32 196.9 40.29 196.75L40.92 194.98C46.76 199.2 51.29 205.08 54.89 211.23C58.48 217.39 61.42 223.92 64.44 229.98C67.3 235.49 70.71 240.54 77.29 240.96C78.23 241.08 79.27 241.13 80.25 241.18C85.58 241.41 90.11 238.18 93.47 234.11L93.48 234.12Z" fill="#0E1016"/>
<path d="M94.44 211.75C86.88 193.62 89.17 173.07 95.11 154.82C103.77 127.42 128.74 96.38 148.78 75.82L153.34 71.11C149.83 84.55 146.56 98.2 143.76 111.79C138.7 134.88 137.12 158.17 133.92 181.61C133.04 187.5 131.61 193.46 129.46 199.07C127.38 204.7 124.78 210.21 120.73 214.73C122.24 208.89 124.08 203.42 125.67 197.85C130.18 180.84 130.83 163.34 133.31 145.87C136.22 122.37 141.72 99.26 148.36 76.55L151.28 78.21C142.86 87.6 134.89 97.44 127.32 107.52C118.9 118.9 110.94 130.65 104.76 143.31C100.1 153.14 96.96 163.73 94.79 174.35C92.28 186.77 93.25 199.16 94.44 211.76V211.75Z" fill="#0E1016"/>
<path d="M169.57 136.05C175.94 138.15 182.34 140.66 188.17 144.02C189.66 144.88 191.13 145.81 192.52 146.84C193.01 147.21 192.22 151.49 191.46 150.79C191.16 150.49 190.71 150.13 190.28 149.79C187.3 147.51 184.02 145.67 180.65 144.01C176.88 142.13 172.56 140.42 168.58 139C166.65 138.32 167.62 135.42 169.58 136.04L169.57 136.05Z" fill="#0E1016"/>
<path d="M138.05 129.14C142.39 129.59 146.69 130.3 150.93 131.26C151.74 131.46 151.14 131.96 151.02 132.58C150.9 133.2 150.4 133.8 150.4 133.8C146.23 133 142.02 132.44 137.8 132.15C135.79 131.97 136.04 128.97 138.06 129.14H138.05Z" fill="#0E1016"/>
<path d="M90.91 137.05C96.16 134.38 101.68 132.69 107.38 131.36C109.08 131 109.68 133.52 107.99 133.96C105.12 134.66 102.28 135.49 99.51 136.5C96.75 137.51 93.97 138.65 91.5 140.06C89.8 140.99 90.63 137.2 90.92 137.06L90.91 137.05Z" fill="#0E1016"/>
<path d="M146.15 138.19C151.47 128.09 161.6 120.6 172.75 118.15C177.9 116.88 183.57 116.64 188.88 116.36C183.17 121.2 178.46 127.27 174.25 133.42C167.5 143.28 161.53 153.73 155.97 164.34C153.18 169.69 150.47 175.08 147.92 180.55C149.5 174.69 151.52 168.96 153.82 163.34C158.44 152.07 163.98 141.17 170.87 131.08C174.44 126 178.23 121 183.07 117.02L184.26 120.11C167.82 121.79 157.81 126.3 146.16 138.19H146.15Z" fill="#0E1016"/>
<path d="M91.42 147.31C89.53 138.42 89.26 128.97 91.62 120.13C92.39 117.16 93.35 114.29 94.6 111.43L97.2 113.33C83.34 126.58 73.01 144.8 71.93 164.2C71.01 176.11 74.51 187.14 77.99 198.6C73.14 192.07 70.27 184.26 68.95 176.25C64.5 149.31 79.41 122.62 100.24 106.34C97.16 113.67 94.33 121.71 92.99 129.54C92.04 135.38 91.71 141.34 91.43 147.3L91.42 147.31Z" fill="#0E1016"/>
<path d="M190.68 159.35C191.11 151.76 191.51 144.32 191.76 136.95C192.09 129.32 190.85 121.99 191.95 114.14C192.49 110.07 194.27 105.68 197.32 102.47C199.9 99.78 203.28 97.12 205.94 94.7L222.94 79.99C239.56 65.22 256.8 50.77 269.53 32.39C273.68 26.38 277.65 20 280.85 13.48L286.62 0.899994C284.13 25.59 280.06 50.47 273.55 74.4C272.18 79.34 270.32 85.48 268.73 90.35C262.96 108.04 254.15 124.8 242.93 139.53C238.49 145.47 233.7 151.14 228.61 156.51C223.51 161.85 218.31 167.2 211.86 171.02C216.5 165.26 221.55 160.06 226.32 154.46C241.47 136.43 254.42 116.16 262.13 93.89C263.82 89.16 265.77 83.12 267.11 78.31C273.07 57.27 277.39 35.66 280.69 14.07L284.91 15.21C283.42 18.86 281.73 22.22 279.91 25.6C274.43 35.68 267.81 45.13 260.06 53.61C244.48 70.3 227.35 84.84 210.18 99.63C207.61 101.94 203.26 105.13 201.18 107.64C199.4 109.76 198.39 112.44 197.87 115.16C196.62 122.2 197.51 129.99 196.58 137.31C195.58 144.88 193.91 152.39 190.71 159.35H190.68Z" fill="#0E1016"/>
<path d="M221.09 137.53C229.36 128.97 236.98 120.09 243.03 109.92C249.06 99.77 253.99 88.98 259.16 78.28C254.61 101.76 242.21 125.15 221.09 137.53V137.53Z" fill="#0E1016"/>
<path d="M60.12 171.02C48.94 159.62 39.65 146.97 29.61 134.77C27.41 132.29 26.31 128.5 26.28 125.38C26.2 117.69 28.85 110.66 29.17 103.06C30.06 84.92 27.59 66.47 21.66 49.31C15.83 32.44 8.18 15.87 0 0C37.22 30.27 68.76 66.02 80.62 113.63C81.83 118.44 82.77 123.33 83.24 128.36C83.37 129.78 78.14 134.2 78.2 132.66C78.27 120.6 75 108.38 71.04 96.95C58.85 61.64 33.36 32.66 4.87 9.23L7.86 6.75C18.45 26.52 28.51 47.19 32.63 69.45C34.63 80.66 35.61 92.02 34.98 103.42C34.82 107.17 34.11 111.13 33.38 114.8C32.53 120.39 30.41 126.77 33.95 131.36C39.04 138 43.93 144.83 48.81 151.63C53.07 157.78 57.2 164.07 60.11 171.02H60.12Z" fill="#0E1016"/>
<path d="M49.3 204.17C45.85 221.94 47.62 240.92 54.15 257.81L55.12 260.29L52.63 259.57C48 258.16 44.14 255.02 41.63 250.95L43.89 250.14C46.65 265.15 55.4 277.72 68.88 285.01C74.25 288.2 80.17 291.47 83.47 297.23C84.49 298.94 85.23 300.79 85.76 302.67C86.13 303.99 82.19 308.59 82.01 307.33C81.6 301.55 79.07 296.58 74.39 293.07C69.69 289.4 64.08 286.54 59.43 282.6C49.65 274.58 43.65 262.82 41.46 250.49L40.6 245.07C40.6 245.07 43.73 249.68 43.72 249.68C45.37 252.31 47.72 254.49 50.48 255.91C51.37 256.38 52.4 256.81 53.26 257.04L51.74 258.8C49.96 254.42 48.6 249.95 47.48 245.4C45.2 236.31 44.23 226.86 44.55 217.5C44.72 212.79 45.08 208.17 46.01 203.41C46.19 202.49 47.08 201.89 48 202.07C48.96 202.26 49.56 203.23 49.32 204.17H49.3Z" fill="#0E1016"/>
<path d="M59.37 221.16C56.34 230.59 57.63 245.33 63.41 253.53C66.75 258.09 71.98 260.61 77.47 261.82C80.41 262.53 83.63 262.96 86.41 264.39C88.54 265.46 90.56 267.05 91.96 269.05C92.6 269.96 88.87 277.55 89.11 275.91C89.31 274.59 89.27 273.66 89.1 272.94C88.45 269.98 85.71 267.78 82.92 266.67C77.98 264.98 72.66 264.42 67.97 261.72C63.75 259.51 60.29 255.81 58.09 251.58C53.52 241.65 51.9 230.29 54.11 219.53C54.74 217.29 56.2 218.25 57.6 219.19C58.52 219.81 59.85 219.71 59.36 221.16H59.37Z" fill="#0E1016"/>
<path d="M92.51 238.14C95.67 258.03 92.93 278.23 88.23 297.64C85.85 307.31 83.05 316.88 79.62 326.26C76.97 333.28 74.25 340.58 69.74 346.72C68.15 348.86 66.56 350.76 64.53 352.59C63.75 353.28 63.44 352.45 62.75 351.67C62.03 350.87 61.26 350.39 62.09 349.71C63.74 348.22 65.36 346.32 66.7 344.48C70.98 338.59 73.53 331.77 76.16 324.97C82.82 306.54 88.03 287.37 90.18 267.85C91.1 258.18 91.29 248.2 89.95 238.61C89.63 236.96 92.16 236.36 92.52 238.13L92.51 238.14Z" fill="#0E1016"/>
<path d="M69.41 241.41C69.41 241.41 69.04 245.47 70.17 250C71.39 254.86 75.08 257.82 75.08 257.82C75.08 257.82 78.48 255.21 79.18 251.19C79.94 246.82 79.41 244.18 79.41 244.18C79.41 244.18 74.6 243.8 73.37 243.35C72.14 242.9 69.42 241.41 69.42 241.41H69.41Z" fill="#0E1016"/>
<path d="M69.44 357.21C63.58 353.07 58.04 348.96 50.8 348.76C42.32 348.18 35.19 355.85 35.56 364.14C36.73 376.89 49.58 383.25 60.91 386.77C45.81 388.16 27.03 375.05 32.5 358.18C38.6 339.99 61.79 340.81 69.44 357.2V357.21Z" fill="#0E1016"/>
<path d="M103.71 359.12C109.74 348.5 125.26 337.31 137.58 344.5C142.98 347.63 145.85 354.16 145.29 360.18C144.26 371.87 133.37 380.55 122.59 383.13C131.05 377.83 140.28 370.34 141.5 359.89C142.24 353.34 137.81 347.13 131.2 346.41C127.95 345.91 124.74 346.95 121.7 348.08C115.05 350.55 109.44 354.83 103.71 359.13V359.12Z" fill="#0E1016"/>
<path d="M110.28 376.08C110.28 376.08 117.23 367.29 122.45 363.71C127.67 360.13 130.84 360.74 131.86 362.18C132.88 363.62 132.19 366.86 127.95 367.97C124.04 368.99 120.79 370.16 116.75 372.34C113.05 374.34 110.28 376.09 110.28 376.09V376.08Z" fill="#0E1016"/>
<path d="M61.25 376.88C59.3 373.19 53.13 367.52 48.37 365.59C43.61 363.66 42.31 366.66 42.67 368.17C43.16 370.22 45.72 370.99 50.75 372.5C56.67 374.28 61.24 376.88 61.24 376.88H61.25Z" fill="#0E1016"/>
<path d="M123.93 284.38C123.56 293.38 122.92 306.01 122.98 314.85C123.11 324.86 124.33 334.97 125.54 344.9C125.7 345.99 124.93 347.01 123.84 347.15C122.79 347.29 121.83 346.57 121.64 345.54C120.71 340.44 120.08 335.36 119.73 330.22C118.49 314.86 120.85 299.39 123.93 284.37V284.38Z" fill="#0E1016"/>
<path d="M143.62 341.89C143.21 335.24 143.71 328.69 144.43 322.08C145.36 314.87 146.28 306.7 151.64 301.24C148.35 307.79 147.79 315.12 147.17 322.34C146.69 329.5 146.57 336.89 147.48 343.96C147.54 344.42 147.46 344.81 147.29 345.12C147.29 345.12 143.69 342.73 143.64 341.88L143.62 341.89Z" fill="#0E1016"/>
<path d="M157.18 387.4C161.37 376.69 160.41 364.27 155.43 354.04C152.76 348.62 148.5 343.82 143.07 341.11C142.3 340.74 141.53 340.39 140.77 340.05C141.72 340.9 142.68 341.67 143.63 342.49C149.72 348.33 153.5 356.35 154.61 364.69C155.54 370.84 155.26 377.45 153.47 383.38C150.17 392.77 143.3 400.95 134.89 406.19C136.57 410.07 138.32 413.86 140.55 417.45C141.49 418.94 142.45 420.38 143.42 421.8C138.38 419.3 133.81 415.8 130.06 411.62C129.03 410.49 128.17 409.32 127.39 408.13C116.93 410.53 105 408.18 96.8 401.07C92.3 397.48 88.58 391.49 85.45 386.71L86.68 394.39C86.86 396.15 86.78 398.14 86.5 399.93C85.78 405.13 81.68 409.66 76.49 410.38L74.43 410.67C74.78 412.79 75.17 415.01 75.78 417.04C76.59 419.95 77.93 422.68 79.44 425.31C79.25 425.19 79.05 425.08 78.86 424.97C73.93 422.03 70.84 416.98 68.43 411.94L67.97 410.93L66.76 410.84C49.57 409.02 40.2 394.65 41.74 378.51L40.33 376.55C40.47 374.54 37.54 374.28 37.32 376.29C36.59 387.35 40.11 398.93 48.33 406.58C52.5 410.36 57.65 413.08 63.18 414.18C63.97 414.33 64.71 414.45 65.52 414.53C70.66 424.27 76.07 428.49 86.87 431.76L84.7 428.14C81.93 423.6 79.39 418.87 78.4 413.66C82.36 413.01 85.7 410.61 87.95 407.76C89.51 405.79 90.43 403.89 90.66 401.42C94.32 406.01 98.47 408.14 103.99 410.42C110.82 413.19 118.53 414.3 125.77 412.58C128.46 415.87 131.65 418.54 135.08 421.02C140.16 424.71 146.27 426.81 152.25 428.63C149.36 424.46 146.16 419.94 143.52 415.64C142.02 413.11 140.67 410.4 139.49 407.68C147.49 403.24 153.63 395.79 157.19 387.41L157.18 387.4Z" fill="#0E1016"/>
<path d="M216.62 166.67C229.07 181.17 238.86 197.71 248.05 214.38C306.9 323.51 329.29 447.36 349.07 568.49C350.61 577.83 352.12 587.18 353.87 596.42C354.14 597.76 353.26 599.08 351.91 599.33C350.57 599.58 349.27 598.7 349.02 597.36C347.28 587.98 345.79 578.62 344.29 569.27C325.02 448.49 303.04 325.05 245.19 215.94C238.46 203.5 231.14 191.37 222.92 179.88C220.18 176.1 217.29 172.27 214.21 168.83C213.62 168.17 214.62 168.55 215.29 167.96C215.95 167.37 216.02 166.01 216.62 166.68V166.67Z" fill="#0E1016"/>
<path d="M144.83 596.56C146.93 583.43 148.14 570.11 148.6 556.81C148.71 555.07 150.03 555.65 151.32 555.73C152.61 555.81 153.84 555.39 153.73 557.14C152.58 570.19 150.87 584.27 148.94 597.23C148.47 599.98 144.41 599.34 144.84 596.56H144.83Z" fill="#0E1016"/>
<path d="M216.55 216.68C251.43 271.93 276.5 332.66 298.69 393.91C320.2 455.25 339.67 517.79 350.82 581.93C351 583 350.27 584.02 349.19 584.19C348.12 584.36 347.11 583.64 346.93 582.58C344.21 566.63 340.67 550.81 336.82 535.06C329.14 503.78 319.67 472.3 309.67 441.66C286.67 372.56 259.96 304.27 223.75 240.94C220.71 235.71 217.59 230.47 214.35 225.4C213.26 223.69 215.27 215.52 215.27 215.52C215.73 215.76 216.17 216.14 216.56 216.68H216.55Z" fill="#0E1016"/>
<path d="M251.13 223.4C257.61 221.28 264.73 221.6 271.03 224.16C277.13 226.99 281.46 232.01 284.84 237.64L281.91 238.71C281.36 236.46 280.5 234.1 279.55 231.89C274.67 220.15 265.75 212.05 253.63 208.35C250.07 207.17 246.41 206.87 242.68 206.06C241.08 205.65 236.7 204.18 238.47 201.74C239.08 201.05 240.15 201 240.82 201.63C242.11 202.84 244.57 203.12 247.05 203.54C249.55 203.93 252.13 204.47 254.61 205.25C270.54 210.14 280.82 222.1 285 238.02L287.88 249.76C285.01 244.81 281.1 235.95 276.8 232.12C270.93 225.89 261.02 224.38 253.54 228.36C251.78 229.51 252.59 228.06 251.87 226.81C251.07 225.43 248.94 224.14 251.16 223.38L251.13 223.4Z" fill="#0E1016"/>
<path d="M302.76 349.1C303.87 351.94 305.94 354.64 307.77 357.14C312.85 363.95 318.98 369.75 325.21 375.52C331.43 381.73 336.37 389.19 339.63 397.37C342.27 403.96 343.29 411 343.27 418.13C343.27 418.13 342.85 432.46 342.85 432.47L339.88 418.43C338.68 412.88 336.36 407.24 333.16 402.47C328.3 395.35 321.05 389.65 312.77 387.14C311.86 386.88 310.87 386.64 310.08 386.54C309.02 386.41 308.27 385.44 308.4 384.37C308.54 383.23 309.66 382.46 310.77 382.73C312.99 383.28 314.82 384.02 316.82 384.93C330.23 391.05 339.59 403.58 343.23 417.7L339.84 417.99C339.91 414.77 339.65 411.36 339.12 408.1C337.51 398.28 332.59 389.17 326.11 381.67C323.89 379.1 321.55 376.92 318.96 374.65C313.77 370.01 309 364.96 304.67 359.5C302.76 357.04 300.86 354.53 299.43 351.71C299.21 351.3 299.01 350.76 298.82 350.23C298.44 349.17 299 348 300.06 347.62C301.21 347.2 302.45 347.9 302.74 349.08L302.76 349.1Z" fill="#0E1016"/>
<path d="M339.73 517.08C354.7 529.44 366.38 546.33 369.99 565.6C371.31 570.8 371.51 580.67 371.89 586.1L368.2 574.43C366.1 568.03 363.03 561.56 358.72 556.35C354.45 551.43 348.53 547.29 342.15 545.77C341.02 545.6 340.25 544.53 340.44 543.41C340.63 542.19 341.91 541.41 343.09 541.8C345.04 542.44 346.57 543.21 348.31 544.14C360.2 550.53 366.55 561.19 371.12 573.49L368.13 574.03C367.53 564.87 364.8 555.58 360.54 547.4C356.14 539.19 350.13 531.87 343.25 525.6C341.27 523.81 339.17 522.05 337.09 520.55C334.78 518.79 337.37 515.32 339.73 517.09V517.08Z" fill="#0E1016"/>
<path d="M180.24 328.93C162.69 328.93 157.99 326.88 157.74 326.76C157.11 326.47 156.84 325.73 157.13 325.1C157.42 324.48 158.15 324.2 158.78 324.49C158.94 324.56 167.84 328.14 205.35 325.39C229.46 323.62 258.8 318.91 262.16 311.45C262.44 310.82 263.18 310.54 263.81 310.82C264.44 311.1 264.72 311.84 264.44 312.47C261.16 319.76 239.69 325.38 205.54 327.88C195.05 328.65 186.76 328.93 180.25 328.93H180.24Z" fill="#0E1016"/>
<path d="M260.59 412.59C259.93 412.59 259.38 412.08 259.34 411.42C259.3 410.73 259.82 410.14 260.51 410.09C266.14 409.74 271.67 409.35 276.5 408.95C294.77 407.44 298.49 404.54 298.52 404.51C299.03 404.05 299.82 404.09 300.29 404.61C300.75 405.12 300.71 405.92 300.19 406.38C299.79 406.73 295.77 409.88 276.71 411.45C271.86 411.85 266.32 412.24 260.67 412.59C260.64 412.59 260.62 412.59 260.59 412.59V412.59Z" fill="#0E1016"/>
<path d="M260.52 516.12C259.86 516.12 259.31 515.61 259.27 514.94C259.23 514.25 259.76 513.66 260.45 513.62L262.95 513.47C306.52 510.87 326.49 508.83 329.61 503.61C329.96 503.02 330.73 502.82 331.32 503.18C331.91 503.53 332.11 504.3 331.75 504.89C328.05 511.08 311.36 513.08 263.09 515.96L260.58 516.11C260.58 516.11 260.53 516.11 260.51 516.11L260.52 516.12Z" fill="#0E1016"/>
<path d="M156.09 380.98L247.28 381.31H250.13C255.91 380.75 261.5 384.32 262.26 390.39C262.52 404.5 262.22 427.75 262.29 441.91C262.23 476.43 262.56 512.85 263.01 547.35C263.44 554.44 258.01 559.91 250.89 559.47C250.12 559.49 247.45 559.45 246.62 559.47C177.04 559.32 88.35 559.12 18.65 558.88L12.95 558.86C12.52 558.86 11.9 558.84 11.33 558.79C6.11 558.3 1.84 553.45 2.03 548.21C2.29 510.67 2.34 434.23 2.39 397.18C2.42 393.18 1.81 388.6 4.72 385.34C6.73 382.79 10.29 381.47 13.42 381.7H16.27L39.07 381.68C41 381.71 40.94 384.57 39.07 384.57L16.27 384.55H13.42C12.4 384.55 11.69 384.55 10.89 384.72C7.76 385.37 5.28 388.31 5.24 391.53C4.95 429.96 5.59 506.38 5.57 545.36V548.21C5.46 552.18 8.98 555.51 12.93 555.21C84.84 554.94 174.77 554.79 246.6 554.61H249.45H250.87H251.58H251.93L252.19 554.59C255.4 554.51 258.17 551.63 258.15 548.42C258.18 548.1 258.15 544.99 258.17 544.51C258.81 503.42 258.94 460.25 258.84 419.13L258.81 396.33V393.48V392.06C258.81 391.8 258.8 391.63 258.79 391.44C258.73 389.74 257.95 388.1 256.75 386.9C254.89 385.11 252.7 384.8 250.11 384.94H247.26L156.07 385.27C153.25 385.23 153.22 381.05 156.07 380.99L156.09 380.98Z" fill="#0E1016"/>
<path d="M95.56 466.74C68.17 444 38.65 418.54 11.55 395.26C31.76 411.09 56.6 430.52 76.55 446.44C83.73 452.18 90.93 457.89 98.05 463.71C99.03 464.53 97.86 465.08 97.24 465.84C96.62 466.6 96.58 467.55 95.56 466.74V466.74Z" fill="#0E1016"/>
<path d="M254.54 394.37C234.23 412.87 213.13 430.45 192.02 448C184.98 453.84 177.9 459.64 170.77 465.38C170.02 465.99 169.46 465.54 168.86 464.79C168.26 464.05 167.83 463.29 168.55 462.68C175.56 456.8 182.63 450.99 189.74 445.23C211.08 427.95 232.44 410.68 254.54 394.38V394.37Z" fill="#0E1016"/>
<path d="M173.2 476.48C173.46 504.82 144.19 524.28 118.15 513.22C103.63 507.24 93.55 492.19 93.57 476.48C93.34 448.05 122.41 428.72 148.55 439.84C163.03 445.87 173.24 460.72 173.21 476.48H173.2ZM170.01 476.48C170.11 443.49 130.79 427.67 107.54 450.65C84.52 473.95 100.44 513.03 133.37 513.1C153.11 513.28 170.16 496.22 170.02 476.47L170.01 476.48Z" fill="#0E1016"/>
<path d="M128.71 493.09L107.69 473.05L112.52 467.98L128.44 483.16L171.93 436.98L177.03 441.78L128.71 493.09Z" fill="#0E1016"/>
<path d="M374.54 599.9H123.19C121.53 599.9 120.19 598.56 120.19 596.9C120.19 595.24 121.53 593.9 123.19 593.9H374.54C376.2 593.9 377.54 595.24 377.54 596.9C377.54 598.56 376.2 599.9 374.54 599.9V599.9Z" fill="#ADD63D"/>
<path d="M256.25 316.08C257.98 315.21 258.92 314.05 259.1 312.39C259.28 310.73 258.63 309.3 258.08 308.1C257.53 306.9 259.88 308.05 261.1 310.52C262.32 312.99 262.79 313.82 261.32 314.64C259.85 315.46 256.26 317.07 256.26 317.07V316.07L256.25 316.08Z" fill="#0E1016"/>
<path d="M257.89 317.99C259.39 317.19 260.74 317.3 261.85 318.04C262.77 318.66 263.07 319.21 263.67 320.59C264.27 321.97 263.21 315.68 262.59 314.9C261.97 314.12 261.86 314.24 260.34 314.9C258.82 315.56 257.88 317.98 257.88 317.98L257.89 317.99Z" fill="#0E1016"/>
<path d="M293.86 406.31C294.6 406.1 295.48 405.59 295.86 404.44C296.24 403.29 295.97 401.99 295.71 401.27C295.45 400.55 297.48 401.79 297.97 403.63C298.46 405.47 299.63 406.21 297.84 406.62C296.05 407.03 293.86 406.31 293.86 406.31Z" fill="#0E1016"/>
<path d="M294.19 408.8C295.48 408.44 296.75 408.5 297.5 409.07C298.25 409.64 298.91 410.26 299.34 411.48C299.77 412.7 299.09 409.43 298.72 408.04C298.35 406.65 297.83 406.63 297.83 406.63L293.38 407.91L294.18 408.81L294.19 408.8Z" fill="#0E1016"/>
<path d="M325.22 506.58C326.09 506.23 327.08 505.43 327.63 504.06C328.04 503.01 328.01 501.67 327.63 500.31C327.25 498.95 329.31 502.16 329.63 503.62C329.95 505.08 330.17 504.81 328.8 505.94C327.43 507.07 325.22 506.79 325.22 506.79V506.58V506.58Z" fill="#0E1016"/>
<path d="M325.22 509.22C326.34 508.81 327.83 508.64 328.87 509.22C329.91 509.8 330.44 510.44 330.87 512.04C331.3 513.64 331.5 511.84 330.87 509.24C330.24 506.64 329.86 505.93 329.86 505.93L325.5 507.47L325.21 509.22H325.22Z" fill="#0E1016"/>
<path d="M160.14 319.82C160.36 315.77 164.42 312.72 170.07 309.16C176.37 305.2 187.01 300.42 195.02 291.13C201.23 283.92 181.86 296.89 175.75 299.76C169.64 302.63 164.89 303.62 161.84 307.12C158.79 310.62 159.06 317.26 159.06 317.26L160.14 319.82Z" fill="#0E1016"/>
<path d="M187.37 200.83C185.8 203.13 186.02 210.41 186.48 214.85C186.94 219.29 181.41 215.65 181.41 210.48C181.41 205.31 186.16 200.33 186.28 200.15C186.4 199.97 187.38 200.82 187.38 200.82L187.37 200.83Z" fill="#0E1016"/>
<path d="M116.76 345.98C118.33 345.1 120.96 342.41 120.49 338.1C120.02 333.79 123.03 338.28 123.13 341.07C123.23 343.86 125.83 343.67 122.15 345.33C118.47 346.99 116.77 345.98 116.77 345.98H116.76Z" fill="#0E1016"/>
<path d="M124.29 334.25C124.47 335.91 124.89 338.7 125.97 340.06C127.05 341.42 128.18 342.04 130.04 342.24C131.9 342.44 124.66 344.93 123.95 343.42C123.24 341.91 123.03 335.78 123.03 335.78L124.29 334.26V334.25Z" fill="#0E1016"/>
<path d="M59.38 278.35C62.08 280.6 66.34 283.52 72.05 284.99C77.76 286.46 81.26 287.84 81.26 287.84C81.26 287.84 77.43 287.27 73.6 287.02C73.03 286.98 72.6 287.24 73.41 287.75C75.25 288.89 70.34 287.51 66.18 285.15C61.29 282.38 59.38 278.35 59.38 278.35V278.35Z" fill="#0E1016"/>
</g>
<defs>
<clipPath id="clip0_118_2691">
<rect width="377.54" height="599.9" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -0,0 +1,8 @@
{
"event": {
"readSecrets": "Secrets Viewed",
"updateSecrets": "Secrets Updated",
"addSecrets": "Secrets Added",
"deleteSecrets": "Secrets Deleted"
}
}

View File

@ -1238,6 +1238,7 @@ module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
"./ee/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
@ -1382,12 +1383,12 @@ module.exports = {
"0%": {
transform: "scale(0.2)",
opacity: 0,
transform: "translateY(120%)",
// transform: "translateY(120%)",
},
"100%": {
transform: "scale(1)",
opacity: 1,
transform: "translateY(100%)",
// transform: "translateY(100%)",
},
},
popright: {
@ -1410,12 +1411,12 @@ module.exports = {
"0%": {
transform: "scale(0.2)",
opacity: 0,
transform: "translateY(80%)",
// transform: "translateY(80%)",
},
"100%": {
transform: "scale(1)",
opacity: 1,
transform: "translateY(100%)",
// transform: "translateY(100%)",
},
},
},