Files
protocol/sites/dev0x-portal/app/routes/_dashboard.app.$appId.settings.tsx
2023-04-06 22:03:52 +02:00

367 lines
16 KiB
TypeScript

import { Form, useActionData, useLoaderData, useNavigate, useNavigation, useOutletContext } from '@remix-run/react';
import * as Drawer from '../components/Drawer';
import { BackButton } from '../components/BackButton';
import { TextInput } from '../components/TextInput';
import { useCallback, useMemo, useRef, useState } from 'react';
import { MultiSelectGroup } from '../components/MultiSelectGroup';
import { MultiSelectCard } from '../components/MultiselectCard';
import { Swap } from '../icons/Swap';
import { Book } from '../icons/Book';
import { Button } from '../components/Button';
import type { AppOutletContext } from './_dashboard.app.$appId';
import { PRODUCT_TO_ZIPPO_ROUTE_TAG, ZIPPO_ROUTE_TAG_TO_PRODUCT, validateFormData } from '../utils/utils';
import type { ActionArgs, LoaderArgs } from '@remix-run/server-runtime';
import { redirect } from '@remix-run/server-runtime';
import { json } from '@remix-run/server-runtime';
import { generateNonce, getRateLimitByTier } from '../utils/utils.server';
import { auth, sessionStorage } from '../auth.server';
import { z } from 'zod';
import type { ErrorWithGeneral } from '../types';
import {
createOnChainTag,
doesSessionExist,
getAppById,
getTeamById,
updateApp,
updateProvisionAccess,
} from '../data/zippo.server';
import { getSignedInUser } from '../auth.server';
import { Alert } from '../components/Alert';
import * as AppSettingsConfirmDialog from '../components/AppSettingsConfirmDialog';
import { ArrowRightIcon } from '@radix-ui/react-icons';
import type { TZippoTier } from 'zippo-interface';
const updateAppModel = z.object({
nonce: z.string().min(16, 'Invalid nonce'), // 16 bytes in hex
name: z.string().min(1, 'App name is required'),
tagName: z.string().optional(),
products: z
.enum(['swap-api', 'orderbook-api'])
.or(z.array(z.enum(['swap-api', 'orderbook-api'])).min(1, 'Please select at least one product')),
});
type ActionInput = z.TypeOf<typeof updateAppModel>;
type Errors = ErrorWithGeneral<Record<keyof ActionInput, string>>;
export async function action({ request, params }: ActionArgs) {
if (!params.appId) {
throw redirect('/apps');
}
const [user, headers] = await getSignedInUser(request);
const session = await sessionStorage.getSession(
headers.has('Set-Cookie') ? headers.get('Set-Cookie') : request.headers.get('Cookie'),
);
if (!user) {
throw redirect('/login', { headers });
}
const nonce = session.get('nonce');
if (!nonce) {
return json({ errors: { general: 'Invalid nonce' } as Errors }, { headers });
}
const formData = await request.formData();
const { body, errors } = validateFormData<ActionInput>({
formData: formData,
schema: updateAppModel,
});
if (errors) {
return json({ errors: errors as Errors, values: body }, { headers });
}
if (body.nonce !== nonce) {
return json({ errors: { general: 'Invalid nonce' } as Errors, values: body }, { headers });
}
// as this is an "admin" like action, we verify if the session is still valid
const sessionResult = await doesSessionExist({ userId: user.id, sessionToken: user.sessionToken });
if (sessionResult.result === 'ERROR' || sessionResult.data === false) {
console.warn(`User with id ${user.id} tried to update app settings but their session was invalid`);
await auth.logout(request, { redirectTo: '/login' });
throw redirect('/login'); // throwing again to stop execution flow for the analyzer
}
// we additionally want to verify that the user has access to this app
const returnedApp = await getAppById(params.appId);
if (returnedApp.result === 'ERROR') {
return json({ errors: { general: 'Error fetching app' } as Errors, values: body }, { headers });
}
if (returnedApp.data.teamId !== user.teamId) {
console.warn(`User with id ${user.id} tried to update app settings but they do not have access to this app`);
return json({ errors: { general: 'You do not have access to this app' } as Errors, values: body }, { headers });
}
const teamRes = await getTeamById({ teamId: user.teamId });
if (teamRes.result === 'ERROR') {
return json({ errors: { general: 'Error fetching team' } as Errors, values: body }, { headers });
}
const productAccess = Array.isArray(body.products) ? body.products : [body.products];
const updateResult = await updateProvisionAccess({
appId: params.appId,
routeTags: productAccess.map((product) => PRODUCT_TO_ZIPPO_ROUTE_TAG[product]),
rateLimits: productAccess.map(() => getRateLimitByTier((teamRes.data.tier as TZippoTier) || 'dev')),
});
if (updateResult.result === 'ERROR') {
return json(
{ errors: { general: 'Error updating app, please try again later' } as Errors, values: body },
{ headers },
);
}
session.set(auth.sessionKey, updateResult.data);
let onChainTagId: string | undefined;
if (body.tagName && !returnedApp.data.onChainTag) {
const onChainTagRes = await createOnChainTag({
name: body.tagName,
teamId: user.teamId,
});
if (onChainTagRes.result === 'ERROR') {
return json({
errors: { general: 'Error creating the onchain tag, please try again later' } as Errors,
values: body,
});
}
onChainTagId = onChainTagRes.data.id;
}
if (onChainTagId || returnedApp.data.name !== body.name) {
const updateResult = await updateApp({ appId: returnedApp.data.id, name: body.name, onChainTagId });
if (updateResult.result === 'ERROR') {
return json(
{
errors: { general: 'Error persisting updated information, please try again later' } as Errors,
values: body,
},
{ headers },
);
}
}
if (onChainTagId || returnedApp.data.name !== body.name) {
const updateResult = await updateApp({ appId: returnedApp.data.id, name: body.name, onChainTagId });
if (updateResult.result === 'ERROR') {
return json(
{
errors: { general: 'Error persisting updated information, please try again later' } as Errors,
values: body,
},
{ headers },
);
}
session.set(auth.sessionKey, updateResult.data);
}
headers.set('Set-Cookie', await sessionStorage.commitSession(session));
throw redirect(`/app/${params.appId}`, { headers });
}
export async function loader({ request }: LoaderArgs) {
const nonce = generateNonce();
const session = await sessionStorage.getSession(request.headers.get('Cookie'));
session.flash('nonce', nonce);
const header = await sessionStorage.commitSession(session);
return json({ nonce }, { headers: { 'Set-Cookie': header } });
}
export default function AppSettings() {
const { app } = useOutletContext<AppOutletContext>();
const { nonce } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const ref = useRef<HTMLFormElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const navigation = useNavigation();
const [appName, setAppName] = useState(app.name);
const [tagName, setTagName] = useState(app.onChainTag?.name ?? '');
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const tagNameFieldEnabled = useMemo(() => app.onChainTag?.name === undefined, [app]);
const [selectedProducts, setSelectedProducts] = useState(
new Set(app.productAccess?.map((product) => ZIPPO_ROUTE_TAG_TO_PRODUCT[product]?.id)),
);
const hasRemovedProducts = useMemo(() => {
if (!app.productAccess) return false;
if (app.productAccess.length > selectedProducts.size) {
return true;
}
return app.productAccess.some((product) => !selectedProducts.has(ZIPPO_ROUTE_TAG_TO_PRODUCT[product]?.id));
}, [app, selectedProducts]);
const addedTag = useMemo(() => {
if (!tagNameFieldEnabled) return false;
return tagName !== '';
}, [tagNameFieldEnabled, tagName]);
const onSubmit = useCallback(() => {
if (!ref.current) {
console.warn('Form ref is not set');
return;
}
if (hasRemovedProducts) {
setShowConfirmationModal(true);
return;
}
ref.current.submit();
}, [ref, hasRemovedProducts]);
return (
<Drawer.Root
open={true}
onOpenChange={(open) => {
if (!open) {
navigate(-1);
}
}}
>
<Drawer.Content position="right" className="w-[694px]" ref={containerRef}>
<div className="flex h-full flex-col pt-8">
<div className="px-10 ">
<BackButton />
<h2 className="text-2.5xl text-grey-900 font-sans leading-[120%] antialiased">App Settings</h2>
<hr className="text-grey-200 mt-4 mb-[52px]" />
</div>
{actionData?.errors?.general && (
<div className="px-10">
<Alert variant="error" className="mb-4">
{actionData?.errors?.general}
</Alert>
</div>
)}
<Form method="post" className="flex flex-grow flex-col justify-between" ref={ref}>
<div className="px-10">
<TextInput
label="App Name"
name="name"
placeholder="Enter app name"
id="inp-app-name"
className="mb-10"
onChange={(e) => setAppName(e.target.value)}
error={actionData?.errors?.name}
initialValue={appName}
/>
<MultiSelectGroup
label="What 0x products should be enabled?"
onChange={(e) => {
const { value, checked } = e.target as HTMLInputElement;
if (checked) {
selectedProducts.add(value);
} else {
selectedProducts.delete(value);
}
setSelectedProducts(new Set(selectedProducts));
}}
>
<MultiSelectCard
title="Swap API"
description="Access efficient liquidity for powering token swaps"
icon={<Swap />}
id="swap-api"
selected={selectedProducts.has('swap-api')}
key="swap-api"
className="h-full"
name="products"
value="swap-api"
/>
<MultiSelectCard
title="Orderbook API"
description="Power limit orders in your application"
icon={<Book />}
id="orderbook-api"
selected={selectedProducts.has('orderbook-api')}
key="orderbook-api"
className="h-full"
name="products"
value="orderbook-api"
/>
</MultiSelectGroup>
</div>
<div className="mt-10 px-10">
<h3 className=" mb-4 text-lg font-medium leading-[26px] text-black antialiased">
0x Explorer tag
</h3>
<p className="text-grey-500 mb-6 text-sm antialiased">
To update the on-chain label for this app{' '}
<a
href="#"
className="inline-flex items-center text-base text-sm text-blue-700 antialiased"
>
Contact us <ArrowRightIcon className="inline-block" />
</a>
</p>
<TextInput
label="Tag name"
name="tagName"
placeholder={tagNameFieldEnabled ? 'Enter Tag name' : tagName}
id="inp-tag-name"
className="mb-10"
disabled={!tagNameFieldEnabled}
onChange={(e) => setTagName(e.target.value)}
error={actionData?.errors?.tagName}
initialValue={tagNameFieldEnabled ? tagName : ''}
/>
</div>
<input type="hidden" name="nonce" value={nonce} />
<div className="flex-grow" />
<div className="p-6">
<Button
size="md"
className="w-full justify-center"
type="button"
onClick={onSubmit}
disabled={navigation.state !== 'idle' || appName === '' || selectedProducts.size === 0}
>
{addedTag ? (
<span className="flex items-center gap-2">
Submit & Save <ArrowRightIcon className="inline-block" height={24} width={24} />
</span>
) : (
'Save Settings'
)}
</Button>
</div>
</Form>
<AppSettingsConfirmDialog.Root
open={showConfirmationModal}
onOpenChange={(open) => {
if (!open) {
setShowConfirmationModal(false);
}
}}
>
<AppSettingsConfirmDialog.Content
appName={app.name}
portalProps={{ container: containerRef?.current }}
onConfirm={() => {
ref.current?.submit();
}}
/>
</AppSettingsConfirmDialog.Root>
</div>
</Drawer.Content>
</Drawer.Root>
);
}