367 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|