From d36d7bfce6c403ad2087d5e1c550c55dbee9adfa Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Mon, 27 Mar 2023 22:00:10 +0700 Subject: [PATCH] Checkpoint service account functionality, added UI and general backend structure --- .../controllers/v2/organizationsController.ts | 1 + .../v2/serviceAccountsController.ts | 232 ++++++++--- backend/src/helpers/serviceAccount.ts | 110 ----- backend/src/middleware/index.ts | 2 + ...eServiceAccountWorkspacePermissionsAuth.ts | 52 +++ backend/src/models/index.ts | 9 +- backend/src/models/serviceAccount.ts | 6 + .../serviceAccountOrganizationPermission.ts | 28 ++ .../src/models/serviceAccountPermission.ts | 39 -- .../serviceAccountWorkspacePermissions.ts | 54 +++ backend/src/routes/v2/serviceAccounts.ts | 164 ++++++-- backend/src/services/smtp.ts | 2 +- backend/src/variables/index.ts | 12 +- backend/src/variables/permissions.ts | 19 - frontend/src/components/basic/Layout.tsx | 1 + .../src/components/navigation/NavHeader.tsx | 15 +- .../utilities/cryptography/crypto.ts | 19 +- frontend/src/hooks/api/index.tsx | 1 + .../src/hooks/api/serviceAccounts/index.tsx | 9 + .../src/hooks/api/serviceAccounts/queries.tsx | 146 +++++++ .../src/hooks/api/serviceAccounts/types.ts | 66 +++ frontend/src/layouts/AppLayout/AppLayout.tsx | 33 +- .../settings/org/{[id].tsx => [id]/index.tsx} | 0 .../service-accounts/[serviceAccountId].tsx | 33 ++ .../pages/settings/service-account/[id].tsx | 20 + .../CreateServiceAccountPage.tsx | 43 ++ .../SAProjectLevelPermissionsTable.tsx | 394 ++++++++++++++++++ .../SAProjectLevelPermissionsTable/index.tsx | 1 + .../ServiceAccountNameChangeSection.tsx | 88 ++++ .../ServiceAccountNameChangeSection/index.tsx | 1 + .../components/index.tsx | 2 + .../CreateServiceAccountPage/index.tsx | 1 + .../OrgSettingsPage/OrgSettingsPage.tsx | 36 +- .../OrgIncidentContactsTable.tsx | 3 +- .../OrgMembersTable/OrgMembersTable.tsx | 28 +- .../OrgServiceAccountsTable.tsx | 353 ++++++++++++++++ .../OrgServiceAccountsTable/index.tsx | 1 + .../OrgSettingsPage/components/index.tsx | 2 + .../ServiceTokenSection.tsx | 40 +- 39 files changed, 1710 insertions(+), 356 deletions(-) create mode 100644 backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts create mode 100644 backend/src/models/serviceAccountOrganizationPermission.ts delete mode 100644 backend/src/models/serviceAccountPermission.ts create mode 100644 backend/src/models/serviceAccountWorkspacePermissions.ts delete mode 100644 backend/src/variables/permissions.ts create mode 100644 frontend/src/hooks/api/serviceAccounts/index.tsx create mode 100644 frontend/src/hooks/api/serviceAccounts/queries.tsx create mode 100644 frontend/src/hooks/api/serviceAccounts/types.ts rename frontend/src/pages/settings/org/{[id].tsx => [id]/index.tsx} (100%) create mode 100644 frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx create mode 100644 frontend/src/pages/settings/service-account/[id].tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx create mode 100644 frontend/src/views/Settings/CreateServiceAccountPage/index.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx create mode 100644 frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index d7e6c1940..613206ba3 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -295,6 +295,7 @@ return res.status(200).send({ */ export const getOrganizationServiceAccounts = async (req: Request, res: Response) => { const { organizationId } = req.params; + const serviceAccounts = await ServiceAccount.find({ organization: new Types.ObjectId(organizationId) }); diff --git a/backend/src/controllers/v2/serviceAccountsController.ts b/backend/src/controllers/v2/serviceAccountsController.ts index 7c25390a7..7d79fb6d7 100644 --- a/backend/src/controllers/v2/serviceAccountsController.ts +++ b/backend/src/controllers/v2/serviceAccountsController.ts @@ -1,34 +1,49 @@ import { Request, Response } from 'express'; import { Types } from 'mongoose'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; import { ServiceAccount, ServiceAccountKey, - ServiceAccountPermission + ServiceAccountOrganizationPermissions, + ServiceAccountWorkspacePermissions } from '../../models'; import { - validateCreateServiceAccountPermission -} from '../../helpers/serviceAccount'; -import { - CreateServiceAccountDto, - AddServiceAccountPermissionDto + CreateServiceAccountDto } from '../../interfaces/serviceAccounts/dto'; -import { - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET -} from '../../variables'; -import { ServiceAccountKeyNotFoundError, ValidationError } from '../../utils/errors'; +import { BadRequestError, ServiceAccountNotFoundError } from '../../utils/errors'; +import { getSaltRounds } from '../../config'; + +/** + * Return service account with id [serviceAccountId] + * @param req + * @param res + */ +export const getServiceAccount = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; + + const serviceAccount = await ServiceAccount.findById(serviceAccountId); + + if (!serviceAccount) { + throw ServiceAccountNotFoundError({ message: 'Failed to find service account' }); + } + + return res.status(200).send({ + serviceAccount + }); +} /** * Create a new service account under organization with id [organizationId] * that has access to workspaces [workspaces] * @param req * @param res - * @returns + * @returns */ export const createServiceAccount = async (req: Request, res: Response) => { const { - organizationId, name, + organizationId, publicKey, expiresIn, }: CreateServiceAccountDto = req.body; @@ -38,15 +53,59 @@ export const createServiceAccount = async (req: Request, res: Response) => { expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); } + + const secret = crypto.randomBytes(16).toString('base64'); + const secretHash = await bcrypt.hash(secret, getSaltRounds()); + // create service account const serviceAccount = await new ServiceAccount({ name, organization: new Types.ObjectId(organizationId), user: req.user, publicKey, - expiresAt + expiresAt, + secretHash }).save(); + + const serviceAccountObj = serviceAccount.toObject(); + + delete serviceAccountObj.secretHash; + // provision default org-level permissions for service account + const permissions = await new ServiceAccountOrganizationPermissions({ + serviceAccount: serviceAccount._id + }).save(); + + const secretId = Buffer.from(serviceAccount._id.toString(), 'hex').toString('base64'); + + return res.status(200).send({ + serviceAccountAccessKey: `SA.${secretId}.${secret}`, + serviceAccount: serviceAccountObj + }); +} + +/** + * Change name of service account with id [serviceAccountId] to [name] + * @param req + * @param res + * @returns + */ +export const changeServiceAccountName = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; + const { name } = req.body; + + const serviceAccount = await ServiceAccount.findOneAndUpdate( + { + _id: new Types.ObjectId(serviceAccountId) + }, + { + name + }, + { + new: true + } + ); + return res.status(200).send({ serviceAccount }); @@ -78,66 +137,111 @@ export const addServiceAccountKey = async (req: Request, res: Response) => { } /** - * Add a permission to service account with id [serviceAccountId] - * @param req - * @param res + * Return organization-level permissions for service account with id [serviceAccountId] + * @param req + * @param res */ -export const addServiceAccountPermission = async (req: Request, res: Response) => { - const { - name, - workspaceId, - environment - }: AddServiceAccountPermissionDto = req.body; +export const getServiceAccountOrganizationPermissions = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; - if (PERMISSION_SA_WORKSPACE_SET.has(name)) { - // case: permission named [name] is workspace-related - - // some such permissions require workspaceId and environment to be present. - - if (!workspaceId || !environment) { - throw ValidationError({ - message: 'Failed validation that is workspace-related permission must specify a workspace and environment' - }); - } else { - const serviceAccountKey = await ServiceAccountKey.findOne({ - serviceAccount: req.serviceAccount._id, - workspace: new Types.ObjectId(workspaceId) - }); - - if (!serviceAccountKey) throw ServiceAccountKeyNotFoundError({ message: 'Failed to find service account key' }); - } - } - - const serviceAccountPermission = await new ServiceAccountPermission({ - serviceAccount: req.serviceAccount._id, - name, - workspace: workspaceId ? new Types.ObjectId(workspaceId) : undefined, - environment + const permissions = await ServiceAccountOrganizationPermissions.findOne({ + serviceAccount: new Types.ObjectId(serviceAccountId), }); return res.status(200).send({ - serviceAccountPermission + permissions }); } /** - * Delete a permission from service account with id [serviceAccountId] + * Return workspace-level permissions for service account with id [serviceAccountId] * @param req * @param res */ -export const deleteServiceAccountPermission = async (req: Request, res: Response) => { - const { serviceAccountPermissionId } = req.params; - - // user must either be an admin/owner of the organization or they must - // have created the service account in the first place to be able to delete it - - // TODO: how to delete just 1 permission? - - - const serviceAccountPermission = await ServiceAccountPermission.findByIdAndDelete(serviceAccountPermissionId); +export const getServiceAccountWorkspacePermissions = async (req: Request, res: Response) => { + const permissions = await ServiceAccountWorkspacePermissions.find({ + serviceAccount: req.serviceAccount._id + }).populate('workspace'); return res.status(200).send({ - serviceAccountPermission + permissions + }); +} + +/** + * Add organization permissions to service account with id [serviceAccountId] + * @param req + * @param res + */ +export const addServiceAccountOrganizationPermission = async (req: Request, res: Response) => { + const permissions = ServiceAccountOrganizationPermissions.findOne({ + serviceAccount: req.serviceAccount._id + }); + + // TODO + + return res.status(200).send({ + permissions + }); +} + +/** + * Add a workspace permissions to service account with id [serviceAccountId] + * @param req + * @param res + */ +export const addServiceAccountWorkspacePermission = async (req: Request, res: Response) => { + const { serviceAccountId } = req.params; + const { + environment, + workspaceId, + canRead = false, + canWrite = false, + canUpdate = false, + canDelete = false + } = req.body; + + if (!req.membership.workspace.environments.some((e: { name: string; slug: string }) => e.slug === environment)) { + return res.status(400).send({ + message: 'Failed to validate workspace environment' + }); + } + + const existingPermission = await ServiceAccountWorkspacePermissions.findOne({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspaceId: new Types.ObjectId(workspaceId), + environment + }); + + if (existingPermission) throw BadRequestError({ message: 'Failed to add workspace permission to service account due to already-existing ' }); + + const permissions = await new ServiceAccountWorkspacePermissions({ + serviceAccount: new Types.ObjectId(serviceAccountId), + workspace: new Types.ObjectId(workspaceId), + environment, + canRead, + canWrite, + canUpdate, + canDelete + }).save(); + + return res.status(200).send({ + permissions + }); +} + +/** + * Delete workspace permissions from service account with id [serviceAccountId] + * @param req + * @param res + */ +export const deleteServiceAccountWorkspacePermission = async (req: Request, res: Response) => { + const { serviceAccountWorkspacePermissionsId } = req.params; + + const permissions = await ServiceAccountWorkspacePermissions.findByIdAndDelete(serviceAccountWorkspacePermissionsId); + + return res.status(200).send({ + permissions }); } @@ -156,10 +260,14 @@ export const deleteServiceAccount = async (req: Request, res: Response) => { // case: service account with id [serviceAccountId] was deleted await ServiceAccountKey.deleteMany({ - serviceAccount: serviceAccount?._id + serviceAccount: serviceAccount._id }); - await ServiceAccountPermission.deleteMany({ + await ServiceAccountOrganizationPermissions.deleteMany({ + serviceAccount: new Types.ObjectId(serviceAccountId) + }); + + await ServiceAccountWorkspacePermissions.deleteMany({ serviceAccount: new Types.ObjectId(serviceAccountId) }); } diff --git a/backend/src/helpers/serviceAccount.ts b/backend/src/helpers/serviceAccount.ts index b82d6b3b0..e69de29bb 100644 --- a/backend/src/helpers/serviceAccount.ts +++ b/backend/src/helpers/serviceAccount.ts @@ -1,110 +0,0 @@ -import { Types } from 'mongoose'; -import { - Workspace, - ServiceAccount, - ServiceAccountKey -} from '../models'; -import { - WorkspaceNotFoundError, - ServiceAccountNotFoundError, - ServiceAccountKeyNotFoundError -} from '../utils/errors'; -import { - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_SET -} from '../variables'; - -/** - * Validate that user with id [userId] can provision the permission - * named [name] for a service account with id [serviceAccountId] and - * optionally workspace with id [workspaceId] and environment [environment] - * @param {Object} obj - * @param {String} obj.name - name of permission to create - * @param {Types.ObjectId} userId - id of user creating the permission - * @param {Types.ObjectId} serviceAccountId - id of service account that permission will be bound to - * @param {Types.ObjectId} workspaceId - id of workspace that permission concerns - * @param {Types.ObjectId} workspaceId - id of service account that permission will be bound to - */ -const validateCreateServiceAccountPermission = async ({ - name, - userId, - serviceAccountId, - workspaceId, - environment -}: { - name: string; - userId: Types.ObjectId; - serviceAccountId: Types.ObjectId, - workspaceId?: Types.ObjectId; - environment?: string; -}) => { - - // TODO: as we upgrade user permissions to be more global, then we should take into account - // the user's permissions as it concerns to being able to interact with service accounts - - if (!PERMISSION_SA_SET.has(name)) throw new Error(`${name} is not a valid permission name`); - - if ([ - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE - ].includes(name)) { - if (workspaceId && environment) { - // case: either workspace id [workspaceId] or environment name [environment] is being passed in - // (i.e. validating a service account permission concerning a workspace and/or environment) - const workspace = await Workspace.findById(workspaceId); - - if (!workspace) { - // case: workspace does not exist - throw WorkspaceNotFoundError({ message: 'Failed to locate workspace' }); - } - - if (!workspace.environments.some((env) => env.slug === environment)) { - // case: environment name [environment] is not a valid environment slug in workspace - throw Error('Failed to locate environment in workspace'); - } - - const serviceAccount = await ServiceAccount.findById(serviceAccountId); - if (!serviceAccount) { - // case: service account does not exist - throw ServiceAccountNotFoundError({ message: 'Failed to locate service account' }); - } - - const serviceAccountKey = await ServiceAccountKey.findOne({ - serviceAccount: serviceAccount._id, - workspace: workspaceId - }); - - if (!serviceAccountKey) { - // case: service account key does not exist - throw ServiceAccountKeyNotFoundError({ message: 'Failed to locate service account key' }); - } - } else { - throw new Error('Failed to validate workspace and environment for workspace-related permission'); - } - } - -} - -const validateDeleteServiceAccountPermission = async ({ - userId, - serviceAccountId, - name, - workspaceId, - environment -}: { - userId: Types.ObjectId; - serviceAccountId: Types.ObjectId; - name: string; - workspaceId: Types.ObjectId; - environment: string; -}) => { - // does the user have the authority to delete the permission? - // does the service account permission exist? - - -} - -export { - validateCreateServiceAccountPermission -} \ No newline at end of file diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 103c6fa16..f1069ddd4 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -11,6 +11,7 @@ import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizati import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireServiceAccountAuth from './requireServiceAccountAuth'; +import requireServiceAccountWorkspacePermissionsAuth from './requireServiceAccountWorkspacePermissionsAuth'; import requireSecretAuth from './requireSecretAuth'; import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; @@ -29,6 +30,7 @@ export { requireServiceTokenAuth, requireServiceTokenDataAuth, requireServiceAccountAuth, + requireServiceAccountWorkspacePermissionsAuth, requireSecretAuth, requireSecretsAuth, validateRequest diff --git a/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts b/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts new file mode 100644 index 000000000..cb6f2980c --- /dev/null +++ b/backend/src/middleware/requireServiceAccountWorkspacePermissionsAuth.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from 'express'; +import { ServiceAccount, ServiceAccountWorkspacePermissions } from '../models'; +import { + ServiceAccountNotFoundError +} from '../utils/errors'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; + +type req = 'params' | 'body' | 'query'; + +const requireServiceAccountWorkspacePermissionsAuth = ({ + acceptedRoles, + acceptedStatuses, + location = 'params' +}: { + acceptedRoles: string[]; + acceptedStatuses: string[]; + location?: req; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + const serviceAccountWorkspacePermissionsId = req[location].serviceAccountWorkspacePermissionsId; + const serviceAccountWorkspacePermissions = await ServiceAccountWorkspacePermissions.findById(serviceAccountWorkspacePermissionsId); + + if (!serviceAccountWorkspacePermissions) { + return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account workspace permission' })); + } + + const serviceAccount = await ServiceAccount.findById(serviceAccountWorkspacePermissions.serviceAccount); + + if (!serviceAccount) { + return next(ServiceAccountNotFoundError({ message: 'Failed to locate Service Account' })); + } + + if (serviceAccount.user.toString() !== req.user.id.toString()) { + // case: creator of the service account is different from + // the user on the request -> apply middleware role/status validation + await validateMembershipOrg({ + userId: req.user._id, + organizationId: serviceAccount.organization, + acceptedRoles, + acceptedStatuses + }); + } + + req.serviceAccount = serviceAccount; + + next(); + } +} + +export default requireServiceAccountWorkspacePermissionsAuth; \ No newline at end of file diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index a7796c6c6..0474452c7 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -12,7 +12,8 @@ import Secret, { ISecret } from './secret'; import ServiceToken, { IServiceToken } from './serviceToken'; import ServiceAccount, { IServiceAccount } from './serviceAccount'; // new import ServiceAccountKey, { IServiceAccountKey } from './serviceAccountKey'; // new -import ServiceAccountPermission, { IServiceAccountPermission } from './serviceAccountPermission'; +import ServiceAccountOrganizationPermissions, { IServiceAccountOrganizationPermissions } from './serviceAccountOrganizationPermission'; // new +import ServiceAccountWorkspacePermissions, { IServiceAccountWorkspacePermissions } from './serviceAccountWorkspacePermissions'; // new import TokenData, { ITokenData } from './tokenData'; import User, { IUser } from './user'; import UserAction, { IUserAction } from './userAction'; @@ -50,8 +51,10 @@ export { IServiceAccount, ServiceAccountKey, IServiceAccountKey, - ServiceAccountPermission, - IServiceAccountPermission, + ServiceAccountOrganizationPermissions, + IServiceAccountOrganizationPermissions, + ServiceAccountWorkspacePermissions, + IServiceAccountWorkspacePermissions, TokenData, ITokenData, User, diff --git a/backend/src/models/serviceAccount.ts b/backend/src/models/serviceAccount.ts index 1aecc4d29..79e99a1eb 100644 --- a/backend/src/models/serviceAccount.ts +++ b/backend/src/models/serviceAccount.ts @@ -7,6 +7,7 @@ export interface IServiceAccount extends Document { user: Types.ObjectId; publicKey: string; expiresAt: Date; + secretHash: string; } const serviceAccountSchema = new Schema( @@ -31,6 +32,11 @@ const serviceAccountSchema = new Schema( }, expiresAt: { type: Date + }, + secretHash: { + type: String, + required: true, + select: false } }, { diff --git a/backend/src/models/serviceAccountOrganizationPermission.ts b/backend/src/models/serviceAccountOrganizationPermission.ts new file mode 100644 index 000000000..e616bfabc --- /dev/null +++ b/backend/src/models/serviceAccountOrganizationPermission.ts @@ -0,0 +1,28 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccountOrganizationPermissions extends Document { + _id: Types.ObjectId; + serviceAccount: Types.ObjectId; + canFoo: boolean; +} + +const serviceAccountOrganizationPermissionsSchema = new Schema( + { + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount', + required: true + }, + canFoo: { + type: Boolean, + default: false + } + }, + { + timestamps: true + } +); + +const ServiceAccountOrganizationPermissions = model('ServiceAccountOrganizationPermissions', serviceAccountOrganizationPermissionsSchema); + +export default ServiceAccountOrganizationPermissions; \ No newline at end of file diff --git a/backend/src/models/serviceAccountPermission.ts b/backend/src/models/serviceAccountPermission.ts deleted file mode 100644 index 2199557da..000000000 --- a/backend/src/models/serviceAccountPermission.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Schema, model, Types, Document } from 'mongoose'; - -export interface IServiceAccountPermission extends Document { - _id: Types.ObjectId; - serviceAccount: Types.ObjectId; - name: string; - workspace?: Types.ObjectId; - environment?: string; -} - -const serviceAccountPermissionSchema = new Schema( - { - serviceAccount: { - type: Schema.Types.ObjectId, - ref: 'ServiceAccount', - required: true - }, - name: { - type: String, - required: true - }, - workspace: { - type: Schema.Types.ObjectId, - ref: 'Workspace', - default: null - }, - environment: { - type: 'String', - default: null - } - }, - { - timestamps: true - } -); - -const ServiceAccountPermission = model('ServiceAccountPermission', serviceAccountPermissionSchema); - -export default ServiceAccountPermission; \ No newline at end of file diff --git a/backend/src/models/serviceAccountWorkspacePermissions.ts b/backend/src/models/serviceAccountWorkspacePermissions.ts new file mode 100644 index 000000000..ea81867c5 --- /dev/null +++ b/backend/src/models/serviceAccountWorkspacePermissions.ts @@ -0,0 +1,54 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface IServiceAccountWorkspacePermissions extends Document { + _id: Types.ObjectId; + serviceAccount: Types.ObjectId; + workspace: Types.ObjectId; + environment: string; + canRead: boolean; + canWrite: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +const serviceAccountWorkspacePermissions = new Schema( + { + serviceAccount: { + type: Schema.Types.ObjectId, + ref: 'ServiceAccount', + required: true + }, + workspace:{ + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + environment: { + type: String, + required: true + }, + canRead: { + type: Boolean, + default: false + }, + canWrite: { + type: Boolean, + default: false + }, + canUpdate: { + type: Boolean, + default: false + }, + canDelete: { + type: Boolean, + default: false + } + }, + { + timestamps: true + } +); + +const ServiceAccountWorkspacePermissions = model('ServiceAccountWorkspacePermissions', serviceAccountWorkspacePermissions); + +export default ServiceAccountWorkspacePermissions; \ No newline at end of file diff --git a/backend/src/routes/v2/serviceAccounts.ts b/backend/src/routes/v2/serviceAccounts.ts index 73461196d..9d49985fa 100644 --- a/backend/src/routes/v2/serviceAccounts.ts +++ b/backend/src/routes/v2/serviceAccounts.ts @@ -1,27 +1,45 @@ import express from 'express'; const router = express.Router(); import { + requireAuth, requireOrganizationAuth, requireWorkspaceAuth, requireServiceAccountAuth, + requireServiceAccountWorkspacePermissionsAuth, validateRequest } from '../../middleware'; -import { body } from 'express-validator'; +import { param, query, body } from 'express-validator'; import { OWNER, ADMIN, MEMBER, - ACCEPTED, - PERMISSION_SA_SET + ACCEPTED } from '../../variables'; import { serviceAccountsController } from '../../controllers/v2'; +router.get( + '/:serviceAccountId', + param('serviceAccountId').exists().isString().trim(), + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.getServiceAccount +); + router.post( '/', body('organizationId').exists().isString().trim(), body('name').exists().isString().trim(), body('publicKey').exists().isString().trim(), - body('expiresIn'), // measured in ms + body('expiresIn').isNumeric(), // measured in ms + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), requireOrganizationAuth({ acceptedRoles: [OWNER, ADMIN, MEMBER], acceptedStatuses: [ACCEPTED], @@ -30,17 +48,119 @@ router.post( serviceAccountsController.createServiceAccount ); -router.post( - '/serviceAccountId/:serviceAccountId/permissions', - body('name').exists().isString().trim().custom((value) => PERMISSION_SA_SET.has(value)), - body('workspaceId').optional().isMongoId(), - body('environment').optional(), +router.patch( + '/:serviceAccountId/name', + param('serviceAccountId').exists().isString().trim(), validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), requireServiceAccountAuth({ acceptedRoles: [OWNER, ADMIN], acceptedStatuses: [ACCEPTED] }), - serviceAccountsController.addServiceAccountPermission + serviceAccountsController.changeServiceAccountName +); + +router.delete( + '/:serviceAccountId', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.deleteServiceAccount +); + +router.get( + '/:serviceAccountId/permissions/organization', + param('serviceAccountId').exists().isString().trim(), + query('offset').exists(), + query('limit').exists(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.getServiceAccountOrganizationPermissions +); + +router.get( + '/:serviceAccountId/permissions/workspace', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.getServiceAccountWorkspacePermissions +); + +router.post( + '/:serviceAccountId/permissions/organization', + param('serviceAccountId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.addServiceAccountOrganizationPermission +); + +router.post( + '/:serviceAccountId/permissions/workspace', + param('serviceAccountId').exists().isString().trim(), + body('workspaceId').exists().isString().notEmpty(), + body('environment').exists().isString().notEmpty(), + body('canRead').isBoolean().optional(), + body('canWrite').isBoolean().optional(), + body('canUpdate').isBoolean().optional(), + body('canDelete').isBoolean().optional(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + serviceAccountsController.addServiceAccountWorkspacePermission +); + +router.delete( + '/:serviceAccountId/permissions/workspace/:serviceAccountWorkspacePermissionsId', + param('serviceAccountId').exists().isString().trim(), + param('serviceAccountWorkspacePermissionsId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireServiceAccountAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + requireServiceAccountWorkspacePermissionsAuth({ + acceptedRoles: [OWNER, ADMIN], + acceptedStatuses: [ACCEPTED] + }), + serviceAccountsController.deleteServiceAccountWorkspacePermission ); // router.post( @@ -55,28 +175,4 @@ router.post( // serviceAccountsController.addServiceAccountKey // ); -router.delete( - '/:serviceAccountId/key/:serviceAccountKeyId', - requireServiceAccountAuth({ - acceptedRoles: [OWNER, ADMIN], - acceptedStatuses: [ACCEPTED] - }), - async (req, res) => { - // TODO: delete service account key id - } -); - -// TODO: create service account permission -// router.post( - -// ); - -// TODO: delete service account permission - -router.delete( - '/:serviceAccountId/service-account-permission/:serviceAccountPermissionId', - -) - - export default router; \ No newline at end of file diff --git a/backend/src/services/smtp.ts b/backend/src/services/smtp.ts index 7a4ebf00b..b30a43447 100644 --- a/backend/src/services/smtp.ts +++ b/backend/src/services/smtp.ts @@ -66,7 +66,7 @@ export const initSmtp = () => { const transporter = nodemailer.createTransport(mailOpts); transporter .verify() - .then(() => { + .then((err) => { Sentry.setUser(null); Sentry.captureMessage('SMTP - Successfully connected'); }) diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 64eceb786..b71044cba 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -63,12 +63,6 @@ import { TOKEN_EMAIL_ORG_INVITATION, TOKEN_EMAIL_PASSWORD_RESET } from './token'; -import { - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET -} from './permissions'; export { OWNER, @@ -130,9 +124,5 @@ export { TOKEN_EMAIL_CONFIRMATION, TOKEN_EMAIL_MFA, TOKEN_EMAIL_ORG_INVITATION, - TOKEN_EMAIL_PASSWORD_RESET, - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET + TOKEN_EMAIL_PASSWORD_RESET }; diff --git a/backend/src/variables/permissions.ts b/backend/src/variables/permissions.ts deleted file mode 100644 index f553c88e3..000000000 --- a/backend/src/variables/permissions.ts +++ /dev/null @@ -1,19 +0,0 @@ -const PERMISSION_SA_WORKSPACE_READ = 'read'; -const PERMISSION_SA_WORKSPACE_WRITE = 'write'; - -const PERMISSION_SA_WORKSPACE_SET = new Set([ - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE -]); - -const PERMISSION_SA_SET = new Set([ - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE -]); - -export { - PERMISSION_SA_WORKSPACE_READ, - PERMISSION_SA_WORKSPACE_WRITE, - PERMISSION_SA_WORKSPACE_SET, - PERMISSION_SA_SET -} \ No newline at end of file diff --git a/frontend/src/components/basic/Layout.tsx b/frontend/src/components/basic/Layout.tsx index e76b99cc3..4063df46b 100644 --- a/frontend/src/components/basic/Layout.tsx +++ b/frontend/src/components/basic/Layout.tsx @@ -176,6 +176,7 @@ const Layout = ({ children }: LayoutProps) => { useEffect(() => { // Put a user in a workspace if they're not in one yet + const putUserInWorkSpace = async () => { if (tempLocalStorage('orgData.id') === '') { const userOrgs = await getOrganizations(); diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index 57a3f96fd..e1ee09080 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -3,21 +3,26 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useOrganization, useWorkspace } from '@app/context'; +// TODO: make links clickable and clean up + /** * This is the component at the top of almost every page. * It shows how to navigate to a certain page. * It future these links should also be clickable and hoverable * @param obj * @param obj.pageName - Name of the page - * @param obj.isProjectRelated - whether this page is related to project or now (determine if it's 2 or 3 navigation steps) + * @param obj.isProjectRelated - whether or not this page is related to project (determine if it's 2 or 3 navigation steps) + * @param obj.isOrganizationRelated - whether or not this page is related to organization (determine if it's 2 or 3 navigation steps) * @returns */ export default function NavHeader({ pageName, - isProjectRelated + isProjectRelated, + isOrganizationRelated }: { pageName: string; isProjectRelated?: boolean; + isOrganizationRelated?: boolean; }): JSX.Element { const { currentWorkspace } = useWorkspace(); const { currentOrg } = useOrganization(); @@ -34,6 +39,12 @@ export default function NavHeader({
{currentWorkspace?.name}
)} + {isOrganizationRelated && ( + <> + +
Organization Settings
+ + )}
{pageName}
diff --git a/frontend/src/components/utilities/cryptography/crypto.ts b/frontend/src/components/utilities/cryptography/crypto.ts index 961dee83f..d1a6fb5da 100644 --- a/frontend/src/components/utilities/cryptography/crypto.ts +++ b/frontend/src/components/utilities/cryptography/crypto.ts @@ -5,6 +5,21 @@ import aes from './aes-256-gcm'; const nacl = require('tweetnacl'); nacl.util = require('tweetnacl-util'); +/** + * Return new base64, NaCl, public-private key pair. + * @returns {Object} obj + * @returns {String} obj.publicKey - base64, NaCl, public key + * @returns {String} obj.privateKey - base64, NaCl, private key + */ +const generateKeyPair = () => { + const pair = nacl.box.keyPair(); + + return ({ + publicKey: nacl.util.encodeBase64(pair.publicKey), + privateKey: nacl.util.encodeBase64(pair.secretKey) + }); +} + type EncryptAsymmetricProps = { plaintext: string; publicKey: string; @@ -189,5 +204,5 @@ export { decryptSymmetric, deriveArgonKey, encryptAssymmetric, - encryptSymmetric -}; + encryptSymmetric, + generateKeyPair}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index c2035bbba..1ae7a4e05 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -2,6 +2,7 @@ export * from './auth'; export * from './incidentContacts'; export * from './keys'; export * from './organization'; +export * from './serviceAccounts'; export * from './serviceTokens'; export * from './subscriptions'; export * from './tags'; diff --git a/frontend/src/hooks/api/serviceAccounts/index.tsx b/frontend/src/hooks/api/serviceAccounts/index.tsx new file mode 100644 index 000000000..f9176b69a --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/index.tsx @@ -0,0 +1,9 @@ +export { + useCreateServiceAccount, + useCreateServiceAccountProjectLevelPermissions, + useDeleteServiceAccount, + useDeleteServiceAccountProjectLevelPermissions, + useGetServiceAccountById, + useGetServiceAccountProjectLevelPermissions, + useGetServiceAccounts, + useRenameServiceAccount} from './queries'; \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceAccounts/queries.tsx b/frontend/src/hooks/api/serviceAccounts/queries.tsx new file mode 100644 index 000000000..62dab2895 --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/queries.tsx @@ -0,0 +1,146 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { apiRequest } from '@app/config/request'; + +import { + CreateServiceAccountDTO, + CreateServiceAccountRes, + CreateServiceAccountWorkspacePermissionsDTO, + DeleteServiceAccountRes, + DeleteServiceAccountWorkspacePermissionsDTO, + DeleteServiceAccountWorkspacePermissionsRes, + RenameServiceAccountDTO, + RenameServiceAccountRes, + ServiceAccount, + ServiceAccountWorkspacePermissions} from './types'; + +const serviceAccountKeys = { + getServiceAccountById: (serviceAccountId: string) => [{ serviceAccountId }, 'service-account'] as const, + getServiceAccounts: (organizationID: string) => [{ organizationID }, 'service-accounts'] as const, + getServiceAccountProjectLevelPermissions: (serviceAccountId: string) => [{ serviceAccountId }, 'service-account-project-level-permissions'] as const +} + +const fetchServiceAccounts = async (organizationID: string) => { + const { data } = await apiRequest.get<{ serviceAccounts: ServiceAccount[] }>( + `/api/v2/organizations/${organizationID}/service-accounts` + ); + + return data.serviceAccounts; +} + +const fetchServiceAccountById = async (serviceAccountId: string) => { + const { data } = await apiRequest.get<{ serviceAccount: ServiceAccount }>( + `/api/v2/service-accounts/${serviceAccountId}` + ); + + return data.serviceAccount; +} + +export const useGetServiceAccounts = (organizationID: string) => + useQuery({ + queryKey: serviceAccountKeys.getServiceAccounts(organizationID), + queryFn: () => fetchServiceAccounts(organizationID), + enabled: Boolean(organizationID) + }); + +export const useGetServiceAccountById = (serviceAccountId: string) => { + return useQuery({ + queryKey: serviceAccountKeys.getServiceAccountById(serviceAccountId), + queryFn: () => fetchServiceAccountById(serviceAccountId), + enabled: true + }); +} + +export const useCreateServiceAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post('/api/v2/service-accounts/', body); + return data; + }, + onSuccess: ({ serviceAccount }) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(serviceAccount.organization)); + } + }); +} + +export const useRenameServiceAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ serviceAccountId, name }) => { + const { data: { serviceAccount } } = await apiRequest.patch(`/api/v2/service-accounts/${serviceAccountId}/name`, { name }); + return serviceAccount; + }, + onSuccess: (serviceAccount) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountById(serviceAccount._id)); + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(serviceAccount.organization)); + } + }); +} + +export const useDeleteServiceAccount = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (serviceAccountId) => { + const { data: { serviceAccount } } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}`); + return serviceAccount; + }, + onSuccess: ({ organization }) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(organization)); + } + }); +} + +const fetchServiceAccountProjectLevelPermissions = async (serviceAccountId: string) => { + const { data: { permissions } } = await apiRequest.get<{ permissions: ServiceAccountWorkspacePermissions[] }>( + `/api/v2/service-accounts/${serviceAccountId}/permissions/workspace` + ); + + console.log('fetchServiceAccountProjectLevelPermissions'); + console.log('prrr: ', permissions); + + return permissions; +} + +export const useGetServiceAccountProjectLevelPermissions = (serviceAccountId: string) => { + return useQuery({ + queryKey: serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccountId), + queryFn: () => fetchServiceAccountProjectLevelPermissions(serviceAccountId), + enabled: true + }); +} + +export const useCreateServiceAccountProjectLevelPermissions = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (body) => { + const { data: { permissions } } = await apiRequest.post(`/api/v2/service-accounts/${body.serviceAccountId}/permissions/workspace`, body); + return permissions; + }, + onSuccess: ({ serviceAccount }) => { + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount)); + } + }); +} + +export const useDeleteServiceAccountProjectLevelPermissions = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ serviceAccountId, serviceAccountWorkspacePermissionsId }) => { + const { data: { permissions } } = await apiRequest.delete(`/api/v2/service-accounts/${serviceAccountId}/permissions/workspace/${serviceAccountWorkspacePermissionsId}`); + console.log('useDeleteServiceAccountProjectLevelPermissions'); + console.log('permissions: ', permissions); + return permissions; + }, + onSuccess: ({ serviceAccount }) => { + console.log('onSuccess3: ', serviceAccount); + queryClient.invalidateQueries(serviceAccountKeys.getServiceAccountProjectLevelPermissions(serviceAccount)); + // queryClient.invalidateQueries(serviceAccountKeys.getServiceAccounts(organization)); + } + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/api/serviceAccounts/types.ts b/frontend/src/hooks/api/serviceAccounts/types.ts new file mode 100644 index 000000000..2b5fc5b81 --- /dev/null +++ b/frontend/src/hooks/api/serviceAccounts/types.ts @@ -0,0 +1,66 @@ +export type ServiceAccount = { + _id: string; + name: string; + organization: string; + user: string; + publicKey: string; + expiresAt: string; +} + +export type CreateServiceAccountDTO = { + name: string; + organizationId: string; + publicKey: string; + expiresIn: number; +} + +export type CreateServiceAccountRes = { + serviceAccount: ServiceAccount; + serviceAccountAccessKey: string; +} + +export type DeleteServiceAccountRes = { + serviceAccount: ServiceAccount; +} + +export type RenameServiceAccountDTO = { + serviceAccountId: string; + name: string; +} + +export type RenameServiceAccountRes = { + serviceAccount: ServiceAccount; +} + +export type ServiceAccountWorkspacePermissions = { + serviceAccount: string; + workspace: string; + environment: string; + canRead: boolean; + canWrite: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +export type CreateServiceAccountWorkspacePermissionsDTO = { + serviceAccountId: string; + workspaceId: string; + environment: string; + canRead: boolean; + canWrite: boolean; + canUpdate: boolean; + canDelete: boolean; +} + +export type CreateServiceAccountWorkspacePermissionsRes = { + permissions: ServiceAccountWorkspacePermissions +} + +export type DeleteServiceAccountWorkspacePermissionsDTO = { + serviceAccountId: string; + serviceAccountWorkspacePermissionsId: string; +} + +export type DeleteServiceAccountWorkspacePermissionsRes = { + permissions: ServiceAccountWorkspacePermissions +} \ No newline at end of file diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 46b339287..b539bb845 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -93,8 +93,8 @@ export const AppLayout = ({ children }: LayoutProps) => { // Placing the localstorage as much as possible // Wait till tony integrates the azure and its launched useEffect(() => { - // Put a user in a workspace if they're not in one yet + const putUserInWorkSpace = async () => { if (tempLocalStorage('orgData.id') === '') { const userOrgs = await getOrganizations(); @@ -114,9 +114,33 @@ export const AppLayout = ({ children }: LayoutProps) => { ) { router.push('/noprojects'); } else if (router.asPath !== '/noprojects') { - const intendedWorkspaceId = router.asPath - .split('/') - [router.asPath.split('/').length - 1].split('?')[0]; + + // const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0); + + // let intendedWorkspaceId; + // if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') { + // intendedWorkspaceId = pathSegments[1]; + // } else if (pathSegments.length >= 3 && pathSegments[0] === 'settings') { + // intendedWorkspaceId = pathSegments[2]; + // } else { + // intendedWorkspaceId = router.asPath + // .split('/') + // [router.asPath.split('/').length - 1].split('?')[0]; + // } + + const pathSegments = router.asPath.split('/').filter(segment => segment.length > 0); + + let intendedWorkspaceId; + if (pathSegments.length >= 2 && pathSegments[0] === 'dashboard') { + [, intendedWorkspaceId] = pathSegments; + } else if (pathSegments.length >= 3 && pathSegments[0] === 'settings') { + [, , intendedWorkspaceId] = pathSegments; + } else { + const lastPathSegment = router.asPath.split('/').pop().split('?'); + [intendedWorkspaceId] = lastPathSegment; + } + + if (!intendedWorkspaceId) return; if (!['callback', 'create', 'authorize'].includes(intendedWorkspaceId)) { localStorage.setItem('projectData.id', intendedWorkspaceId); @@ -192,7 +216,6 @@ export const AppLayout = ({ children }: LayoutProps) => { }); if (addMembers) { - console.log('adding other users'); // not using hooks because need at this point only const orgUsers = await fetchOrgUsers(currentOrg._id); orgUsers.forEach(({ status, user: orgUser }) => { diff --git a/frontend/src/pages/settings/org/[id].tsx b/frontend/src/pages/settings/org/[id]/index.tsx similarity index 100% rename from frontend/src/pages/settings/org/[id].tsx rename to frontend/src/pages/settings/org/[id]/index.tsx diff --git a/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx b/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx new file mode 100644 index 000000000..332fbbf27 --- /dev/null +++ b/frontend/src/pages/settings/org/[id]/service-accounts/[serviceAccountId].tsx @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'next-i18next'; + +import { getTranslatedServerSideProps } from '@app/components/utilities/withTranslateProps'; +import { CreateServiceAccountPage } from '@app/views/Settings/CreateServiceAccountPage'; + +export default function ServiceAccountPage() { + const router = useRouter(); + // const { orgId, serviceAccountId } = router.query; + const { t } = useTranslation(); + + return ( + <> + + Edit Service Account + + +
+ + + ); +} + +ServiceAccountPage.requireAuth = true; + +export const getServerSidePros = getTranslatedServerSideProps([ + 'settings', + 'settings-org', + 'section-incident', + 'section-members' +]); \ No newline at end of file diff --git a/frontend/src/pages/settings/service-account/[id].tsx b/frontend/src/pages/settings/service-account/[id].tsx new file mode 100644 index 000000000..1f23f980d --- /dev/null +++ b/frontend/src/pages/settings/service-account/[id].tsx @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import Head from 'next/head'; + +export default function NewServiceAccountPage() { + console.log('NewServiceAccountPage'); + return ( +
+ + Some title + + +
+ Hello! +
+ {/* */} +
+ ); +} + +// NewServiceAccountPage.requireAuth = true; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx new file mode 100644 index 000000000..d1c91ff35 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/CreateServiceAccountPage.tsx @@ -0,0 +1,43 @@ +import { useRouter } from 'next/router'; + +import NavHeader from '@app/components/navigation/NavHeader'; + +import { SAProjectLevelPermissionsTable } from './components/SAProjectLevelPermissionsTable'; +import { ServiceAccountNameChangeSection } from './components'; + +export const CreateServiceAccountPage = () => { + const router = useRouter(); + const { serviceAccountId }: { serviceAccountId: string } = router.query; + + return ( +
+ +
+

Service Account

+

+ A service account represents a machine identity such as a VM or application client. +

+
+
+ {typeof serviceAccountId === 'string' && ( + + )} + {/*
+

Organization-Level Permissions

+ +
*/} +
+

Project-Level Permissions

+ +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx new file mode 100644 index 000000000..e64f54b89 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/SAProjectLevelPermissionsTable.tsx @@ -0,0 +1,394 @@ +import { useEffect, useState } from 'react'; +import { Controller,useForm } from 'react-hook-form'; +import { + faKey, + 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, + Checkbox, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr} from '@app/components/v2'; +import { usePopUp } from '@app/hooks'; +import { + useCreateServiceAccountProjectLevelPermissions, + useDeleteServiceAccountProjectLevelPermissions, + useGetServiceAccountProjectLevelPermissions, + useGetUserWorkspaces} from '@app/hooks/api'; + +const createProjectLevelPermissionSchema = yup.object({ + workspace: yup.string().required().label('Workspace'), + environment: yup.string().required().label('Environment'), + permissions: yup.object().shape({ + canRead: yup.boolean().required(), + canWrite: yup.boolean().required(), + canUpdate: yup.boolean().required(), + canDelete: yup.boolean().required(), + }).defined().required() +}); + +type CreateProjectLevelPermissionForm = yup.InferType; + +type Props = { + serviceAccountId: string; +} + +export const SAProjectLevelPermissionsTable = ({ + serviceAccountId +}: Props) => { + const { data: userWorkspaces, isLoading: isUserWorkspacesLoading } = useGetUserWorkspaces(); + const [searchPermissions, setSearchPermissions] = useState(''); + const [defaultValues, setDefaultValues] = useState(undefined); + + const { data: permissions, isLoading: isPermissionsLoading } = useGetServiceAccountProjectLevelPermissions(serviceAccountId); + + const createServiceAccountProjectLevelPermissions = useCreateServiceAccountProjectLevelPermissions(); + const deleteServiceAccountProjectLevelPermissions = useDeleteServiceAccountProjectLevelPermissions(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addProjectLevelPermissions', + 'removeProjectLevelPermissions', + ] as const); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(createProjectLevelPermissionSchema), defaultValues }) + + const onAddProjectLevelPermissions = async ({ + workspace, + environment, + permissions: { canRead, canWrite, canUpdate, canDelete } + }: CreateProjectLevelPermissionForm) => { + await createServiceAccountProjectLevelPermissions.mutateAsync({ + serviceAccountId, + workspaceId: workspace, + environment, + canRead, + canWrite, + canUpdate, + canDelete + }); + handlePopUpClose('addProjectLevelPermissions'); + } + + const onRemoveProjectLevelPermissions = async () => { + const serviceAccountWorkspacePermissionsId = (popUp?.removeProjectLevelPermissions?.data as { _id: string })?._id; + await deleteServiceAccountProjectLevelPermissions.mutateAsync({ + serviceAccountId, + serviceAccountWorkspacePermissionsId + }); + handlePopUpClose('removeProjectLevelPermissions'); + } + + useEffect(() => { + if (userWorkspaces) { + setDefaultValues({ + workspace: String(userWorkspaces?.[0]?._id), + environment: String(userWorkspaces?.[0]?.environments?.[0]?.slug), + permissions: { + canRead: true, + canWrite: false, + canUpdate: false, + canDelete: false, + } + }); + } + }, [userWorkspaces]); + + + return ( +
+
+
+ setSearchPermissions(e.target.value)} + leftIcon={} + placeholder="Search service account project-level permissions..." + /> +
+ +
+ + + + + + + + + + + + + + {isPermissionsLoading && } + {!isPermissionsLoading && permissions && ( + permissions.map(({ + _id, + workspace, + environment, + canRead, + canWrite, + canUpdate, + canDelete + }) => { + const environmentName = (workspace.environments.find((env) => env.slug === environment))?.name; + return ( + + + + + + + + + + ); + }) + )} + {!isPermissionsLoading && permissions?.length === 0 && ( + + + + )} + +
ProjectEnvironmentReadWriteUpdateDelete +
{workspace.name}{environmentName} + + + + + + + + + handlePopUpOpen('removeProjectLevelPermissions', { _id })} + > + + +
+ +
+
+ { + handlePopUpToggle('addProjectLevelPermissions', isOpen); + }} + > + +
+ {!isUserWorkspacesLoading && userWorkspaces && ( + <> + ( + + + + )} + /> + { + /* eslint-disable-next-line no-underscore-dangle */ + const environments = userWorkspaces?.find((userWorkspace) => userWorkspace._id === control?._formValues?.workspace)?.environments ?? []; + return ( + + + + ); + }} + /> + + )} + { + const options = [ + { + label: 'Read (default)', + value: 'canRead' + }, + { + label: 'Write', + value: 'canWrite' + }, + { + label: 'Update', + value: 'canUpdate' + }, + { + label: 'Delete', + value: 'canDelete' + } + ]; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
+ + + + +
+ +
+
+ handlePopUpToggle('removeProjectLevelPermissions', isOpen)} + onDeleteApproved={onRemoveProjectLevelPermissions} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx new file mode 100644 index 000000000..164b60d51 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/SAProjectLevelPermissionsTable/index.tsx @@ -0,0 +1 @@ +export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx new file mode 100644 index 000000000..006975544 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/ServiceAccountNameChangeSection.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { faCheck } 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, + FormControl, + Input} from '@app/components/v2'; +import { + useGetServiceAccountById, + useRenameServiceAccount +} from '@app/hooks/api'; + +const formSchema = yup.object({ + name: yup.string().required().label('Service Account Name') +}); + +type FormData = yup.InferType; + +type Props = { + serviceAccountId: string; +} + +export const ServiceAccountNameChangeSection = ({ + serviceAccountId +}: Props) => { + const { data: serviceAccount, isLoading: isServiceAccountLoading } = useGetServiceAccountById(serviceAccountId); + + const renameServiceAccount = useRenameServiceAccount(); + + const { + handleSubmit, + control, + reset, + formState: { isDirty, isSubmitting } + } = useForm({ resolver: yupResolver(formSchema) }); + + useEffect(() => { + reset({ name: serviceAccount?.name }); + }, [serviceAccount?.name]); + + const onFormSubmit = async ({ name }: FormData) => { + try { + await renameServiceAccount.mutateAsync({ + serviceAccountId, + name + }); + } catch (err) { + console.error(err); + } + } + + return ( +
+

Service Account Name

+
+ {!isServiceAccountLoading && ( + ( + + + + )} + control={control} + name="name" + /> + )} +
+ +
+ ); +} diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx new file mode 100644 index 000000000..711dae779 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/ServiceAccountNameChangeSection/index.tsx @@ -0,0 +1 @@ +export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx new file mode 100644 index 000000000..151430ddf --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/components/index.tsx @@ -0,0 +1,2 @@ +export { SAProjectLevelPermissionsTable } from './SAProjectLevelPermissionsTable'; +export { ServiceAccountNameChangeSection } from './ServiceAccountNameChangeSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx b/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx new file mode 100644 index 000000000..b308a6841 --- /dev/null +++ b/frontend/src/views/Settings/CreateServiceAccountPage/index.tsx @@ -0,0 +1 @@ +export { CreateServiceAccountPage } from './CreateServiceAccountPage'; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx index 473b6f8c3..e74e1d5d4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/OrgSettingsPage.tsx @@ -20,10 +20,15 @@ import { useGetUserWsKey, useRenameOrg, useUpdateOrgUserRole, - useUploadWsKey + useUploadWsKey, } from '@app/hooks/api'; -import { OrgIncidentContactsTable, OrgMembersTable, OrgNameChangeSection } from './components'; +import { + OrgIncidentContactsTable, + OrgMembersTable, + OrgNameChangeSection, + OrgServiceAccountsTable +} from './components'; export const OrgSettingsPage = () => { const host = window.location.origin; @@ -36,12 +41,11 @@ export const OrgSettingsPage = () => { const { createNotification } = useNotificationContext(); const orgId = currentOrg?._id || ''; + const { data: orgUsers, isLoading: isOrgUserLoading } = useGetOrgUsers(orgId); - const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = - useGetUserWorkspaceMemberships(orgId); + const { data: workspaceMemberships, isLoading: IsWsMembershipLoading } = useGetUserWorkspaceMemberships(orgId); const { data: wsKey } = useGetUserWsKey(currentWorkspace?._id || ''); - const { data: incidentContact, isLoading: IsIncidentContactLoading } = - useGetOrgIncidentContact(orgId); + const { data: incidentContact, isLoading: IsIncidentContactLoading } = useGetOrgIncidentContact(orgId); const renameOrg = useRenameOrg(); const removeUserOrgMembership = useDeleteOrgMembership(); @@ -222,17 +226,15 @@ export const OrgSettingsPage = () => { return (
-
-
-

{t('settings-org:title')}

-

- {t('settings-org:description')} -

-
+
+

{t('settings-org:title')}

+

+ {t('settings-org:description')} +

-
+

{t('section-members:org-members')}

@@ -249,6 +251,12 @@ export const OrgSettingsPage = () => { onGrantAccess={onGrantUserAccess} />
+
+

+ Service Accounts +

+ +
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx index c3bd55f51..51829e1df 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsTable/OrgIncidentContactsTable.tsx @@ -26,7 +26,8 @@ import { Td, Th, THead, - Tr} from '@app/components/v2'; + Tr +} from '@app/components/v2'; import { usePopUp } from '@app/hooks'; import { IncidentContact } from '@app/hooks/api/types'; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx index 9bed5954b..2fcba82b4 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgMembersTable/OrgMembersTable.tsx @@ -93,7 +93,7 @@ export const OrgMembersTable = ({ () => members.find(({ user }) => userId === user?._id)?.role === 'owner', [userId, members] ); - + const filterdUser = useMemo( () => members.filter( @@ -117,20 +117,18 @@ export const OrgMembersTable = ({ placeholder="Search members..." />
-
- -
+
diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx new file mode 100644 index 000000000..3bab7f4bc --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/OrgServiceAccountsTable.tsx @@ -0,0 +1,353 @@ +import { useEffect, useMemo,useState } from 'react'; +import { Controller,useForm } from 'react-hook-form'; +import { useRouter } from 'next/router'; +import { + faCheck, + faCopy, + faMagnifyingGlass, + faPencil, + faPlus, + faServer, + 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 { generateKeyPair } from '@app/components/utilities/cryptography/crypto'; +import { + Button, + DeleteActionModal, + EmptyState, + FormControl, + IconButton, + Input, + Modal, + ModalContent, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from '@app/components/v2'; +import { useOrganization, useWorkspace } from '@app/context'; +import { usePopUp, useToggle } from '@app/hooks'; +import { + useCreateServiceAccount, + useDeleteServiceAccount, + useGetServiceAccounts} from '@app/hooks/api'; + +const serviceAccountExpiration = [ + { label: '1 Day', value: 86400 }, + { label: '7 Days', value: 604800 }, + { label: '1 Month', value: 2592000 }, + { label: '6 months', value: 15552000 }, + { label: '12 months', value: 31104000 }, + { label: 'Never', value: -1 } +]; + +const addServiceAccountFormSchema = yup.object({ + name: yup.string().required().label('Name').trim(), + expiresIn: yup.string().required().label('Service Account Expiration') +}); + +type TAddServiceAccountForm = yup.InferType; + +export const OrgServiceAccountsTable = () => { + const router = useRouter(); + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + console.log('currentWorkspace: ', currentWorkspace); + + const orgId = currentOrg?._id || ''; + const [step, setStep] = useState(0); + const [isAccessKeyCopied, setIsAccessKeyCopied] = useToggle(false); + const [isPrivateKeyCopied, setIsPrivateKeyCopied] = useToggle(false); + const [accessKey, setAccessKey] = useState(''); + const [privateKey, setPrivateKey] = useState(''); + const [searchServiceAccountFilter, setSearchServiceAccountFilter] = useState(''); + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + 'addServiceAccount', + 'removeServiceAccount', + ] as const); + + const { data: serviceAccounts = [], isLoading: isServiceAccountsLoading } = useGetServiceAccounts(orgId); + + const createServiceAccount = useCreateServiceAccount(); + const removeServiceAccount = useDeleteServiceAccount(); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isAccessKeyCopied) { + timer = setTimeout(() => setIsAccessKeyCopied.off(), 2000); + } + + if (isPrivateKeyCopied) { + timer = setTimeout(() => setIsPrivateKeyCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isAccessKeyCopied, isPrivateKeyCopied]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ resolver: yupResolver(addServiceAccountFormSchema) }); + + const onAddServiceAccount = async ({ name, expiresIn }: TAddServiceAccountForm) => { + if (!currentOrg?._id) return; + + const keyPair = generateKeyPair(); + setPrivateKey(keyPair.privateKey); + + const serviceAccountDetails = await createServiceAccount.mutateAsync({ + name, + organizationId: currentOrg?._id, + publicKey: keyPair.publicKey, + expiresIn + }); + + console.log('serviceAccountDetails: ', serviceAccountDetails); + + setAccessKey(serviceAccountDetails.serviceAccountAccessKey); + + setStep(1); + reset(); + } + + const onRemoveServiceAccount = async () => { + console.log('onRemoveServiceAccount'); + + const serviceAccountId = (popUp?.removeServiceAccount?.data as { _id: string })?._id; + console.log('serviceAccountId: ', serviceAccountId); + + await removeServiceAccount.mutateAsync(serviceAccountId); + handlePopUpClose('removeServiceAccount'); + } + + const filteredServiceAccounts = useMemo( + () => + serviceAccounts.filter( + ({ name }) => + name.toLowerCase().includes(searchServiceAccountFilter) + ), + [serviceAccounts, searchServiceAccountFilter] + ); + + const renderStep = (stepToRender: number) => { + switch (stepToRender) { + case 0: + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ); + case 1: + return ( + <> +

Access Key

+
+

{accessKey}

+ { + navigator.clipboard.writeText(accessKey); + setIsAccessKeyCopied.on(); + }} + > + + + Copy + + +
+

Private Key

+
+

{privateKey}

+ { + navigator.clipboard.writeText(privateKey); + setIsPrivateKeyCopied.on(); + }} + > + + + Copy + + +
+ + ); + default: + return
+ } + } + + console.log('serviceAccounts: ', serviceAccounts); + + return ( +
+
+
+ setSearchServiceAccountFilter(e.target.value)} + leftIcon={} + placeholder="Search service accounts..." + /> +
+ +
+ + + + + + + + {isServiceAccountsLoading && } + {!isServiceAccountsLoading && ( + filteredServiceAccounts.map(({ + name, + expiresAt, + _id: serviceAccountId + }) => { + return ( + + + + + + ); + }) + )} + {!isServiceAccountsLoading && filteredServiceAccounts?.length === 0 && ( + + + + )} + +
NameValid Until +
{name}{new Date(expiresAt).toUTCString()} +
+ { + if (currentWorkspace?._id) { + router.push(`/settings/org/${currentWorkspace._id}/service-accounts/${serviceAccountId}`); + } + }} + className="mr-2" + > + + + handlePopUpOpen('removeServiceAccount', { _id: serviceAccountId })} + > + + +
+
+ +
+
+ { + handlePopUpToggle('addServiceAccount', isOpen); + reset(); + }} + > + + {renderStep(step)} + + + handlePopUpToggle('removeServiceAccount', isOpen)} + onDeleteApproved={onRemoveServiceAccount} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx new file mode 100644 index 000000000..fb22cd2c4 --- /dev/null +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgServiceAccountsTable/index.tsx @@ -0,0 +1 @@ +export { OrgServiceAccountsTable } from './OrgServiceAccountsTable'; \ No newline at end of file diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx index 15a09b0d2..bfe044235 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/index.tsx @@ -1,3 +1,5 @@ export { OrgIncidentContactsTable } from './OrgIncidentContactsTable'; export { OrgMembersTable } from './OrgMembersTable'; export { OrgNameChangeSection } from './OrgNameChangeSection'; +export { OrgServiceAccountsTable } from './OrgServiceAccountsTable'; + diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx index 429876435..b857a3224 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -43,7 +43,7 @@ const apiTokenExpiry = [ const createServiceTokenSchema = yup.object({ name: yup.string().required().label('Service Token Name'), environment: yup.string().required().label('Environment'), - expiresIn: yup.string().required().label('Service Token Name'), + expiresIn: yup.string().required().label('Service Token Expiration'), permissions: yup.object().shape({ read: yup.boolean().required(), write: yup.boolean().required() @@ -202,7 +202,7 @@ export const ServiceTokenSection = ({ defaultValue={String(apiTokenExpiry?.[0]?.value)} render={({ field: { onChange, ...field }, fieldState: { error } }) => ( @@ -269,42 +269,6 @@ export const ServiceTokenSection = ({ ); }} /> - {/* { - return ( - { - onChange(state); - }} - > - Read (default) - - ); - }} - /> - { - return ( - { - onChange(state); - }} - > - Write (optional) - - ); - }} - /> */}