diff --git a/packages/backend/src/ab/ab.controller.ts b/packages/backend/src/ab/ab.controller.ts index e0ff9a292..7e70210e0 100644 --- a/packages/backend/src/ab/ab.controller.ts +++ b/packages/backend/src/ab/ab.controller.ts @@ -1,7 +1,18 @@ -import { Controller } from '@nestjs/common'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/strategies/jwt.guard'; import { AbService } from './ab.service'; +import { HasExperimentAccess } from './guards/hasExperimentAccess'; @Controller() export class AbController { constructor(private readonly abService: AbService) {} + + @Get('experiments/:experimentId') + @UseGuards(HasExperimentAccess) + @UseGuards(JwtAuthGuard) + async getStrategies( + @Param('experimentId') experimentId: string, + ): Promise { + return this.abService.getExperimentById(experimentId); + } } diff --git a/packages/backend/src/ab/ab.service.ts b/packages/backend/src/ab/ab.service.ts index 22a96d7c4..cecd97c8c 100644 --- a/packages/backend/src/ab/ab.service.ts +++ b/packages/backend/src/ab/ab.service.ts @@ -16,4 +16,39 @@ export class AbService { }, }); } + + async hasPermissionOnExperiment( + experimentId: string, + userId: string, + roles?: Array, + ) { + const experimentOfProject = await this.prisma.userProject.findFirst({ + where: { + userId, + project: { + environments: { + some: { ExperimentEnvironment: { some: { experimentId } } }, + }, + }, + }, + }); + + if (!experimentOfProject) { + return false; + } + + if (!roles || roles.length === 0) { + return true; + } + + return roles.includes(experimentOfProject.role); + } + + getExperimentById(experimentId: string) { + return this.prisma.experiment.findFirst({ + where: { + uuid: experimentId, + }, + }); + } } diff --git a/packages/backend/src/ab/guards/hasExperimentAccess.ts b/packages/backend/src/ab/guards/hasExperimentAccess.ts new file mode 100644 index 000000000..9ab33c745 --- /dev/null +++ b/packages/backend/src/ab/guards/hasExperimentAccess.ts @@ -0,0 +1,26 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRetrieveDTO } from '../../users/users.dto'; +import { AbService } from '../ab.service'; + +@Injectable() +export class HasExperimentAccess implements CanActivate { + constructor( + private readonly abService: AbService, + private reflector: Reflector, + ) {} + + canActivate(context: ExecutionContext): Promise { + const roles = this.reflector.get('roles', context.getHandler()); + const req = context.switchToHttp().getRequest(); + + const experimentId = req.params.experimentId; + const user: UserRetrieveDTO = req.user; + + return this.abService.hasPermissionOnExperiment( + experimentId, + user.uuid, + roles, + ); + } +} diff --git a/packages/backend/test/ab/ab.e2e-spec.ts b/packages/backend/test/ab/ab.e2e-spec.ts new file mode 100644 index 000000000..8b31afe0a --- /dev/null +++ b/packages/backend/test/ab/ab.e2e-spec.ts @@ -0,0 +1,59 @@ +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { seedDb, cleanupDb } from '../helpers/seed'; +import { prepareApp } from '../helpers/prepareApp'; +import { verifyAuthGuard } from '../helpers/verify-auth-guard'; +import { authenticate } from '../helpers/authenticate'; + +describe('AbController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + app = await prepareApp(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await seedDb(); + }); + + afterEach(async () => { + await cleanupDb(); + }); + + describe('experiments/1 (GET)', () => { + it('gives a 401 when the user is not authenticated', () => + verifyAuthGuard(app, '/experiments/1', 'get')); + + it('gives a 403 when trying to access an invalid experiment', async () => { + const access_token = await authenticate(app); + + return request(app.getHttpServer()) + .get('/experiments/2') + .set('Authorization', `Bearer ${access_token}`) + .expect(403) + .expect({ + statusCode: 403, + message: 'Forbidden resource', + error: 'Forbidden', + }); + }); + + it('gives the experiment when everything is fine', async () => { + const access_token = await authenticate(app); + + const response = await request(app.getHttpServer()) + .get('/experiments/1') + .set('Authorization', `Bearer ${access_token}`); + + expect(response.status).toBe(200); + expect(response.body.uuid).toBe('1'); + expect(response.body.name).toBe('New homepage experiment'); + expect(response.body.key).toBe('newHomepageExperiment'); + expect(response.body.createdAt).toBeDefined(); + }); + }); +}); diff --git a/packages/backend/test/helpers/seeds/ab.ts b/packages/backend/test/helpers/seeds/ab.ts index 6f4d178a1..83188a00e 100644 --- a/packages/backend/test/helpers/seeds/ab.ts +++ b/packages/backend/test/helpers/seeds/ab.ts @@ -30,6 +30,15 @@ export const seedAbExperiments = async (prismaClient: PrismaClient) => { }, }); + const otherExperiment = await prismaClient.experiment.create({ + data: { + uuid: '2', + name: 'New other experiment', + description: 'Switch the new other design (experiment)', + key: 'otherHomepageExperiment', + }, + }); + await seedVariationHits( prismaClient, controlVariation, diff --git a/packages/frontend/app/components/Breadcrumbs/DesktopNav.tsx b/packages/frontend/app/components/Breadcrumbs/DesktopNav.tsx index 0b336378f..65c99484d 100644 --- a/packages/frontend/app/components/Breadcrumbs/DesktopNav.tsx +++ b/packages/frontend/app/components/Breadcrumbs/DesktopNav.tsx @@ -28,6 +28,12 @@ const Separator = styled("div", { display: "inline-block", }); +const HStack = styled("span", { + display: "flex", + alignItems: "center", + gap: "$spacing$2", +}); + export interface DesktopNavProps { crumbs: Crumbs; } @@ -53,8 +59,12 @@ export const DesktopNav = ({ crumbs }: DesktopNavProps) => { } to={crumb.link} > - {crumb.label} + + {crumb.icon} + {crumb.label} + + {!currentPage && ( diff --git a/packages/frontend/app/components/Breadcrumbs/types.ts b/packages/frontend/app/components/Breadcrumbs/types.ts index ded269f15..3538ecf74 100644 --- a/packages/frontend/app/components/Breadcrumbs/types.ts +++ b/packages/frontend/app/components/Breadcrumbs/types.ts @@ -1,7 +1,10 @@ +import React from "react"; + export interface Crumb { link: string; label: string; forceNotCurrent?: boolean; + icon?: React.ReactNode; } export type Crumbs = Array; diff --git a/packages/frontend/app/modules/ab/services/getExperimentById.ts b/packages/frontend/app/modules/ab/services/getExperimentById.ts new file mode 100644 index 000000000..94dcfb4fc --- /dev/null +++ b/packages/frontend/app/modules/ab/services/getExperimentById.ts @@ -0,0 +1,11 @@ +import { Constants } from "~/constants"; + +export const getExperimentById = async ( + experimentId: string, + accessToken: string +) => + fetch(`${Constants.BackendUrl}/experiments/${experimentId}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }).then((res) => res.json()); diff --git a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/ab/$experimentId/settings.tsx b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/ab/$experimentId/settings.tsx new file mode 100644 index 000000000..bfbf80743 --- /dev/null +++ b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/ab/$experimentId/settings.tsx @@ -0,0 +1,167 @@ +import { BreadCrumbs } from "~/components/Breadcrumbs"; +import { DashboardLayout } from "~/layouts/DashboardLayout"; +import { authGuard } from "~/modules/auth/services/auth-guard"; +import { Environment } from "~/modules/environments/types"; +import { getProject } from "~/modules/projects/services/getProject"; +import { Project, UserProject, UserRoles } from "~/modules/projects/types"; +import { User } from "~/modules/user/types"; +import { getSession } from "~/sessions"; +import { Header } from "~/components/Header"; +import { + CardSection, + SectionContent, + SectionHeader, +} from "~/components/Section"; +import { AiOutlineExperiment, AiOutlineSetting } from "react-icons/ai"; +import { HorizontalNav, NavItem } from "~/components/HorizontalNav"; +import { ButtonCopy } from "~/components/ButtonCopy"; +import { Typography } from "~/components/Typography"; +import { DeleteButton } from "~/components/Buttons/DeleteButton"; +import { Crumbs } from "~/components/Breadcrumbs/types"; +import { HideMobile } from "~/components/HideMobile"; +import { VisuallyHidden } from "~/components/VisuallyHidden"; +import { MetaFunction, LoaderFunction } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { Experiment } from "~/modules/ab/types"; +import { getExperimentById } from "~/modules/ab/services/getExperimentById"; + +interface MetaArgs { + data?: { + project?: Project; + environment?: Environment; + experiment?: Experiment; + }; +} + +export const meta: MetaFunction = ({ data }: MetaArgs) => { + const projectName = data?.project?.name || "An error ocurred"; + const envName = data?.environment?.name || "An error ocurred"; + const experimentName = data?.experiment?.name || "An error ocurred"; + + return { + title: `Progressively | ${projectName} | ${envName} | ${experimentName} | Settings`, + }; +}; + +interface LoaderData { + project: Project; + environment: Environment; + experiment: Experiment; + user: User; + userRole?: UserRoles; +} + +export const loader: LoaderFunction = async ({ + request, + params, +}): Promise => { + const user = await authGuard(request); + + const session = await getSession(request.headers.get("Cookie")); + const authCookie = session.get("auth-cookie"); + + const project: Project = await getProject(params.id!, authCookie, true); + + const environment = project.environments.find( + (env) => env.uuid === params.env + ); + + const experiment = await getExperimentById(params.experimentId!, authCookie); + + const userProject: UserProject | undefined = project.userProject?.find( + (userProject) => userProject.userId === user.uuid + ); + + return { + project, + environment: environment!, + experiment, + user, + userRole: userProject?.role, + }; +}; + +export default function ExperimentSettingsPage() { + const { project, environment, experiment, user, userRole } = + useLoaderData(); + + const crumbs: Crumbs = [ + { + link: "/dashboard", + label: "Projects", + }, + { + link: `/dashboard/projects/${project.uuid}`, + label: project.name, + }, + { + link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/ab`, + label: environment.name, + }, + { + link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/ab/${experiment.uuid}`, + label: experiment.name, + forceNotCurrent: true, + icon: , + }, + ]; + + return ( + } + header={ +
+ {experiment.key} + + } + /> + } + subNav={ + + } + > + Settings + + + } + > + {userRole === UserRoles.Admin && ( + + + You can delete an A/B experiment at any time, but you {`won’t`}{" "} + be able to access its insights anymore and false will be served + to the application using it. + + } + /> + + + + + + Delete {experiment.name} forever + + + + Delete {experiment.name} forever + + + + + + )} + + ); +} diff --git a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/delete.tsx b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/delete.tsx index 4718cd492..a9661c6d3 100644 --- a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/delete.tsx +++ b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/delete.tsx @@ -27,6 +27,7 @@ import { Form, useTransition, } from "@remix-run/react"; +import { FiFlag } from "react-icons/fi"; interface MetaArgs { data?: { @@ -143,6 +144,7 @@ export default function DeleteFlagPage() { { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}`, label: currentFlag.name, + icon: , }, ]; diff --git a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/index.tsx b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/index.tsx index 2102c3bd4..1669f7462 100644 --- a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/index.tsx +++ b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/index.tsx @@ -32,6 +32,7 @@ import { Crumbs } from "~/components/Breadcrumbs/types"; import { HideMobile } from "~/components/HideMobile"; import { MetaFunction, ActionFunction, LoaderFunction } from "@remix-run/node"; import { useSearchParams, useLoaderData } from "@remix-run/react"; +import { FiFlag } from "react-icons/fi"; interface MetaArgs { data?: { @@ -131,6 +132,7 @@ export default function FlagById() { { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}`, label: currentFlag.name, + icon: , }, ]; diff --git a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/insights.tsx b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/insights.tsx index 7b410be84..0d54838f1 100644 --- a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/insights.tsx +++ b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/insights.tsx @@ -31,6 +31,7 @@ import { Crumbs } from "~/components/Breadcrumbs/types"; import { HideMobile } from "~/components/HideMobile"; import { MetaFunction, ActionFunction, LoaderFunction } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { FiFlag } from "react-icons/fi"; interface MetaArgs { data?: { @@ -158,6 +159,7 @@ export default function FlagById() { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}`, label: currentFlag.name, forceNotCurrent: true, + icon: , }, ]; diff --git a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/settings.tsx b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/settings.tsx index 7526304e0..b86426e29 100644 --- a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/settings.tsx +++ b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/settings.tsx @@ -29,6 +29,7 @@ import { HideMobile } from "~/components/HideMobile"; import { VisuallyHidden } from "~/components/VisuallyHidden"; import { MetaFunction, LoaderFunction, ActionFunction } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; +import { FiFlag } from "react-icons/fi"; interface MetaArgs { data?: { @@ -121,6 +122,7 @@ export default function FlagSettingPage() { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}`, label: currentFlag.name, forceNotCurrent: true, + icon: , }, ]; diff --git a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/$stratId/delete.tsx b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/$stratId/delete.tsx index f91f895be..c17f9c92b 100644 --- a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/$stratId/delete.tsx +++ b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/$stratId/delete.tsx @@ -29,6 +29,7 @@ import { Form, useTransition, } from "@remix-run/react"; +import { FiFlag } from "react-icons/fi"; interface MetaArgs { data?: { @@ -155,6 +156,7 @@ export default function DeleteStrategyPage() { { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}`, label: currentFlag.name, + icon: , }, { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}/strategies/1/delete`, diff --git a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/create.tsx b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/create.tsx index e78dd319b..4039634f4 100644 --- a/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/create.tsx +++ b/packages/frontend/app/routes/dashboard/projects/$id/environments/$env/flags/$flagId/strategies/create.tsx @@ -37,6 +37,7 @@ import { LoaderFunction, } from "@remix-run/node"; import { useLoaderData, useActionData, Form } from "@remix-run/react"; +import { FiFlag } from "react-icons/fi"; interface MetaArgs { data?: { @@ -198,6 +199,7 @@ export default function StrategyCreatePage() { { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}`, label: currentFlag.name, + icon: , }, { link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}/strategies/create`, diff --git a/packages/frontend/cypress/e2e/projects/id/environments/envId/ab/experimentId/settings.spec.ts b/packages/frontend/cypress/e2e/projects/id/environments/envId/ab/experimentId/settings.spec.ts new file mode 100644 index 000000000..5418542f8 --- /dev/null +++ b/packages/frontend/cypress/e2e/projects/id/environments/envId/ab/experimentId/settings.spec.ts @@ -0,0 +1,62 @@ +describe("/dashboard/projects/[id]/environments/[envId]/ab/[experimentId]/settings", () => { + before(cy.seed); + after(cy.cleanup); + + describe("not authenticated", () => { + beforeEach(() => { + cy.visit("/dashboard/projects/1/environments/1/ab/1/settings"); + }); + + it("checks that the route is protected", () => { + cy.checkProtectedRoute(); + }); + }); + + describe("authenticated", () => { + describe("user: Jane", () => { + beforeEach(() => { + cy.signIn("Jane"); + cy.visit("/dashboard/projects/1/environments/1/ab/1/settings", { + failOnStatusCode: false, + }); + }); + + it("shouldnt show anything when Jane tries to visit Marvin s project", () => { + cy.checkProtectedRoute(); + }); + }); + + describe("user: Marvin", () => { + beforeEach(() => { + cy.signIn("Marvin"); + cy.visit("/dashboard/projects/1/environments/1/ab/1/settings"); + cy.injectAxe(); + }); + + it("shows a page layout", () => { + cy.title().should( + "eq", + "Progressively | Project from seeding | Production | New homepage experiment | Settings" + ); + + cy.findByRole("heading", { name: "Danger zone" }).should("be.visible"); + + cy.findByText( + "You can delete an A/B experiment at any time, but you won’t be able to access its insights anymore and false will be served to the application using it." + ).should("be.visible"); + + cy.findByRole("link", { + name: "Delete New homepage experiment forever", + }) + .should("be.visible") + .and( + "have.attr", + "href", + "/dashboard/projects/1/environments/1/ab/1/delete" + ); + + cy.checkA11y(); + }); + }); + }); +}); diff --git a/packages/frontend/cypress/e2e/projects/id/environments/envId/flags/flagId/settings.spec.ts b/packages/frontend/cypress/e2e/projects/id/environments/envId/flags/flagId/settings.spec.ts index c1178c46d..d8b5ca5de 100644 --- a/packages/frontend/cypress/e2e/projects/id/environments/envId/flags/flagId/settings.spec.ts +++ b/packages/frontend/cypress/e2e/projects/id/environments/envId/flags/flagId/settings.spec.ts @@ -1,4 +1,4 @@ -describe("/dashboard/projects/[id]/environments/[envId]/settings/[flagId]/settings", () => { +describe("/dashboard/projects/[id]/environments/[envId]/flags/[flagId]/settings", () => { before(cy.seed); after(cy.cleanup);