Skip to content

Commit

Permalink
feat(security): allow to regenerate project token
Browse files Browse the repository at this point in the history
  • Loading branch information
gregberge committed Dec 30, 2024
1 parent cb719a5 commit 505d23e
Show file tree
Hide file tree
Showing 16 changed files with 276 additions and 72 deletions.
8 changes: 8 additions & 0 deletions apps/backend/src/graphql/__generated__/resolver-types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions apps/backend/src/graphql/__generated__/schema.gql

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions apps/backend/src/graphql/definitions/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@ export const typeDefs = gql`
importGitlabProject(input: ImportGitlabProjectInput!): Project!
"Update Project"
updateProject(input: UpdateProjectInput!): Project!
"Regenerate project token"
regenerateProjectToken(id: ID!): Project!
"Link GitHub Repository"
linkGithubRepository(input: LinkGithubRepositoryInput!): Project!
"Unlink GitHub Repository"
Expand Down Expand Up @@ -851,5 +853,17 @@ export const resolvers: IResolvers = {

return { projectContributorId };
},
regenerateProjectToken: async (_root, args, ctx) => {
if (!ctx.auth) {
throw unauthenticated();
}
const project = await getAdminProject({
id: args.id,
user: ctx.auth.user,
});

const token = await Project.generateToken();
return project.$query().patchAndFetch({ token });
},
},
};
2 changes: 1 addition & 1 deletion apps/frontend/src/containers/Project/Contributors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function ProjectContributors(props: {
teamAccountId={project.account.id}
/>
</CardBody>
<CardFooter className="flex items-center justify-between">
<CardFooter className="flex items-center justify-between gap-4">
{hasAdminPermission ? (
<>
<div>
Expand Down
12 changes: 6 additions & 6 deletions apps/frontend/src/containers/Project/Delete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const DeleteProjectMutation = graphql(`
}
`);

const DeleteProjectButton = (props: DeleteProjectButtonProps) => {
function DeleteProjectButton(props: DeleteProjectButtonProps) {
const client = useApolloClient();
const form = useForm<ConfirmDeleteInputs>({
defaultValues: {
Expand Down Expand Up @@ -72,7 +72,7 @@ const DeleteProjectButton = (props: DeleteProjectButtonProps) => {
This project will be deleted, along with all of its Builds,
Screenshots, Screenshot Diffs, and Settings.
</DialogText>
<div className="bg-danger-hover text-danger-low my-8 rounded p-2">
<div className="bg-danger-hover text-danger-low my-4 rounded p-2">
<strong>Warning:</strong> This action is not reversible.
Please be certain.
</div>
Expand Down Expand Up @@ -119,7 +119,7 @@ const DeleteProjectButton = (props: DeleteProjectButtonProps) => {
</Modal>
</DialogTrigger>
);
};
}

const ProjectFragment = graphql(`
fragment ProjectDelete_Project on Project {
Expand All @@ -132,9 +132,9 @@ const ProjectFragment = graphql(`
}
`);

export const ProjectDelete = (props: {
export function ProjectDelete(props: {
project: FragmentType<typeof ProjectFragment>;
}) => {
}) {
const project = useFragment(ProjectFragment, props.project);
return (
<Card intent="danger">
Expand All @@ -154,4 +154,4 @@ export const ProjectDelete = (props: {
</CardFooter>
</Card>
);
};
}
169 changes: 160 additions & 9 deletions apps/frontend/src/containers/Project/Token.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { ComponentProps, useEffect, useRef } from "react";
import { useApolloClient } from "@apollo/client";
import { DialogTrigger } from "react-aria-components";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";

import { FragmentType, graphql, useFragment } from "@/gql";
import { ProjectPermission } from "@/gql/graphql";
import { useProjectContext } from "@/pages/Project";
import { Button } from "@/ui/Button";
import {
Card,
CardBody,
Expand All @@ -7,19 +15,45 @@ import {
CardTitle,
} from "@/ui/Card";
import { Code } from "@/ui/Code";
import {
Dialog,
DialogBody,
DialogDismiss,
DialogFooter,
DialogText,
DialogTitle,
useOverlayTriggerState,
} from "@/ui/Dialog";
import { Form } from "@/ui/Form";
import { FormSubmit } from "@/ui/FormSubmit";
import { FormTextInput } from "@/ui/FormTextInput";
import { Link } from "@/ui/Link";
import { Modal } from "@/ui/Modal";
import { Pre } from "@/ui/Pre";
import { usePrevious } from "@/ui/usePrevious";

const ProjectFragment = graphql(`
fragment ProjectToken_Project on Project {
id
token
name
account {
id
slug
}
}
`);

export const ProjectToken = (props: {
export function ProjectToken(props: {
project: FragmentType<typeof ProjectFragment>;
}) => {
}) {
const project = useFragment(ProjectFragment, props.project);

// The user may not have permission
if (!project.token) {
return null;
}

return (
<Card>
<CardBody>
Expand All @@ -28,20 +62,137 @@ export const ProjectToken = (props: {
Use this <Code>ARGOS_TOKEN</Code> to authenticate your project when
you send screenshots to Argos.
</CardParagraph>
<Pre code={`ARGOS_TOKEN=${project.token}`} />
<ProjectTokenPre projectId={project.id} token={project.token} />
<CardParagraph>
<strong>
This token should be kept secret. Do not expose it publicly.
</strong>
</CardParagraph>
</CardBody>
<CardFooter>
Read{" "}
<Link href="https://argos-ci.com/docs" target="_blank">
Argos documentation
</Link>{" "}
for more information about installing and using it.
<CardFooter className="flex items-center justify-between gap-4">
<div>
Read{" "}
<Link href="https://argos-ci.com/docs" target="_blank">
Argos documentation
</Link>{" "}
for more information about installing and using it.
</div>
<RegenerateTokenButton
projectId={project.id}
projectSlug={`${project.account.slug}/${project.name}`}
/>
</CardFooter>
</Card>
);
}

function RegenerateTokenButton(
props: ComponentProps<typeof RegenerateTokenDialog>,
) {
const { permissions } = useProjectContext();
const hasAdminPermission = permissions.includes(ProjectPermission.Admin);
if (!hasAdminPermission) {
return null;
}
return (
<DialogTrigger>
<Button variant="secondary">Regenerate token</Button>
<Modal>
<Dialog size="medium">
<RegenerateTokenDialog {...props} />
</Dialog>
</Modal>
</DialogTrigger>
);
}

function ProjectTokenPre(props: { token: string; projectId: string }) {
const { token, projectId } = props;
const copyRef = useRef<() => void>(null);
const previous = usePrevious({ projectId, token });
// Copy the token when it has been changed.
useEffect(() => {
if (!previous) {
return;
}

// Token has changed.
if (previous.projectId === projectId && previous.token !== token) {
copyRef.current?.();
}
}, [token, projectId, previous]);
return <Pre code={token} copyRef={copyRef} />;
}

const RegenerateTokenMutation = graphql(`
mutation RegenerateTokenMutation($projectId: ID!) {
regenerateProjectToken(id: $projectId) {
id
token
}
}
`);

type RenerateTokenInputs = {
slug: string;
};

function RegenerateTokenDialog(props: {
projectId: string;
projectSlug: string;
}) {
const { projectId, projectSlug } = props;
const client = useApolloClient();
const state = useOverlayTriggerState();
const form = useForm<RenerateTokenInputs>({
defaultValues: { slug: "" },
});
const onSubmit: SubmitHandler<RenerateTokenInputs> = async () => {
await client.mutate({
mutation: RegenerateTokenMutation,
variables: {
projectId,
},
});
state.close();
};
return (
<Dialog size="medium">
<FormProvider {...form}>
<Form onSubmit={onSubmit}>
<DialogBody>
<DialogTitle>Regenerate token</DialogTitle>
<DialogText>
Regenerating the token if you suspect it has been compromised.
</DialogText>
<div className="bg-danger-hover text-danger-low my-4 rounded p-2">
<strong>Warning:</strong> By regenerating the token, the current
token will be invalidated immediately.
</div>
<FormTextInput
{...form.register("slug", {
validate: (value) => {
if (value !== projectSlug) {
return "Project name does not match";
}
return true;
},
})}
className="mb-4"
label={
<>
Enter the project name <strong>{projectSlug}</strong> to
continue:
</>
}
/>
</DialogBody>
<DialogFooter>
<DialogDismiss>Cancel</DialogDismiss>
<FormSubmit variant="destructive">Regenerate</FormSubmit>
</DialogFooter>
</Form>
</FormProvider>
</Dialog>
);
}
4 changes: 2 additions & 2 deletions apps/frontend/src/containers/Team/Delete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const DeleteTeamButton = (props: DeleteTeamButtonProps) => {
Argos recommends that you transfer projects you want to keep
to another Team before deleting this Team.
</DialogText>
<div className="bg-danger-hover text-danger-low my-8 rounded p-2">
<div className="bg-danger-hover text-danger-low my-4 rounded p-2">
<strong>Warning:</strong> This action is not reversible.
Please be certain.
</div>
Expand Down Expand Up @@ -167,7 +167,7 @@ export const TeamDelete = (props: {
</CardParagraph>
</CardBody>
{hasActiveNonCanceledSubscription ? (
<CardFooter className="flex items-center justify-between">
<CardFooter className="flex items-center justify-between gap-4">
<div>
A subscription is active on the team. Please cancel your
subscription before deleting the team.
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/containers/Team/GitHubLight.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function TeamGitHubLight(props: {
</div>
)}
</CardBody>
<CardFooter className="flex items-center justify-between">
<CardFooter className="flex items-center justify-between gap-4">
<p>
Learn more about{" "}
<Link
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/containers/Team/GitHubSSO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function TeamGitHubSSO(props: {
</div>
)}
</CardBody>
<CardFooter className="flex items-center justify-between">
<CardFooter className="flex items-center justify-between gap-4">
{priced ? (
<div>
This feature is available as part of GitHub SSO for Teams, available
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/containers/Team/Members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,7 @@ export const TeamMembers = (props: {
) : null}
</Modal>
</CardBody>
<CardFooter className="flex items-center justify-between">
<CardFooter className="flex items-center justify-between gap-4">
{team.inviteLink ? (
<>
<div>Invite people to collaborate in the Team.</div>
Expand Down
9 changes: 7 additions & 2 deletions apps/frontend/src/gql/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ const documents = {
"\n mutation ProjectGitRepository_updateEnablePrComment(\n $projectId: ID!\n $enabled: Boolean!\n ) {\n updateProjectPrComment(\n input: { projectId: $projectId, enabled: $enabled }\n ) {\n id\n prCommentEnabled\n }\n }\n": types.ProjectGitRepository_UpdateEnablePrCommentDocument,
"\n mutation ProjectStatusChecks_updateProject(\n $id: ID!\n $summaryCheck: SummaryCheck\n ) {\n updateProject(input: { id: $id, summaryCheck: $summaryCheck }) {\n id\n summaryCheck\n }\n }\n": types.ProjectStatusChecks_UpdateProjectDocument,
"\n fragment ProjectStatusChecks_Project on Project {\n id\n summaryCheck\n }\n": types.ProjectStatusChecks_ProjectFragmentDoc,
"\n fragment ProjectToken_Project on Project {\n token\n }\n": types.ProjectToken_ProjectFragmentDoc,
"\n fragment ProjectToken_Project on Project {\n id\n token\n name\n account {\n id\n slug\n }\n }\n": types.ProjectToken_ProjectFragmentDoc,
"\n mutation RegenerateTokenMutation($projectId: ID!) {\n regenerateProjectToken(id: $projectId) {\n id\n token\n }\n }\n": types.RegenerateTokenMutationDocument,
"\n query TransferProject_me {\n me {\n id\n ...AccountItem_Account\n teams {\n id\n ...AccountItem_Account\n }\n }\n }\n": types.TransferProject_MeDocument,
"\n fragment ProjectTransfer_Account on Account {\n id\n name\n slug\n avatar {\n ...AccountAvatarFragment\n }\n }\n": types.ProjectTransfer_AccountFragmentDoc,
"\n query ProjectTransfer_Review(\n $projectId: ID!\n $actualAccountId: ID!\n $targetAccountId: ID!\n ) {\n projectById(id: $projectId) {\n id\n builds {\n pageInfo {\n totalCount\n }\n }\n totalScreenshots\n }\n\n actualAccount: accountById(id: $actualAccountId) {\n id\n ...ProjectTransfer_Account\n plan {\n id\n displayName\n }\n }\n\n targetAccount: accountById(id: $targetAccountId) {\n id\n ...ProjectTransfer_Account\n plan {\n id\n displayName\n }\n }\n }\n": types.ProjectTransfer_ReviewDocument,
Expand Down Expand Up @@ -363,7 +364,11 @@ export function graphql(source: "\n fragment ProjectStatusChecks_Project on Pro
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ProjectToken_Project on Project {\n token\n }\n"): (typeof documents)["\n fragment ProjectToken_Project on Project {\n token\n }\n"];
export function graphql(source: "\n fragment ProjectToken_Project on Project {\n id\n token\n name\n account {\n id\n slug\n }\n }\n"): (typeof documents)["\n fragment ProjectToken_Project on Project {\n id\n token\n name\n account {\n id\n slug\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation RegenerateTokenMutation($projectId: ID!) {\n regenerateProjectToken(id: $projectId) {\n id\n token\n }\n }\n"): (typeof documents)["\n mutation RegenerateTokenMutation($projectId: ID!) {\n regenerateProjectToken(id: $projectId) {\n id\n token\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
Expand Down
Loading

0 comments on commit 505d23e

Please sign in to comment.