mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge branch 'main' of github.com:atimapreandrew/infisical
This commit is contained in:
@@ -1,72 +0,0 @@
|
|||||||
import { Request, Response } from "express";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import bcrypt from "bcrypt";
|
|
||||||
import { APIKeyData } from "../../models";
|
|
||||||
import { getSaltRounds } from "../../config";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return API key data for user with id [req.user_id]
|
|
||||||
* @param req
|
|
||||||
* @param res
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const getAPIKeyData = async (req: Request, res: Response) => {
|
|
||||||
const apiKeyData = await APIKeyData.find({
|
|
||||||
user: req.user._id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).send({
|
|
||||||
apiKeyData,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new API key data for user with id [req.user._id]
|
|
||||||
* @param req
|
|
||||||
* @param res
|
|
||||||
*/
|
|
||||||
export const createAPIKeyData = async (req: Request, res: Response) => {
|
|
||||||
const { name, expiresIn } = req.body;
|
|
||||||
|
|
||||||
const secret = crypto.randomBytes(16).toString("hex");
|
|
||||||
const secretHash = await bcrypt.hash(secret, await getSaltRounds());
|
|
||||||
|
|
||||||
const expiresAt = new Date();
|
|
||||||
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
|
|
||||||
|
|
||||||
let apiKeyData = await new APIKeyData({
|
|
||||||
name,
|
|
||||||
lastUsed: new Date(),
|
|
||||||
expiresAt,
|
|
||||||
user: req.user._id,
|
|
||||||
secretHash,
|
|
||||||
}).save();
|
|
||||||
|
|
||||||
// return api key data without sensitive data
|
|
||||||
// FIX: fix this any
|
|
||||||
apiKeyData = (await APIKeyData.findById(apiKeyData._id)) as any;
|
|
||||||
|
|
||||||
if (!apiKeyData) throw new Error("Failed to find API key data");
|
|
||||||
|
|
||||||
const apiKey = `ak.${apiKeyData._id.toString()}.${secret}`;
|
|
||||||
|
|
||||||
return res.status(200).send({
|
|
||||||
apiKey,
|
|
||||||
apiKeyData,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete API key data with id [apiKeyDataId].
|
|
||||||
* @param req
|
|
||||||
* @param res
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const deleteAPIKeyData = async (req: Request, res: Response) => {
|
|
||||||
const { apiKeyDataId } = req.params;
|
|
||||||
const apiKeyData = await APIKeyData.findByIdAndDelete(apiKeyDataId);
|
|
||||||
|
|
||||||
return res.status(200).send({
|
|
||||||
apiKeyData,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -4,7 +4,6 @@ import * as usersController from "./usersController";
|
|||||||
import * as organizationsController from "./organizationsController";
|
import * as organizationsController from "./organizationsController";
|
||||||
import * as workspaceController from "./workspaceController";
|
import * as workspaceController from "./workspaceController";
|
||||||
import * as serviceTokenDataController from "./serviceTokenDataController";
|
import * as serviceTokenDataController from "./serviceTokenDataController";
|
||||||
import * as apiKeyDataController from "./apiKeyDataController";
|
|
||||||
import * as secretController from "./secretController";
|
import * as secretController from "./secretController";
|
||||||
import * as secretsController from "./secretsController";
|
import * as secretsController from "./secretsController";
|
||||||
import * as serviceAccountsController from "./serviceAccountsController";
|
import * as serviceAccountsController from "./serviceAccountsController";
|
||||||
@@ -18,7 +17,6 @@ export {
|
|||||||
organizationsController,
|
organizationsController,
|
||||||
workspaceController,
|
workspaceController,
|
||||||
serviceTokenDataController,
|
serviceTokenDataController,
|
||||||
apiKeyDataController,
|
|
||||||
secretController,
|
secretController,
|
||||||
secretsController,
|
secretsController,
|
||||||
serviceAccountsController,
|
serviceAccountsController,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const createWorkspaceTag = async (req: Request, res: Response) => {
|
|||||||
user: new Types.ObjectId(req.user._id),
|
user: new Types.ObjectId(req.user._id),
|
||||||
};
|
};
|
||||||
|
|
||||||
const createdTag = await new Tag(tagToCreate);
|
const createdTag = await new Tag(tagToCreate).save();
|
||||||
|
|
||||||
res.json(createdTag);
|
res.json(createdTag);
|
||||||
};
|
};
|
||||||
@@ -48,7 +48,11 @@ export const deleteWorkspaceTag = async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
export const getWorkspaceTags = async (req: Request, res: Response) => {
|
export const getWorkspaceTags = async (req: Request, res: Response) => {
|
||||||
const { workspaceId } = req.params;
|
const { workspaceId } = req.params;
|
||||||
const workspaceTags = await Tag.find({ workspace: workspaceId });
|
|
||||||
|
const workspaceTags = await Tag.find({
|
||||||
|
workspace: new Types.ObjectId(workspaceId)
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
workspaceTags
|
workspaceTags
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
|
import { Types } from "mongoose";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
import {
|
import {
|
||||||
MembershipOrg,
|
MembershipOrg,
|
||||||
User,
|
User,
|
||||||
|
APIKeyData,
|
||||||
|
TokenVersion
|
||||||
} from "../../models";
|
} from "../../models";
|
||||||
|
import { getSaltRounds } from "../../config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the current user.
|
* Return the current user.
|
||||||
@@ -117,3 +123,106 @@ export const getMyOrganizations = async (req: Request, res: Response) => {
|
|||||||
organizations,
|
organizations,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return API keys belonging to current user.
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getMyAPIKeys = async (req: Request, res: Response) => {
|
||||||
|
const apiKeyData = await APIKeyData.find({
|
||||||
|
user: req.user._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(apiKeyData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new API key for current user.
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const createAPIKey = async (req: Request, res: Response) => {
|
||||||
|
const { name, expiresIn } = req.body;
|
||||||
|
|
||||||
|
const secret = crypto.randomBytes(16).toString("hex");
|
||||||
|
const secretHash = await bcrypt.hash(secret, await getSaltRounds());
|
||||||
|
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
|
||||||
|
|
||||||
|
let apiKeyData = await new APIKeyData({
|
||||||
|
name,
|
||||||
|
lastUsed: new Date(),
|
||||||
|
expiresAt,
|
||||||
|
user: req.user._id,
|
||||||
|
secretHash,
|
||||||
|
}).save();
|
||||||
|
|
||||||
|
// return api key data without sensitive data
|
||||||
|
apiKeyData = (await APIKeyData.findById(apiKeyData._id)) as any;
|
||||||
|
|
||||||
|
if (!apiKeyData) throw new Error("Failed to find API key data");
|
||||||
|
|
||||||
|
const apiKey = `ak.${apiKeyData._id.toString()}.${secret}`;
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
apiKey,
|
||||||
|
apiKeyData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete API key with id [apiKeyDataId] belonging to current user
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
*/
|
||||||
|
export const deleteAPIKey = async (req: Request, res: Response) => {
|
||||||
|
const { apiKeyDataId } = req.params;
|
||||||
|
|
||||||
|
const apiKeyData = await APIKeyData.findOneAndDelete({
|
||||||
|
_id: new Types.ObjectId(apiKeyDataId),
|
||||||
|
user: req.user._id
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
apiKeyData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return active sessions (TokenVersion) belonging to user
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getMySessions = async (req: Request, res: Response) => {
|
||||||
|
const tokenVersions = await TokenVersion.find({
|
||||||
|
user: req.user._id
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(tokenVersions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all active sessions belong to user
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const deleteMySessions = async (req: Request, res: Response) => {
|
||||||
|
await TokenVersion.updateMany({
|
||||||
|
user: req.user._id,
|
||||||
|
}, {
|
||||||
|
$inc: {
|
||||||
|
refreshVersion: 1,
|
||||||
|
accessVersion: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
message: "Successfully revoked all sessions"
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -54,23 +54,20 @@ export const getSecretVersions = async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
const { secretId, workspaceId, environment, folderId } = req.params;
|
const { secretId } = req.params;
|
||||||
|
|
||||||
const offset: number = parseInt(req.query.offset as string);
|
const offset: number = parseInt(req.query.offset as string);
|
||||||
const limit: number = parseInt(req.query.limit as string);
|
const limit: number = parseInt(req.query.limit as string);
|
||||||
|
|
||||||
const secretVersions = await SecretVersion.find({
|
const secretVersions = await SecretVersion.find({
|
||||||
secret: secretId,
|
secret: secretId
|
||||||
workspace: workspaceId,
|
|
||||||
environment,
|
|
||||||
folder: folderId,
|
|
||||||
})
|
})
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.skip(offset)
|
.skip(offset)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
secretVersions,
|
secretVersions
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,7 +132,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
|||||||
// validate secret version
|
// validate secret version
|
||||||
const oldSecretVersion = await SecretVersion.findOne({
|
const oldSecretVersion = await SecretVersion.findOne({
|
||||||
secret: secretId,
|
secret: secretId,
|
||||||
version,
|
version
|
||||||
}).select("+secretBlindIndex");
|
}).select("+secretBlindIndex");
|
||||||
|
|
||||||
if (!oldSecretVersion) throw new Error("Failed to find secret version");
|
if (!oldSecretVersion) throw new Error("Failed to find secret version");
|
||||||
@@ -154,7 +151,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
|||||||
secretValueTag,
|
secretValueTag,
|
||||||
algorithm,
|
algorithm,
|
||||||
folder,
|
folder,
|
||||||
keyEncoding,
|
keyEncoding
|
||||||
} = oldSecretVersion;
|
} = oldSecretVersion;
|
||||||
|
|
||||||
// update secret
|
// update secret
|
||||||
@@ -162,7 +159,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
|||||||
secretId,
|
secretId,
|
||||||
{
|
{
|
||||||
$inc: {
|
$inc: {
|
||||||
version: 1,
|
version: 1
|
||||||
},
|
},
|
||||||
workspace,
|
workspace,
|
||||||
type,
|
type,
|
||||||
@@ -177,10 +174,10 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
|||||||
secretValueTag,
|
secretValueTag,
|
||||||
folderId: folder,
|
folderId: folder,
|
||||||
algorithm,
|
algorithm,
|
||||||
keyEncoding,
|
keyEncoding
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
new: true,
|
new: true
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -204,17 +201,17 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => {
|
|||||||
secretValueTag,
|
secretValueTag,
|
||||||
folder,
|
folder,
|
||||||
algorithm,
|
algorithm,
|
||||||
keyEncoding,
|
keyEncoding
|
||||||
}).save();
|
}).save();
|
||||||
|
|
||||||
// take secret snapshot
|
// take secret snapshot
|
||||||
await EESecretService.takeSecretSnapshot({
|
await EESecretService.takeSecretSnapshot({
|
||||||
workspaceId: secret.workspace,
|
workspaceId: secret.workspace,
|
||||||
environment,
|
environment,
|
||||||
folderId: folder,
|
folderId: folder
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
secret,
|
secret
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ import {
|
|||||||
secrets as v2SecretsRouter,
|
secrets as v2SecretsRouter,
|
||||||
serviceTokenData as v2ServiceTokenDataRouter,
|
serviceTokenData as v2ServiceTokenDataRouter,
|
||||||
serviceAccounts as v2ServiceAccountsRouter,
|
serviceAccounts as v2ServiceAccountsRouter,
|
||||||
apiKeyData as v2APIKeyDataRouter,
|
|
||||||
environment as v2EnvironmentRouter,
|
environment as v2EnvironmentRouter,
|
||||||
tags as v2TagsRouter,
|
tags as v2TagsRouter,
|
||||||
} from "./routes/v2";
|
} from "./routes/v2";
|
||||||
@@ -138,7 +137,6 @@ const main = async () => {
|
|||||||
app.use("/api/v2/secrets", v2SecretsRouter); // note: in the process of moving to v3/secrets
|
app.use("/api/v2/secrets", v2SecretsRouter); // note: in the process of moving to v3/secrets
|
||||||
app.use("/api/v2/service-token", v2ServiceTokenDataRouter);
|
app.use("/api/v2/service-token", v2ServiceTokenDataRouter);
|
||||||
app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new
|
app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new
|
||||||
app.use("/api/v2/api-key", v2APIKeyDataRouter);
|
|
||||||
|
|
||||||
// v3 routes (experimental)
|
// v3 routes (experimental)
|
||||||
app.use("/api/v3/auth", v3AuthRouter);
|
app.use("/api/v3/auth", v3AuthRouter);
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
const router = express.Router();
|
|
||||||
import { body, param } from "express-validator";
|
|
||||||
import {
|
|
||||||
requireAuth,
|
|
||||||
validateRequest,
|
|
||||||
} from "../../middleware";
|
|
||||||
import { apiKeyDataController } from "../../controllers/v2";
|
|
||||||
import {
|
|
||||||
AUTH_MODE_JWT,
|
|
||||||
} from "../../variables";
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
"/",
|
|
||||||
requireAuth({
|
|
||||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
|
||||||
}),
|
|
||||||
apiKeyDataController.getAPIKeyData
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/",
|
|
||||||
requireAuth({
|
|
||||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
|
||||||
}),
|
|
||||||
body("name").exists().trim(),
|
|
||||||
body("expiresIn"), // measured in ms
|
|
||||||
validateRequest,
|
|
||||||
apiKeyDataController.createAPIKeyData
|
|
||||||
);
|
|
||||||
|
|
||||||
router.delete(
|
|
||||||
"/:apiKeyDataId",
|
|
||||||
requireAuth({
|
|
||||||
acceptedAuthModes: [AUTH_MODE_JWT],
|
|
||||||
}),
|
|
||||||
param("apiKeyDataId").exists().trim(),
|
|
||||||
validateRequest,
|
|
||||||
apiKeyDataController.deleteAPIKeyData
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -7,7 +7,6 @@ import secret from "./secret"; // deprecated
|
|||||||
import secrets from "./secrets";
|
import secrets from "./secrets";
|
||||||
import serviceTokenData from "./serviceTokenData";
|
import serviceTokenData from "./serviceTokenData";
|
||||||
import serviceAccounts from "./serviceAccounts";
|
import serviceAccounts from "./serviceAccounts";
|
||||||
import apiKeyData from "./apiKeyData";
|
|
||||||
import environment from "./environment"
|
import environment from "./environment"
|
||||||
import tags from "./tags"
|
import tags from "./tags"
|
||||||
|
|
||||||
@@ -21,7 +20,6 @@ export {
|
|||||||
secrets,
|
secrets,
|
||||||
serviceTokenData,
|
serviceTokenData,
|
||||||
serviceAccounts,
|
serviceAccounts,
|
||||||
apiKeyData,
|
|
||||||
environment,
|
environment,
|
||||||
tags,
|
tags,
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
requireAuth,
|
requireAuth,
|
||||||
validateRequest,
|
validateRequest,
|
||||||
} from "../../middleware";
|
} from "../../middleware";
|
||||||
import { body } from "express-validator";
|
import { body, param } from "express-validator";
|
||||||
import { usersController } from "../../controllers/v2";
|
import { usersController } from "../../controllers/v2";
|
||||||
import {
|
import {
|
||||||
AUTH_MODE_API_KEY,
|
AUTH_MODE_API_KEY,
|
||||||
@@ -37,4 +37,49 @@ router.get(
|
|||||||
usersController.getMyOrganizations
|
usersController.getMyOrganizations
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/me/api-keys",
|
||||||
|
requireAuth({
|
||||||
|
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||||
|
}),
|
||||||
|
usersController.getMyAPIKeys
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/me/api-keys",
|
||||||
|
requireAuth({
|
||||||
|
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||||
|
}),
|
||||||
|
body("name").exists().isString().trim(),
|
||||||
|
body("expiresIn").isNumeric(),
|
||||||
|
validateRequest,
|
||||||
|
usersController.createAPIKey
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
"/me/api-keys/:apiKeyDataId",
|
||||||
|
requireAuth({
|
||||||
|
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||||
|
}),
|
||||||
|
param("apiKeyDataId").exists().trim(),
|
||||||
|
validateRequest,
|
||||||
|
usersController.deleteAPIKey
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get( // new
|
||||||
|
"/me/sessions",
|
||||||
|
requireAuth({
|
||||||
|
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||||
|
}),
|
||||||
|
usersController.getMySessions
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete( // new
|
||||||
|
"/me/sessions",
|
||||||
|
requireAuth({
|
||||||
|
acceptedAuthModes: [AUTH_MODE_JWT],
|
||||||
|
}),
|
||||||
|
usersController.deleteMySessions
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -50,6 +50,7 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
# The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used
|
# The host that should be used to pull secrets from. If left empty, the value specified in Global configuration will be used
|
||||||
hostAPI: https://app.infisical.com/api
|
hostAPI: https://app.infisical.com/api
|
||||||
|
resyncInterval: 60 # <-- the time in seconds between secret re-sync. Faster re-syncs will require higher rate limits
|
||||||
authentication:
|
authentication:
|
||||||
serviceToken:
|
serviceToken:
|
||||||
serviceTokenSecretReference:
|
serviceTokenSecretReference:
|
||||||
@@ -79,6 +80,11 @@ spec:
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="resyncInterval">
|
||||||
|
This property defines the time in seconds between each secret re-sync from Infisical. Shorter time between re-syncs will require higher rate limits only available on paid plans.
|
||||||
|
Default re-sync interval is every 1 minute.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="authentication">
|
<Accordion title="authentication">
|
||||||
The `authentication` property tells the operator where it should look to find credentials needed to fetch secrets from Infisical.
|
The `authentication` property tells the operator where it should look to find credentials needed to fetch secrets from Infisical.
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
import { Fragment, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
|
|
||||||
import addAPIKey from "@app/pages/api/apiKey/addAPIKey";
|
|
||||||
|
|
||||||
import Button from "../buttons/Button";
|
|
||||||
import InputField from "../InputField";
|
|
||||||
import ListBox from "../Listbox";
|
|
||||||
|
|
||||||
const expiryMapping = {
|
|
||||||
"1 day": 86400,
|
|
||||||
"7 days": 604800,
|
|
||||||
"1 month": 2592000,
|
|
||||||
"6 months": 15552000,
|
|
||||||
"12 months": 31104000
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
closeModal: () => void;
|
|
||||||
// TODO: These never and any will be filled by single folder that contains types and hooks about the API
|
|
||||||
apiKeys: any[];
|
|
||||||
setApiKeys: (arg: any[]) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: convert to TS
|
|
||||||
const AddApiKeyDialog = ({ isOpen, closeModal, apiKeys, setApiKeys }: Props) => {
|
|
||||||
const [apiKey, setApiKey] = useState("");
|
|
||||||
const [apiKeyName, setApiKeyName] = useState("");
|
|
||||||
const [apiKeyExpiresIn, setApiKeyExpiresIn] = useState("1 day");
|
|
||||||
const [apiKeyCopied, setApiKeyCopied] = useState(false);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const generateAPIKey = async () => {
|
|
||||||
const newApiKey = await addAPIKey({
|
|
||||||
name: apiKeyName,
|
|
||||||
expiresIn: expiryMapping[apiKeyExpiresIn as keyof typeof expiryMapping]
|
|
||||||
});
|
|
||||||
|
|
||||||
setApiKeys([...apiKeys, newApiKey.apiKeyData]);
|
|
||||||
setApiKey(newApiKey.apiKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
function copyToClipboard() {
|
|
||||||
// Get the text field
|
|
||||||
const copyText = document.getElementById("apiKey") as HTMLInputElement;
|
|
||||||
|
|
||||||
// Select the text field
|
|
||||||
copyText.select();
|
|
||||||
copyText.setSelectionRange(0, 99999); // For mobile devices
|
|
||||||
|
|
||||||
// Copy the text inside the text field
|
|
||||||
navigator.clipboard.writeText(copyText.value);
|
|
||||||
|
|
||||||
setApiKeyCopied(true);
|
|
||||||
setTimeout(() => setApiKeyCopied(false), 2000);
|
|
||||||
// Alert the copied text
|
|
||||||
// alert("Copied the text: " + copyText.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeAddApiKeyModal = () => {
|
|
||||||
closeModal();
|
|
||||||
setApiKeyName("");
|
|
||||||
setApiKey("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="z-50">
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative" onClose={closeModal}>
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-bunker-700 bg-opacity-80" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
|
||||||
<Transition.Child
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 scale-100"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
{apiKey === "" ? (
|
|
||||||
<Dialog.Panel className="w-full max-w-md transform rounded-md border border-gray-700 bg-bunker-800 p-6 text-left align-middle shadow-xl transition-all">
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="z-50 text-lg font-medium leading-6 text-gray-400"
|
|
||||||
>
|
|
||||||
{t("section.api-key.add-dialog.title")}
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-2 mb-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{t("section.api-key.add-dialog.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-2 max-h-28">
|
|
||||||
<InputField
|
|
||||||
label={t("section.api-key.add-dialog.name")}
|
|
||||||
onChangeHandler={setApiKeyName}
|
|
||||||
type="varName"
|
|
||||||
value={apiKeyName}
|
|
||||||
placeholder=""
|
|
||||||
isRequired
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-28">
|
|
||||||
<ListBox
|
|
||||||
isSelected={apiKeyExpiresIn}
|
|
||||||
onChange={setApiKeyExpiresIn}
|
|
||||||
data={["1 day", "7 days", "1 month", "6 months", "12 months"]}
|
|
||||||
isFull
|
|
||||||
text={`${t("common.expired-in")}: `}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-max">
|
|
||||||
<div className="mt-6 flex w-max flex-col justify-start">
|
|
||||||
<Button
|
|
||||||
onButtonPressed={() => generateAPIKey()}
|
|
||||||
color="mineshaft"
|
|
||||||
text={t("section.api-key.add-dialog.add") as string}
|
|
||||||
textDisabled={t("section.api-key.add-dialog.add") as string}
|
|
||||||
size="md"
|
|
||||||
active={apiKeyName !== ""}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
) : (
|
|
||||||
<Dialog.Panel className="w-full max-w-md transform rounded-md border border-gray-700 bg-bunker-800 p-6 text-left align-middle shadow-xl transition-all">
|
|
||||||
<Dialog.Title
|
|
||||||
as="h3"
|
|
||||||
className="z-50 text-lg font-medium leading-6 text-gray-400"
|
|
||||||
>
|
|
||||||
{t("section.api-key.add-dialog.copy-service-token")}
|
|
||||||
</Dialog.Title>
|
|
||||||
<div className="mt-2 mb-4">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{t("section.api-key.add-dialog.copy-service-token-description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="mt-2 mr-2 flex h-20 w-full items-center justify-end rounded-md bg-white/[0.07] text-base text-gray-400">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={apiKey}
|
|
||||||
disabled
|
|
||||||
id="apiKey"
|
|
||||||
className="invisible w-full min-w-full bg-white/0 py-2 px-2 text-gray-400 outline-none"
|
|
||||||
/>
|
|
||||||
<div className="w-full max-w-md break-words bg-white/0 py-2 pl-14 pr-2 text-sm text-gray-400 outline-none">
|
|
||||||
{apiKey}
|
|
||||||
</div>
|
|
||||||
<div className="group relative inline-block h-full font-normal text-gray-400 underline duration-200 hover:text-primary">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
className="h-full border-l border-white/20 py-2 pl-3.5 pr-4 duration-200 hover:bg-white/[0.12]"
|
|
||||||
>
|
|
||||||
{apiKeyCopied ? (
|
|
||||||
<FontAwesomeIcon icon={faCheck} className="pr-0.5" />
|
|
||||||
) : (
|
|
||||||
<FontAwesomeIcon icon={faCopy} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-chicago-900 px-3 py-2 text-center text-sm text-gray-400 duration-300 group-hover:flex group-hover:animate-popup">
|
|
||||||
{t("common.click-to-copy")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex w-max flex-col justify-start">
|
|
||||||
<Button
|
|
||||||
onButtonPressed={() => closeAddApiKeyModal()}
|
|
||||||
color="mineshaft"
|
|
||||||
text="Close"
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Dialog.Panel>
|
|
||||||
)}
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddApiKeyDialog;
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { faX } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
|
||||||
|
|
||||||
import deleteAPIKey from "../../../pages/api/apiKey/deleteAPIKey";
|
|
||||||
import guidGenerator from "../../utilities/randomId";
|
|
||||||
import Button from "../buttons/Button";
|
|
||||||
|
|
||||||
interface TokenProps {
|
|
||||||
_id: string;
|
|
||||||
name: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceTokensProps {
|
|
||||||
data: TokenProps[];
|
|
||||||
setApiKeys: (value: TokenProps[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the component that we utilize for the api key table
|
|
||||||
* @param {object} obj
|
|
||||||
* @param {any[]} obj.data - current state of the api key table
|
|
||||||
* @param {function} obj.setApiKeys - updating the state of the api key table
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const ApiKeyTable = ({ data, setApiKeys }: 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" />
|
|
||||||
<table className="w-full my-1">
|
|
||||||
<thead className="text-bunker-300 text-sm font-light">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left pl-6 pt-2.5 pb-2">API KEY NAME</th>
|
|
||||||
<th className="text-left pl-6 pt-2.5 pb-2">VALID UNTIL</th>
|
|
||||||
<th aria-label="button" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data?.length > 0 ? (
|
|
||||||
data?.map((row) => (
|
|
||||||
<tr
|
|
||||||
key={guidGenerator()}
|
|
||||||
className="bg-bunker-800 hover:bg-bunker-800/5 duration-100"
|
|
||||||
>
|
|
||||||
<td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300">
|
|
||||||
{row.name}
|
|
||||||
</td>
|
|
||||||
<td className="pl-6 py-2 border-mineshaft-700 border-t text-gray-300">
|
|
||||||
{new Date(row.expiresAt).toUTCString()}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 border-mineshaft-700 border-t">
|
|
||||||
<div className="opacity-50 hover:opacity-100 duration-200 flex items-center">
|
|
||||||
<Button
|
|
||||||
onButtonPressed={() => {
|
|
||||||
deleteAPIKey({ apiKeyId: row._id });
|
|
||||||
setApiKeys(data.filter((token) => token._id !== row._id));
|
|
||||||
createNotification({
|
|
||||||
text: `'${row.name}' API key has been revoked.`,
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
color="red"
|
|
||||||
size="icon-sm"
|
|
||||||
icon={faX}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="text-center pt-7 pb-5 text-bunker-300 text-sm">
|
|
||||||
No API keys yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ApiKeyTable;
|
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from "@fortawesome/free-solid-svg-icons";
|
import { faEye, faEyeSlash, faPenToSquare, faPlus, faX } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { plans } from "public/data/frequentConstants";
|
|
||||||
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import { Select, SelectItem } from "@app/components/v2";
|
import { Select, SelectItem } from "@app/components/v2";
|
||||||
|
import { useSubscription } from "@app/context";
|
||||||
import updateUserProjectPermission from "@app/ee/api/memberships/UpdateUserProjectPermission";
|
import updateUserProjectPermission from "@app/ee/api/memberships/UpdateUserProjectPermission";
|
||||||
import getOrganizationSubscriptions from "@app/pages/api/organization/GetOrgSubscription";
|
|
||||||
import changeUserRoleInWorkspace from "@app/pages/api/workspace/changeUserRoleInWorkspace";
|
import changeUserRoleInWorkspace from "@app/pages/api/workspace/changeUserRoleInWorkspace";
|
||||||
import deleteUserFromWorkspace from "@app/pages/api/workspace/deleteUserFromWorkspace";
|
import deleteUserFromWorkspace from "@app/pages/api/workspace/deleteUserFromWorkspace";
|
||||||
import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey";
|
import getLatestFileKey from "@app/pages/api/workspace/getLatestFileKey";
|
||||||
@@ -40,13 +39,12 @@ type EnvironmentProps = {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoading }: Props) => {
|
const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoading }: Props) => {
|
||||||
|
const { subscription } = useSubscription();
|
||||||
const [roleSelected, setRoleSelected] = useState(
|
const [roleSelected, setRoleSelected] = useState(
|
||||||
Array(userData?.length).fill(userData.map((user) => user.role))
|
Array(userData?.length).fill(userData.map((user) => user.role))
|
||||||
);
|
);
|
||||||
const host = window.location.origin;
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [myRole, setMyRole] = useState("member");
|
const [myRole, setMyRole] = useState("member");
|
||||||
const [currentPlan, setCurrentPlan] = useState("");
|
|
||||||
const [workspaceEnvs, setWorkspaceEnvs] = useState<EnvironmentProps[]>([]);
|
const [workspaceEnvs, setWorkspaceEnvs] = useState<EnvironmentProps[]>([]);
|
||||||
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
||||||
const { createNotification } = useNotificationContext();
|
const { createNotification } = useNotificationContext();
|
||||||
@@ -128,7 +126,7 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
|
|||||||
denials = [];
|
denials = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentPlan !== plans.professional && host === "https://app.infisical.com" && workspaceId !== "63ea8121b6e2b0543ba79616") {
|
if (subscription?.rbac === false) {
|
||||||
setIsUpgradeModalOpen(true);
|
setIsUpgradeModalOpen(true);
|
||||||
} else {
|
} else {
|
||||||
const allDenials = userData[index].deniedPermissions
|
const allDenials = userData[index].deniedPermissions
|
||||||
@@ -167,14 +165,6 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
|
|||||||
(async () => {
|
(async () => {
|
||||||
const result = await getProjectInfo({ projectId: workspaceId });
|
const result = await getProjectInfo({ projectId: workspaceId });
|
||||||
setWorkspaceEnvs(result.environments);
|
setWorkspaceEnvs(result.environments);
|
||||||
|
|
||||||
const orgId = localStorage.getItem("orgData.id") as string;
|
|
||||||
const subscriptions = await getOrganizationSubscriptions({
|
|
||||||
orgId
|
|
||||||
});
|
|
||||||
if (subscriptions) {
|
|
||||||
setCurrentPlan(subscriptions.data[0].plan.product);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, [userData, myUser]);
|
}, [userData, myUser]);
|
||||||
|
|
||||||
@@ -208,11 +198,13 @@ const ProjectUsersTable = ({ userData, changeData, myUser, filter, isUserListLoa
|
|||||||
return (
|
return (
|
||||||
<div className="table-container relative mb-6 mt-1 min-w-max rounded-md border border-mineshaft-600 bg-bunker">
|
<div className="table-container relative mb-6 mt-1 min-w-max rounded-md border border-mineshaft-600 bg-bunker">
|
||||||
<div className="absolute h-[3.1rem] w-full rounded-t-md bg-white/5" />
|
<div className="absolute h-[3.1rem] w-full rounded-t-md bg-white/5" />
|
||||||
<UpgradePlanModal
|
{subscription && (
|
||||||
isOpen={isUpgradeModalOpen}
|
<UpgradePlanModal
|
||||||
onClose={closeUpgradeModal}
|
isOpen={isUpgradeModalOpen}
|
||||||
text="You can change user permissions if you switch to Infisical's Professional plan."
|
onClose={closeUpgradeModal}
|
||||||
/>
|
text={subscription.slug === null ? "You can use RBAC under an Enterprise license" : "You can use RBAC if you switch to Infisical's Team Plan."}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<table className="my-0.5 w-full">
|
<table className="my-0.5 w-full">
|
||||||
<thead className="text-xs font-light text-gray-400 bg-mineshaft-800">
|
<thead className="text-xs font-light text-gray-400 bg-mineshaft-800">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import StripeRedirect from "@app/pages/api/organization/StripeRedirect";
|
|
||||||
|
|
||||||
import { tempLocalStorage } from "../utilities/checks/tempLocalStorage";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
plan: {
|
|
||||||
name: string;
|
|
||||||
price: string;
|
|
||||||
priceExplanation?: string;
|
|
||||||
text: string;
|
|
||||||
subtext?: string;
|
|
||||||
buttonTextMain: string;
|
|
||||||
buttonTextSecondary: string;
|
|
||||||
current: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Plan({ plan }: Props) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative flex flex-col justify-between border min-w-fit w-96 rounded-lg h-68 mr-4 bg-mineshaft-800 ${
|
|
||||||
plan.name !== "Starter" && plan.current === true ? "border-2 border-primary" : "border-mineshaft-600"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex flex-row justify-between items-center relative z-10">
|
|
||||||
<p className="px-6 py-4 text-3xl font-semibold text-gray-400">{plan.name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flwx-row items-end justify-start mb-4">
|
|
||||||
<p className="pl-6 text-3xl font-semibold text-primary">{plan.price}</p>
|
|
||||||
<p className="pl-3 mb-1 text-lg text-gray-400">{plan.priceExplanation}</p>
|
|
||||||
</div>
|
|
||||||
<p className="relative z-10 max-w-fit px-6 text-base text-gray-400">{plan.text}</p>
|
|
||||||
<p className="relative z-10 max-w-fit px-6 text-base text-gray-400">{plan.subtext}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center">
|
|
||||||
{plan.current === false ? (
|
|
||||||
<>
|
|
||||||
{plan.buttonTextMain === "Schedule a Demo" ? (
|
|
||||||
<a href="/scheduledemo" target='_blank rel="noopener"'>
|
|
||||||
<div className="relative z-10 mx-5 mt-3 mb-4 py-2 px-4 border border-1 border-mineshaft-600 hover:text-black hover:border-primary text-gray-400 font-semibold hover:bg-primary bg-bunker duration-200 cursor-pointer rounded-md flex w-max">
|
|
||||||
{plan.buttonTextMain}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`relative z-10 mx-5 mt-3 mb-4 py-2 px-4 border border-1 border-mineshaft-600 text-gray-400 font-semibold ${
|
|
||||||
plan.buttonTextMain === "Downgrade"
|
|
||||||
? "hover:bg-red hover:text-white hover:border-red"
|
|
||||||
: "hover:bg-primary hover:text-black hover:border-primary"
|
|
||||||
} bg-bunker duration-200 cursor-pointer rounded-md flex w-max`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
StripeRedirect({
|
|
||||||
orgId: tempLocalStorage("orgData.id")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{plan.buttonTextMain}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<a href="https://infisical.com/pricing" target='_blank rel="noopener"'>
|
|
||||||
<div className="relative z-10 text-gray-400 font-semibold hover:text-primary duration-200 cursor-pointer mb-0.5">
|
|
||||||
{plan.buttonTextSecondary}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`h-8 w-full rounded-b-md flex justify-center items-center z-10 ${
|
|
||||||
plan.name !== "Starter" && plan.current === true ? "bg-primary" : "bg-mineshaft-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-xs text-black font-semibold">CURRENT PLAN</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -57,7 +57,7 @@ export default function NavHeader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ml-4 flex flex-row items-center pt-6">
|
<div className="flex flex-row items-center pt-6">
|
||||||
<div className="mr-2 flex h-6 w-6 items-center justify-center rounded-md bg-primary-900 text-mineshaft-100">
|
<div className="mr-2 flex h-6 w-6 items-center justify-center rounded-md bg-primary-900 text-mineshaft-100">
|
||||||
{currentOrg?.name?.charAt(0)}
|
{currentOrg?.name?.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
108
frontend/src/components/utilities/attemptChangePassword.ts
Normal file
108
frontend/src/components/utilities/attemptChangePassword.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/* eslint-disable new-cap */
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import jsrp from "jsrp";
|
||||||
|
|
||||||
|
import changePassword2 from "@app/pages/api/auth/ChangePassword2";
|
||||||
|
import SRP1 from "@app/pages/api/auth/SRP1";
|
||||||
|
|
||||||
|
import Aes256Gcm from "./cryptography/aes-256-gcm";
|
||||||
|
import { deriveArgonKey } from "./cryptography/crypto";
|
||||||
|
import { saveTokenToLocalStorage } from "./saveTokenToLocalStorage";
|
||||||
|
|
||||||
|
const clientOldPassword = new jsrp.client();
|
||||||
|
const clientNewPassword = new jsrp.client();
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
email: string;
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attemptChangePassword = ({ email, currentPassword, newPassword }: Params): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
clientOldPassword.init({ username: email, password: currentPassword }, async () => {
|
||||||
|
let serverPublicKey; let salt;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clientPublicKey = clientOldPassword.getPublicKey();
|
||||||
|
|
||||||
|
const res = await SRP1({ clientPublicKey });
|
||||||
|
|
||||||
|
serverPublicKey = res.serverPublicKey;
|
||||||
|
salt = res.salt;
|
||||||
|
|
||||||
|
clientOldPassword.setSalt(salt);
|
||||||
|
clientOldPassword.setServerPublicKey(serverPublicKey);
|
||||||
|
|
||||||
|
const clientProof = clientOldPassword.getProof();
|
||||||
|
|
||||||
|
clientNewPassword.init({ username: email, password: newPassword }, async () => {
|
||||||
|
clientNewPassword.createVerifier(async (err, result) => {
|
||||||
|
try {
|
||||||
|
const derivedKey = await deriveArgonKey({
|
||||||
|
password: newPassword,
|
||||||
|
salt: result.salt,
|
||||||
|
mem: 65536,
|
||||||
|
time: 3,
|
||||||
|
parallelism: 1,
|
||||||
|
hashLen: 32
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||||
|
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
|
||||||
|
const {
|
||||||
|
ciphertext: encryptedPrivateKey,
|
||||||
|
iv: encryptedPrivateKeyIV,
|
||||||
|
tag: encryptedPrivateKeyTag
|
||||||
|
} = Aes256Gcm.encrypt({
|
||||||
|
text: localStorage.getItem("PRIVATE_KEY") as string,
|
||||||
|
secret: key
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
ciphertext: protectedKey,
|
||||||
|
iv: protectedKeyIV,
|
||||||
|
tag: protectedKeyTag
|
||||||
|
} = Aes256Gcm.encrypt({
|
||||||
|
text: key.toString("hex"),
|
||||||
|
secret: Buffer.from(derivedKey.hash)
|
||||||
|
});
|
||||||
|
|
||||||
|
await changePassword2({
|
||||||
|
clientProof,
|
||||||
|
protectedKey,
|
||||||
|
protectedKeyIV,
|
||||||
|
protectedKeyTag,
|
||||||
|
encryptedPrivateKey,
|
||||||
|
encryptedPrivateKeyIV,
|
||||||
|
encryptedPrivateKeyTag,
|
||||||
|
salt: result.salt,
|
||||||
|
verifier: result.verifier
|
||||||
|
});
|
||||||
|
|
||||||
|
saveTokenToLocalStorage({
|
||||||
|
encryptedPrivateKey,
|
||||||
|
iv: encryptedPrivateKeyIV,
|
||||||
|
tag: encryptedPrivateKeyTag
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
} catch (err2) {
|
||||||
|
console.error(err2);
|
||||||
|
reject(err2);
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default attemptChangePassword;
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
useGetAuthToken,
|
useGetAuthToken,
|
||||||
useGetCommonPasswords,
|
useGetCommonPasswords,
|
||||||
useRevokeAllSessions,
|
useSendMfaToken,
|
||||||
useSendMfaToken,
|
|
||||||
useVerifyMfaToken
|
useVerifyMfaToken
|
||||||
} from "./queries";
|
} from "./queries"
|
||||||
|
|||||||
@@ -51,15 +51,6 @@ export const useGetAuthToken = () =>
|
|||||||
retry: 0
|
retry: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useRevokeAllSessions = () => {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const { data } = await apiRequest.delete("/api/v1/auth/sessions");
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchCommonPasswords = async () => {
|
const fetchCommonPasswords = async () => {
|
||||||
const { data } = await apiRequest.get("/api/v1/auth/common-passwords");
|
const { data } = await apiRequest.get("/api/v1/auth/common-passwords");
|
||||||
return data || [];
|
return data || [];
|
||||||
|
|||||||
@@ -81,76 +81,79 @@ export const useGetProjectSecrets = ({
|
|||||||
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
|
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
|
||||||
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
|
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
|
||||||
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
|
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
|
||||||
select: useCallback((data: EncryptedSecret[]) => {
|
select: useCallback(
|
||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
(data: EncryptedSecret[]) => {
|
||||||
const latestKey = decryptFileKey;
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
const key = decryptAssymmetric({
|
const latestKey = decryptFileKey;
|
||||||
ciphertext: latestKey.encryptedKey,
|
const key = decryptAssymmetric({
|
||||||
nonce: latestKey.nonce,
|
ciphertext: latestKey.encryptedKey,
|
||||||
publicKey: latestKey.sender.publicKey,
|
nonce: latestKey.nonce,
|
||||||
privateKey: PRIVATE_KEY
|
publicKey: latestKey.sender.publicKey,
|
||||||
});
|
privateKey: PRIVATE_KEY
|
||||||
|
|
||||||
const sharedSecrets: DecryptedSecret[] = [];
|
|
||||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
|
||||||
// this used for add-only mode in dashboard
|
|
||||||
// type won't be there thus only one key is shown
|
|
||||||
const duplicateSecretKey: Record<string, boolean> = {};
|
|
||||||
data.forEach((encSecret: EncryptedSecret) => {
|
|
||||||
const secretKey = decryptSymmetric({
|
|
||||||
ciphertext: encSecret.secretKeyCiphertext,
|
|
||||||
iv: encSecret.secretKeyIV,
|
|
||||||
tag: encSecret.secretKeyTag,
|
|
||||||
key
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const secretValue = decryptSymmetric({
|
const sharedSecrets: DecryptedSecret[] = [];
|
||||||
ciphertext: encSecret.secretValueCiphertext,
|
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||||
iv: encSecret.secretValueIV,
|
// this used for add-only mode in dashboard
|
||||||
tag: encSecret.secretValueTag,
|
// type won't be there thus only one key is shown
|
||||||
key
|
const duplicateSecretKey: Record<string, boolean> = {};
|
||||||
});
|
data.forEach((encSecret: EncryptedSecret) => {
|
||||||
|
const secretKey = decryptSymmetric({
|
||||||
|
ciphertext: encSecret.secretKeyCiphertext,
|
||||||
|
iv: encSecret.secretKeyIV,
|
||||||
|
tag: encSecret.secretKeyTag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
|
||||||
const secretComment = decryptSymmetric({
|
const secretValue = decryptSymmetric({
|
||||||
ciphertext: encSecret.secretCommentCiphertext,
|
ciphertext: encSecret.secretValueCiphertext,
|
||||||
iv: encSecret.secretCommentIV,
|
iv: encSecret.secretValueIV,
|
||||||
tag: encSecret.secretCommentTag,
|
tag: encSecret.secretValueTag,
|
||||||
key
|
key
|
||||||
});
|
});
|
||||||
|
|
||||||
const decryptedSecret = {
|
const secretComment = decryptSymmetric({
|
||||||
_id: encSecret._id,
|
ciphertext: encSecret.secretCommentCiphertext,
|
||||||
env: encSecret.environment,
|
iv: encSecret.secretCommentIV,
|
||||||
key: secretKey,
|
tag: encSecret.secretCommentTag,
|
||||||
value: secretValue,
|
key
|
||||||
tags: encSecret.tags,
|
});
|
||||||
comment: secretComment,
|
|
||||||
createdAt: encSecret.createdAt,
|
|
||||||
updatedAt: encSecret.updatedAt
|
|
||||||
};
|
|
||||||
|
|
||||||
if (encSecret.type === "personal") {
|
const decryptedSecret = {
|
||||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
_id: encSecret._id,
|
||||||
id: encSecret._id,
|
env: encSecret.environment,
|
||||||
value: secretValue
|
key: secretKey,
|
||||||
|
value: secretValue,
|
||||||
|
tags: encSecret.tags,
|
||||||
|
comment: secretComment,
|
||||||
|
createdAt: encSecret.createdAt,
|
||||||
|
updatedAt: encSecret.updatedAt
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
if (encSecret.type === "personal") {
|
||||||
sharedSecrets.push(decryptedSecret);
|
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||||
|
id: encSecret._id,
|
||||||
|
value: secretValue
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||||
|
sharedSecrets.push(decryptedSecret);
|
||||||
|
}
|
||||||
|
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||||
}
|
}
|
||||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
});
|
||||||
}
|
sharedSecrets.forEach((val) => {
|
||||||
});
|
const dupKey = `${val.key}-${val.env}`;
|
||||||
sharedSecrets.forEach((val) => {
|
if (personalSecrets?.[dupKey]) {
|
||||||
const dupKey = `${val.key}-${val.env}`;
|
val.idOverride = personalSecrets[dupKey].id;
|
||||||
if (personalSecrets?.[dupKey]) {
|
val.valueOverride = personalSecrets[dupKey].value;
|
||||||
val.idOverride = personalSecrets[dupKey].id;
|
val.overrideAction = "modified";
|
||||||
val.valueOverride = personalSecrets[dupKey].value;
|
}
|
||||||
val.overrideAction = "modified";
|
});
|
||||||
}
|
return { secrets: sharedSecrets };
|
||||||
});
|
},
|
||||||
return { secrets: sharedSecrets };
|
[decryptFileKey]
|
||||||
}, [decryptFileKey])
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useGetProjectSecretsByKey = ({
|
export const useGetProjectSecretsByKey = ({
|
||||||
@@ -167,82 +170,85 @@ export const useGetProjectSecretsByKey = ({
|
|||||||
// right now secretpath is passed as folderid as only this is used in overview
|
// right now secretpath is passed as folderid as only this is used in overview
|
||||||
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath),
|
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath),
|
||||||
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
|
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
|
||||||
select: useCallback((data: EncryptedSecret[]) => {
|
select: useCallback(
|
||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
(data: EncryptedSecret[]) => {
|
||||||
const latestKey = decryptFileKey;
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
const key = decryptAssymmetric({
|
const latestKey = decryptFileKey;
|
||||||
ciphertext: latestKey.encryptedKey,
|
const key = decryptAssymmetric({
|
||||||
nonce: latestKey.nonce,
|
ciphertext: latestKey.encryptedKey,
|
||||||
publicKey: latestKey.sender.publicKey,
|
nonce: latestKey.nonce,
|
||||||
privateKey: PRIVATE_KEY
|
publicKey: latestKey.sender.publicKey,
|
||||||
});
|
privateKey: PRIVATE_KEY
|
||||||
|
|
||||||
const sharedSecrets: Record<string, DecryptedSecret[]> = {};
|
|
||||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
|
||||||
// this used for add-only mode in dashboard
|
|
||||||
// type won't be there thus only one key is shown
|
|
||||||
const duplicateSecretKey: Record<string, boolean> = {};
|
|
||||||
const uniqSecKeys: Record<string, boolean> = {};
|
|
||||||
data.forEach((encSecret: EncryptedSecret) => {
|
|
||||||
const secretKey = decryptSymmetric({
|
|
||||||
ciphertext: encSecret.secretKeyCiphertext,
|
|
||||||
iv: encSecret.secretKeyIV,
|
|
||||||
tag: encSecret.secretKeyTag,
|
|
||||||
key
|
|
||||||
});
|
|
||||||
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
|
|
||||||
|
|
||||||
const secretValue = decryptSymmetric({
|
|
||||||
ciphertext: encSecret.secretValueCiphertext,
|
|
||||||
iv: encSecret.secretValueIV,
|
|
||||||
tag: encSecret.secretValueTag,
|
|
||||||
key
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const secretComment = decryptSymmetric({
|
const sharedSecrets: Record<string, DecryptedSecret[]> = {};
|
||||||
ciphertext: encSecret.secretCommentCiphertext,
|
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||||
iv: encSecret.secretCommentIV,
|
// this used for add-only mode in dashboard
|
||||||
tag: encSecret.secretCommentTag,
|
// type won't be there thus only one key is shown
|
||||||
key
|
const duplicateSecretKey: Record<string, boolean> = {};
|
||||||
});
|
const uniqSecKeys: Record<string, boolean> = {};
|
||||||
|
data.forEach((encSecret: EncryptedSecret) => {
|
||||||
|
const secretKey = decryptSymmetric({
|
||||||
|
ciphertext: encSecret.secretKeyCiphertext,
|
||||||
|
iv: encSecret.secretKeyIV,
|
||||||
|
tag: encSecret.secretKeyTag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
if (!uniqSecKeys?.[secretKey]) uniqSecKeys[secretKey] = true;
|
||||||
|
|
||||||
const decryptedSecret = {
|
const secretValue = decryptSymmetric({
|
||||||
_id: encSecret._id,
|
ciphertext: encSecret.secretValueCiphertext,
|
||||||
env: encSecret.environment,
|
iv: encSecret.secretValueIV,
|
||||||
key: secretKey,
|
tag: encSecret.secretValueTag,
|
||||||
value: secretValue,
|
key
|
||||||
tags: encSecret.tags,
|
});
|
||||||
comment: secretComment,
|
|
||||||
createdAt: encSecret.createdAt,
|
|
||||||
updatedAt: encSecret.updatedAt
|
|
||||||
};
|
|
||||||
|
|
||||||
if (encSecret.type === "personal") {
|
const secretComment = decryptSymmetric({
|
||||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
ciphertext: encSecret.secretCommentCiphertext,
|
||||||
id: encSecret._id,
|
iv: encSecret.secretCommentIV,
|
||||||
value: secretValue
|
tag: encSecret.secretCommentTag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedSecret = {
|
||||||
|
_id: encSecret._id,
|
||||||
|
env: encSecret.environment,
|
||||||
|
key: secretKey,
|
||||||
|
value: secretValue,
|
||||||
|
tags: encSecret.tags,
|
||||||
|
comment: secretComment,
|
||||||
|
createdAt: encSecret.createdAt,
|
||||||
|
updatedAt: encSecret.updatedAt
|
||||||
};
|
};
|
||||||
} else {
|
|
||||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
if (encSecret.type === "personal") {
|
||||||
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = [];
|
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||||
sharedSecrets[secretKey].push(decryptedSecret);
|
id: encSecret._id,
|
||||||
}
|
value: secretValue
|
||||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
};
|
||||||
}
|
} else {
|
||||||
});
|
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||||
Object.keys(sharedSecrets).forEach((secName) => {
|
if (!sharedSecrets?.[secretKey]) sharedSecrets[secretKey] = [];
|
||||||
sharedSecrets[secName].forEach((val) => {
|
sharedSecrets[secretKey].push(decryptedSecret);
|
||||||
const dupKey = `${val.key}-${val.env}`;
|
}
|
||||||
if (personalSecrets?.[dupKey]) {
|
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||||
val.idOverride = personalSecrets[dupKey].id;
|
|
||||||
val.valueOverride = personalSecrets[dupKey].value;
|
|
||||||
val.overrideAction = "modified";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
Object.keys(sharedSecrets).forEach((secName) => {
|
||||||
|
sharedSecrets[secName].forEach((val) => {
|
||||||
|
const dupKey = `${val.key}-${val.env}`;
|
||||||
|
if (personalSecrets?.[dupKey]) {
|
||||||
|
val.idOverride = personalSecrets[dupKey].id;
|
||||||
|
val.valueOverride = personalSecrets[dupKey].value;
|
||||||
|
val.overrideAction = "modified";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
|
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
|
||||||
}, [decryptFileKey])
|
},
|
||||||
|
[decryptFileKey]
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
|
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
|
||||||
@@ -263,29 +269,32 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
|
|||||||
enabled: Boolean(dto.secretId && dto.decryptFileKey),
|
enabled: Boolean(dto.secretId && dto.decryptFileKey),
|
||||||
queryKey: secretKeys.getSecretVersion(dto.secretId),
|
queryKey: secretKeys.getSecretVersion(dto.secretId),
|
||||||
queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit),
|
queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit),
|
||||||
select: useCallback((data: EncryptedSecretVersion[]) => {
|
select: useCallback(
|
||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
(data: EncryptedSecretVersion[]) => {
|
||||||
const latestKey = dto.decryptFileKey;
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
const key = decryptAssymmetric({
|
const latestKey = dto.decryptFileKey;
|
||||||
ciphertext: latestKey.encryptedKey,
|
const key = decryptAssymmetric({
|
||||||
nonce: latestKey.nonce,
|
ciphertext: latestKey.encryptedKey,
|
||||||
publicKey: latestKey.sender.publicKey,
|
nonce: latestKey.nonce,
|
||||||
privateKey: PRIVATE_KEY
|
publicKey: latestKey.sender.publicKey,
|
||||||
});
|
privateKey: PRIVATE_KEY
|
||||||
|
});
|
||||||
|
|
||||||
return data
|
return data
|
||||||
.map((el) => ({
|
.map((el) => ({
|
||||||
createdAt: el.createdAt,
|
createdAt: el.createdAt,
|
||||||
id: el._id,
|
id: el._id,
|
||||||
value: decryptSymmetric({
|
value: decryptSymmetric({
|
||||||
ciphertext: el.secretValueCiphertext,
|
ciphertext: el.secretValueCiphertext,
|
||||||
iv: el.secretValueIV,
|
iv: el.secretValueIV,
|
||||||
tag: el.secretValueTag,
|
tag: el.secretValueTag,
|
||||||
key
|
key
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
}, [])
|
},
|
||||||
|
[dto.decryptFileKey]
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const useBatchSecretsOp = () => {
|
export const useBatchSecretsOp = () => {
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ export {
|
|||||||
fetchOrgUsers,
|
fetchOrgUsers,
|
||||||
useAddUserToOrg,
|
useAddUserToOrg,
|
||||||
useAddUserToWs,
|
useAddUserToWs,
|
||||||
|
useCreateAPIKey,
|
||||||
|
useDeleteAPIKey,
|
||||||
useDeleteOrgMembership,
|
useDeleteOrgMembership,
|
||||||
|
useGetMyAPIKeys,
|
||||||
|
useGetMySessions,
|
||||||
useGetOrgUsers,
|
useGetOrgUsers,
|
||||||
useGetUser,
|
useGetUser,
|
||||||
useGetUserAction,
|
useGetUserAction,
|
||||||
useLogoutUser,
|
useLogoutUser,
|
||||||
useRegisterUserAction,
|
useRegisterUserAction,
|
||||||
useUpdateOrgUserRole
|
useRevokeMySessions,
|
||||||
} from "./queries";
|
useUpdateOrgUserRole} from "./queries";
|
||||||
|
|||||||
@@ -12,16 +12,20 @@ import {
|
|||||||
AddUserToOrgDTO,
|
AddUserToOrgDTO,
|
||||||
AddUserToWsDTO,
|
AddUserToWsDTO,
|
||||||
AddUserToWsRes,
|
AddUserToWsRes,
|
||||||
|
APIKeyData,
|
||||||
|
CreateAPIKeyRes,
|
||||||
DeletOrgMembershipDTO,
|
DeletOrgMembershipDTO,
|
||||||
OrgUser,
|
OrgUser,
|
||||||
|
TokenVersion,
|
||||||
UpdateOrgUserRoleDTO,
|
UpdateOrgUserRoleDTO,
|
||||||
User
|
User} from "./types";
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const userKeys = {
|
const userKeys = {
|
||||||
getUser: ["user"] as const,
|
getUser: ["user"] as const,
|
||||||
userAction: ["user-action"] as const,
|
userAction: ["user-action"] as const,
|
||||||
getOrgUsers: (orgId: string) => [{ orgId }, "user"]
|
getOrgUsers: (orgId: string) => [{ orgId }, "user"],
|
||||||
|
myAPIKeys: ["api-keys"] as const,
|
||||||
|
mySessions: ["sessions"] as const
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchUserDetails = async () => {
|
export const fetchUserDetails = async () => {
|
||||||
@@ -167,3 +171,90 @@ export const useLogoutUser = () =>
|
|||||||
localStorage.setItem("PRIVATE_KEY", "");
|
localStorage.setItem("PRIVATE_KEY", "");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useGetMyAPIKeys = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: userKeys.myAPIKeys,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.get<APIKeyData[]>(
|
||||||
|
"/api/v2/users/me/api-keys"
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCreateAPIKey = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
name,
|
||||||
|
expiresIn
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}) => {
|
||||||
|
const { data } = await apiRequest.post<CreateAPIKeyRes>(
|
||||||
|
"/api/v2/users/me/api-keys",
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
expiresIn
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries(userKeys.myAPIKeys);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeleteAPIKey = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (apiKeyDataId: string) => {
|
||||||
|
const { data } = await apiRequest.delete(
|
||||||
|
`/api/v2/users/me/api-keys/${apiKeyDataId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries(userKeys.myAPIKeys);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGetMySessions = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: userKeys.mySessions,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiRequest.get<TokenVersion[]>(
|
||||||
|
"/api/v2/users/me/sessions"
|
||||||
|
);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRevokeMySessions = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
console.log("useRevokeAllSessions 1");
|
||||||
|
const { data } = await apiRequest.delete(
|
||||||
|
"/api/v2/users/me/sessions"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("useRevokeAllSessions 2: ", data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess() {
|
||||||
|
queryClient.invalidateQueries(userKeys.mySessions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -61,3 +61,27 @@ export type AddUserToOrgDTO = {
|
|||||||
inviteeEmail: string;
|
inviteeEmail: string;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateAPIKeyRes = {
|
||||||
|
apiKey: string;
|
||||||
|
apiKeyData: APIKeyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type APIKeyData = {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
user: string;
|
||||||
|
lastUsed: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TokenVersion = {
|
||||||
|
_id: string;
|
||||||
|
user: string;
|
||||||
|
userAgent: string;
|
||||||
|
ip: string;
|
||||||
|
lastUsed: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { useLeaveConfirm } from "./useLeaveConfirm";
|
export { useLeaveConfirm } from "./useLeaveConfirm";
|
||||||
export { usePersistentState } from "./usePersistentState";
|
export { usePersistentState } from "./usePersistentState";
|
||||||
export { usePopUp } from "./usePopUp";
|
export { usePopUp } from "./usePopUp";
|
||||||
|
export { useSyntaxHighlight } from "./useSyntaxHighlight";
|
||||||
export { useToggle } from "./useToggle";
|
export { useToggle } from "./useToggle";
|
||||||
|
|||||||
45
frontend/src/hooks/useSyntaxHighlight.tsx
Normal file
45
frontend/src/hooks/useSyntaxHighlight.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { faCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
const REGEX = /([$]{.*?})/g;
|
||||||
|
|
||||||
|
export const useSyntaxHighlight = () => {
|
||||||
|
const syntaxHighlight = useCallback((text: string, isHidden?: boolean) => {
|
||||||
|
if (isHidden) {
|
||||||
|
return text
|
||||||
|
.split("")
|
||||||
|
.slice(0, 200)
|
||||||
|
.map((el, i) =>
|
||||||
|
el === "\n" ? (
|
||||||
|
el
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
key={`${text}_${el}_${i + 1}`}
|
||||||
|
className="mr-0.5 text-xxs"
|
||||||
|
icon={faCircle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// append a space on last new line this to show new line in ui for code component
|
||||||
|
const val = text.at(-1) === "\n" ? text.concat(" ") : text;
|
||||||
|
if (val?.length === 0) return <span className="font-mono text-bunker-400/80">EMPTY</span>;
|
||||||
|
return val?.split(REGEX).map((word, i) =>
|
||||||
|
word.match(REGEX) !== null ? (
|
||||||
|
<span className="ph-no-capture text-yellow" key={`${val}-${i + 1}`}>
|
||||||
|
${
|
||||||
|
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={`${word}_${i + 1}`} className="ph-no-capture">
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return syntaxHighlight;
|
||||||
|
};
|
||||||
@@ -158,7 +158,9 @@ export default function Activity() {
|
|||||||
<link rel="icon" href="/infisical.ico" />
|
<link rel="icon" href="/infisical.ico" />
|
||||||
<meta property="og:image" content="/images/message.png" />
|
<meta property="og:image" content="/images/message.png" />
|
||||||
</Head>
|
</Head>
|
||||||
<NavHeader pageName="Audit Logs" isProjectRelated />
|
<div className="ml-6">
|
||||||
|
<NavHeader pageName="Audit Logs" isProjectRelated />
|
||||||
|
</div>
|
||||||
{currentSidebarAction && (
|
{currentSidebarAction && (
|
||||||
<ActivitySideBar toggleSidebar={toggleSidebar} currentAction={currentSidebarAction} />
|
<ActivitySideBar toggleSidebar={toggleSidebar} currentAction={currentSidebarAction} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
name: string;
|
|
||||||
expiresIn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This route adds an API key for the user
|
|
||||||
* @param {object} obj
|
|
||||||
* @param {string} obj.name - name of the API key
|
|
||||||
* @param {string} obj.expiresIn - how soon the API key expires in ms
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const addAPIKey = ({ name, expiresIn }: Props) =>
|
|
||||||
SecurityClient.fetchCall("/api/v2/api-key/", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
name,
|
|
||||||
expiresIn
|
|
||||||
})
|
|
||||||
}).then(async (res) => {
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
console.log("Failed to add API key");
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default addAPIKey;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
apiKeyId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This route revokes the API key with id [apiKeyId]
|
|
||||||
* @param {object} obj
|
|
||||||
* @param {string} obj.apiKeyId - id of the API key to delete
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const deleteAPIKey = ({ apiKeyId }: Props) =>
|
|
||||||
SecurityClient.fetchCall(`/api/v2/api-key/${apiKeyId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
}).then(async (res) => {
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
console.log("Failed to delete API key");
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default deleteAPIKey;
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import SecurityClient from "@app/components/utilities/SecurityClient";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This route gets API keys for the user
|
|
||||||
* @param {*} param0
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const getAPIKeys = () =>
|
|
||||||
SecurityClient.fetchCall("/api/v2/api-key", {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
}
|
|
||||||
}).then(async (res) => {
|
|
||||||
if (res && res.status === 200) {
|
|
||||||
return (await res.json()).apiKeyData;
|
|
||||||
}
|
|
||||||
console.log("Failed to get API keys");
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
export default getAPIKeys;
|
|
||||||
@@ -1,322 +1,18 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { faBan,faCheck, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import Button from "@app/components/basic/buttons/Button";
|
import { PersonalSettingsPage } from "@app/views/Settings/PersonalSettingsPage";
|
||||||
import InputField from "@app/components/basic/InputField";
|
|
||||||
import ListBox from "@app/components/basic/Listbox";
|
|
||||||
import ApiKeyTable from "@app/components/basic/table/ApiKeyTable";
|
|
||||||
import NavHeader from "@app/components/navigation/NavHeader";
|
|
||||||
import checkPassword from "@app/components/utilities/checks/checkPassword";
|
|
||||||
import changePassword from "@app/components/utilities/cryptography/changePassword";
|
|
||||||
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
|
||||||
import {
|
|
||||||
useGetCommonPasswords,
|
|
||||||
useRevokeAllSessions} from "@app/hooks/api";
|
|
||||||
import { SecuritySection } from "@app/views/Settings/PersonalSettingsPage/SecuritySection/SecuritySection";
|
|
||||||
|
|
||||||
import AddApiKeyDialog from "../../../components/basic/dialog/AddApiKeyDialog";
|
|
||||||
import getAPIKeys from "../../api/apiKey/getAPIKeys";
|
|
||||||
import getUser from "../../api/user/getUser";
|
|
||||||
|
|
||||||
type Errors = {
|
|
||||||
length?: string,
|
|
||||||
upperCase?: string,
|
|
||||||
lowerCase?: string,
|
|
||||||
number?: string,
|
|
||||||
specialChar?: string,
|
|
||||||
repeatedChar?: string,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PersonalSettings() {
|
export default function PersonalSettings() {
|
||||||
const { data: commonPasswords } = useGetCommonPasswords();
|
const { t } = useTranslation();
|
||||||
const [personalEmail, setPersonalEmail] = useState("");
|
|
||||||
const [personalName, setPersonalName] = useState("");
|
|
||||||
const [currentPasswordError, setCurrentPasswordError] = useState(false);
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [backupPassword, setBackupPassword] = useState("");
|
|
||||||
const [passwordChanged, setPasswordChanged] = useState(false);
|
|
||||||
const [backupKeyIssued, setBackupKeyIssued] = useState(false);
|
|
||||||
const [backupKeyError, setBackupKeyError] = useState(false);
|
|
||||||
const [isAddApiKeyDialogOpen, setIsAddApiKeyDialogOpen] = useState(false);
|
|
||||||
const [apiKeys, setApiKeys] = useState<any[]>([]);
|
|
||||||
const [errors, setErrors] = useState<Errors>({});
|
|
||||||
|
|
||||||
const revokeAllSessions = useRevokeAllSessions();
|
|
||||||
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
const lang = router.locale ?? "en";
|
|
||||||
|
|
||||||
const setLanguage = async (to: string) => {
|
|
||||||
router.push(router.asPath, router.asPath, { locale: to });
|
|
||||||
localStorage.setItem("lang", to);
|
|
||||||
i18n.changeLanguage(to);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const load = async () => {
|
|
||||||
try {
|
|
||||||
const user = await getUser();
|
|
||||||
setApiKeys(await getAPIKeys());
|
|
||||||
setPersonalEmail(user.email);
|
|
||||||
setPersonalName(`${user.firstName} ${user.lastName}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeAddApiKeyModal = () => {
|
|
||||||
setIsAddApiKeyDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-h-screen flex-col justify-between bg-bunker-800 text-white">
|
<div className="bg-bunker-800 text-white h-full">
|
||||||
<Head>
|
<Head>
|
||||||
<title>{t("common.head-title", { title: t("settings.personal.title") })}</title>
|
<title>{t("common.head-title", { title: t("settings.personal.title") })}</title>
|
||||||
<link rel="icon" href="/infisical.ico" />
|
<link rel="icon" href="/infisical.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<AddApiKeyDialog
|
<PersonalSettingsPage />
|
||||||
isOpen={isAddApiKeyDialogOpen}
|
|
||||||
closeModal={closeAddApiKeyModal}
|
|
||||||
apiKeys={apiKeys}
|
|
||||||
setApiKeys={setApiKeys}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-row">
|
|
||||||
<div className="max-h-screen w-full pb-2">
|
|
||||||
<NavHeader pageName={t("settings.personal.title")} isProjectRelated={false} />
|
|
||||||
<div className="ml-6 mt-8 mb-6 flex max-w-5xl flex-row items-center justify-between text-xl">
|
|
||||||
<div className="flex flex-col items-start justify-start text-3xl">
|
|
||||||
<p className="mr-4 font-semibold text-gray-200">{t("settings.personal.title")}</p>
|
|
||||||
<p className="mr-4 text-base font-normal text-gray-400">
|
|
||||||
{t("settings.personal.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-6 mr-6 flex max-w-5xl flex-col text-mineshaft-50">
|
|
||||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
|
|
||||||
<p className="self-start text-xl font-semibold">
|
|
||||||
{t("settings.personal.change-language")}
|
|
||||||
</p>
|
|
||||||
<div className="w-ful mt-4 max-h-28">
|
|
||||||
<ListBox
|
|
||||||
isSelected={lang}
|
|
||||||
onChange={setLanguage}
|
|
||||||
data={["en", "ko", "fr", "es"]}
|
|
||||||
text={`${t("common.language")}: `}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<SecuritySection />
|
|
||||||
<div className="mt-2 mb-8 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-4">
|
|
||||||
<div className="flex w-full flex-row justify-between">
|
|
||||||
<div className="flex w-full flex-col">
|
|
||||||
<p className="mb-3 text-xl font-semibold">
|
|
||||||
{t("settings.personal.api-keys.title")}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-400">
|
|
||||||
{t("settings.personal.api-keys.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 w-40">
|
|
||||||
<Button
|
|
||||||
text={String(t("settings.personal.api-keys.add-new"))}
|
|
||||||
onButtonPressed={() => {
|
|
||||||
setIsAddApiKeyDialogOpen(true);
|
|
||||||
}}
|
|
||||||
color="mineshaft"
|
|
||||||
icon={faPlus}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ApiKeyTable data={apiKeys} setApiKeys={setApiKeys as any} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-5 pb-6">
|
|
||||||
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
|
|
||||||
<div className="flex w-full max-w-3xl flex-col justify-between">
|
|
||||||
<p className="mb-3 min-w-max text-xl font-semibold">
|
|
||||||
{t("section.password.change")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full max-w-xl">
|
|
||||||
<InputField
|
|
||||||
label={t("section.password.current") as string}
|
|
||||||
onChangeHandler={(password) => {
|
|
||||||
setCurrentPassword(password);
|
|
||||||
}}
|
|
||||||
type="password"
|
|
||||||
value={currentPassword}
|
|
||||||
isRequired
|
|
||||||
error={currentPasswordError}
|
|
||||||
errorText={t("section.password.current-wrong") as string}
|
|
||||||
autoComplete="current-password"
|
|
||||||
id="current-password"
|
|
||||||
/>
|
|
||||||
<div className="py-2" />
|
|
||||||
<InputField
|
|
||||||
label={t("section.password.new") as string}
|
|
||||||
onChangeHandler={(password) => {
|
|
||||||
setNewPassword(password);
|
|
||||||
checkPassword({
|
|
||||||
password,
|
|
||||||
commonPasswords,
|
|
||||||
setErrors
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
isRequired
|
|
||||||
error={Object.keys(errors).length > 0}
|
|
||||||
autoComplete="new-password"
|
|
||||||
id="new-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{Object.keys(errors).length > 0 && (
|
|
||||||
<div className="mt-4 flex w-full flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
|
||||||
<div className="mb-2 text-sm text-gray-400">{t("section.password.validate-base")}</div>
|
|
||||||
{Object.keys(errors).map((key) => {
|
|
||||||
if (errors[key as keyof Errors]) {
|
|
||||||
return (
|
|
||||||
<div className="ml-1 flex flex-row items-top justify-start" key={key}>
|
|
||||||
<div>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faXmark}
|
|
||||||
className="text-md text-red ml-0.5 mr-2.5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
{errors[key as keyof Errors]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-3 flex w-52 flex-row items-center pr-3">
|
|
||||||
<Button
|
|
||||||
text={t("section.password.change") as string}
|
|
||||||
onButtonPressed={() => {
|
|
||||||
const errorCheck = checkPassword({
|
|
||||||
password: newPassword,
|
|
||||||
commonPasswords,
|
|
||||||
setErrors
|
|
||||||
});
|
|
||||||
if (!errorCheck) {
|
|
||||||
changePassword(
|
|
||||||
personalEmail,
|
|
||||||
currentPassword,
|
|
||||||
newPassword,
|
|
||||||
setCurrentPasswordError,
|
|
||||||
setPasswordChanged,
|
|
||||||
setCurrentPassword,
|
|
||||||
setNewPassword
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
active={Object.keys(errors).length === 0 && currentPassword !== ""}
|
|
||||||
color="mineshaft"
|
|
||||||
size="md"
|
|
||||||
textDisabled={t("section.password.change") as string}
|
|
||||||
/>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faCheck}
|
|
||||||
className={`ml-4 text-3xl text-primary ${
|
|
||||||
passwordChanged ? "opacity-100" : "opacity-0"
|
|
||||||
} duration-300`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-2">
|
|
||||||
<div className="my-4 flex w-full flex-row justify-between">
|
|
||||||
<p className="text-xl font-semibold w-full">
|
|
||||||
Sessions
|
|
||||||
</p>
|
|
||||||
<div className="w-40">
|
|
||||||
<Button
|
|
||||||
text="Revoke all"
|
|
||||||
onButtonPressed={async () => {
|
|
||||||
await revokeAllSessions.mutateAsync();
|
|
||||||
router.push("/login");
|
|
||||||
}}
|
|
||||||
color="mineshaft"
|
|
||||||
icon={faBan}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mb-5 text-sm text-mineshaft-300">
|
|
||||||
Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your account out all active sessions across all browsers and CLIs.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-5 pb-6">
|
|
||||||
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
|
|
||||||
<div className="flex w-full max-w-3xl flex-col justify-between">
|
|
||||||
<p className="mb-3 min-w-max text-xl font-semibold">
|
|
||||||
{t("settings.personal.emergency.name")}
|
|
||||||
</p>
|
|
||||||
<p className="min-w-max text-sm text-mineshaft-300">
|
|
||||||
{t("settings.personal.emergency.text1")}
|
|
||||||
</p>
|
|
||||||
<p className="mb-5 min-w-max text-sm text-mineshaft-300">
|
|
||||||
{t("settings.personal.emergency.text2")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-4 w-full max-w-xl">
|
|
||||||
<InputField
|
|
||||||
label={t("section.password.current") as string}
|
|
||||||
onChangeHandler={setBackupPassword}
|
|
||||||
type="password"
|
|
||||||
value={backupPassword}
|
|
||||||
isRequired
|
|
||||||
error={backupKeyError}
|
|
||||||
errorText={t("section.password.current-wrong") as string}
|
|
||||||
autoComplete="current-password"
|
|
||||||
id="current-password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex w-60 flex-row items-center">
|
|
||||||
<Button
|
|
||||||
text={t("settings.personal.emergency.download") as string}
|
|
||||||
onButtonPressed={() => {
|
|
||||||
issueBackupKey({
|
|
||||||
email: personalEmail,
|
|
||||||
password: backupPassword,
|
|
||||||
personalName,
|
|
||||||
setBackupKeyError,
|
|
||||||
setBackupKeyIssued
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
color="mineshaft"
|
|
||||||
size="md"
|
|
||||||
active={backupPassword !== ""}
|
|
||||||
textDisabled={t("settings.personal.emergency.download") as string}
|
|
||||||
/>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={faCheck}
|
|
||||||
className={`ml-4 text-3xl text-primary ${
|
|
||||||
backupKeyIssued ? "opacity-100" : "opacity-0"
|
|
||||||
} duration-300`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ export default function Users() {
|
|||||||
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
|
<title>{t("common.head-title", { title: t("settings.members.title") })}</title>
|
||||||
<link rel="icon" href="/infisical.ico" />
|
<link rel="icon" href="/infisical.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
<NavHeader pageName={t("settings.members.title")} isProjectRelated />
|
<div className="ml-6">
|
||||||
|
<NavHeader pageName={t("settings.members.title")} isProjectRelated />
|
||||||
|
</div>
|
||||||
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4">
|
<div className="flex flex-col items-start justify-start px-6 py-6 pb-0 text-3xl mb-4">
|
||||||
<p className="mr-4 font-semibold text-white">{t("settings.members.title")}</p>
|
<p className="mr-4 font-semibold text-white">{t("settings.members.title")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const DashboardEnvOverview = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||||
<div className="relative right-5">
|
<div className="relative right-5 ml-4">
|
||||||
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
|
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
|
|||||||
@@ -545,7 +545,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
|
|||||||
<FormProvider {...method}>
|
<FormProvider {...method}>
|
||||||
<form autoComplete="off" className="h-full">
|
<form autoComplete="off" className="h-full">
|
||||||
{/* breadcrumb row */}
|
{/* breadcrumb row */}
|
||||||
<div className="relative right-6 -top-2 mb-2">
|
<div className="relative right-6 -top-2 mb-2 ml-6">
|
||||||
<NavHeader
|
<NavHeader
|
||||||
pageName={t("dashboard.title")}
|
pageName={t("dashboard.title")}
|
||||||
currentEnv={
|
currentEnv={
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
/* eslint-disable react/jsx-no-useless-fragment */
|
/* eslint-disable react/jsx-no-useless-fragment */
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { faCircle, faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-icons";
|
import { faEye, faEyeSlash, faKey, faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
import { useSyntaxHighlight } from "@app/hooks";
|
||||||
|
|
||||||
|
import { useToggle } from "~/hooks/useToggle";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
secrets: any[] | undefined;
|
secrets: any[] | undefined;
|
||||||
@@ -12,7 +15,8 @@ type Props = {
|
|||||||
userAvailableEnvs?: any[];
|
userAvailableEnvs?: any[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const REGEX = /([$]{.*?})/g;
|
const SEC_VAL_LINE_HEIGHT = 21;
|
||||||
|
const MAX_MULTI_LINE = 6;
|
||||||
|
|
||||||
const DashboardInput = ({
|
const DashboardInput = ({
|
||||||
isOverridden,
|
isOverridden,
|
||||||
@@ -25,84 +29,60 @@ const DashboardInput = ({
|
|||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
secret?: any;
|
secret?: any;
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const syntaxHighlight = useCallback((val: string) => {
|
const ref = useRef<HTMLElement | null>(null);
|
||||||
if (val === undefined)
|
const [isFocused, setIsFocused] = useToggle();
|
||||||
return (
|
const syntaxHighlight = useSyntaxHighlight();
|
||||||
<span className="cursor-default font-sans text-xs italic text-red-500/80">
|
|
||||||
<FontAwesomeIcon icon={faMinus} className="mt-1" />
|
const value = isOverridden ? secret.valueOverride : secret?.value;
|
||||||
</span>
|
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
|
||||||
);
|
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
|
||||||
if (val?.length === 0)
|
|
||||||
return <span className="w-full font-sans text-bunker-400/80">EMPTY</span>;
|
|
||||||
return val?.split(REGEX).map((word, index) =>
|
|
||||||
word.match(REGEX) !== null ? (
|
|
||||||
<span className="ph-no-capture text-yellow" key={`${val}-${index + 1}`}>
|
|
||||||
{word.slice(0, 2)}
|
|
||||||
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
|
||||||
{word.slice(word.length - 1, word.length) === "}" ? (
|
|
||||||
<span className="ph-no-capture text-yellow">
|
|
||||||
{word.slice(word.length - 1, word.length)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="ph-no-capture text-yellow-400">
|
|
||||||
{word.slice(word.length - 1, word.length)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span key={word} className="ph-no-capture">
|
|
||||||
{word}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={`row-${secret?.key || ""}--`}
|
key={`row-${secret?.key || ""}--`}
|
||||||
className={`flex h-10 w-full cursor-default flex-row items-center justify-center ${
|
className={`flex w-full cursor-default flex-row ${
|
||||||
!(secret?.value || secret?.value === "") ? "bg-red-800/10" : "bg-mineshaft-900/30"
|
!(secret?.value || secret?.value === "") ? "bg-red-800/10" : "bg-mineshaft-900/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="group relative flex w-full cursor-default flex-col justify-center whitespace-pre">
|
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
|
||||||
<input
|
<textarea
|
||||||
value={isOverridden ? secret.valueOverride : secret?.value || ""}
|
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
className={twMerge(
|
value={value}
|
||||||
"ph-no-capture no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full cursor-default bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-transparent outline-none no-scrollbar",
|
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
|
||||||
isSecretValueHidden && "text-transparent focus:text-transparent active:text-transparent"
|
style={{ height: `${maxMultilineHeight}px` }}
|
||||||
)}
|
|
||||||
spellCheck="false"
|
spellCheck="false"
|
||||||
|
onBlur={() => setIsFocused.off()}
|
||||||
|
onFocus={() => setIsFocused.on()}
|
||||||
|
onInput={(el) => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||||
|
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onScroll={(el) => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||||
|
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div
|
<pre className="whitespace-pre-wrap break-words">
|
||||||
className={twMerge(
|
<code
|
||||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full cursor-default flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible",
|
ref={ref}
|
||||||
isSecretValueHidden && secret?.value ? "invisible" : "visible",
|
className={`absolute top-1.5 left-3.5 z-10 overflow-auto font-mono text-sm transition-all no-scrollbar ${
|
||||||
isSecretValueHidden &&
|
isOverridden && "text-primary-300"
|
||||||
secret?.value &&
|
}`}
|
||||||
"duration-50 text-bunker-800 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400",
|
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
|
||||||
!secret?.value && "justify-center text-bunker-400"
|
>
|
||||||
)}
|
{value === undefined ? (
|
||||||
>
|
<span className="cursor-default font-sans text-xs italic text-red-500/80">
|
||||||
{syntaxHighlight(secret?.value)}
|
<FontAwesomeIcon icon={faMinus} className="mt-1" />
|
||||||
</div>
|
</span>
|
||||||
{isSecretValueHidden && secret?.value && (
|
) : (
|
||||||
<div className="duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden">
|
syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)
|
||||||
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
|
)}
|
||||||
{(isOverridden ? secret.valueOverride : secret?.value || "")
|
</code>
|
||||||
?.split("")
|
</pre>
|
||||||
.map((_a: string, index: number) => (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
key={`${secret?.value}_${index + 1}`}
|
|
||||||
className="mr-0.5 text-xxs"
|
|
||||||
icon={faCircle}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{(isOverridden ? secret.valueOverride : secret?.value || "")?.split("").length ===
|
|
||||||
0 && <span className="text-sm text-bunker-400/80">EMPTY</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -122,14 +102,14 @@ export const EnvComparisonRow = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="group flex min-w-full flex-row items-center hover:bg-mineshaft-800">
|
<tr className="group flex min-w-full flex-row hover:bg-mineshaft-800">
|
||||||
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
<td className="flex w-10 justify-center border-none px-4">
|
||||||
<div className="w-10 text-center text-xs text-bunker-400">
|
<div className="flex h-8 w-10 items-center justify-center text-center text-xs text-bunker-400">
|
||||||
<FontAwesomeIcon icon={faKey} />
|
<FontAwesomeIcon icon={faKey} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
|
<td className="flex min-w-[200px] flex-row justify-between lg:min-w-[220px] xl:min-w-[250px]">
|
||||||
<div className="flex h-8 cursor-default flex-row items-center truncate">
|
<div className="flex h-8 cursor-default flex-row items-center justify-center truncate">
|
||||||
{secrets![0].key || ""}
|
{secrets![0].key || ""}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ export const SecretDetailDrawer = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
||||||
<div className="rounded-sm bg-primary-500/30 px-1">Value:</div>
|
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||||
<div className="font-mono">{value}</div>
|
<div className="break-all font-mono">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useRef } from "react";
|
||||||
import { useFormContext, useWatch } from "react-hook-form";
|
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||||
import { faCircle } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { useSyntaxHighlight, useToggle } from "@app/hooks";
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
import { FormData } from "../../DashboardPage.utils";
|
import { FormData } from "../../DashboardPage.utils";
|
||||||
|
|
||||||
@@ -13,95 +12,94 @@ type Props = {
|
|||||||
index: number;
|
index: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const REGEX = /([$]{.*?})/g;
|
const SEC_VAL_LINE_HEIGHT = 21;
|
||||||
|
const MAX_MULTI_LINE = 6;
|
||||||
|
|
||||||
export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridden }: Props) => {
|
export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridden }: Props) => {
|
||||||
const { register, control } = useFormContext<FormData>();
|
const { control } = useFormContext<FormData>();
|
||||||
|
const ref = useRef<HTMLElement | null>(null);
|
||||||
|
const [isFocused, setIsFocused] = useToggle();
|
||||||
|
const syntaxHighlight = useSyntaxHighlight();
|
||||||
|
|
||||||
const secretValue = useWatch({ control, name: `secrets.${index}.value` });
|
const secretValue = useWatch({ control, name: `secrets.${index}.value` });
|
||||||
const secretValueOverride = useWatch({ control, name: `secrets.${index}.valueOverride` });
|
const secretValueOverride = useWatch({ control, name: `secrets.${index}.valueOverride` });
|
||||||
const value = isOverridden ? secretValueOverride : secretValue;
|
const value = isOverridden ? secretValueOverride : secretValue;
|
||||||
|
|
||||||
const syntaxHighlight = useCallback((val: string) => {
|
const multilineExpandUnit = ((value?.match(/\n/g)?.length || 0) + 1) * SEC_VAL_LINE_HEIGHT;
|
||||||
if (val?.length === 0) return <span className="font-sans text-bunker-400/80">EMPTY</span>;
|
const maxMultilineHeight = Math.min(multilineExpandUnit, 21 * MAX_MULTI_LINE);
|
||||||
return val?.split(REGEX).map((word, i) =>
|
|
||||||
word.match(REGEX) !== null ? (
|
|
||||||
<span className="ph-no-capture text-yellow" key={`${val}-${i + 1}`}>
|
|
||||||
{word.slice(0, 2)}
|
|
||||||
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
|
|
||||||
{word.slice(word.length - 1, word.length) === "}" ? (
|
|
||||||
<span className="ph-no-capture text-yellow">
|
|
||||||
{word.slice(word.length - 1, word.length)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="ph-no-capture text-yellow-400">
|
|
||||||
{word.slice(word.length - 1, word.length)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span key={`${word}_${i + 1}`} className="ph-no-capture">
|
|
||||||
{word}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex w-full flex-col justify-center whitespace-pre px-1.5">
|
<div className="group relative flex w-full flex-col whitespace-pre px-1.5 pt-1.5">
|
||||||
{isOverridden ? (
|
{isOverridden ? (
|
||||||
<input
|
<Controller
|
||||||
{...register(`secrets.${index}.valueOverride`)}
|
control={control}
|
||||||
readOnly={isReadOnly}
|
name={`secrets.${index}.valueOverride`}
|
||||||
className={twMerge(
|
render={({ field }) => (
|
||||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar",
|
<textarea
|
||||||
!isSecretValueHidden &&
|
key={`secrets.${index}.valueOverride`}
|
||||||
"text-transparent focus:text-transparent active:text-transparent"
|
{...field}
|
||||||
|
readOnly={isReadOnly}
|
||||||
|
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
|
||||||
|
style={{ height: `${maxMultilineHeight}px` }}
|
||||||
|
spellCheck="false"
|
||||||
|
onBlur={() => setIsFocused.off()}
|
||||||
|
onFocus={() => setIsFocused.on()}
|
||||||
|
onInput={(el) => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||||
|
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onScroll={(el) => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||||
|
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
spellCheck="false"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<Controller
|
||||||
{...register(`secrets.${index}.value`)}
|
control={control}
|
||||||
readOnly={isReadOnly}
|
name={`secrets.${index}.value`}
|
||||||
className={twMerge(
|
key={`secrets.${index}.value`}
|
||||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 peer z-10 w-full bg-transparent px-2 py-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar",
|
render={({ field }) => (
|
||||||
!isSecretValueHidden &&
|
<textarea
|
||||||
"text-transparent focus:text-transparent active:text-transparent"
|
{...field}
|
||||||
|
readOnly={isReadOnly}
|
||||||
|
className="ph-no-capture min-w-16 duration-50 peer z-20 w-full resize-none overflow-auto text-ellipsis bg-transparent px-2 font-mono text-sm text-transparent caret-white outline-none no-scrollbar"
|
||||||
|
style={{ height: `${maxMultilineHeight}px` }}
|
||||||
|
spellCheck="false"
|
||||||
|
onBlur={() => setIsFocused.off()}
|
||||||
|
onFocus={() => setIsFocused.on()}
|
||||||
|
onInput={(el) => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||||
|
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onScroll={(el) => {
|
||||||
|
if (ref.current) {
|
||||||
|
ref.current.scrollTop = el.currentTarget.scrollTop;
|
||||||
|
ref.current.scrollLeft = el.currentTarget.scrollLeft;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
spellCheck="false"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<pre className="whitespace-pre-wrap break-words">
|
||||||
className={twMerge(
|
<code
|
||||||
"ph-no-capture min-w-16 no-scrollbar::-webkit-scrollbar duration-50 absolute z-0 mt-0.5 flex h-10 w-full flex-row overflow-x-scroll whitespace-pre bg-transparent px-2 py-2 font-mono text-sm outline-none no-scrollbar peer-focus:visible",
|
ref={ref}
|
||||||
isSecretValueHidden ? "invisible" : "visible",
|
className={`absolute top-1.5 left-3.5 z-10 w-full overflow-auto font-mono text-sm transition-all no-scrollbar ${
|
||||||
isOverridden
|
isOverridden && "text-primary-300"
|
||||||
? "text-primary-300"
|
}`}
|
||||||
: "duration-50 text-gray-400 group-hover:text-gray-400 peer-focus:text-gray-100 peer-active:text-gray-400"
|
style={{ height: `${maxMultilineHeight}px`, width: "calc(100% - 12px)" }}
|
||||||
)}
|
>
|
||||||
>
|
{syntaxHighlight(value || "", isSecretValueHidden ? !isFocused : isSecretValueHidden)}
|
||||||
{syntaxHighlight(value || "")}
|
</code>
|
||||||
</div>
|
</pre>
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
"duration-50 peer absolute z-0 flex h-10 w-full flex-row items-center justify-between text-clip pr-2 text-bunker-400 group-hover:bg-white/[0.00] peer-focus:hidden peer-active:hidden",
|
|
||||||
!isSecretValueHidden ? "invisible" : "visible"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="no-scrollbar::-webkit-scrollbar flex flex-row items-center overflow-x-scroll px-2 no-scrollbar">
|
|
||||||
{value?.split("").map((val, i) => (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
key={`${value}_${val}_${i + 1}`}
|
|
||||||
className="mr-0.5 text-xxs"
|
|
||||||
icon={faCircle}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{value?.split("").length === 0 && (
|
|
||||||
<span className="text-sm text-bunker-400/80">EMPTY</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export const SecretInputRow = memo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="group flex flex-row items-center" key={index}>
|
<tr className="group flex flex-row" key={index}>
|
||||||
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
||||||
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
|
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -198,7 +198,7 @@ export const SecretInputRow = memo(
|
|||||||
</HoverCard>
|
</HoverCard>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<td className="flex h-10 w-full flex-grow flex-row items-center justify-center border-r border-none border-red">
|
<td className="flex w-full flex-grow flex-row border-r border-none border-red">
|
||||||
<MaskedInput
|
<MaskedInput
|
||||||
isReadOnly={
|
isReadOnly={
|
||||||
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
||||||
@@ -208,8 +208,8 @@ export const SecretInputRow = memo(
|
|||||||
index={index}
|
index={index}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="min-w-sm flex h-10 items-center">
|
<td className="min-w-sm flex">
|
||||||
<div className="flex items-center pl-2">
|
<div className="flex h-8 items-center pl-2">
|
||||||
{secretTags.map(({ id, slug }, i) => (
|
{secretTags.map(({ id, slug }, i) => (
|
||||||
<Tag
|
<Tag
|
||||||
className={cx(
|
className={cx(
|
||||||
@@ -289,7 +289,7 @@ export const SecretInputRow = memo(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-row items-center pr-2">
|
<div className="flex h-8 flex-row items-center pr-2">
|
||||||
{!isAddOnly && (
|
{!isAddOnly && (
|
||||||
<div>
|
<div>
|
||||||
<Tooltip content="Override with a personal value">
|
<Tooltip content="Override with a personal value">
|
||||||
@@ -346,35 +346,37 @@ export const SecretInputRow = memo(
|
|||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
<div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all">
|
||||||
{!isAddOnly && (
|
<div className="flex h-8 items-center space-x-2.5">
|
||||||
|
{!isAddOnly && (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
|
<Tooltip content="Settings">
|
||||||
|
<IconButton
|
||||||
|
size="lg"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
onClick={onRowExpand}
|
||||||
|
ariaLabel="expand"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsis} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="opacity-0 group-hover:opacity-100">
|
<div className="opacity-0 group-hover:opacity-100">
|
||||||
<Tooltip content="Settings">
|
<Tooltip content="Delete">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="lg"
|
size="md"
|
||||||
colorSchema="primary"
|
|
||||||
variant="plain"
|
variant="plain"
|
||||||
onClick={onRowExpand}
|
colorSchema="danger"
|
||||||
ariaLabel="expand"
|
ariaLabel="delete"
|
||||||
|
isDisabled={isReadOnly || isRollbackMode}
|
||||||
|
onClick={() => onSecretDelete(index, secId, idOverride)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faEllipsis} />
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="opacity-0 group-hover:opacity-100">
|
|
||||||
<Tooltip content="Delete">
|
|
||||||
<IconButton
|
|
||||||
size="md"
|
|
||||||
variant="plain"
|
|
||||||
colorSchema="danger"
|
|
||||||
ariaLabel="delete"
|
|
||||||
isDisabled={isReadOnly || isRollbackMode}
|
|
||||||
onClick={() => onSecretDelete(index, secId, idOverride)}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faXmark} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -178,7 +178,9 @@ export const IntegrationsPage = ({ frameworkIntegrations }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-7xl px-8 pb-12 text-white">
|
<div className="container mx-auto max-w-7xl px-8 pb-12 text-white">
|
||||||
<NavHeader pageName={t("integrations.title")} isProjectRelated />
|
<div className="ml-4">
|
||||||
|
<NavHeader pageName={t("integrations.title")} isProjectRelated />
|
||||||
|
</div>
|
||||||
<IntegrationsSection
|
<IntegrationsSection
|
||||||
isLoading={isIntegrationLoading}
|
isLoading={isIntegrationLoading}
|
||||||
integrations={integrations}
|
integrations={integrations}
|
||||||
|
|||||||
@@ -9,16 +9,13 @@ import {
|
|||||||
export const BillingSettingsPage = () => {
|
export const BillingSettingsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="h-full py-8 px-4">
|
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||||
<NavHeader pageName={t("billing.title")} />
|
<div className="max-w-screen-lg w-full">
|
||||||
|
<NavHeader pageName={t("billing.title")} />
|
||||||
<div className="ml-4 flex text-3xl mt-8 items-start max-w-screen-lg">
|
<div className="my-8">
|
||||||
<div className="flex-1">
|
<p className="text-3xl font-semibold text-gray-200">{t("billing.title")}</p>
|
||||||
<p className="font-semibold text-gray-200">{t("billing.title")}</p>
|
<div />
|
||||||
</div>
|
</div>
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<BillingTabGroup />
|
<BillingTabGroup />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const CurrentPlanSection = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<h2 className="text-xl font-semibold flex-1 text-white mb-8">Current Usage</h2>
|
<h2 className="text-xl font-semibold flex-1 text-white mb-8">Current Usage</h2>
|
||||||
<TableContainer className="mt-4">
|
<TableContainer className="mt-4">
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const PreviewSection = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!isSubscriptionLoading && subscription?.slug !== "enterprise" && subscription?.slug !== "pro" && subscription?.slug !== "pro-annual" && (
|
{!isSubscriptionLoading && subscription?.slug !== "enterprise" && subscription?.slug !== "pro" && subscription?.slug !== "pro-annual" && (
|
||||||
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mt-8 flex items-center bg-mineshaft-600 max-w-screen-lg">
|
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 border border-mineshaft-600 mb-6 flex items-center bg-mineshaft-600 max-w-screen-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h2 className="text-xl font-semibold text-mineshaft-50">Become Infisical</h2>
|
<h2 className="text-xl font-semibold text-mineshaft-50">Become Infisical</h2>
|
||||||
<p className="text-gray-400 mt-4">Unlimited members, projects, RBAC, smart alerts, and so much more</p>
|
<p className="text-gray-400 mt-4">Unlimited members, projects, RBAC, smart alerts, and so much more</p>
|
||||||
@@ -58,8 +58,8 @@ export const PreviewSection = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoading && data && subscription && (
|
{!isLoading && subscription && data && (
|
||||||
<div className="flex mt-8 max-w-screen-lg">
|
<div className="flex mb-6 max-w-screen-lg">
|
||||||
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 mr-4 border border-mineshaft-600">
|
<div className="p-4 bg-mineshaft-900 rounded-lg flex-1 mr-4 border border-mineshaft-600">
|
||||||
<p className="mb-2 text-gray-400">Current plan</p>
|
<p className="mb-2 text-gray-400">Current plan</p>
|
||||||
<p className="text-2xl mb-8 text-mineshaft-50 font-semibold">
|
<p className="text-2xl mb-8 text-mineshaft-50 font-semibold">
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
|
||||||
import Button from "@app/components/basic/buttons/Button";
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import { FormControl,Input } from "@app/components/v2";
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
import {
|
import {
|
||||||
useGetOrgBillingDetails,
|
useGetOrgBillingDetails,
|
||||||
@@ -26,7 +28,7 @@ export const CompanyNameSection = () => {
|
|||||||
resolver: yupResolver(schema)
|
resolver: yupResolver(schema)
|
||||||
});
|
});
|
||||||
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
|
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
|
||||||
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
|
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -40,7 +42,7 @@ export const CompanyNameSection = () => {
|
|||||||
try {
|
try {
|
||||||
if (!currentOrg?._id) return;
|
if (!currentOrg?._id) return;
|
||||||
if (name === "") return;
|
if (name === "") return;
|
||||||
await updateOrgBillingDetails.mutateAsync({
|
await mutateAsync({
|
||||||
name,
|
name,
|
||||||
organizationId: currentOrg._id
|
organizationId: currentOrg._id
|
||||||
});
|
});
|
||||||
@@ -61,7 +63,7 @@ export const CompanyNameSection = () => {
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||||
Business name
|
Business name
|
||||||
@@ -82,15 +84,14 @@ export const CompanyNameSection = () => {
|
|||||||
name="name"
|
name="name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-block">
|
<Button
|
||||||
<Button
|
type="submit"
|
||||||
text="Save"
|
colorSchema="secondary"
|
||||||
type="submit"
|
isLoading={isLoading}
|
||||||
color="mineshaft"
|
isDisabled={isLoading}
|
||||||
size="md"
|
>
|
||||||
onButtonPressed={() => console.log("Saved company name")}
|
Save
|
||||||
/>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
|
||||||
import Button from "@app/components/basic/buttons/Button";
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
FormControl,
|
FormControl,
|
||||||
Input} from "@app/components/v2";
|
Input} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
@@ -28,7 +28,7 @@ export const InvoiceEmailSection = () => {
|
|||||||
resolver: yupResolver(schema)
|
resolver: yupResolver(schema)
|
||||||
});
|
});
|
||||||
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
|
const { data } = useGetOrgBillingDetails(currentOrg?._id ?? "");
|
||||||
const updateOrgBillingDetails = useUpdateOrgBillingDetails();
|
const { mutateAsync, isLoading } = useUpdateOrgBillingDetails();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -43,7 +43,7 @@ export const InvoiceEmailSection = () => {
|
|||||||
if (!currentOrg?._id) return;
|
if (!currentOrg?._id) return;
|
||||||
if (email === "") return;
|
if (email === "") return;
|
||||||
|
|
||||||
await updateOrgBillingDetails.mutateAsync({
|
await mutateAsync({
|
||||||
email,
|
email,
|
||||||
organizationId: currentOrg._id
|
organizationId: currentOrg._id
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@ export const InvoiceEmailSection = () => {
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onFormSubmit)}
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold flex-1 text-white mb-8">
|
<h2 className="text-xl font-semibold flex-1 text-white mb-8">
|
||||||
Invoice email recipient
|
Invoice email recipient
|
||||||
@@ -85,15 +85,13 @@ export const InvoiceEmailSection = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-block">
|
<Button
|
||||||
<Button
|
type="submit"
|
||||||
text="Save"
|
colorSchema="secondary"
|
||||||
type="submit"
|
isLoading={isLoading}
|
||||||
color="mineshaft"
|
>
|
||||||
size="md"
|
Save
|
||||||
onButtonPressed={() => console.log("Saved email address")}
|
</Button>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
import Button from "@app/components/basic/buttons/Button";
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button
|
||||||
|
} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
import { useAddOrgPmtMethod } from "@app/hooks/api";
|
import { useAddOrgPmtMethod } from "@app/hooks/api";
|
||||||
|
|
||||||
@@ -8,11 +12,11 @@ import { PmtMethodsTable } from "./PmtMethodsTable";
|
|||||||
|
|
||||||
export const PmtMethodsSection = () => {
|
export const PmtMethodsSection = () => {
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const addOrgPmtMethod = useAddOrgPmtMethod();
|
const { mutateAsync, isLoading } = useAddOrgPmtMethod();
|
||||||
|
|
||||||
const handleAddPmtMethodBtnClick = async () => {
|
const handleAddPmtMethodBtnClick = async () => {
|
||||||
if (!currentOrg?._id) return;
|
if (!currentOrg?._id) return;
|
||||||
const url = await addOrgPmtMethod.mutateAsync({
|
const url = await mutateAsync({
|
||||||
organizationId: currentOrg._id,
|
organizationId: currentOrg._id,
|
||||||
success_url: window.location.href,
|
success_url: window.location.href,
|
||||||
cancel_url: window.location.href
|
cancel_url: window.location.href
|
||||||
@@ -22,21 +26,19 @@ export const PmtMethodsSection = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<div className="flex items-center mb-8">
|
<div className="flex items-center mb-8">
|
||||||
<h2 className="text-xl font-semibold flex-1 text-white">
|
<h2 className="text-xl font-semibold flex-1 text-white">
|
||||||
Payment Methods
|
Payment Methods
|
||||||
</h2>
|
</h2>
|
||||||
<div className="inline-block">
|
<Button
|
||||||
<Button
|
onClick={handleAddPmtMethodBtnClick}
|
||||||
text="Add method"
|
colorSchema="secondary"
|
||||||
type="submit"
|
isLoading={isLoading}
|
||||||
color="mineshaft"
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
size="md"
|
>
|
||||||
icon={faPlus}
|
Add method
|
||||||
onButtonPressed={handleAddPmtMethodBtnClick}
|
</Button>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<PmtMethodsTable />
|
<PmtMethodsTable />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
import Button from "@app/components/basic/buttons/Button";
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/v2";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
import { TaxIDModal } from "./TaxIDModal";
|
import { TaxIDModal } from "./TaxIDModal";
|
||||||
@@ -12,21 +14,18 @@ export const TaxIDSection = () => {
|
|||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<div className="flex items-center mb-8">
|
<div className="flex items-center mb-8">
|
||||||
<h2 className="text-xl font-semibold flex-1 text-white">
|
<h2 className="text-xl font-semibold flex-1 text-white">
|
||||||
Tax ID
|
Tax ID
|
||||||
</h2>
|
</h2>
|
||||||
<div className="inline-block">
|
<Button
|
||||||
<Button
|
onClick={() => handlePopUpOpen("addTaxID")}
|
||||||
text="Add Tax ID"
|
colorSchema="secondary"
|
||||||
type="submit"
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
color="mineshaft"
|
>
|
||||||
size="md"
|
Add method
|
||||||
icon={faPlus}
|
</Button>
|
||||||
onButtonPressed={() => handlePopUpOpen("addTaxID")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<TaxIDTable />
|
<TaxIDTable />
|
||||||
<TaxIDModal
|
<TaxIDModal
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { InvoicesTable } from "./InvoicesTable";
|
|||||||
|
|
||||||
export const BillingReceiptsTab = () => {
|
export const BillingReceiptsTab = () => {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-mineshaft-900 mt-8 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<h2 className="text-xl font-semibold flex-1 text-white">Invoices</h2>
|
<h2 className="text-xl font-semibold flex-1 text-white">Invoices</h2>
|
||||||
<InvoicesTable />
|
<InvoicesTable />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const tabs = [
|
|||||||
export const BillingTabGroup = () => {
|
export const BillingTabGroup = () => {
|
||||||
return (
|
return (
|
||||||
<Tab.Group>
|
<Tab.Group>
|
||||||
<Tab.List className="mt-8 border-b-2 border-mineshaft-800 max-w-screen-lg">
|
<Tab.List className="mt-8 mb-6 border-b-2 border-mineshaft-800 max-w-screen-lg">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<Tab as={Fragment} key={tab.key}>
|
<Tab as={Fragment} key={tab.key}>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
|
|||||||
@@ -1,301 +1,66 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
|
||||||
import NavHeader from "@app/components/navigation/NavHeader";
|
import NavHeader from "@app/components/navigation/NavHeader";
|
||||||
import {
|
|
||||||
decryptAssymmetric,
|
|
||||||
encryptAssymmetric
|
|
||||||
} from "@app/components/utilities/cryptography/crypto";
|
|
||||||
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context";
|
|
||||||
import {
|
|
||||||
useAddIncidentContact,
|
|
||||||
useAddUserToOrg,
|
|
||||||
useDeleteIncidentContact,
|
|
||||||
useDeleteOrgMembership,
|
|
||||||
useGetOrgIncidentContact,
|
|
||||||
useGetOrgUsers,
|
|
||||||
useGetUserWorkspaceMemberships,
|
|
||||||
useGetUserWsKey,
|
|
||||||
useRenameOrg,
|
|
||||||
useUpdateOrgUserRole,
|
|
||||||
useUploadWsKey
|
|
||||||
} from "@app/hooks/api";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
OrgIncidentContactsTable,
|
OrgIncidentContactsSection,
|
||||||
OrgMembersTable,
|
OrgMembersSection,
|
||||||
OrgNameChangeSection,
|
OrgNameChangeSection,
|
||||||
OrgServiceAccountsTable
|
OrgServiceAccountsTable
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
|
||||||
export const OrgSettingsPage = () => {
|
export const OrgSettingsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currentOrg } = useOrganization();
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
const { user } = useUser();
|
|
||||||
const { subscription } = useSubscription();
|
|
||||||
const { createNotification } = useNotificationContext();
|
|
||||||
|
|
||||||
const orgId = currentOrg?._id || "";
|
|
||||||
|
|
||||||
const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId);
|
|
||||||
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } =
|
|
||||||
useGetUserWorkspaceMemberships(orgId);
|
|
||||||
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
|
|
||||||
const { data: incidentContact, isLoading: IsIncidentContactLoading } =
|
|
||||||
useGetOrgIncidentContact(orgId);
|
|
||||||
|
|
||||||
const renameOrg = useRenameOrg();
|
|
||||||
const removeUserOrgMembership = useDeleteOrgMembership();
|
|
||||||
const addUserToOrg = useAddUserToOrg();
|
|
||||||
const updateOrgUserRole = useUpdateOrgUserRole();
|
|
||||||
const uploadWsKey = useUploadWsKey();
|
|
||||||
const addIncidentContact = useAddIncidentContact();
|
|
||||||
const removeIncidentContact = useDeleteIncidentContact();
|
|
||||||
|
|
||||||
const [completeInviteLink, setcompleteInviteLink] = useState<string | undefined>("");
|
|
||||||
|
|
||||||
const isMoreUsersNotAllowed = subscription?.memberLimit ? (subscription.membersUsed >= subscription.memberLimit) : false;
|
|
||||||
|
|
||||||
const onRenameOrg = async (name: string) => {
|
|
||||||
if (!currentOrg?._id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await renameOrg.mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully renamed organization",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to rename organization",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemoveUserOrgMembership = async (membershipId: string) => {
|
|
||||||
if (!currentOrg?._id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully removed user from org",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to remove user from the organization",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAddUserToOrg = async (email: string) => {
|
|
||||||
if (!currentOrg?._id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data } = await addUserToOrg.mutateAsync({
|
|
||||||
organizationId: currentOrg?._id,
|
|
||||||
inviteeEmail: email
|
|
||||||
});
|
|
||||||
setcompleteInviteLink(data?.completeInviteLink);
|
|
||||||
|
|
||||||
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
|
|
||||||
if (!data.completeInviteLink) {
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully invited user to the organization.",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to invite user to org",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUpdateOrgUserRole = async (membershipId: string, role: string) => {
|
|
||||||
if (!currentOrg?._id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully updated user role",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to update user role",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onGrantUserAccess = async (userId: string, publicKey: string) => {
|
|
||||||
try {
|
|
||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
|
||||||
if (!PRIVATE_KEY || !wsKey) return;
|
|
||||||
|
|
||||||
// assymmetrically decrypt symmetric key with local private key
|
|
||||||
const key = decryptAssymmetric({
|
|
||||||
ciphertext: wsKey.encryptedKey,
|
|
||||||
nonce: wsKey.nonce,
|
|
||||||
publicKey: wsKey.sender.publicKey,
|
|
||||||
privateKey: PRIVATE_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
const { ciphertext, nonce } = encryptAssymmetric({
|
|
||||||
plaintext: key,
|
|
||||||
publicKey,
|
|
||||||
privateKey: PRIVATE_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
await uploadWsKey.mutateAsync({
|
|
||||||
userId,
|
|
||||||
nonce,
|
|
||||||
encryptedKey: ciphertext,
|
|
||||||
workspaceId: currentWorkspace?._id || ""
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to grant access to user",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAddIncidentContact = async (email: string) => {
|
|
||||||
if (!currentOrg?._id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await addIncidentContact.mutateAsync({ orgId, email });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully added incident contact",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to add incident contact",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemoveIncidentContact = async (email: string) => {
|
|
||||||
if (!currentOrg?._id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await removeIncidentContact.mutateAsync({ orgId, email });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully removed incident contact",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to remove incident contact",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||||
|
<div className="max-w-screen-lg w-full">
|
||||||
<NavHeader pageName={t("settings.org.title")} />
|
<NavHeader pageName={t("settings.org.title")} />
|
||||||
<div className="my-8 ml-8 max-w-5xl">
|
<div className="my-8">
|
||||||
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
|
<p className="text-3xl font-semibold text-gray-200">{t("settings.org.title")}</p>
|
||||||
<p className="text-base font-normal text-gray-400">{t("settings.org.description")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-8xl ml-6 mr-6 flex flex-col text-mineshaft-50">
|
<OrgNameChangeSection />
|
||||||
<OrgNameChangeSection orgName={currentOrg?.name} onOrgNameChange={onRenameOrg} />
|
<OrgMembersSection />
|
||||||
<div className="mb-6 w-full rounded-md bg-white/5 p-6">
|
<OrgIncidentContactsSection />
|
||||||
<p className="mr-4 mb-4 text-xl font-semibold text-white">
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
{t("section.members.org-members")}
|
|
||||||
</p>
|
|
||||||
<OrgMembersTable
|
|
||||||
isLoading={isOrgUserLoading || IsWsMembershipLoading}
|
|
||||||
isMoreUserNotAllowed={isMoreUsersNotAllowed}
|
|
||||||
orgName={currentOrg?.name || ""}
|
|
||||||
members={orgUsers}
|
|
||||||
workspaceMemberships={workspaceMemberships}
|
|
||||||
onInviteMember={onAddUserToOrg}
|
|
||||||
userId={user?._id || ""}
|
|
||||||
onRemoveMember={onRemoveUserOrgMembership}
|
|
||||||
onRoleChange={onUpdateOrgUserRole}
|
|
||||||
onGrantAccess={onGrantUserAccess}
|
|
||||||
completeInviteLink={completeInviteLink}
|
|
||||||
setCompleteInviteLink={setcompleteInviteLink}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-6 mt-2 w-full rounded-md bg-white/5 p-6">
|
|
||||||
<p className="mr-4 mb-4 text-xl font-semibold text-white">Service Accounts</p>
|
|
||||||
<OrgServiceAccountsTable />
|
<OrgServiceAccountsTable />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pt-6 pb-6">
|
|
||||||
<div className="flex w-full max-w-5xl flex-row items-center justify-between">
|
|
||||||
<div className="flex w-full max-w-3xl flex-col justify-between">
|
|
||||||
<p className="mb-3 min-w-max text-xl font-semibold">
|
|
||||||
{t("section.incident.incident-contacts")}
|
|
||||||
</p>
|
|
||||||
<p className="mb-2 min-w-max text-xs text-gray-500">
|
|
||||||
{t("section.incident.incident-contacts-description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<OrgIncidentContactsTable
|
|
||||||
isLoading={IsIncidentContactLoading}
|
|
||||||
contacts={incidentContact}
|
|
||||||
onRemoveContact={onRemoveIncidentContact}
|
|
||||||
onAddContact={onAddIncidentContact}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* <div className="border-l border-red pb-4 pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pt-2 max-w-6xl">
|
{/* <div className="border-l border-red pb-4 pl-6 flex flex-col items-start flex flex-col items-start w-full mb-6 mt-4 pt-2 max-w-6xl">
|
||||||
<p className="text-xl font-bold text-red">
|
<p className="text-xl font-bold text-red">
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 text-md text-gray-400">
|
<p className="mt-4 text-md text-gray-400">
|
||||||
As soon as you delete an organization, you will
|
As soon as you delete an organization, you will
|
||||||
not be able to undo it. This will immediately
|
not be able to undo it. This will immediately
|
||||||
remove all organization members and cancel your
|
remove all organization members and cancel your
|
||||||
subscription. If you still want to do that,
|
subscription. If you still want to do that,
|
||||||
please enter the name of the organization below.
|
please enter the name of the organization below.
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
|
<div className="max-h-28 w-full max-w-xl mr-auto mt-8 max-w-xl">
|
||||||
<InputField
|
<InputField
|
||||||
label="Organization to be Deleted"
|
label="Organization to be Deleted"
|
||||||
onChangeHandler={
|
onChangeHandler={
|
||||||
setWorkspaceToBeDeletedName
|
setWorkspaceToBeDeletedName
|
||||||
}
|
}
|
||||||
type="varName"
|
type="varName"
|
||||||
value={workspaceToBeDeletedName}
|
value={workspaceToBeDeletedName}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
isRequired
|
isRequired
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
className="mt-6 w-full max-w-xl inline-flex justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-400 hover:bg-red hover:text-white hover:font-bold hover:text-semibold duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||||
onClick={executeDeletingWorkspace}
|
onClick={executeDeletingWorkspace}
|
||||||
>
|
>
|
||||||
Delete Project
|
Delete Project
|
||||||
</button>
|
</button>
|
||||||
<p className="mt-0.5 ml-1 text-xs text-gray-500">
|
<p className="mt-0.5 ml-1 text-xs text-gray-500">
|
||||||
Note: You can only delete a project in case you
|
Note: You can only delete a project in case you
|
||||||
have more than one.
|
have more than one.
|
||||||
</p>
|
</p>
|
||||||
</div> */}
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useOrganization } from "@app/context";
|
||||||
|
import {
|
||||||
|
useAddIncidentContact
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const addContactFormSchema = yup.object({
|
||||||
|
email: yup.string().email().required().label("Email").trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAddContactForm = yup.InferType<typeof addContactFormSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["addContact"]>;
|
||||||
|
handlePopUpClose: (popUpName: keyof UsePopUpState<["addContact"]>) => void;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addContact"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddOrgIncidentContactModal = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpClose,
|
||||||
|
handlePopUpToggle
|
||||||
|
}: Props) => {
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { data: serverDetails } = useFetchServerStatus()
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset
|
||||||
|
} = useForm<TAddContactForm>({ resolver: yupResolver(addContactFormSchema) });
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAddIncidentContact();
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ email }: TAddContactForm) => {
|
||||||
|
try {
|
||||||
|
if (!currentOrg?._id) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
orgId: currentOrg._id,
|
||||||
|
email
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully added incident contact",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (serverDetails?.emailConfigured){
|
||||||
|
handlePopUpClose("addContact");
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to add incident contact",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.addContact?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("addContact", isOpen);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title="Add an Incident Contact"
|
||||||
|
subTitle="This contact will be notified in the unlikely event of a severe incident."
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="email"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Add Incident Contact
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
variant="plain"
|
||||||
|
onClick={() => handlePopUpClose("addContact")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
|
||||||
|
import { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal";
|
||||||
|
import { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
||||||
|
|
||||||
|
export const OrgIncidentContactsSection = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
|
"addContact"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
|
<div className="flex justify-between mb-8">
|
||||||
|
<p className="min-w-max text-xl font-semibold">
|
||||||
|
{t("section.incident.incident-contacts")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => handlePopUpOpen("addContact")}
|
||||||
|
>
|
||||||
|
Add contact
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<OrgIncidentContactsTable />
|
||||||
|
<AddOrgIncidentContactModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpClose={handlePopUpClose}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
faContactBook,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faTrash
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
DeleteActionModal,
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useOrganization } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useDeleteIncidentContact,
|
||||||
|
useGetOrgIncidentContact} from "@app/hooks/api";
|
||||||
|
|
||||||
|
export const OrgIncidentContactsTable = () => {
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { data: contacts, isLoading } = useGetOrgIncidentContact(currentOrg?._id ?? "");
|
||||||
|
const [searchContact, setSearchContact] = useState("");
|
||||||
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
|
"removeContact",
|
||||||
|
"setUpEmail"
|
||||||
|
] as const);
|
||||||
|
const { mutateAsync } = useDeleteIncidentContact();
|
||||||
|
|
||||||
|
const onRemoveIncidentContact = async () => {
|
||||||
|
try {
|
||||||
|
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
|
||||||
|
|
||||||
|
if (!currentOrg?._id) return;
|
||||||
|
await mutateAsync({
|
||||||
|
orgId: currentOrg._id,
|
||||||
|
email: incidentContactEmail
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully removed incident contact",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("removeContact");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to remove incident contact",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredContacts = contacts ? contacts.filter(({ email }) =>
|
||||||
|
email.toLocaleLowerCase().includes(searchContact)
|
||||||
|
) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
value={searchContact}
|
||||||
|
onChange={(e) => setSearchContact(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search incident contact by email..."
|
||||||
|
/>
|
||||||
|
<TableContainer className="mt-4">
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Email</Th>
|
||||||
|
<Th aria-label="actions" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && <TableSkeleton columns={2} key="incident-contact" />}
|
||||||
|
{filteredContacts?.map(({ email }) => (
|
||||||
|
<Tr key={email}>
|
||||||
|
<Td className="w-full">{email}</Td>
|
||||||
|
<Td className="mr-4">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="delete"
|
||||||
|
colorSchema="danger"
|
||||||
|
onClick={() => handlePopUpOpen("removeContact", { email })}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{filteredContacts?.length === 0 && !isLoading && (
|
||||||
|
<EmptyState title="No incident contacts found" icon={faContactBook} />
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.removeContact.isOpen}
|
||||||
|
deleteKey="remove"
|
||||||
|
title="Do you want to remove this email from incident contact?"
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("removeContact", isOpen)}
|
||||||
|
onDeleteApproved={onRemoveIncidentContact}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { AddOrgIncidentContactModal } from "./AddOrgIncidentContactModal"
|
||||||
|
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import {
|
|
||||||
faContactBook,
|
|
||||||
faMagnifyingGlass,
|
|
||||||
faPlus,
|
|
||||||
faTrash
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DeleteActionModal,
|
|
||||||
EmailServiceSetupModal,
|
|
||||||
EmptyState,
|
|
||||||
FormControl,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalContent,
|
|
||||||
Table,
|
|
||||||
TableContainer,
|
|
||||||
TableSkeleton,
|
|
||||||
TBody,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
THead,
|
|
||||||
Tr
|
|
||||||
} from "@app/components/v2";
|
|
||||||
import { usePopUp } from "@app/hooks";
|
|
||||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
|
||||||
import { IncidentContact } from "@app/hooks/api/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isLoading?: boolean;
|
|
||||||
contacts?: IncidentContact[];
|
|
||||||
onRemoveContact: (email: string) => Promise<void>;
|
|
||||||
onAddContact: (email: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const addContactFormSchema = yup.object({
|
|
||||||
email: yup.string().email().required().label("Email").trim()
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddContactForm = yup.InferType<typeof addContactFormSchema>;
|
|
||||||
|
|
||||||
export const OrgIncidentContactsTable = ({
|
|
||||||
contacts = [],
|
|
||||||
onAddContact,
|
|
||||||
onRemoveContact,
|
|
||||||
isLoading
|
|
||||||
}: Props) => {
|
|
||||||
const [searchContact, setSearchContact] = useState("");
|
|
||||||
const {data: serverDetails } = useFetchServerStatus()
|
|
||||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
|
||||||
"addContact",
|
|
||||||
"removeContact",
|
|
||||||
"setUpEmail"
|
|
||||||
] as const);
|
|
||||||
|
|
||||||
const {
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
formState: { isSubmitting }
|
|
||||||
} = useForm<TAddContactForm>({ resolver: yupResolver(addContactFormSchema) });
|
|
||||||
|
|
||||||
const onAddIncidentContact = ({ email }: TAddContactForm) => {
|
|
||||||
onAddContact(email);
|
|
||||||
handlePopUpClose("addContact");
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRemoveIncidentContact = async () => {
|
|
||||||
const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email;
|
|
||||||
await onRemoveContact(incidentContactEmail);
|
|
||||||
handlePopUpClose("removeContact");
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredContacts = contacts.filter(({ email }) =>
|
|
||||||
email.toLocaleLowerCase().includes(searchContact)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="mb-4 flex">
|
|
||||||
<div className="mr-4 flex-1">
|
|
||||||
<Input
|
|
||||||
value={searchContact}
|
|
||||||
onChange={(e) => setSearchContact(e.target.value)}
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
|
||||||
placeholder="Search incident contact by email..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
|
||||||
onClick={() => {
|
|
||||||
if (serverDetails?.emailConfigured){
|
|
||||||
handlePopUpOpen("addContact");
|
|
||||||
} else {
|
|
||||||
handlePopUpOpen("setUpEmail");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Contact
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<TableContainer>
|
|
||||||
<Table>
|
|
||||||
<THead>
|
|
||||||
<Tr>
|
|
||||||
<Th>Email</Th>
|
|
||||||
<Th aria-label="actions" />
|
|
||||||
</Tr>
|
|
||||||
</THead>
|
|
||||||
<TBody>
|
|
||||||
{isLoading && <TableSkeleton columns={2} key="incident-contact" />}
|
|
||||||
{filteredContacts?.map(({ email }) => (
|
|
||||||
<Tr key={email}>
|
|
||||||
<Td className="w-full">{email}</Td>
|
|
||||||
<Td className="mr-4">
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="delete"
|
|
||||||
colorSchema="danger"
|
|
||||||
onClick={() => handlePopUpOpen("removeContact", { email })}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
|
||||||
</IconButton>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
</TBody>
|
|
||||||
</Table>
|
|
||||||
{filteredContacts?.length === 0 && !isLoading && (
|
|
||||||
<EmptyState title="No incident contacts found" icon={faContactBook} />
|
|
||||||
)}
|
|
||||||
</TableContainer>
|
|
||||||
</div>
|
|
||||||
<Modal
|
|
||||||
isOpen={popUp?.addContact?.isOpen}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
handlePopUpToggle("addContact", isOpen);
|
|
||||||
reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModalContent
|
|
||||||
title="Add an Incident Contact"
|
|
||||||
subTitle="This contact will be notified in the unlikely event of a severe incident."
|
|
||||||
>
|
|
||||||
<form onSubmit={handleSubmit(onAddIncidentContact)}>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
defaultValue=""
|
|
||||||
name="email"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-8 flex items-center">
|
|
||||||
<Button
|
|
||||||
className="mr-4"
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
isDisabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Add Incident Contact
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
colorSchema="secondary"
|
|
||||||
variant="plain"
|
|
||||||
onClick={() => handlePopUpClose("addContact")}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
<DeleteActionModal
|
|
||||||
isOpen={popUp.removeContact.isOpen}
|
|
||||||
deleteKey="remove"
|
|
||||||
title="Do you want to remove this email from incident contact?"
|
|
||||||
onChange={(isOpen) => handlePopUpToggle("removeContact", isOpen)}
|
|
||||||
onDeleteApproved={onRemoveIncidentContact}
|
|
||||||
/>
|
|
||||||
<EmailServiceSetupModal
|
|
||||||
isOpen={popUp.setUpEmail?.isOpen}
|
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useOrganization } from "@app/context";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useAddUserToOrg
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const addMemberFormSchema = yup.object({
|
||||||
|
email: yup.string().email().required().label("Email").trim()
|
||||||
|
});
|
||||||
|
|
||||||
|
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>; // TODO: change to FormData
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["addMember"]>;
|
||||||
|
handlePopUpClose: (popUpName: keyof UsePopUpState<["addMember"]>) => void;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addMember"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: test no-SMTP setup case
|
||||||
|
|
||||||
|
export const AddOrgMemberModal = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpToggle,
|
||||||
|
handlePopUpClose
|
||||||
|
}: Props) => {
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { data: serverDetails } = useFetchServerStatus();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
|
||||||
|
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
||||||
|
const [completeInviteLink, setCompleteInviteLink] = useState<string | undefined>("");
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset
|
||||||
|
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = useAddUserToOrg();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
if (isInviteLinkCopied) {
|
||||||
|
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isInviteLinkCopied]);
|
||||||
|
|
||||||
|
const copyTokenToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(completeInviteLink as string);
|
||||||
|
setInviteLinkCopied.on();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ email }: TAddMemberForm) => {
|
||||||
|
try {
|
||||||
|
if (!currentOrg?._id) return;
|
||||||
|
|
||||||
|
const { data } = await mutateAsync({
|
||||||
|
organizationId: currentOrg?._id,
|
||||||
|
inviteeEmail: email
|
||||||
|
});
|
||||||
|
|
||||||
|
setCompleteInviteLink(data?.completeInviteLink);
|
||||||
|
|
||||||
|
if (!data.completeInviteLink) {
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully sent an invite to the user.",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverDetails?.emailConfigured){
|
||||||
|
handlePopUpClose("addMember");
|
||||||
|
}
|
||||||
|
|
||||||
|
reset();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to send an invite to the user",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.addMember?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("addMember", isOpen);
|
||||||
|
setCompleteInviteLink(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
title={`Invite others to ${currentOrg?.name ?? ""}`}
|
||||||
|
subTitle={
|
||||||
|
<div>
|
||||||
|
{!completeInviteLink && <div>
|
||||||
|
An invite is specific to an email address and expires after 1 day.
|
||||||
|
<br />
|
||||||
|
For security reasons, you will need to separately add members to projects.
|
||||||
|
</div>}
|
||||||
|
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!completeInviteLink && (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} >
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="email"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
variant="plain"
|
||||||
|
onClick={() => handlePopUpClose("addMember")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{completeInviteLink && (
|
||||||
|
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||||
|
<p className="mr-4 break-all">{completeInviteLink}</p>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative"
|
||||||
|
onClick={copyTokenToClipboard}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
||||||
|
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
UpgradePlanModal
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useSubscription } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
|
||||||
|
import { AddOrgMemberModal } from "./AddOrgMemberModal";
|
||||||
|
import { OrgMembersTable } from "./OrgMembersTable";
|
||||||
|
|
||||||
|
export const OrgMembersSection = () => {
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
|
"addMember",
|
||||||
|
"upgradePlan",
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
const isMoreMembersAllowed = subscription?.memberLimit ? (subscription.membersUsed < subscription.memberLimit) : true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
|
<div className="flex justify-between mb-8">
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">
|
||||||
|
Organization members
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMoreMembersAllowed) {
|
||||||
|
handlePopUpOpen("addMember");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlePopUpOpen("upgradePlan");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<OrgMembersTable />
|
||||||
|
<UpgradePlanModal
|
||||||
|
isOpen={popUp.upgradePlan.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||||
|
text="Add more members by upgrading to a higher Infisical plan."
|
||||||
|
/>
|
||||||
|
<AddOrgMemberModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
handlePopUpClose={handlePopUpClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { faCheck, faCopy, faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
|
import { faMagnifyingGlass, faPlus, faTrash, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
decryptAssymmetric,
|
||||||
|
encryptAssymmetric
|
||||||
|
} from "@app/components/utilities/cryptography/crypto";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
EmailServiceSetupModal, EmptyState,
|
EmptyState,
|
||||||
FormControl,
|
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
|
||||||
ModalContent,
|
|
||||||
Select,
|
Select,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
Table,
|
Table,
|
||||||
@@ -25,73 +24,63 @@ import {
|
|||||||
Td,
|
Td,
|
||||||
Th,
|
Th,
|
||||||
THead,
|
THead,
|
||||||
Tr,
|
Tr
|
||||||
UpgradePlanModal} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { usePopUp, useToggle } from "@app/hooks";
|
import { useOrganization, useUser, useWorkspace } from "@app/context";
|
||||||
|
import { usePopUp } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useAddUserToOrg,
|
||||||
|
useDeleteOrgMembership,
|
||||||
|
useGetOrgUsers,
|
||||||
|
useGetUserWorkspaceMemberships,
|
||||||
|
useGetUserWsKey,
|
||||||
|
useUpdateOrgUserRole,
|
||||||
|
useUploadWsKey
|
||||||
|
} from "@app/hooks/api";
|
||||||
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
import { useFetchServerStatus } from "@app/hooks/api/serverDetails";
|
||||||
import { OrgUser, Workspace } from "@app/hooks/api/types";
|
|
||||||
|
|
||||||
type Props = {
|
export const OrgMembersTable = () => {
|
||||||
members?: OrgUser[];
|
|
||||||
workspaceMemberships?: Record<string, Workspace[]>;
|
|
||||||
orgName: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isMoreUserNotAllowed: boolean;
|
|
||||||
onRemoveMember: (userId: string) => Promise<void>;
|
|
||||||
onInviteMember: (email: string) => Promise<void>;
|
|
||||||
onRoleChange: (membershipId: string, role: string) => Promise<void>;
|
|
||||||
onGrantAccess: (userId: string, publicKey: string) => Promise<void>;
|
|
||||||
// the current user id to block remove org button
|
|
||||||
userId: string;
|
|
||||||
completeInviteLink: string | undefined,
|
|
||||||
setCompleteInviteLink: Dispatch<SetStateAction<string | undefined>>
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMemberFormSchema = yup.object({
|
|
||||||
email: yup.string().email().required().label("Email").trim()
|
|
||||||
});
|
|
||||||
|
|
||||||
type TAddMemberForm = yup.InferType<typeof addMemberFormSchema>;
|
|
||||||
|
|
||||||
export const OrgMembersTable = ({
|
|
||||||
members = [],
|
|
||||||
workspaceMemberships = {},
|
|
||||||
orgName,
|
|
||||||
isMoreUserNotAllowed,
|
|
||||||
onRemoveMember,
|
|
||||||
onInviteMember,
|
|
||||||
onGrantAccess,
|
|
||||||
onRoleChange,
|
|
||||||
userId,
|
|
||||||
isLoading,
|
|
||||||
completeInviteLink,
|
|
||||||
setCompleteInviteLink
|
|
||||||
}: Props) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { user: currentUser } = useUser();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||||
const {data: serverDetails } = useFetchServerStatus()
|
|
||||||
const [isInviteLinkCopied, setInviteLinkCopied] = useToggle(false);
|
const { data: serverDetails } = useFetchServerStatus()
|
||||||
|
const { data: members, isLoading: isOrgUserLoading } = useGetOrgUsers(currentOrg?._id ?? ""); // members
|
||||||
|
const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = useGetUserWorkspaceMemberships(currentOrg?._id ?? "");
|
||||||
|
const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || "");
|
||||||
|
|
||||||
|
const uploadWsKey = useUploadWsKey();
|
||||||
|
const addUserToOrg = useAddUserToOrg();
|
||||||
|
const removeUserOrgMembership = useDeleteOrgMembership();
|
||||||
|
const updateOrgUserRole = useUpdateOrgUserRole();
|
||||||
|
|
||||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
"addMember",
|
|
||||||
"removeMember",
|
"removeMember",
|
||||||
"upgradePlan",
|
|
||||||
"setUpEmail"
|
"setUpEmail"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const {
|
const isLoading = isOrgUserLoading || IsWsMembershipLoading;
|
||||||
control,
|
const userId = currentUser?._id || "";
|
||||||
handleSubmit,
|
const onRemoveMember = async (membershipId: string) => {
|
||||||
reset,
|
if (!currentOrg?._id) return;
|
||||||
formState: { isSubmitting }
|
|
||||||
} = useForm<TAddMemberForm>({ resolver: yupResolver(addMemberFormSchema) });
|
|
||||||
|
|
||||||
const onAddMember = async ({ email }: TAddMemberForm) => {
|
try {
|
||||||
await onInviteMember(email);
|
if (!currentOrg?._id) return;
|
||||||
if (serverDetails?.emailConfigured){
|
await removeUserOrgMembership.mutateAsync({ orgId: currentOrg?._id, membershipId });
|
||||||
handlePopUpClose("addMember");
|
createNotification({
|
||||||
}
|
text: "Successfully removed user from org",
|
||||||
|
type: "success"
|
||||||
reset();
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to remove user from the organization",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveOrgMemberApproved = async () => {
|
const onRemoveOrgMemberApproved = async () => {
|
||||||
@@ -100,62 +89,111 @@ export const OrgMembersTable = ({
|
|||||||
handlePopUpClose("removeMember");
|
handlePopUpClose("removeMember");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const onInviteMember = async (email: string) => {
|
||||||
|
if (!currentOrg?._id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await addUserToOrg.mutateAsync({
|
||||||
|
organizationId: currentOrg?._id,
|
||||||
|
inviteeEmail: email
|
||||||
|
});
|
||||||
|
|
||||||
|
// only show this notification when email is configured. A [completeInviteLink] will not be sent if smtp is configured
|
||||||
|
if (!data.completeInviteLink) {
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully invited user to the organization.",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to invite user to org",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGrantAccess = async (targetUserId: string, publicKey: string) => {
|
||||||
|
try {
|
||||||
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
|
if (!PRIVATE_KEY || !wsKey) return;
|
||||||
|
|
||||||
|
// assymmetrically decrypt symmetric key with local private key
|
||||||
|
const key = decryptAssymmetric({
|
||||||
|
ciphertext: wsKey.encryptedKey,
|
||||||
|
nonce: wsKey.nonce,
|
||||||
|
publicKey: wsKey.sender.publicKey,
|
||||||
|
privateKey: PRIVATE_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ciphertext, nonce } = encryptAssymmetric({
|
||||||
|
plaintext: key,
|
||||||
|
publicKey,
|
||||||
|
privateKey: PRIVATE_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
await uploadWsKey.mutateAsync({
|
||||||
|
userId: targetUserId,
|
||||||
|
nonce,
|
||||||
|
encryptedKey: ciphertext,
|
||||||
|
workspaceId: currentWorkspace?._id || ""
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to grant access to user",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRoleChange = async (membershipId: string, role: string) => {
|
||||||
|
if (!currentOrg?._id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateOrgUserRole.mutateAsync({ organizationId: currentOrg?._id, membershipId, role });
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully updated user role",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update user role",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isIamOwner = useMemo(
|
const isIamOwner = useMemo(
|
||||||
() => members.find(({ user }) => userId === user?._id)?.role === "owner",
|
() => members ? members.find(({ user }) => userId === user?._id)?.role === "owner" : [],
|
||||||
[userId, members]
|
[userId, members]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterdUser = useMemo(
|
const filterdUser = useMemo(
|
||||||
() =>
|
() =>
|
||||||
members.filter(
|
members ? members.filter(
|
||||||
({ user, inviteEmail }) =>
|
({ user, inviteEmail }) =>
|
||||||
user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
|
user?.firstName?.toLowerCase().includes(searchMemberFilter) ||
|
||||||
user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
|
user?.lastName?.toLowerCase().includes(searchMemberFilter) ||
|
||||||
user?.email?.toLowerCase().includes(searchMemberFilter) ||
|
user?.email?.toLowerCase().includes(searchMemberFilter) ||
|
||||||
inviteEmail?.includes(searchMemberFilter)
|
inviteEmail?.includes(searchMemberFilter)
|
||||||
),
|
) : [],
|
||||||
[members, searchMemberFilter]
|
[members, searchMemberFilter]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timer: NodeJS.Timeout;
|
|
||||||
if (isInviteLinkCopied) {
|
|
||||||
timer = setTimeout(() => setInviteLinkCopied.off(), 2000);
|
|
||||||
}
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [isInviteLinkCopied]);
|
|
||||||
|
|
||||||
const copyTokenToClipboard = () => {
|
|
||||||
navigator.clipboard.writeText(completeInviteLink as string);
|
|
||||||
setInviteLinkCopied.on();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="mb-4 flex">
|
<Input
|
||||||
<div className="mr-4 flex-1">
|
|
||||||
<Input
|
|
||||||
value={searchMemberFilter}
|
value={searchMemberFilter}
|
||||||
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
onChange={(e) => setSearchMemberFilter(e.target.value)}
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
placeholder="Search members..."
|
placeholder="Search members..."
|
||||||
/>
|
/>
|
||||||
</div>
|
<TableContainer className="mt-4">
|
||||||
<Button
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
|
||||||
onClick={() => {
|
|
||||||
if (isMoreUserNotAllowed) {
|
|
||||||
handlePopUpOpen("upgradePlan");
|
|
||||||
} else {
|
|
||||||
handlePopUpOpen("addMember");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Member
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<TableContainer>
|
|
||||||
<Table>
|
<Table>
|
||||||
<THead>
|
<THead>
|
||||||
<Tr>
|
<Tr>
|
||||||
@@ -167,7 +205,7 @@ export const OrgMembersTable = ({
|
|||||||
</Tr>
|
</Tr>
|
||||||
</THead>
|
</THead>
|
||||||
<TBody>
|
<TBody>
|
||||||
{isLoading && <TableSkeleton columns={5} key="org-members" />}
|
{isLoading && <TableSkeleton columns={5} key="org-memberships" />}
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
|
filterdUser.map(({ user, inviteEmail, role, _id: orgMembershipId, status }) => {
|
||||||
const name = user ? `${user.firstName} ${user.lastName}` : "-";
|
const name = user ? `${user.firstName} ${user.lastName}` : "-";
|
||||||
@@ -213,7 +251,7 @@ export const OrgMembersTable = ({
|
|||||||
<Td>
|
<Td>
|
||||||
{userWs ? (
|
{userWs ? (
|
||||||
userWs?.map(({ name: wsName, _id }) => (
|
userWs?.map(({ name: wsName, _id }) => (
|
||||||
<Tag key={`user-${user._id}-workspace-${_id}`} className="my-1">
|
<Tag key={`user-${currentUser._id}-workspace-${_id}`} className="my-1">
|
||||||
{wsName}
|
{wsName}
|
||||||
</Tag>
|
</Tag>
|
||||||
))
|
))
|
||||||
@@ -254,74 +292,6 @@ export const OrgMembersTable = ({
|
|||||||
<EmptyState title="No project members found" icon={faUsers} />
|
<EmptyState title="No project members found" icon={faUsers} />
|
||||||
)}
|
)}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</div>
|
|
||||||
<Modal
|
|
||||||
isOpen={popUp?.addMember?.isOpen}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
handlePopUpToggle("addMember", isOpen);
|
|
||||||
setCompleteInviteLink(undefined)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModalContent
|
|
||||||
title={`Invite others to ${orgName}`}
|
|
||||||
subTitle={
|
|
||||||
<div>
|
|
||||||
{!completeInviteLink && <div>
|
|
||||||
An invite is specific to an email address and expires after 1 day.
|
|
||||||
<br />
|
|
||||||
For security reasons, you will need to separately add members to projects.
|
|
||||||
</div>}
|
|
||||||
{completeInviteLink && "This Infisical instance does not have a email provider setup. Please share this invite link with the invitee manually"}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!completeInviteLink && <form onSubmit={handleSubmit(onAddMember)} >
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
defaultValue=""
|
|
||||||
name="email"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl label="Email" isError={Boolean(error)} errorText={error?.message}>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-8 flex items-center">
|
|
||||||
<Button
|
|
||||||
className="mr-4"
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
isDisabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Add Member
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
colorSchema="secondary"
|
|
||||||
variant="plain"
|
|
||||||
onClick={() => handlePopUpClose("addMember")}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>}
|
|
||||||
{
|
|
||||||
completeInviteLink &&
|
|
||||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
|
||||||
<p className="mr-4 break-all">{completeInviteLink}</p>
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="copy icon"
|
|
||||||
colorSchema="secondary"
|
|
||||||
className="group relative"
|
|
||||||
onClick={copyTokenToClipboard}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isInviteLinkCopied ? faCheck : faCopy} />
|
|
||||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">click to copy</span>
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.removeMember.isOpen}
|
isOpen={popUp.removeMember.isOpen}
|
||||||
deleteKey="remove"
|
deleteKey="remove"
|
||||||
@@ -329,15 +299,6 @@ export const OrgMembersTable = ({
|
|||||||
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)}
|
||||||
onDeleteApproved={onRemoveOrgMemberApproved}
|
onDeleteApproved={onRemoveOrgMemberApproved}
|
||||||
/>
|
/>
|
||||||
<UpgradePlanModal
|
|
||||||
isOpen={popUp.upgradePlan.isOpen}
|
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
|
||||||
text="You can add custom environments if you switch to Infisical's Team plan."
|
|
||||||
/>
|
|
||||||
<EmailServiceSetupModal
|
|
||||||
isOpen={popUp.setUpEmail?.isOpen}
|
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("setUpEmail", isOpen)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { OrgMembersSection } from "./OrgMembersSection";
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { OrgMembersTable } from "./OrgMembersTable";
|
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import { Button, FormControl, Input } from "@app/components/v2";
|
import { Button, FormControl, Input } from "@app/components/v2";
|
||||||
|
import { useOrganization } from "@app/context";
|
||||||
type Props = {
|
import { useRenameOrg } from "@app/hooks/api";
|
||||||
orgName?: string;
|
|
||||||
onOrgNameChange: (name: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formSchema = yup.object({
|
const formSchema = yup.object({
|
||||||
name: yup.string().required().label("Project Name")
|
name: yup.string().required().label("Project Name")
|
||||||
@@ -19,33 +14,55 @@ const formSchema = yup.object({
|
|||||||
|
|
||||||
type FormData = yup.InferType<typeof formSchema>;
|
type FormData = yup.InferType<typeof formSchema>;
|
||||||
|
|
||||||
export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.Element => {
|
export const OrgNameChangeSection = (): JSX.Element => {
|
||||||
|
const { currentOrg } = useOrganization();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
reset,
|
reset
|
||||||
formState: { isDirty, isSubmitting }
|
|
||||||
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
|
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
|
||||||
const { t } = useTranslation();
|
const { mutateAsync, isLoading } = useRenameOrg();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset({ name: orgName });
|
if (currentOrg) {
|
||||||
}, [orgName]);
|
reset({ name: currentOrg.name });
|
||||||
|
}
|
||||||
|
}, [currentOrg]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: FormData) => {
|
const onFormSubmit = async ({ name }: FormData) => {
|
||||||
await onOrgNameChange(name);
|
try {
|
||||||
|
if (!currentOrg?._id) return;
|
||||||
|
if (name === "") return;
|
||||||
|
|
||||||
|
await mutateAsync({ orgId: currentOrg?._id, newOrgName: name });
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully renamed organization",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to rename organization",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form
|
||||||
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-3">
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
<p className="mb-4 mt-2 text-xl font-semibold">{t("common.display-name")}</p>
|
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||||
<div className="mb-2 w-full max-w-lg">
|
>
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||||
|
Organization name
|
||||||
|
</p>
|
||||||
|
<div className="mb-2 max-w-md">
|
||||||
<Controller
|
<Controller
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||||
<Input placeholder="Type your org name" {...field} />
|
<Input placeholder="Acme Corp" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
@@ -53,17 +70,13 @@ export const OrgNameChangeSection = ({ onOrgNameChange, orgName }: Props): JSX.E
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isSubmitting}
|
isLoading={isLoading}
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outline_bg"
|
colorSchema="secondary"
|
||||||
size="sm"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
isDisabled={!isDirty || isSubmitting}
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
|
||||||
>
|
>
|
||||||
{t("common.save-changes")}
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useEffect, useMemo,useState } from "react";
|
import { useEffect, useMemo,useState } from "react";
|
||||||
import { Controller,useForm } from "react-hook-form";
|
import {
|
||||||
|
// Controller,
|
||||||
|
// useForm
|
||||||
|
} from "react-hook-form";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {
|
import {
|
||||||
faCheck,
|
faCheck,
|
||||||
@@ -10,21 +13,21 @@ import {
|
|||||||
faServer,
|
faServer,
|
||||||
faTrash} from "@fortawesome/free-solid-svg-icons";
|
faTrash} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
|
// import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
// import * as yup from "yup";
|
||||||
|
// import { generateKeyPair } from "@app/components/utilities/cryptography/crypto";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
FormControl,
|
// FormControl,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
Select,
|
// Select,
|
||||||
SelectItem,
|
// SelectItem,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -37,25 +40,26 @@ import {
|
|||||||
import { useOrganization, useWorkspace } from "@app/context";
|
import { useOrganization, useWorkspace } from "@app/context";
|
||||||
import { usePopUp, useToggle } from "@app/hooks";
|
import { usePopUp, useToggle } from "@app/hooks";
|
||||||
import {
|
import {
|
||||||
useCreateServiceAccount,
|
// useCreateServiceAccount,
|
||||||
useDeleteServiceAccount,
|
useDeleteServiceAccount,
|
||||||
useGetServiceAccounts} from "@app/hooks/api";
|
useGetServiceAccounts
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
|
||||||
const serviceAccountExpiration = [
|
// const serviceAccountExpiration = [
|
||||||
{ label: "1 Day", value: 86400 },
|
// { label: "1 Day", value: 86400 },
|
||||||
{ label: "7 Days", value: 604800 },
|
// { label: "7 Days", value: 604800 },
|
||||||
{ label: "1 Month", value: 2592000 },
|
// { label: "1 Month", value: 2592000 },
|
||||||
{ label: "6 months", value: 15552000 },
|
// { label: "6 months", value: 15552000 },
|
||||||
{ label: "12 months", value: 31104000 },
|
// { label: "12 months", value: 31104000 },
|
||||||
{ label: "Never", value: -1 }
|
// { label: "Never", value: -1 }
|
||||||
];
|
// ];
|
||||||
|
|
||||||
const addServiceAccountFormSchema = yup.object({
|
// const addServiceAccountFormSchema = yup.object({
|
||||||
name: yup.string().required().label("Name").trim(),
|
// name: yup.string().required().label("Name").trim(),
|
||||||
expiresIn: yup.string().required().label("Service Account Expiration")
|
// expiresIn: yup.string().required().label("Service Account Expiration")
|
||||||
});
|
// });
|
||||||
|
|
||||||
type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
|
// type TAddServiceAccountForm = yup.InferType<typeof addServiceAccountFormSchema>;
|
||||||
|
|
||||||
export const OrgServiceAccountsTable = () => {
|
export const OrgServiceAccountsTable = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -67,9 +71,9 @@ export const OrgServiceAccountsTable = () => {
|
|||||||
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
|
const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false);
|
||||||
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
|
const [isPublicKeyCopied, setIsPublicKeyCopied] = useToggle(false);
|
||||||
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
|
const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false);
|
||||||
const [accessKey, setAccessKey] = useState("");
|
const [accessKey] = useState("");
|
||||||
const [publicKey, setPublicKey] = useState("");
|
const [publicKey] = useState("");
|
||||||
const [privateKey, setPrivateKey] = useState("");
|
const [privateKey] = useState("");
|
||||||
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
|
const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState("");
|
||||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||||
"addServiceAccount",
|
"addServiceAccount",
|
||||||
@@ -78,7 +82,7 @@ export const OrgServiceAccountsTable = () => {
|
|||||||
|
|
||||||
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId);
|
const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId);
|
||||||
|
|
||||||
const createServiceAccount = useCreateServiceAccount();
|
// const createServiceAccount = useCreateServiceAccount();
|
||||||
const removeServiceAccount = useDeleteServiceAccount();
|
const removeServiceAccount = useDeleteServiceAccount();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,32 +102,32 @@ export const OrgServiceAccountsTable = () => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
|
}, [isAccessKeyCopied, isPublicKeyCopied, isPrivateKeyCopied]);
|
||||||
|
|
||||||
const {
|
// const {
|
||||||
control,
|
// control,
|
||||||
handleSubmit,
|
// handleSubmit,
|
||||||
reset,
|
// reset,
|
||||||
formState: { isSubmitting }
|
// formState: { isSubmitting }
|
||||||
} = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
|
// } = useForm<TAddServiceAccountForm>({ resolver: yupResolver(addServiceAccountFormSchema) });
|
||||||
|
|
||||||
const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
|
// const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => {
|
||||||
if (!currentOrg?._id) return;
|
// if (!currentOrg?._id) return;
|
||||||
|
|
||||||
const keyPair = generateKeyPair();
|
// const keyPair = generateKeyPair();
|
||||||
setPublicKey(keyPair.publicKey);
|
// setPublicKey(keyPair.publicKey);
|
||||||
setPrivateKey(keyPair.privateKey);
|
// setPrivateKey(keyPair.privateKey);
|
||||||
|
|
||||||
const serviceAccountDetails = await createServiceAccount.mutateAsync({
|
// const serviceAccountDetails = await createServiceAccount.mutateAsync({
|
||||||
name,
|
// name,
|
||||||
organizationId: currentOrg?._id,
|
// organizationId: currentOrg?._id,
|
||||||
publicKey: keyPair.publicKey,
|
// publicKey: keyPair.publicKey,
|
||||||
expiresIn: Number(expiresIn)
|
// expiresIn: Number(expiresIn)
|
||||||
});
|
// });
|
||||||
|
|
||||||
setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
|
// setAccessKey(serviceAccountDetails.serviceAccountAccessKey);
|
||||||
|
|
||||||
setStep(1);
|
// setStep(1);
|
||||||
reset();
|
// reset();
|
||||||
}
|
// }
|
||||||
|
|
||||||
const onRemoveServiceAccount = async () => {
|
const onRemoveServiceAccount = async () => {
|
||||||
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
|
const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id;
|
||||||
@@ -144,63 +148,67 @@ export const OrgServiceAccountsTable = () => {
|
|||||||
switch (stepToRender) {
|
switch (stepToRender) {
|
||||||
case 0:
|
case 0:
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onAddServiceAccount)}>
|
<div>
|
||||||
<Controller
|
We are currently revising the service account mechanism. In the meantime,
|
||||||
control={control}
|
please use service tokens or API key to fetch secrets via API request.
|
||||||
defaultValue=""
|
</div>
|
||||||
name="name"
|
// <form onSubmit={handleSubmit(onAddServiceAccount)}>
|
||||||
render={({ field, fieldState: { error } }) => (
|
// <Controller
|
||||||
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
|
// control={control}
|
||||||
<Input {...field} />
|
// defaultValue=""
|
||||||
</FormControl>
|
// name="name"
|
||||||
)}
|
// render={({ field, fieldState: { error } }) => (
|
||||||
/>
|
// <FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
<Controller
|
// <Input {...field} />
|
||||||
control={control}
|
// </FormControl>
|
||||||
name="expiresIn"
|
// )}
|
||||||
defaultValue={String(serviceAccountExpiration?.[0]?.value)}
|
// />
|
||||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
// <Controller
|
||||||
return (
|
// control={control}
|
||||||
<FormControl
|
// name="expiresIn"
|
||||||
label="Expiration"
|
// defaultValue={String(serviceAccountExpiration?.[0]?.value)}
|
||||||
errorText={error?.message}
|
// render={({ field: { onChange, ...field }, fieldState: { error } }) => {
|
||||||
isError={Boolean(error)}
|
// return (
|
||||||
>
|
// <FormControl
|
||||||
<Select
|
// label="Expiration"
|
||||||
defaultValue={field.value}
|
// errorText={error?.message}
|
||||||
{...field}
|
// isError={Boolean(error)}
|
||||||
onValueChange={(e) => onChange(e)}
|
// >
|
||||||
className="w-full"
|
// <Select
|
||||||
>
|
// defaultValue={field.value}
|
||||||
{serviceAccountExpiration.map(({ label, value }) => (
|
// {...field}
|
||||||
<SelectItem value={String(value)} key={label}>
|
// onValueChange={(e) => onChange(e)}
|
||||||
{label}
|
// className="w-full"
|
||||||
</SelectItem>
|
// >
|
||||||
))}
|
// {serviceAccountExpiration.map(({ label, value }) => (
|
||||||
</Select>
|
// <SelectItem value={String(value)} key={label}>
|
||||||
</FormControl>
|
// {label}
|
||||||
);
|
// </SelectItem>
|
||||||
}}
|
// ))}
|
||||||
/>
|
// </Select>
|
||||||
<div className="mt-8 flex items-center">
|
// </FormControl>
|
||||||
<Button
|
// );
|
||||||
className="mr-4"
|
// }}
|
||||||
size="sm"
|
// />
|
||||||
type="submit"
|
// <div className="mt-8 flex items-center">
|
||||||
isLoading={isSubmitting}
|
// <Button
|
||||||
isDisabled={isSubmitting}
|
// className="mr-4"
|
||||||
>
|
// size="sm"
|
||||||
Create Service Account
|
// type="submit"
|
||||||
</Button>
|
// isLoading={isSubmitting}
|
||||||
<Button
|
// isDisabled={isSubmitting}
|
||||||
colorSchema="secondary"
|
// >
|
||||||
variant="plain"
|
// Create Service Account
|
||||||
onClick={() => handlePopUpClose("addServiceAccount")}
|
// </Button>
|
||||||
>
|
// <Button
|
||||||
Cancel
|
// colorSchema="secondary"
|
||||||
</Button>
|
// variant="plain"
|
||||||
</div>
|
// onClick={() => handlePopUpClose("addServiceAccount")}
|
||||||
</form>
|
// >
|
||||||
|
// Cancel
|
||||||
|
// </Button>
|
||||||
|
// </div>
|
||||||
|
// </form>
|
||||||
);
|
);
|
||||||
case 1:
|
case 1:
|
||||||
return (
|
return (
|
||||||
@@ -269,27 +277,27 @@ export const OrgServiceAccountsTable = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="mb-4 flex">
|
<div className="flex justify-between mb-8">
|
||||||
<div className="mr-4 flex-1">
|
<p className="text-xl font-semibold text-mineshaft-100">Service Accounts</p>
|
||||||
<Input
|
|
||||||
value={searchServiceAccountFilter}
|
|
||||||
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
|
||||||
placeholder="Search service accounts..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStep(0);
|
setStep(0);
|
||||||
reset();
|
// reset();
|
||||||
handlePopUpOpen("addServiceAccount");
|
handlePopUpOpen("addServiceAccount");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add Service Account
|
Add Service Account
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TableContainer>
|
<Input
|
||||||
|
value={searchServiceAccountFilter}
|
||||||
|
onChange={(e) => setSearchServiceAccountFilter(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search service accounts..."
|
||||||
|
/>
|
||||||
|
<TableContainer className="mt-4">
|
||||||
<Table>
|
<Table>
|
||||||
<THead>
|
<THead>
|
||||||
<Th>Name</Th>
|
<Th>Name</Th>
|
||||||
@@ -345,7 +353,7 @@ export const OrgServiceAccountsTable = () => {
|
|||||||
isOpen={popUp?.addServiceAccount?.isOpen}
|
isOpen={popUp?.addServiceAccount?.isOpen}
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
handlePopUpToggle("addServiceAccount", isOpen);
|
handlePopUpToggle("addServiceAccount", isOpen);
|
||||||
reset();
|
// reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { OrgIncidentContactsTable } from "./OrgIncidentContactsTable";
|
export { OrgIncidentContactsSection } from "./OrgIncidentContactsSection";
|
||||||
export { OrgMembersTable } from "./OrgMembersTable";
|
export { OrgMembersSection } from "./OrgMembersSection";
|
||||||
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
export { OrgNameChangeSection } from "./OrgNameChangeSection";
|
||||||
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
export { OrgServiceAccountsTable } from "./OrgServiceAccountsTable";
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/v2";
|
||||||
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
import { AddAPIKeyModal } from "./AddAPIKeyModal";
|
||||||
|
import { APIKeyTable } from "./APIKeyTable";
|
||||||
|
|
||||||
|
export const APIKeySection = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||||
|
"addAPIKey"
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
|
<div className="flex justify-between mb-8">
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">
|
||||||
|
{t("settings.personal.api-keys.title")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => handlePopUpOpen("addAPIKey")}
|
||||||
|
>
|
||||||
|
Add API Key
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<APIKeyTable />
|
||||||
|
<AddAPIKeyModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { faKey,faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
useDeleteAPIKey,
|
||||||
|
useGetMyAPIKeys} from "@app/hooks/api";
|
||||||
|
|
||||||
|
export const APIKeyTable = () => {
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { data, isLoading } = useGetMyAPIKeys();
|
||||||
|
const { mutateAsync } = useDeleteAPIKey();
|
||||||
|
|
||||||
|
const handleDeleteAPIKeyDataClick = async (apiKeyDataId: string) => {
|
||||||
|
try {
|
||||||
|
await mutateAsync(apiKeyDataId);
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully deleted API key",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to delete API key",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateToFormat: string) => {
|
||||||
|
const date = new Date(dateToFormat);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
|
||||||
|
const formattedDate = `${day}/${month}/${year}`;
|
||||||
|
|
||||||
|
return formattedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<TableContainer className="">
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th className="flex-1">Name</Th>
|
||||||
|
<Th className="flex-1">Last active</Th>
|
||||||
|
<Th className="flex-1">Created</Th>
|
||||||
|
<Th className="flex-1">Expiration</Th>
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && <TableSkeleton columns={4} key="api-keys" />}
|
||||||
|
{!isLoading && data && data.length > 0 && data.map(({
|
||||||
|
_id,
|
||||||
|
name,
|
||||||
|
createdAt,
|
||||||
|
expiresAt,
|
||||||
|
lastUsed
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Tr className="h-10" key={`api-key-${_id}`}>
|
||||||
|
<Td>{name}</Td>
|
||||||
|
<Td>{formatDate(lastUsed)}</Td>
|
||||||
|
<Td>{formatDate(createdAt)}</Td>
|
||||||
|
<Td>{formatDate(expiresAt)}</Td>
|
||||||
|
<Td>
|
||||||
|
<IconButton
|
||||||
|
onClick={async () => {
|
||||||
|
await handleDeleteAPIKeyDataClick(_id);
|
||||||
|
}}
|
||||||
|
size="lg"
|
||||||
|
colorSchema="danger"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</IconButton>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && data && data?.length === 0 && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={5}>
|
||||||
|
<EmptyState
|
||||||
|
title="No API Keys on file"
|
||||||
|
icon={faKey}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
import { useCreateAPIKey } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const expirations = [
|
||||||
|
{ label: "1 day", value: "1d" },
|
||||||
|
{ label: "7 days", value: "7d" },
|
||||||
|
{ label: "1 month", value: "1mo" },
|
||||||
|
{ label: "6 months", value: "6mo" },
|
||||||
|
{ label: "12 months", value: "12mo" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const expirationMapping: { [key: string]: number } = {
|
||||||
|
"1d": 86400,
|
||||||
|
"7d": 604800,
|
||||||
|
"1mo": 2592000,
|
||||||
|
"6mo": 15552000,
|
||||||
|
"12mo": 31104000
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
name: yup.string().required("API Key name is required"),
|
||||||
|
expiresIn: yup.string().required("API Key expiration window is required")
|
||||||
|
}).required();
|
||||||
|
|
||||||
|
export type FormData = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["addAPIKey"]>;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addAPIKey"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddAPIKeyModal = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpToggle
|
||||||
|
}: Props) => {
|
||||||
|
const [newAPIKey, setNewAPIKey] = useState("");
|
||||||
|
const [isAPIKeyCopied, setIsAPIKeyCopied] = useToggle(false);
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { mutateAsync, isLoading } = useCreateAPIKey();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: yupResolver(schema)
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
|
||||||
|
if (isAPIKeyCopied) {
|
||||||
|
timer = setTimeout(() => setIsAPIKeyCopied.off(), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [setIsAPIKeyCopied]);
|
||||||
|
|
||||||
|
const copyTokenToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(newAPIKey);
|
||||||
|
setIsAPIKeyCopied.on();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ name, expiresIn }: FormData) => {
|
||||||
|
try {
|
||||||
|
const { apiKey } = await mutateAsync({
|
||||||
|
name,
|
||||||
|
expiresIn: expirationMapping[expiresIn]
|
||||||
|
});
|
||||||
|
|
||||||
|
setNewAPIKey(apiKey);
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully created API key",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to create API key",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAPIKey = Boolean(newAPIKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.addAPIKey?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("addAPIKey", isOpen);
|
||||||
|
reset();
|
||||||
|
setNewAPIKey("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent title="Create API Key">
|
||||||
|
{!hasAPIKey ? (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="My API Key"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="expiresIn"
|
||||||
|
defaultValue="6mo"
|
||||||
|
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Expiration"
|
||||||
|
errorText={error?.message}
|
||||||
|
isError={Boolean(error)}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
defaultValue={field.value}
|
||||||
|
{...field}
|
||||||
|
onValueChange={(e) => onChange(e)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{expirations.map(({ label, value }) => (
|
||||||
|
<SelectItem value={String(value || "")} key={`api-key-expiration-${label}`}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button colorSchema="secondary" variant="plain">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
|
||||||
|
<p className="mr-4 break-all">{newAPIKey}</p>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="copy icon"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="group relative"
|
||||||
|
onClick={copyTokenToClipboard}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={isAPIKeyCopied ? faCheck : faCopy} />
|
||||||
|
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
||||||
|
Click to copy
|
||||||
|
</span>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
export { APIKeySection } from "./APIKeySection";
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import ListBox from "@app/components/basic/Listbox";
|
||||||
|
|
||||||
|
export const ChangeLanguageSection = () => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const lang = router.locale ?? "en";
|
||||||
|
|
||||||
|
const setLanguage = async (to: string) => {
|
||||||
|
router.push(router.asPath, router.asPath, { locale: to });
|
||||||
|
localStorage.setItem("lang", to);
|
||||||
|
i18n.changeLanguage(to);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||||
|
{t("settings.personal.change-language")}
|
||||||
|
</p>
|
||||||
|
<div className="w-28">
|
||||||
|
<ListBox
|
||||||
|
isSelected={lang}
|
||||||
|
onChange={setLanguage}
|
||||||
|
data={["en", "ko", "fr", "es"]}
|
||||||
|
text={`${t("common.language")}: `}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ChangeLanguageSection } from "./ChangeLanguageSection";
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import attemptChangePassword from "@app/components/utilities/attemptChangePassword";
|
||||||
|
import checkPassword from "@app/components/utilities/checks/checkPassword";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useUser } from "@app/context";
|
||||||
|
import { useGetCommonPasswords } from "@app/hooks/api";
|
||||||
|
|
||||||
|
type Errors = {
|
||||||
|
length?: string,
|
||||||
|
upperCase?: string,
|
||||||
|
lowerCase?: string,
|
||||||
|
number?: string,
|
||||||
|
specialChar?: string,
|
||||||
|
repeatedChar?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
oldPassword: yup.string().required("Old password is required"),
|
||||||
|
newPassword: yup.string().required("New password is required")
|
||||||
|
}).required();
|
||||||
|
|
||||||
|
export type FormData = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
export const ChangePasswordSection = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { user } = useUser();
|
||||||
|
const { data: commonPasswords } = useGetCommonPasswords();
|
||||||
|
const { reset, control, handleSubmit } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
oldPassword: "",
|
||||||
|
newPassword: ""
|
||||||
|
},
|
||||||
|
resolver: yupResolver(schema)
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Errors>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ oldPassword, newPassword }: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!user?.email) return;
|
||||||
|
if (!commonPasswords) return;
|
||||||
|
|
||||||
|
const errorCheck = checkPassword({
|
||||||
|
password: newPassword,
|
||||||
|
commonPasswords,
|
||||||
|
setErrors
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errorCheck) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
await attemptChangePassword({
|
||||||
|
email: user.email,
|
||||||
|
currentPassword: oldPassword,
|
||||||
|
newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully changed password",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
window.location.href = "/login";
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setIsLoading(false);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to change password",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||||
|
Change password
|
||||||
|
</h2>
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Controller
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input
|
||||||
|
placeholder="Old password"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
className="bg-mineshaft-800"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="oldPassword"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Controller
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input
|
||||||
|
placeholder="New password"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
className="bg-mineshaft-800"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="newPassword"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{Object.keys(errors).length > 0 && (
|
||||||
|
<div className="my-4 max-w-md flex flex-col items-start rounded-md bg-white/5 px-2 py-2">
|
||||||
|
<div className="mb-2 text-sm text-gray-400">{t("section.password.validate-base")}</div>
|
||||||
|
{Object.keys(errors).map((key) => {
|
||||||
|
if (errors[key as keyof Errors]) {
|
||||||
|
return (
|
||||||
|
<div className="ml-1 flex flex-row items-top justify-start" key={key}>
|
||||||
|
<div>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faXmark}
|
||||||
|
className="text-md text-red ml-0.5 mr-2.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
{errors[key as keyof Errors]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorSchema="secondary"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ChangePasswordSection } from "./ChangePasswordSection";
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import issueBackupKey from "@app/components/utilities/cryptography/issueBackupKey";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useUser } from "@app/context";
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
password: yup.string().required("Password is required")
|
||||||
|
}).required();
|
||||||
|
|
||||||
|
export type FormData = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
export const EmergencyKitSection = () => {
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { user } = useUser();
|
||||||
|
const { reset, control, handleSubmit } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
resolver: yupResolver(schema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = ({
|
||||||
|
password
|
||||||
|
}: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!user?.email) return;
|
||||||
|
|
||||||
|
issueBackupKey({
|
||||||
|
email: user.email,
|
||||||
|
password,
|
||||||
|
personalName: `${user.firstName} ${user.lastName}`,
|
||||||
|
setBackupKeyError: () => {},
|
||||||
|
setBackupKeyIssued: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
reset();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to download emergency kit",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
|
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||||
|
>
|
||||||
|
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100">
|
||||||
|
Emergency Kit
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mb-8">
|
||||||
|
The kit contains information you can use to recover your account.
|
||||||
|
</p>
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Controller
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input
|
||||||
|
placeholder="Password"
|
||||||
|
type="password"
|
||||||
|
{...field}
|
||||||
|
className="bg-mineshaft-800"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
control={control}
|
||||||
|
name="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorSchema="secondary"
|
||||||
|
isLoading={false}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { EmergencyKitSection } from "./EmergencyKitSection";
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { APIKeySection } from "../APIKeySection";
|
||||||
|
|
||||||
|
export const PersonalAPIKeyTab = () => {
|
||||||
|
return (
|
||||||
|
<APIKeySection />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { PersonalAPIKeyTab } from "./PersonalAPIKeyTab";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ChangePasswordSection } from "../ChangePasswordSection";
|
||||||
|
import { EmergencyKitSection } from "../EmergencyKitSection";
|
||||||
|
import { SecuritySection } from "../SecuritySection";
|
||||||
|
import { SessionsSection } from "../SessionsSection";
|
||||||
|
|
||||||
|
export const PersonalSecurityTab = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SecuritySection />
|
||||||
|
<SessionsSection />
|
||||||
|
<ChangePasswordSection />
|
||||||
|
<EmergencyKitSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { PersonalSecurityTab } from "./PersonalSecurityTab";
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import NavHeader from "@app/components/navigation/NavHeader";
|
||||||
|
|
||||||
|
import { PersonalTabGroup } from "./PersonalTabGroup";
|
||||||
|
|
||||||
|
export const PersonalSettingsPage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||||
|
<div className="max-w-screen-lg w-full">
|
||||||
|
<NavHeader pageName={t("settings.personal.title")} isProjectRelated={false} />
|
||||||
|
<div className="my-8">
|
||||||
|
<p className="text-3xl font-semibold text-gray-200">
|
||||||
|
{t("settings.personal.title")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PersonalTabGroup />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Fragment } from "react"
|
||||||
|
import { Tab } from "@headlessui/react"
|
||||||
|
|
||||||
|
import { PersonalAPIKeyTab } from "../PersonalAPIKeyTab";
|
||||||
|
import { PersonalSecurityTab } from "../PersonalSecurityTab";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ name: "Security", key: "tab-account-security" },
|
||||||
|
{ name: "API Keys", key: "tab-account-api-keys" }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PersonalTabGroup = () => {
|
||||||
|
return (
|
||||||
|
<Tab.Group>
|
||||||
|
<Tab.List className="mb-6 border-b-2 border-mineshaft-800 w-full">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Tab as={Fragment} key={tab.key}>
|
||||||
|
{({ selected }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-30 p-4 font-semibold outline-none ${selected ? "border-b-2 border-white text-white" : "text-mineshaft-400"}`}
|
||||||
|
>
|
||||||
|
{tab.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels>
|
||||||
|
<Tab.Panel>
|
||||||
|
<PersonalSecurityTab />
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel>
|
||||||
|
<PersonalAPIKeyTab />
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { PersonalTabGroup } from "./PersonalTabGroup";
|
||||||
@@ -51,8 +51,8 @@ export const SecuritySection = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form>
|
<form>
|
||||||
<div className="mb-6 mt-2 flex w-full flex-col items-start rounded-md bg-white/5 px-6 pb-6 pt-2">
|
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<p className="mb-4 mt-2 text-xl font-semibold">
|
<p className="text-xl font-semibold text-mineshaft-100 mb-8">
|
||||||
Two-factor Authentication
|
Two-factor Authentication
|
||||||
</p>
|
</p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { faBan } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useRevokeMySessions } from "@app/hooks/api";
|
||||||
|
|
||||||
|
import { SessionsTable } from "./SessionsTable";
|
||||||
|
|
||||||
|
export const SessionsSection = () => {
|
||||||
|
const { mutateAsync } = useRevokeMySessions();
|
||||||
|
|
||||||
|
const onRevokeAllSessionsClick = async () => {
|
||||||
|
try {
|
||||||
|
await mutateAsync();
|
||||||
|
window.location.href = "/login";
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 mb-6 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
|
<div className="flex justify-between mb-8">
|
||||||
|
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100">
|
||||||
|
Sessions
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
type="submit"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faBan} />}
|
||||||
|
onClick={onRevokeAllSessionsClick}
|
||||||
|
>
|
||||||
|
Revoke all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mb-8">
|
||||||
|
Logging into Infisical via browser or CLI creates a session. Revoking all sessions logs your account out all active sessions across all browsers and CLIs.
|
||||||
|
</p>
|
||||||
|
<SessionsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useGetMySessions } from "@app/hooks/api";
|
||||||
|
|
||||||
|
export const SessionsTable = () => {
|
||||||
|
const { data, isLoading } = useGetMySessions();
|
||||||
|
|
||||||
|
const formatDate = (dateToFormat: string) => {
|
||||||
|
const date = new Date(dateToFormat);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
|
||||||
|
const formattedDate = `${day}/${month}/${year}`;
|
||||||
|
|
||||||
|
return formattedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer className="mt-4">
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Created</Th>
|
||||||
|
<Th>Last active</Th>
|
||||||
|
<Th>IP address</Th>
|
||||||
|
<Th>Device</Th>
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && <TableSkeleton columns={4} key="sesssions" />}
|
||||||
|
{!isLoading && data && data.length > 0 && data.map(({
|
||||||
|
_id,
|
||||||
|
createdAt,
|
||||||
|
lastUsed,
|
||||||
|
ip,
|
||||||
|
userAgent
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Tr className="h-10" key={`session-${_id}`}>
|
||||||
|
<Td>{formatDate(createdAt)}</Td>
|
||||||
|
<Td>{formatDate(lastUsed)}</Td>
|
||||||
|
<Td>{ip}</Td>
|
||||||
|
<Td>{userAgent}</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!isLoading && data && data?.length === 0 && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={4}>
|
||||||
|
<EmptyState
|
||||||
|
title="No sessions on file"
|
||||||
|
icon={faServer}
|
||||||
|
/>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SessionsSection } from "./SessionsSection";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { PersonalSettingsPage } from "./PersonalSettingsPage";
|
||||||
@@ -1,443 +1,23 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
|
||||||
import NavHeader from "@app/components/navigation/NavHeader";
|
import NavHeader from "@app/components/navigation/NavHeader";
|
||||||
// TODO(akhilmhdh):Refactor this into a better utility module package
|
|
||||||
import {
|
|
||||||
decryptAssymmetric,
|
|
||||||
decryptSymmetric,
|
|
||||||
encryptSymmetric
|
|
||||||
} from "@app/components/utilities/cryptography/crypto";
|
|
||||||
import { Button, FormControl, Input } from "@app/components/v2";
|
|
||||||
import { useSubscription, useWorkspace } from "@app/context";
|
|
||||||
import { useToggle } from "@app/hooks";
|
|
||||||
import {
|
|
||||||
useCreateServiceToken,
|
|
||||||
useCreateWsEnvironment,
|
|
||||||
useCreateWsTag,
|
|
||||||
useDeleteServiceToken,
|
|
||||||
useDeleteWorkspace,
|
|
||||||
useDeleteWsEnvironment,
|
|
||||||
useDeleteWsTag,
|
|
||||||
useGetUserWsKey,
|
|
||||||
useGetUserWsServiceTokens,
|
|
||||||
useGetWorkspaceIndexStatus,
|
|
||||||
useGetWorkspaceSecrets,
|
|
||||||
useGetWsTags,
|
|
||||||
useNameWorkspaceSecrets,
|
|
||||||
useRenameWorkspace,
|
|
||||||
useToggleAutoCapitalization,
|
|
||||||
useUpdateWsEnvironment
|
|
||||||
} from "@app/hooks/api";
|
|
||||||
|
|
||||||
import { AutoCapitalizationSection } from "./components/AutoCapitalizationSection/AutoCapitalizationSection";
|
import { ProjectTabGroup } from "./components";
|
||||||
import { SecretTagsSection } from "./components/SecretTagsSection";
|
|
||||||
import {
|
|
||||||
CopyProjectIDSection,
|
|
||||||
CreateServiceToken,
|
|
||||||
CreateUpdateEnvFormData,
|
|
||||||
CreateWsTag,
|
|
||||||
E2EESection,
|
|
||||||
EnvironmentSection,
|
|
||||||
ProjectIndexSecretsSection,
|
|
||||||
ProjectNameChangeSection,
|
|
||||||
ServiceTokenSection} from "./components";
|
|
||||||
|
|
||||||
export const ProjectSettingsPage = () => {
|
export const ProjectSettingsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currentWorkspace, workspaces, isLoading: isWorkspaceLoading } = useWorkspace();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const workspaceID = currentWorkspace?._id || "";
|
|
||||||
const { createNotification } = useNotificationContext();
|
|
||||||
// delete action worksapce
|
|
||||||
const [deleteProjectInput, setDeleteProjectInput] = useState("");
|
|
||||||
const [isDeleting, setIsDeleting] = useToggle();
|
|
||||||
|
|
||||||
const renameWorkspace = useRenameWorkspace();
|
|
||||||
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
|
|
||||||
const toggleAutoCapitalization = useToggleAutoCapitalization();
|
|
||||||
|
|
||||||
const deleteWorkspace = useDeleteWorkspace();
|
|
||||||
// env crud operation
|
|
||||||
const createWsEnv = useCreateWsEnvironment();
|
|
||||||
const updateWsEnv = useUpdateWsEnvironment();
|
|
||||||
const deleteWsEnv = useDeleteWsEnvironment();
|
|
||||||
|
|
||||||
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } =
|
|
||||||
useGetWorkspaceIndexStatus(workspaceID);
|
|
||||||
|
|
||||||
// service token
|
|
||||||
const { data: serviceTokens, isLoading: isServiceTokenLoading } = useGetUserWsServiceTokens({
|
|
||||||
workspaceID: currentWorkspace?._id || ""
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: latestFileKey } = useGetUserWsKey(workspaceID);
|
|
||||||
const { data: encryptedSecrets } = useGetWorkspaceSecrets(workspaceID);
|
|
||||||
|
|
||||||
const createServiceToken = useCreateServiceToken();
|
|
||||||
const deleteServiceToken = useDeleteServiceToken();
|
|
||||||
|
|
||||||
// tag
|
|
||||||
const { data: wsTags, isLoading: isTagLoading } = useGetWsTags(workspaceID);
|
|
||||||
const createWsTag = useCreateWsTag();
|
|
||||||
const deleteWsTag = useDeleteWsTag();
|
|
||||||
|
|
||||||
// get user subscription
|
|
||||||
const { subscription } = useSubscription();
|
|
||||||
|
|
||||||
const isEnvServiceAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
|
|
||||||
|
|
||||||
const onRenameWorkspace = async (name: string) => {
|
|
||||||
try {
|
|
||||||
await renameWorkspace.mutateAsync({ workspaceID, newWorkspaceName: name });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully renamed workspace",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to rename workspace",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onAutoCapitalizationToggle = async (state: boolean) => {
|
|
||||||
try {
|
|
||||||
await toggleAutoCapitalization.mutateAsync({
|
|
||||||
workspaceID,
|
|
||||||
state
|
|
||||||
});
|
|
||||||
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
|
|
||||||
createNotification({
|
|
||||||
text,
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to update auto capitalization",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteWorkspace = async () => {
|
|
||||||
setIsDeleting.on();
|
|
||||||
try {
|
|
||||||
await deleteWorkspace.mutateAsync({ workspaceID });
|
|
||||||
// redirect user to first workspace user is part of
|
|
||||||
const ws = workspaces.find(({ _id }) => _id !== workspaceID);
|
|
||||||
if (!ws) {
|
|
||||||
router.push("/noprojects");
|
|
||||||
}
|
|
||||||
router.push(`/dashboard/${ws?._id}`);
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully deleted workspace",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to delete workspace",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsDeleting.off();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// workspace environment operation
|
|
||||||
const onCreateWsEnv = async ({ environmentName, environmentSlug }: CreateUpdateEnvFormData) => {
|
|
||||||
try {
|
|
||||||
await createWsEnv.mutateAsync({ workspaceID, environmentName, environmentSlug });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully created environment",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to create environment",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onUpdateWsEnv = async (
|
|
||||||
oldEnvironmentSlug: string,
|
|
||||||
{ environmentName, environmentSlug }: CreateUpdateEnvFormData
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await updateWsEnv.mutateAsync({
|
|
||||||
workspaceID,
|
|
||||||
environmentName,
|
|
||||||
environmentSlug,
|
|
||||||
oldEnvironmentSlug
|
|
||||||
});
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully updated environment",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to update environment",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteWsEnv = async (environmentSlug: string) => {
|
|
||||||
try {
|
|
||||||
await deleteWsEnv.mutateAsync({
|
|
||||||
workspaceID,
|
|
||||||
environmentSlug
|
|
||||||
});
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully deleted environment",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to delete environment",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCreateServiceToken = async ({
|
|
||||||
environment,
|
|
||||||
expiresIn,
|
|
||||||
name,
|
|
||||||
permissions,
|
|
||||||
secretPath
|
|
||||||
}: CreateServiceToken) => {
|
|
||||||
// type guard
|
|
||||||
if (!latestFileKey) return "";
|
|
||||||
try {
|
|
||||||
const key = decryptAssymmetric({
|
|
||||||
ciphertext: latestFileKey.encryptedKey,
|
|
||||||
nonce: latestFileKey.nonce,
|
|
||||||
publicKey: latestFileKey.sender.publicKey,
|
|
||||||
privateKey: localStorage.getItem("PRIVATE_KEY") as string
|
|
||||||
});
|
|
||||||
|
|
||||||
const randomBytes = crypto.randomBytes(16).toString("hex");
|
|
||||||
|
|
||||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
|
||||||
plaintext: key,
|
|
||||||
key: randomBytes
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await createServiceToken.mutateAsync({
|
|
||||||
encryptedKey: ciphertext,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
environment,
|
|
||||||
secretPath,
|
|
||||||
expiresIn: Number(expiresIn),
|
|
||||||
name,
|
|
||||||
workspaceId: workspaceID,
|
|
||||||
randomBytes,
|
|
||||||
permissions: Object.entries(permissions)
|
|
||||||
.filter(([, permissionsValue]) => permissionsValue)
|
|
||||||
.map(([permissionsKey]) => permissionsKey)
|
|
||||||
});
|
|
||||||
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully created a service token",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
return res.serviceToken;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to create a service token",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCreateWsTag = async ({ name }: CreateWsTag) => {
|
|
||||||
try {
|
|
||||||
const res = await createWsTag.mutateAsync({
|
|
||||||
workspaceID,
|
|
||||||
tagName: name,
|
|
||||||
tagSlug: name.replace(" ", "_")
|
|
||||||
});
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully created a tag",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
return res.name;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to create a tag",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteTag = async (tagID: string) => {
|
|
||||||
try {
|
|
||||||
await deleteWsTag.mutateAsync({ tagID });
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully deleted tag",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to delete the tag",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDeleteServiceToken = async (tokenID: string) => {
|
|
||||||
try {
|
|
||||||
await deleteServiceToken.mutateAsync(tokenID);
|
|
||||||
createNotification({
|
|
||||||
text: "Successfully revoked service token",
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
createNotification({
|
|
||||||
text: "Failed to delete service token",
|
|
||||||
type: "error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEnableBlindIndices = async () => {
|
|
||||||
if (!currentWorkspace?._id) return;
|
|
||||||
if (!encryptedSecrets) return;
|
|
||||||
if (!latestFileKey) return;
|
|
||||||
|
|
||||||
const key = decryptAssymmetric({
|
|
||||||
ciphertext: latestFileKey.encryptedKey,
|
|
||||||
nonce: latestFileKey.nonce,
|
|
||||||
publicKey: latestFileKey.sender.publicKey,
|
|
||||||
privateKey: localStorage.getItem("PRIVATE_KEY") as string
|
|
||||||
});
|
|
||||||
|
|
||||||
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
|
|
||||||
const secretName = decryptSymmetric({
|
|
||||||
ciphertext: encryptedSecret.secretKeyCiphertext,
|
|
||||||
iv: encryptedSecret.secretKeyIV,
|
|
||||||
tag: encryptedSecret.secretKeyTag,
|
|
||||||
key
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
secretName,
|
|
||||||
_id: encryptedSecret._id
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await nameWorkspaceSecrets.mutateAsync({
|
|
||||||
workspaceId: currentWorkspace._id,
|
|
||||||
secretsToUpdate
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark container mx-auto flex flex-col px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
<div className="flex justify-center bg-bunker-800 text-white w-full h-full px-6">
|
||||||
{/* TODO(akhilmhdh): Remove this right when layout is refactored */}
|
<div className="max-w-screen-lg w-full">
|
||||||
<div className="relative right-5">
|
<div className="relative right-5 ml-4">
|
||||||
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
|
<NavHeader pageName={t("settings.project.title")} isProjectRelated />
|
||||||
</div>
|
|
||||||
<div className="my-8 flex max-w-5xl flex-row items-center justify-between text-xl">
|
|
||||||
<div className="flex flex-col items-start justify-start text-3xl">
|
|
||||||
<p className="mr-4 font-semibold text-gray-200">{t("settings.project.title")}</p>
|
|
||||||
<p className="mr-4 text-base font-normal text-gray-400">
|
|
||||||
{t("settings.project.description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="my-8">
|
||||||
<ProjectNameChangeSection
|
<p className="text-3xl font-semibold text-gray-200">
|
||||||
workspaceName={currentWorkspace?.name}
|
{t("settings.project.title")}
|
||||||
onProjectNameChange={onRenameWorkspace}
|
</p>
|
||||||
/>
|
|
||||||
<CopyProjectIDSection workspaceID={currentWorkspace?._id || ""} />
|
|
||||||
<EnvironmentSection
|
|
||||||
isLoading={isWorkspaceLoading}
|
|
||||||
environments={currentWorkspace?.environments || []}
|
|
||||||
onCreate={onCreateWsEnv}
|
|
||||||
onDelete={onDeleteWsEnv}
|
|
||||||
onUpdate={onUpdateWsEnv}
|
|
||||||
isEnvServiceAllowed={isEnvServiceAllowed}
|
|
||||||
/>
|
|
||||||
<ServiceTokenSection
|
|
||||||
isLoading={isServiceTokenLoading}
|
|
||||||
tokens={serviceTokens || []}
|
|
||||||
environments={currentWorkspace?.environments || []}
|
|
||||||
onDeleteToken={onDeleteServiceToken}
|
|
||||||
workspaceName={currentWorkspace?.name || ""}
|
|
||||||
onCreateToken={onCreateServiceToken}
|
|
||||||
/>
|
|
||||||
<SecretTagsSection
|
|
||||||
isLoading={isTagLoading}
|
|
||||||
tags={wsTags || []}
|
|
||||||
onDeleteTag={onDeleteTag}
|
|
||||||
workspaceName={currentWorkspace?.name || ""}
|
|
||||||
onCreateTag={onCreateWsTag}
|
|
||||||
/>
|
|
||||||
<AutoCapitalizationSection
|
|
||||||
workspaceAutoCapitalization={currentWorkspace?.autoCapitalization}
|
|
||||||
onAutoCapitalizationChange={onAutoCapitalizationToggle}
|
|
||||||
/>
|
|
||||||
{!isBlindIndexedLoading && !isBlindIndexed && (
|
|
||||||
<ProjectIndexSecretsSection
|
|
||||||
onEnableBlindIndices={onEnableBlindIndices}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<E2EESection
|
|
||||||
workspaceId={currentWorkspace?._id || ""}
|
|
||||||
/>
|
|
||||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md border-l border-red bg-mineshaft-900 px-6 pl-6 pb-4 pt-4">
|
|
||||||
<p className="text-xl font-bold text-red">{t("settings.project.danger-zone")}</p>
|
|
||||||
<p className="text-md mt-2 text-gray-400">{t("settings.project.danger-zone-note")}</p>
|
|
||||||
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
|
|
||||||
<FormControl
|
|
||||||
label={
|
|
||||||
<div className="mb-0.5 text-sm font-normal text-gray-400">
|
|
||||||
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
|
|
||||||
workspace
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
onChange={(e) => setDeleteProjectInput(e.target.value)}
|
|
||||||
value={deleteProjectInput}
|
|
||||||
placeholder="Type the project name to delete"
|
|
||||||
className="bg-mineshaft-800"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<ProjectTabGroup />
|
||||||
colorSchema="danger"
|
|
||||||
onClick={onDeleteWorkspace}
|
|
||||||
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting}
|
|
||||||
isLoading={isDeleting}
|
|
||||||
>
|
|
||||||
{t("settings.project.delete-project")}
|
|
||||||
</Button>
|
|
||||||
<p className="mt-3 ml-0.5 text-xs text-gray-500">
|
|
||||||
{t("settings.project.delete-project-note")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import { Checkbox } from "@app/components/v2";
|
import { Checkbox } from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { useToggleAutoCapitalization } from "@app/hooks/api";
|
||||||
|
|
||||||
type Props = {
|
export const AutoCapitalizationSection = () => {
|
||||||
workspaceAutoCapitalization?: boolean;
|
|
||||||
onAutoCapitalizationChange: (state: boolean) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AutoCapitalizationSection = ({
|
|
||||||
workspaceAutoCapitalization,
|
|
||||||
onAutoCapitalizationChange
|
|
||||||
}: Props) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { mutateAsync } = useToggleAutoCapitalization();
|
||||||
|
|
||||||
|
const handleToggleCapitalizationToggle = async (state: boolean) => {
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?._id) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
workspaceID: currentWorkspace._id,
|
||||||
|
state
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = `Successfully ${state ? "enabled" : "disabled"} auto capitalization`;
|
||||||
|
createNotification({
|
||||||
|
text,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update auto capitalization",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-2">
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<p className="mb-4 mt-2 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
|
<p className="mb-3 text-xl font-semibold">{t("settings.project.auto-capitalization")}</p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
className="data-[state=checked]:bg-primary"
|
className="data-[state=checked]:bg-primary"
|
||||||
id="autoCapitalization"
|
id="autoCapitalization"
|
||||||
isChecked={workspaceAutoCapitalization}
|
isChecked={currentWorkspace?.autoCapitalization ?? false}
|
||||||
onCheckedChange={(state) => {
|
onCheckedChange={(state) => {
|
||||||
onAutoCapitalizationChange(state as boolean);
|
handleToggleCapitalizationToggle(state as boolean);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("settings.project.auto-capitalization-description")}
|
{t("settings.project.auto-capitalization-description")}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
|
|
||||||
import { IconButton } from "@app/components/v2";
|
|
||||||
import { useToggle } from "@app/hooks";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
workspaceID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CopyProjectIDSection = ({ workspaceID }: Props): JSX.Element => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let timer: NodeJS.Timeout;
|
|
||||||
if (isProjectIdCopied) {
|
|
||||||
timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
|
|
||||||
}
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [isProjectIdCopied]);
|
|
||||||
|
|
||||||
const copyProjectIdToClipboard = () => {
|
|
||||||
navigator.clipboard.writeText(workspaceID);
|
|
||||||
setIsProjectIdCopied.on();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pt-4 pb-2">
|
|
||||||
<p className="self-start text-xl font-semibold">{t("common.project-id")}</p>
|
|
||||||
<p className="mt-4 text-sm text-bunker-300 mb-2">{t("settings.project.auto-generated")}</p>
|
|
||||||
<div className="mt-2 mb-3 mr-2 flex items-center justify-end rounded-md bg-white/[0.07] text-base text-gray-400">
|
|
||||||
<p className="mr-2 pl-4 font-bold">{`${t("common.project-id")}:`}</p>
|
|
||||||
<p className="mr-4">{workspaceID}</p>
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="copy icon"
|
|
||||||
colorSchema="secondary"
|
|
||||||
className="group relative"
|
|
||||||
onClick={() => copyProjectIdToClipboard()}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />
|
|
||||||
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
|
|
||||||
{t("common.click-to-copy")}
|
|
||||||
</span>
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { CopyProjectIDSection } from "./CopyProjectIDSection";
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import { Button, FormControl, Input } from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { useToggle } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useDeleteWorkspace
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
|
||||||
|
export const DeleteProjectSection = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { currentWorkspace, workspaces } = useWorkspace();
|
||||||
|
const [isDeleting, setIsDeleting] = useToggle();
|
||||||
|
const [deleteProjectInput, setDeleteProjectInput] = useState("");
|
||||||
|
const deleteWorkspace = useDeleteWorkspace();
|
||||||
|
|
||||||
|
const onDeleteWorkspace = async () => {
|
||||||
|
setIsDeleting.on();
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?._id) return;
|
||||||
|
await deleteWorkspace.mutateAsync({
|
||||||
|
workspaceID: currentWorkspace?._id
|
||||||
|
});
|
||||||
|
// redirect user to first workspace user is part of
|
||||||
|
const ws = workspaces.find(({ _id }) => _id !== currentWorkspace?._id);
|
||||||
|
if (!ws) {
|
||||||
|
router.push("/noprojects");
|
||||||
|
}
|
||||||
|
router.push(`/dashboard/${ws?._id}`);
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully deleted workspace",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to delete workspace",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeleting.off();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-red">
|
||||||
|
<p className="mb-3 text-xl font-semibold text-red">{t("settings.project.danger-zone")}</p>
|
||||||
|
<p className="text-gray-400 mb-8">{t("settings.project.danger-zone-note")}</p>
|
||||||
|
<div className="mr-auto mt-4 max-h-28 w-full max-w-md">
|
||||||
|
<FormControl
|
||||||
|
label={
|
||||||
|
<div className="mb-0.5 text-sm font-normal text-gray-400">
|
||||||
|
Type <span className="font-bold">{currentWorkspace?.name}</span> to delete the
|
||||||
|
workspace
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
onChange={(e) => setDeleteProjectInput(e.target.value)}
|
||||||
|
value={deleteProjectInput}
|
||||||
|
placeholder="Type the project name to delete"
|
||||||
|
className="bg-mineshaft-800"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
colorSchema="danger"
|
||||||
|
onClick={onDeleteWorkspace}
|
||||||
|
isDisabled={deleteProjectInput !== currentWorkspace?.name || isDeleting}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
{t("settings.project.delete-project")}
|
||||||
|
</Button>
|
||||||
|
<p className="mt-3 ml-0.5 text-xs text-gray-500">
|
||||||
|
{t("settings.project.delete-project-note")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { DeleteProjectSection } from "./DeleteProjectSection";
|
||||||
@@ -4,29 +4,27 @@ import {
|
|||||||
decryptAssymmetric,
|
decryptAssymmetric,
|
||||||
encryptAssymmetric
|
encryptAssymmetric
|
||||||
} from "@app/components/utilities/cryptography/crypto";
|
} from "@app/components/utilities/cryptography/crypto";
|
||||||
import {
|
import { Checkbox } from "@app/components/v2";
|
||||||
Checkbox
|
import { useWorkspace } from "@app/context";
|
||||||
} from "@app/components/v2";
|
|
||||||
|
|
||||||
import getBot from "../../../../../pages/api/bot/getBot";
|
import getBot from "../../../../../pages/api/bot/getBot";
|
||||||
import setBotActiveStatus from "../../../../../pages/api/bot/setBotActiveStatus";
|
import setBotActiveStatus from "../../../../../pages/api/bot/setBotActiveStatus";
|
||||||
import getLatestFileKey from "../../../../../pages/api/workspace/getLatestFileKey";
|
import getLatestFileKey from "../../../../../pages/api/workspace/getLatestFileKey";
|
||||||
|
|
||||||
type Props = {
|
export const E2EESection = () => {
|
||||||
workspaceId: string;
|
const { currentWorkspace } = useWorkspace();
|
||||||
}
|
|
||||||
|
|
||||||
export const E2EESection = ({
|
|
||||||
workspaceId
|
|
||||||
}: Props) => {
|
|
||||||
const [bot, setBot] = useState<any>(null);
|
const [bot, setBot] = useState<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
// get project bot
|
if (currentWorkspace) {
|
||||||
setBot(await getBot({ workspaceId }));
|
// get project bot
|
||||||
|
setBot(await getBot({
|
||||||
|
workspaceId: currentWorkspace._id
|
||||||
|
}));
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, [currentWorkspace]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activate bot for project by performing the following steps:
|
* Activate bot for project by performing the following steps:
|
||||||
@@ -38,12 +36,16 @@ export const E2EESection = ({
|
|||||||
const toggleBotActivate = async () => {
|
const toggleBotActivate = async () => {
|
||||||
let botKey;
|
let botKey;
|
||||||
try {
|
try {
|
||||||
|
if (!currentWorkspace?._id) return;
|
||||||
|
|
||||||
if (bot) {
|
if (bot) {
|
||||||
// case: there is a bot
|
// case: there is a bot
|
||||||
|
|
||||||
if (!bot.isActive) {
|
if (!bot.isActive) {
|
||||||
// bot is not active -> activate bot
|
// bot is not active -> activate bot
|
||||||
const key = await getLatestFileKey({ workspaceId });
|
const key = await getLatestFileKey({
|
||||||
|
workspaceId: currentWorkspace._id
|
||||||
|
});
|
||||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
|
||||||
|
|
||||||
if (!PRIVATE_KEY) {
|
if (!PRIVATE_KEY) {
|
||||||
@@ -91,12 +93,12 @@ export const E2EESection = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return bot ? (
|
return bot ? (
|
||||||
<div className="mb-6 mt-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-2">
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<p className="mb-4 mt-2 text-xl font-semibold">End-to-End Encryption</p>
|
<p className="mb-3 text-xl font-semibold">End-to-End Encryption</p>
|
||||||
<p className="text-md my-2 text-gray-400">
|
<p className="text-gray-400 mb-8">
|
||||||
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values.
|
Disabling, end-to-end encryption (E2EE) unlocks capabilities like native integrations to cloud providers as well as HTTP calls to get secrets back raw but enables the server to read/decrypt your secret values.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-md my-2 mb-4 text-gray-400">
|
<p className="text-gray-400 mb-8">
|
||||||
Note that, even with E2EE disabled, your secrets are always encrypted at rest.
|
Note that, even with E2EE disabled, your secrets are always encrypted at rest.
|
||||||
</p>
|
</p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { useCreateWsEnvironment } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["createEnv"]>;
|
||||||
|
handlePopUpClose: (popUpName: keyof UsePopUpState<["createEnv"]>) => void;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createEnv"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
environmentName: yup.string().label("Environment Name").required(),
|
||||||
|
environmentSlug: yup.string().label("Environment Slug").required()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormData = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
export const AddEnvironmentModal = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpClose,
|
||||||
|
handlePopUpToggle
|
||||||
|
}: Props) => {
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { mutateAsync, isLoading } = useCreateWsEnvironment();
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: yupResolver(schema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFormSubmit = async ({
|
||||||
|
environmentName,
|
||||||
|
environmentSlug
|
||||||
|
}: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?._id) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
workspaceID: currentWorkspace._id,
|
||||||
|
environmentName,
|
||||||
|
environmentSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully created environment",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("createEnv");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to create environment",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.createEnv?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("createEnv", isOpen);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent title="Create a new environment">
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="environmentName"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Environment Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="environmentSlug"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Environment Slug"
|
||||||
|
helperText="Slugs are shorthands used in cli to access environment"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button colorSchema="secondary" variant="plain">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,224 +1,100 @@
|
|||||||
import { Controller, useForm } from "react-hook-form";
|
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { faPencil, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
|
||||||
import * as yup from "yup";
|
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
EmptyState,
|
|
||||||
FormControl,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Modal,
|
|
||||||
ModalContent,
|
|
||||||
Table,
|
|
||||||
TableContainer,
|
|
||||||
TableSkeleton,
|
|
||||||
TBody,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
THead,
|
|
||||||
Tr,
|
|
||||||
UpgradePlanModal
|
UpgradePlanModal
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
|
import { useSubscription,useWorkspace } from "@app/context";
|
||||||
|
import {
|
||||||
|
useDeleteWsEnvironment
|
||||||
|
} from "@app/hooks/api";
|
||||||
import { usePopUp } from "@app/hooks/usePopUp";
|
import { usePopUp } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
type Props = {
|
import { AddEnvironmentModal } from "./AddEnvironmentModal";
|
||||||
environments: Array<{ name: string; slug: string }>;
|
import { EnvironmentTable } from "./EnvironmentTable";
|
||||||
isLoading?: boolean;
|
import { UpdateEnvironmentModal } from "./UpdateEnvironmentModal";
|
||||||
isEnvServiceAllowed: boolean;
|
|
||||||
onCreate: (data: CreateUpdateEnvFormData) => Promise<void>;
|
|
||||||
onUpdate: (oldEnvSlug: string, data: CreateUpdateEnvFormData) => Promise<void>;
|
|
||||||
onDelete: (envSlug: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createUpdateEnvSchema = yup.object({
|
export const EnvironmentSection = () => {
|
||||||
environmentName: yup.string().label("Environment Name").required(),
|
const { createNotification } = useNotificationContext();
|
||||||
environmentSlug: yup.string().label("Environment Slug").required()
|
const { subscription } = useSubscription();
|
||||||
});
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
export type CreateUpdateEnvFormData = yup.InferType<typeof createUpdateEnvSchema>;
|
const deleteWsEnvironment = useDeleteWsEnvironment();
|
||||||
|
|
||||||
export const EnvironmentSection = ({
|
const isMoreEnvironmentsAllowed = (subscription?.environmentLimit && currentWorkspace?.environments) ? (currentWorkspace.environments.length < subscription.environmentLimit) : true;
|
||||||
environments,
|
|
||||||
isEnvServiceAllowed,
|
|
||||||
onCreate,
|
|
||||||
onDelete,
|
|
||||||
isLoading,
|
|
||||||
onUpdate
|
|
||||||
}: Props): JSX.Element => {
|
|
||||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||||
"createUpdateEnv",
|
"createEnv",
|
||||||
|
"updateEnv",
|
||||||
"deleteEnv",
|
"deleteEnv",
|
||||||
"upgradePlan"
|
"upgradePlan"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const {
|
const onEnvDeleteSubmit = async (environmentSlug: string) => {
|
||||||
control,
|
try {
|
||||||
handleSubmit,
|
if (!currentWorkspace?._id) return;
|
||||||
reset,
|
|
||||||
formState: { isSubmitting }
|
await deleteWsEnvironment.mutateAsync({
|
||||||
} = useForm<CreateUpdateEnvFormData>({
|
workspaceID: currentWorkspace._id,
|
||||||
resolver: yupResolver(createUpdateEnvSchema)
|
environmentSlug
|
||||||
});
|
});
|
||||||
|
|
||||||
const isEnvUpdate = Boolean(popUp?.createUpdateEnv?.data);
|
createNotification({
|
||||||
const oldEnvSlug = (popUp?.createUpdateEnv?.data as { slug: string })?.slug;
|
text: "Successfully deleted environment",
|
||||||
|
type: "success"
|
||||||
const onEnvModalSubmit = async (data: CreateUpdateEnvFormData) => {
|
});
|
||||||
if (isEnvUpdate) {
|
|
||||||
await onUpdate(oldEnvSlug, data);
|
handlePopUpClose("deleteEnv");
|
||||||
} else {
|
} catch (err) {
|
||||||
await onCreate(data);
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to delete environment",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
handlePopUpClose("createUpdateEnv");
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEnvDeleteSubmit = async (envSlug: string) => {
|
|
||||||
await onDelete(envSlug);
|
|
||||||
handlePopUpClose("deleteEnv");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 mb-4 flex w-full flex-col items-start rounded-md bg-mineshaft-900 p-6">
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
<div className="mb-2 flex w-full flex-row justify-between">
|
<div className="flex justify-between mb-8">
|
||||||
<div className="flex w-full flex-col">
|
<p className="text-xl font-semibold text-mineshaft-100">
|
||||||
<p className="mb-3 text-xl font-semibold">Project Environments</p>
|
Environments
|
||||||
<p className="mb-4 text-base text-gray-400">
|
</p>
|
||||||
Choose which environments will show up in your dashboard like development, staging,
|
|
||||||
production
|
|
||||||
</p>
|
|
||||||
<p className="mr-1 self-start text-sm text-gray-500">
|
|
||||||
Note: the text in slugs shows how these environmant should be accessed in CLI.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
|
colorSchema="secondary"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isEnvServiceAllowed) {
|
if (isMoreEnvironmentsAllowed) {
|
||||||
handlePopUpOpen("createUpdateEnv");
|
handlePopUpOpen("createEnv");
|
||||||
} else {
|
} else {
|
||||||
handlePopUpOpen("upgradePlan");
|
handlePopUpOpen("upgradePlan");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
colorSchema="primary"
|
|
||||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
|
||||||
>
|
>
|
||||||
Add New Environment
|
Create environment
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TableContainer>
|
<p className="text-gray-400 mb-8">
|
||||||
<Table>
|
Choose which environments will show up in your dashboard like development, staging, production
|
||||||
<THead>
|
</p>
|
||||||
<Tr>
|
<EnvironmentTable
|
||||||
<Th>Name</Th>
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
<Th>Slug</Th>
|
/>
|
||||||
<Th aria-label="button" />
|
<AddEnvironmentModal
|
||||||
</Tr>
|
popUp={popUp}
|
||||||
</THead>
|
handlePopUpClose={handlePopUpClose}
|
||||||
<TBody>
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
{isLoading && <TableSkeleton columns={3} key="project-envs" />}
|
/>
|
||||||
{!isLoading &&
|
<UpdateEnvironmentModal
|
||||||
environments.map(({ name, slug }) => (
|
popUp={popUp}
|
||||||
<Tr key={name}>
|
handlePopUpClose={handlePopUpClose}
|
||||||
<Td>{name}</Td>
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
<Td>{slug}</Td>
|
/>
|
||||||
<Td className="flex items-center justify-end">
|
|
||||||
<IconButton
|
|
||||||
className="mr-3 py-2"
|
|
||||||
onClick={() => {
|
|
||||||
handlePopUpOpen("createUpdateEnv", { name, slug });
|
|
||||||
reset({ environmentName: name, environmentSlug: slug });
|
|
||||||
}}
|
|
||||||
colorSchema="primary"
|
|
||||||
variant="plain"
|
|
||||||
ariaLabel="update"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faPencil} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
handlePopUpOpen("deleteEnv", { name, slug });
|
|
||||||
}}
|
|
||||||
size="lg"
|
|
||||||
colorSchema="danger"
|
|
||||||
variant="plain"
|
|
||||||
ariaLabel="update"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faXmark} />
|
|
||||||
</IconButton>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
{!isLoading && environments?.length === 0 && (
|
|
||||||
<Tr>
|
|
||||||
<Td colSpan={3}>
|
|
||||||
<EmptyState title="No environments found" />
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
)}
|
|
||||||
</TBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
<Modal
|
|
||||||
isOpen={popUp?.createUpdateEnv?.isOpen}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
handlePopUpToggle("createUpdateEnv", isOpen);
|
|
||||||
reset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ModalContent title={isEnvUpdate ? "Update environment" : "Create a new environment"}>
|
|
||||||
<form onSubmit={handleSubmit(onEnvModalSubmit)}>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
defaultValue=""
|
|
||||||
name="environmentName"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
label="Environment Name"
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText={error?.message}
|
|
||||||
>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={control}
|
|
||||||
defaultValue=""
|
|
||||||
name="environmentSlug"
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormControl
|
|
||||||
label="Environment Slug"
|
|
||||||
helperText="Slugs are shorthands used in cli to access environment"
|
|
||||||
isError={Boolean(error)}
|
|
||||||
errorText={error?.message}
|
|
||||||
>
|
|
||||||
<Input {...field} />
|
|
||||||
</FormControl>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="mt-8 flex items-center">
|
|
||||||
<Button
|
|
||||||
className="mr-4"
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
|
||||||
isLoading={isSubmitting}
|
|
||||||
isDisabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{isEnvUpdate ? "Update" : "Create"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button colorSchema="secondary" variant="plain">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
isOpen={popUp.deleteEnv.isOpen}
|
isOpen={popUp.deleteEnv.isOpen}
|
||||||
title={`Are you sure want to delete ${
|
title={`Are you sure want to delete ${
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr,
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<["updateEnv", "deleteEnv", "deleteEnv", "upgradePlan"]>,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
slug
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvironmentTable = ({
|
||||||
|
handlePopUpOpen
|
||||||
|
}: Props) => {
|
||||||
|
const { currentWorkspace, isLoading } = useWorkspace();
|
||||||
|
return (
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>Slug</Th>
|
||||||
|
<Th aria-label="button" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && <TableSkeleton columns={3} key="project-envs" />}
|
||||||
|
{!isLoading && currentWorkspace && currentWorkspace.environments.map(({ name, slug }) => (
|
||||||
|
<Tr key={name}>
|
||||||
|
<Td>{name}</Td>
|
||||||
|
<Td>{slug}</Td>
|
||||||
|
<Td className="flex items-center justify-end">
|
||||||
|
<IconButton
|
||||||
|
className="mr-3 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpOpen("updateEnv", { name, slug });
|
||||||
|
}}
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPencil} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => {
|
||||||
|
handlePopUpOpen("deleteEnv", { name, slug });
|
||||||
|
}}
|
||||||
|
size="lg"
|
||||||
|
colorSchema="danger"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</IconButton>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
{!isLoading && currentWorkspace && currentWorkspace.environments?.length === 0 && (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={3}>
|
||||||
|
<EmptyState title="No environments found" />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import { useUpdateWsEnvironment } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["updateEnv"]>;
|
||||||
|
handlePopUpClose: (popUpName: keyof UsePopUpState<["updateEnv"]>) => void;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["updateEnv"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
environmentName: yup.string().label("Environment Name").required(),
|
||||||
|
environmentSlug: yup.string().label("Environment Slug").required()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FormData = yup.InferType<typeof schema>;
|
||||||
|
|
||||||
|
export const UpdateEnvironmentModal = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpClose,
|
||||||
|
handlePopUpToggle
|
||||||
|
}: Props) => {
|
||||||
|
const { createNotification } = useNotificationContext();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
const { mutateAsync, isLoading } = useUpdateWsEnvironment();
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: yupResolver(schema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldEnvironmentSlug = (popUp?.updateEnv?.data as { slug: string })?.slug;
|
||||||
|
|
||||||
|
const onFormSubmit = async ({
|
||||||
|
environmentName,
|
||||||
|
environmentSlug
|
||||||
|
}: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!currentWorkspace?._id) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
workspaceID: currentWorkspace._id,
|
||||||
|
environmentName,
|
||||||
|
environmentSlug,
|
||||||
|
oldEnvironmentSlug
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully updated environment",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("updateEnv");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update environment",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.updateEnv?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("updateEnv", isOpen);
|
||||||
|
reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent title="Update environment">
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="environmentName"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Environment Name"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
name="environmentSlug"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Environment Slug"
|
||||||
|
helperText="Slugs are shorthands used in cli to access environment"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 flex items-center">
|
||||||
|
<Button
|
||||||
|
className="mr-4"
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button colorSchema="secondary" variant="plain">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { AutoCapitalizationSection } from "../AutoCapitalizationSection";
|
||||||
|
import { DeleteProjectSection } from "../DeleteProjectSection";
|
||||||
|
import { E2EESection } from "../E2EESection";
|
||||||
|
import { EnvironmentSection } from "../EnvironmentSection";
|
||||||
|
import { ProjectIndexSecretsSection } from "../ProjectIndexSecretsSection";
|
||||||
|
import { ProjectNameChangeSection } from "../ProjectNameChangeSection";
|
||||||
|
import { SecretTagsSection } from "../SecretTagsSection";
|
||||||
|
|
||||||
|
export const ProjectGeneralTab = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProjectNameChangeSection />
|
||||||
|
<EnvironmentSection />
|
||||||
|
<SecretTagsSection />
|
||||||
|
<AutoCapitalizationSection />
|
||||||
|
<ProjectIndexSecretsSection />
|
||||||
|
<E2EESection />
|
||||||
|
<DeleteProjectSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectGeneralTab } from "./ProjectGeneralTab";
|
||||||
@@ -1,24 +1,64 @@
|
|||||||
|
import {
|
||||||
|
decryptAssymmetric,
|
||||||
|
decryptSymmetric
|
||||||
|
} from "@app/components/utilities/cryptography/crypto";
|
||||||
import { Button } from "@app/components/v2";
|
import { Button } from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
|
import {
|
||||||
|
useGetUserWsKey,
|
||||||
|
useGetWorkspaceIndexStatus,
|
||||||
|
useGetWorkspaceSecrets,
|
||||||
|
useNameWorkspaceSecrets
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
|
||||||
// TODO: add check so that this only shows up if user is
|
// TODO: add check so that this only shows up if user is
|
||||||
// an admin in the workspace
|
// an admin in the workspace
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onEnableBlindIndices: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectIndexSecretsSection = ({
|
export const ProjectIndexSecretsSection = () => {
|
||||||
onEnableBlindIndices
|
const { currentWorkspace } = useWorkspace();
|
||||||
}: Props) => {
|
const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(currentWorkspace?._id ?? "");
|
||||||
return (
|
const { data: latestFileKey } = useGetUserWsKey(currentWorkspace?._id ?? "");
|
||||||
<div className="rounded-md bg-mineshaft-900 p-6 my-2">
|
const { data: encryptedSecrets } = useGetWorkspaceSecrets(currentWorkspace?._id ?? "");
|
||||||
<p className="mb-4 text-xl font-semibold">Blind Indices</p>
|
const nameWorkspaceSecrets = useNameWorkspaceSecrets();
|
||||||
<p className="mb-4 text-sm text-gray-400">
|
|
||||||
|
const onEnableBlindIndices = async () => {
|
||||||
|
if (!currentWorkspace?._id) return;
|
||||||
|
if (!encryptedSecrets) return;
|
||||||
|
if (!latestFileKey) return;
|
||||||
|
|
||||||
|
const key = decryptAssymmetric({
|
||||||
|
ciphertext: latestFileKey.encryptedKey,
|
||||||
|
nonce: latestFileKey.nonce,
|
||||||
|
publicKey: latestFileKey.sender.publicKey,
|
||||||
|
privateKey: localStorage.getItem("PRIVATE_KEY") as string
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => {
|
||||||
|
const secretName = decryptSymmetric({
|
||||||
|
ciphertext: encryptedSecret.secretKeyCiphertext,
|
||||||
|
iv: encryptedSecret.secretKeyIV,
|
||||||
|
tag: encryptedSecret.secretKeyTag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
secretName,
|
||||||
|
_id: encryptedSecret._id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await nameWorkspaceSecrets.mutateAsync({
|
||||||
|
workspaceId: currentWorkspace._id,
|
||||||
|
secretsToUpdate
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (!isBlindIndexedLoading && !isBlindIndexed) ? (
|
||||||
|
<div className="mb-6 p-4 bg-mineshaft-900 max-w-screen-lg rounded-lg border border-mineshaft-600">
|
||||||
|
<p className="mb-3 text-xl font-semibold">Blind Indices</p>
|
||||||
|
<p className="text-gray-400 mb-8">
|
||||||
Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing.
|
Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing.
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-4 text-sm text-gray-400">
|
|
||||||
Learn more about it here.
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onEnableBlindIndices}
|
onClick={onEnableBlindIndices}
|
||||||
color="mineshaft"
|
color="mineshaft"
|
||||||
@@ -28,5 +68,7 @@ export const ProjectIndexSecretsSection = ({
|
|||||||
Enable Blind Indexing
|
Enable Blind Indexing
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
) : (
|
||||||
|
<div />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
import { yupResolver } from "@hookform/resolvers/yup";
|
||||||
import * as yup from "yup";
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||||
import { Button, FormControl, Input } from "@app/components/v2";
|
import { Button, FormControl, Input } from "@app/components/v2";
|
||||||
|
import { useWorkspace } from "@app/context";
|
||||||
type Props = {
|
import {
|
||||||
workspaceName?: string;
|
useRenameWorkspace
|
||||||
onProjectNameChange: (name: string) => Promise<void>;
|
} from "@app/hooks/api";
|
||||||
};
|
|
||||||
|
|
||||||
const formSchema = yup.object({
|
const formSchema = yup.object({
|
||||||
name: yup.string().required().label("Project Name")
|
name: yup.string().required().label("Project Name")
|
||||||
@@ -19,36 +17,68 @@ const formSchema = yup.object({
|
|||||||
|
|
||||||
type FormData = yup.InferType<typeof formSchema>;
|
type FormData = yup.InferType<typeof formSchema>;
|
||||||
|
|
||||||
export const ProjectNameChangeSection = ({
|
export const ProjectNameChangeSection = () => {
|
||||||
workspaceName,
|
const { createNotification } = useNotificationContext();
|
||||||
onProjectNameChange
|
const { currentWorkspace } = useWorkspace();
|
||||||
}: Props): JSX.Element => {
|
const { mutateAsync, isLoading } = useRenameWorkspace();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
reset,
|
reset
|
||||||
formState: { isDirty, isSubmitting }
|
|
||||||
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
|
} = useForm<FormData>({ resolver: yupResolver(formSchema) });
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reset({ name: workspaceName });
|
if (currentWorkspace) {
|
||||||
}, [workspaceName]);
|
reset({
|
||||||
|
name: currentWorkspace.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [currentWorkspace]);
|
||||||
|
|
||||||
const onFormSubmit = async ({ name }: FormData) => {
|
const onFormSubmit = async ({ name }: FormData) => {
|
||||||
await onProjectNameChange(name);
|
try {
|
||||||
|
if (!currentWorkspace?._id) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
workspaceID: currentWorkspace._id,
|
||||||
|
newWorkspaceName: name
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully renamed workspace",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to rename workspace",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
<form
|
||||||
<div className="mb-6 flex w-full flex-col items-start rounded-md bg-mineshaft-900 px-6 pb-6 pt-3">
|
onSubmit={handleSubmit(onFormSubmit)}
|
||||||
<p className="mb-4 mt-2 text-xl font-semibold">{t("common.display-name")}</p>
|
className="p-4 bg-mineshaft-900 mb-6 max-w-screen-lg rounded-lg border border-mineshaft-600"
|
||||||
<div className="mb-2 w-full max-w-lg">
|
>
|
||||||
|
<h2 className="text-xl font-semibold flex-1 text-mineshaft-100 mb-8">
|
||||||
|
{t("common.display-name")}
|
||||||
|
</h2>
|
||||||
|
<div className="max-w-md">
|
||||||
<Controller
|
<Controller
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||||
<Input placeholder="Type your project name" {...field} className="bg-mineshaft-800" />
|
<Input
|
||||||
|
placeholder="Project name"
|
||||||
|
{...field}
|
||||||
|
className="bg-mineshaft-800"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
@@ -56,17 +86,13 @@ export const ProjectNameChangeSection = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isSubmitting}
|
colorSchema="secondary"
|
||||||
color="primary"
|
|
||||||
variant="outline_bg"
|
|
||||||
size="sm"
|
|
||||||
type="submit"
|
type="submit"
|
||||||
isDisabled={!isDirty || isSubmitting}
|
isLoading={isLoading}
|
||||||
leftIcon={<FontAwesomeIcon icon={faCheck} />}
|
isDisabled={isLoading}
|
||||||
>
|
>
|
||||||
{t("common.save-changes")}
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ServiceTokenSection } from "../ServiceTokenSection";
|
||||||
|
|
||||||
|
export const ProjectServiceTokensTab = () => {
|
||||||
|
return (
|
||||||
|
<ServiceTokenSection />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectServiceTokensTab } from "./ProjectServiceTokensTab";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user