Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ab): a/b settings pane #21

Merged
merged 3 commits into from
Jul 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/backend/src/ab/ab.controller.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
return this.abService.getExperimentById(experimentId);
}
}
35 changes: 35 additions & 0 deletions packages/backend/src/ab/ab.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,39 @@ export class AbService {
},
});
}

async hasPermissionOnExperiment(
experimentId: string,
userId: string,
roles?: Array<string>,
) {
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,
},
});
}
}
26 changes: 26 additions & 0 deletions packages/backend/src/ab/guards/hasExperimentAccess.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
const roles = this.reflector.get<string[]>('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,
);
}
}
59 changes: 59 additions & 0 deletions packages/backend/test/ab/ab.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
9 changes: 9 additions & 0 deletions packages/backend/test/helpers/seeds/ab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion packages/frontend/app/components/Breadcrumbs/DesktopNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -53,8 +59,12 @@ export const DesktopNav = ({ crumbs }: DesktopNavProps) => {
}
to={crumb.link}
>
{crumb.label}
<HStack>
{crumb.icon}
{crumb.label}
</HStack>
</Link>

{!currentPage && (
<Separator aria-hidden>
<MdChevronRight />
Expand Down
3 changes: 3 additions & 0 deletions packages/frontend/app/components/Breadcrumbs/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import React from "react";

export interface Crumb {
link: string;
label: string;
forceNotCurrent?: boolean;
icon?: React.ReactNode;
}

export type Crumbs = Array<Crumb>;
Expand Down
11 changes: 11 additions & 0 deletions packages/frontend/app/modules/ab/services/getExperimentById.ts
Original file line number Diff line number Diff line change
@@ -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());
Original file line number Diff line number Diff line change
@@ -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<LoaderData> => {
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<LoaderData>();

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: <AiOutlineExperiment aria-hidden />,
},
];

return (
<DashboardLayout
user={user}
breadcrumb={<BreadCrumbs crumbs={crumbs} />}
header={
<Header
tagline="A/B experiment"
title={experiment.name}
startAction={
<HideMobile>
<ButtonCopy toCopy={experiment.key}>{experiment.key}</ButtonCopy>
</HideMobile>
}
/>
}
subNav={
<HorizontalNav label={`A/B experiment related`}>
<NavItem
to={`/dashboard/projects/${project.uuid}/environments/${environment.uuid}/ab/${experiment.uuid}/settings`}
icon={<AiOutlineSetting />}
>
Settings
</NavItem>
</HorizontalNav>
}
>
{userRole === UserRoles.Admin && (
<CardSection id="danger">
<SectionHeader
title="Danger zone"
description={
<Typography>
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.
</Typography>
}
/>

<SectionContent>
<DeleteButton
to={`/dashboard/projects/${project.uuid}/environments/${environment.uuid}/ab/${experiment.uuid}/delete`}
>
<span>
<span aria-hidden>
Delete <HideMobile>{experiment.name} forever</HideMobile>
</span>

<VisuallyHidden>
Delete {experiment.name} forever
</VisuallyHidden>
</span>
</DeleteButton>
</SectionContent>
</CardSection>
)}
</DashboardLayout>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
Form,
useTransition,
} from "@remix-run/react";
import { FiFlag } from "react-icons/fi";

interface MetaArgs {
data?: {
Expand Down Expand Up @@ -143,6 +144,7 @@ export default function DeleteFlagPage() {
{
link: `/dashboard/projects/${project.uuid}/environments/${environment.uuid}/flags/${currentFlag.uuid}`,
label: currentFlag.name,
icon: <FiFlag aria-hidden />,
},
];

Expand Down
Loading