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 secret key #192

Merged
merged 7 commits into from
Apr 10, 2024
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: 13 additions & 0 deletions web-portal/backend/src/apps/apps.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
UseGuards,
Body,
Post,
Put,
Patch,
} from '@nestjs/common';
import { AppsService } from './apps.service';
Expand Down Expand Up @@ -90,4 +91,16 @@ export class AppsController {
// @note: This action updates app rules in bulk for given appId;
return this.appsService.batchUpdateAppRules(appId, updateRulesDto);
}

@Put(':appId/secret')
async updateAppSecret(@Param('appId') appId: string) {
// @note: This action updates app secret for given appId;
return this.appsService.updateSecretKeyRule(appId, 'generate');
}

@Delete(':appId/secret')
async deleteAppSecret(@Param('appId') appId: string) {
// @note: This action deletes app secret for given appId;
return this.appsService.updateSecretKeyRule(appId, 'delete');
}
}
58 changes: 57 additions & 1 deletion web-portal/backend/src/apps/apps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable, Inject, HttpException, HttpStatus } from '@nestjs/common';
import { CustomPrismaService } from 'nestjs-prisma';
import { AppRule, PrismaClient } from '@/.generated/client';
import { UserService } from '../user/user.service';
import { createHash, randomBytes } from 'crypto';

@Injectable()
export class AppsService {
Expand Down Expand Up @@ -162,7 +163,11 @@ export class AppsService {
where: { id: ruleId },
});

if (!ruleType) {
if (
!ruleType ||
ruleType.id === 'secret-key' ||
ruleType.name === 'secret-key'
) {
throw new HttpException(
'Attempted to update invalid rule type',
HttpStatus.BAD_REQUEST,
Expand Down Expand Up @@ -233,4 +238,55 @@ export class AppsService {

return updatedAppRules;
}

async updateSecretKeyRule(appId: string, action: 'generate' | 'delete') {
const ruleType = await this.prisma.client.ruleType.findFirstOrThrow({
where: { id: 'secret-key' },
});

const secretIdExists = await this.prisma.client.appRule.findFirst({
where: { appId, ruleId: ruleType.id },
});

if (action === 'delete' && secretIdExists) {
const deleteSecretKey = await this.prisma.client.appRule.delete({
where: { id: secretIdExists.id },
});

if (deleteSecretKey) {
return { delete: true };
}
}

if (secretIdExists && action === 'generate') {
const secretKey = randomBytes(8).toString('hex');
const hashedKey = createHash('sha256').update(secretKey).digest('hex');

const updateSecretKey = await this.prisma.client.appRule.update({
where: { id: secretIdExists.id },
data: {
value: hashedKey,
},
});

if (updateSecretKey) {
return { key: secretKey };
}
} else if (!secretIdExists && action === 'generate') {
const secretKey = randomBytes(8).toString('hex');
const hashedKey = createHash('sha256').update(secretKey).digest('hex');

const newSecretKey = await this.prisma.client.appRule.create({
data: {
appId,
ruleId: ruleType.id,
value: hashedKey,
},
});

if (newSecretKey) {
return { key: secretKey };
}
}
}
}
2 changes: 1 addition & 1 deletion web-portal/backend/src/tenant/tenant.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, Inject, HttpException, HttpStatus } from '@nestjs/common';
import { CustomPrismaService } from 'nestjs-prisma';
import { PrismaClient, TransactionType } from '@/.generated/client';
import { PrismaClient } from '@/.generated/client';
import { createHash, randomBytes } from 'crypto';

@Injectable()
Expand Down
2 changes: 1 addition & 1 deletion web-portal/frontend/components/apps/appRules.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const AppRules: React.FC<{ rule: Partial<IRuleType> }> = ({ rule }) => {
}}
/>
</Flex>
{values?.length > 0 && (
{values?.length > 0 && rule.name != "secret-key" && (
<Flex wrap={"wrap"} my={8}>
{values}
</Flex>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Button, Flex, TextInput, Pill, Stack, Text } from "@mantine/core";
import _ from "lodash";
import { useState } from "react";
import { useForm, matches } from "@mantine/form";
import { IconPlus } from "@tabler/icons-react";
import { useUpdateRuleMutation } from "@frontend/utils/hooks";
Expand Down Expand Up @@ -46,10 +45,6 @@ export default function AllowedOriginsForm() {

const formValidation = () => form.validate();

if (isSuccess) {
router.replace("/apps/" + appId + "?i=rules");
}

const values = value.map((item) => (
<Pill
key={item}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ export default function AllowedUserAgentsForm() {

const formValidation = () => form.validate();

if (isSuccess) {
router.replace("/apps/" + appId + "?i=rules");
}
const values = value.map((item) => (
<Pill
key={item}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import _ from "lodash";
import { Button } from "@mantine/core";
import React, { useState } from "react";
import React from "react";

import { endpointsAtom, existingRuleValuesAtom } from "@frontend/utils/atoms";
import { useAtomValue, useAtom } from "jotai";
Expand All @@ -23,9 +23,7 @@ export default function ApprovedChainForm() {
rule,
);
const [value, setValue] = useAtom(existingRuleValuesAtom);
if (isSuccess) {
router.replace("/apps/" + appId + "?i=rules");
}

return (
<React.Fragment>
<SearchableMultiSelect items={items} value={value} setValue={setValue} />
Expand Down
140 changes: 128 additions & 12 deletions web-portal/frontend/components/apps/forms/secretKeyRuleForm.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,139 @@
import React from "react";
import { useSearchParams } from "next/navigation";
import { Button, PasswordInput } from "@mantine/core";
import React, { useEffect, useState } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import {
Button,
Tooltip,
CopyButton,
Title,
Stack,
Text,
Flex,
CheckIcon,
} from "@mantine/core";
import { useSecretKeyMutation } from "@frontend/utils/hooks";
import { useAtomValue } from "jotai";
import { existingRuleValuesAtom } from "@frontend/utils/atoms";
import { IconCopy } from "@tabler/icons-react";

const SecretKeyDisplay = ({ secret }: { secret: string }) => (
<Stack align="center">
<Title order={3}>{`Here's your newly generated secret key`}</Title>
<CopyButton value={secret}>
{({ copied, copy }) => (
<Tooltip
label={
!copied ? (
"Copy Secret Key"
) : (
<Flex align="center">
<CheckIcon size={12} style={{ marginRight: 8 }} />
<Text> Copied Secret Key </Text>
</Flex>
)
}
bg={!copied ? "carrot" : "green"}
withArrow
>
<Title
order={3}
c={"black"}
style={{ textAlign: "center", borderRadius: 8 }}
bg={"gray"}
p={4}
onClick={copy}
w={"100%"}
>
<IconCopy size={16} style={{ marginRight: 8 }} />
{secret}
</Title>
</Tooltip>
)}
</CopyButton>
<Text>
This key is used to authenticate your app with the X-API field. Keep it
safe and do not share it with anyone.
</Text>
<Text c="red">Note: You will only see it once.</Text>
</Stack>
);

const SecretKeyGenerator = ({
ruleValues,
onGenerate,
isPending,
isSuccess,
action,
}: {
ruleValues: Array<string>;
onGenerate: () => void;
isPending: boolean;
isSuccess: boolean;
action: string | null;
}) => (
<Stack align="center">
<Title order={3}>Generate a new secret key</Title>
<Text>
{ruleValues.length > 0
? "You already have created a secret key for this app, However, you can create a new one. The previous key will no longer work!"
: "Generate a Key to authenticate your app with the X-API field."}
</Text>
<Text c="red">Note: You will only see it once.</Text>

<Button
fullWidth
onClick={onGenerate}
style={{ marginTop: 32 }}
loading={isPending && !isSuccess && action === "generate"}
>
Generate New
</Button>
</Stack>
);

export default function SecretKeyForm() {
const appId = useParams()?.app as string;
const searchParams = useSearchParams();
const { mutateAsync, isPending, isSuccess } = useSecretKeyMutation(appId);
const ruleValues = useAtomValue(existingRuleValuesAtom);
const key = searchParams?.get("key") as string;
const [action, setAction] = useState<null | string>(null);
const router = useRouter();

// TODO- work on this
const handleGenerate = async () => {
setAction("generate");
await mutateAsync("generate");
};

return (
<React.Fragment>
<PasswordInput
label="Your Secret Key"
value={searchParams?.get("key") as string}
readOnly
/>
{key ? (
<SecretKeyDisplay secret={key} />
) : (
<SecretKeyGenerator
ruleValues={ruleValues}
onGenerate={handleGenerate}
isPending={isPending}
isSuccess={isSuccess}
action={action}
/>
)}

<Button fullWidth style={{ marginTop: 32 }}>
Create New Secret Key
</Button>
{!key && ruleValues.length > 0 && (
<Button
fullWidth
onClick={async () => {
setAction("delete");
await mutateAsync("delete");
router.push(`/apps/${appId}?i=rules`);
}}
variant="outline"
color="black"
loading={isPending && !isSuccess && action === "delete"}
style={{ marginTop: 16 }}
>
Remove Secret Key
</Button>
)}
</React.Fragment>
);
}
42 changes: 40 additions & 2 deletions web-portal/frontend/utils/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const useCreateAppMutation = (

export const useUpdateRuleMutation = (appId: string, ruleName: string) => {
const queryClient = useQueryClient();

const router = useRouter();
const updateRuleMutation = async (data?: Array<string>) => {
const response = await fetch(`/api/apps/${appId}/rules`, {
method: "PATCH",
Expand All @@ -130,7 +130,8 @@ export const useUpdateRuleMutation = (appId: string, ruleName: string) => {
return useMutation({
mutationFn: updateRuleMutation,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user"] }); // TODO <--- revisit this
queryClient.invalidateQueries({ queryKey: ["user"] });
router.replace("/apps/" + appId + "?i=rules");
console.log(ruleName + " Updated");
},
});
Expand All @@ -151,3 +152,40 @@ export const useBillingHistory = (id: string) => {
enabled: Boolean(id),
});
};

export const useSecretKeyMutation = (appId: string) => {
const queryClient = useQueryClient();
const { address: userAddress } = useAccount();
const router = useRouter();
const createSecretKeyMutation = async (action: string) => {
const response = await fetch(`/api/apps/${appId}/secret`, {
method: action == "generate" ? "PUT" : "DELETE",
headers: {
"Content-Type": "application/json",
},
});

if (!response.ok) {
throw new Error("Failed to update secret key");
}

return response.json();
};

return useMutation({
mutationFn: createSecretKeyMutation,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ["user", userAddress, "apps"],
});

const { key } = data;
if (key) {
router.replace(
"/apps/" + appId + "?i=rules&rule=secret-key&key=" + key,
);
}
console.log("Secret Key Updated");
},
});
};