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

Signing key rotation UI #1297

Merged
merged 13 commits into from
Apr 24, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use client';

import { Button } from '@inngest/components/Button';
import { RiAddLine } from '@remixicon/react';
import { useMutation } from 'urql';

import { graphql } from '@/gql';

const Mutation = graphql(`
mutation CreateSigningKey($envID: UUID!) {
createSigningKey(envID: $envID) {
createdAt
}
}
`);

type Props = {
disabled?: boolean;
envID: string;
};

export function CreateSigningKeyButton({ disabled, envID }: Props) {
const [, createSigningKey] = useMutation(Mutation);

return (
<Button
btnAction={() => createSigningKey({ envID })}
disabled={disabled}
kind="primary"
icon={<RiAddLine />}
label="Create new signing key"
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { useState } from 'react';
import { Button } from '@inngest/components/Button';
import { RiDeleteBin2Line } from '@remixicon/react';

import { DeleteSigningKeyModal } from './DeleteSigningKeyModal';

type Props = {
signingKeyID: string;
};

export function DeleteSigningKeyButton({ signingKeyID }: Props) {
const [isModalOpen, setIsModalOpen] = useState(false);

return (
<>
<Button
appearance="outlined"
aria-label="Delete"
btnAction={() => setIsModalOpen(true)}
icon={<RiDeleteBin2Line />}
kind="danger"
size="small"
tooltip="Delete"
/>

<DeleteSigningKeyModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
signingKeyID={signingKeyID}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Alert } from '@inngest/components/Alert';
import { Button } from '@inngest/components/Button';
import { Modal } from '@inngest/components/Modal';
import { useMutation } from 'urql';

import { graphql } from '@/gql';

const Mutation = graphql(`
mutation DeleteSigningKey($signingKeyID: UUID!) {
deleteSigningKey(id: $signingKeyID) {
createdAt
}
}
`);

type Props = {
isOpen: boolean;
onClose: () => void;
signingKeyID: string;
};

export function DeleteSigningKeyModal(props: Props) {
const { isOpen, signingKeyID } = props;
const [error, setError] = useState<string>();
const [isFetching, setIsFetching] = useState(false);
const [, deleteSigningKey] = useMutation(Mutation);

function onClose() {
setError(undefined);
props.onClose();
}

async function onConfirm() {
setIsFetching(true);
try {
const res = await deleteSigningKey(
{ signingKeyID },
{
// Bust cache
additionalTypenames: ['SigningKey'],
}
);
if (res.error) {
throw res.error;
}

onClose();
} catch (error) {
if (!(error instanceof Error)) {
setError('Unknown error');
return;
}

setError(error.message);
} finally {
setIsFetching(false);
}
}

return (
<Modal className="w-full max-w-3xl" isOpen={isOpen} onClose={onClose}>
<Modal.Header>Permanently delete key</Modal.Header>

<Modal.Body>
<p className="mb-4">Are you sure you want to permanently delete this key?</p>

<Alert severity="info">
This key is inactive, so deletion will not affect communication between Inngest and your
apps.
</Alert>
</Modal.Body>

<Modal.Footer>
{error && (
<Alert className="mb-6" severity="error">
{error}
</Alert>
)}

<div className="flex justify-end gap-2">
<Button label="Close" appearance="outlined" btnAction={onClose} />
<Button
btnAction={onConfirm}
disabled={isFetching}
kind="danger"
label="Permanently delete"
/>
</div>
</Modal.Footer>
</Modal>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { DeleteSigningKeyButton } from './DeleteSigningKeyButton';
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import cn from '@/utils/cn';

type Props = {
className?: string;
value: string;
};

export function InlineCode({ value }: Props) {
export function InlineCode({ className, value }: Props) {
return (
<code className="inline-flex items-center rounded bg-slate-200 px-2 py-1 font-mono text-xs font-semibold leading-none">
<code
className={cn(
'inline-flex items-center rounded-sm bg-slate-200 px-1 py-1 font-mono text-xs font-semibold leading-none',
className
)}
>
{value}
</code>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import { useState } from 'react';
import { Button } from '@inngest/components/Button';

import { RotateSigningKeyModal } from './RotateSigningKeyModal';

type Props = {
disabled?: boolean;
envID: string;
};

export function RotateSigningKeyButton({ disabled, envID }: Props) {
const [isModalOpen, setIsModalOpen] = useState(false);

return (
<>
<Button
btnAction={() => setIsModalOpen(true)}
disabled={disabled}
kind="danger"
label="Rotate key"
/>

<RotateSigningKeyModal
envID={envID}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Alert } from '@inngest/components/Alert';
import { Button } from '@inngest/components/Button';
import { Modal } from '@inngest/components/Modal';
import { useMutation } from 'urql';

import { graphql } from '@/gql';
import { InlineCode } from '../InlineCode';

const Mutation = graphql(`
mutation RotateSigningKey($envID: UUID!) {
rotateSigningKey(envID: $envID) {
createdAt
}
}
`);

type Props = {
envID: string;
isOpen: boolean;
onClose: () => void;
};

export function RotateSigningKeyModal(props: Props) {
const { envID, isOpen } = props;
const [error, setError] = useState<string>();
const [isFetching, setIsFetching] = useState(false);
const [, rotateSigningKey] = useMutation(Mutation);

function onClose() {
setError(undefined);
props.onClose();
}

async function onConfirm() {
setIsFetching(true);
try {
const res = await rotateSigningKey(
{ envID },
{
// Bust cache
additionalTypenames: ['SigningKey'],
}
);
if (res.error) {
throw res.error;
}

setError(undefined);
onClose();
} catch (error) {
if (!(error instanceof Error)) {
setError('Unknown error');
return;
}

setError(error.message);
} finally {
setIsFetching(false);
}
}

return (
<Modal className="w-full max-w-3xl" isOpen={isOpen} onClose={onClose}>
<Modal.Header>Rotate key</Modal.Header>

<Modal.Body>
<p className="mb-4">
Before rotating, ensure that all of your apps have the correct{' '}
<InlineCode value="INNGEST_SIGNING_KEY" /> and{' '}
<InlineCode value="INNGEST_SIGNING_KEY_FALLBACK" /> environment variables.
</p>

<Alert severity="warning">
This will permanently delete and replace the current key. It is irreversible.
</Alert>
</Modal.Body>

<Modal.Footer>
{error && (
<Alert className="mb-6" severity="error">
{error}
</Alert>
)}

<div className="flex justify-end gap-2">
<Button label="Close" appearance="outlined" btnAction={onClose} />
<Button btnAction={onConfirm} disabled={isFetching} kind="danger" label="Rotate" />
</div>
</Modal.Footer>
</Modal>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { RotateSigningKeyButton } from './RotateSigningKeyButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Badge } from '@inngest/components/Badge';
import { Card } from '@inngest/components/Card';
import { RiStarFill } from '@remixicon/react';

import { Secret } from '@/components/Secret';
import { Time } from '@/components/Time';
import { DeleteSigningKeyButton } from './DeleteSigningKeyButton';

type Props = {
signingKey: {
createdAt: Date;
decryptedValue: string;
id: string;
isActive: boolean;
user: {
email: string;
name: string | null;
} | null;
};
};

export function SigningKey({ signingKey }: Props) {
let accentColor = 'bg-emerald-600';
let controls = null;
let description = null;
let title = 'Current key';

if (!signingKey.isActive) {
accentColor = 'bg-amber-400';
controls = (
<div className="flex gap-2">
<DeleteSigningKeyButton signingKeyID={signingKey.id} />
</div>
);
description = 'This key is inactive. You can activate it using rotation.';
title = 'New key';
}

let pill = null;
if (signingKey.createdAt > new Date(Date.now() - 24 * 60 * 60 * 1000)) {
pill = (
<Badge className="border-0 bg-amber-100">
<RiStarFill className="text-amber-600" size={16} />
<span className="text-amber-600">New</span>
</Badge>
);
}

return (
<Card accentColor={accentColor} accentPosition="left" className="mb-4">
<Card.Content className="px-4 py-0">
<div className="py-4">
<div className="flex">
<div className="flex grow items-center gap-2 font-medium text-slate-950">
{title}
{pill}
</div>
{controls && <div>{controls}</div>}
</div>
<p className="text-sm text-slate-500">{description}</p>
</div>

<Secret className="mb-4" kind="signing-key" secret={signingKey.decryptedValue} />
</Card.Content>

<Card.Footer className="flex text-sm text-slate-500">
<span className="grow">
Created at <Time value={signingKey.createdAt} />
</span>

{signingKey.user && <span>Created by {signingKey.user.name ?? signingKey.user.email}</span>}
</Card.Footer>
</Card>
);
}