icynet-admin/components/OAuth2Page/OAuth2Page.tsx

461 lines
14 KiB
TypeScript

import useSWR, { mutate } from 'swr';
import Image from 'next/image';
import {
OAuth2ClientListItem,
OAuth2ClientURL,
OAuth2ClientURLType,
} from '../../lib/types/oauth2-client.interface';
import { PaginatedResponse } from '../../lib/types/paginated-response.interface';
import { Paginator } from '../common/Paginator/Paginator';
import styles from './OAuth2Page.module.scss';
import { UPLOADS_URL } from '../../lib/constants';
import application from '../../public/application.png';
import { ChangeEvent, useMemo, useRef, useState } from 'react';
import useUser from '../../lib/hooks/useUser';
import { Container } from '../common/Container/Container';
import { Header } from '../common/Header/Header';
import Modal from '../common/Modal/Modal/Modal';
import ModalHeader from '../common/Modal/ModalHeader/ModalHeader';
import ModalBody from '../common/Modal/ModalBody/ModalBody';
import ModalFooter from '../common/Modal/ModalFooter/ModalFooter';
import ModalService from '../common/Modal/services/ModalService';
import { Dropdown } from '../common/Dropdown/Dropdown';
import { ModalProps } from '../../lib/types/modal.interface';
import { useForm } from '../../lib/hooks/useForm';
import { FormWrapper } from '../common/Form/FormWrapper/FormWrapper';
import { FormControl } from '../common/Form/FormControl/FormControl';
import toast from 'react-hot-toast';
import { Button } from '../common/Button/Button';
import userHasPrivileges from '../../lib/utils/has-privileges';
import { publishJSON } from '../../lib/utils/fetch';
const LINK_NAMES = {
redirect_uri: 'Redirect URI',
terms: 'Terms of Service',
privacy: 'Privacy Policy',
website: 'Website',
};
const LinkEdit = ({
formData,
handleInputChange,
linkType,
}: {
formData: Partial<OAuth2ClientListItem>;
handleInputChange: (
e: ChangeEvent,
setValue?: any,
formField?: string
) => void;
linkType: OAuth2ClientURLType;
}) => {
const formUrl = useMemo<Partial<OAuth2ClientURL>>(
() => (formData.urls || []).find(({ type }) => type === linkType) || {},
[formData, linkType]
);
return (
<FormControl>
<label htmlFor={linkType}>{LINK_NAMES[linkType]}</label>
<input
id={linkType}
name={linkType}
value={formUrl?.url || ''}
onChange={(e) => {
if (!formUrl.type) {
formUrl.type = linkType;
(formData.urls as Partial<OAuth2ClientURL>[]) = [
...(formData.urls || []),
formUrl,
];
}
formUrl.url = e.target.value;
handleInputChange(e, formData.urls, 'urls');
}}
/>
</FormControl>
);
};
const EditClientModal = ({
close,
modalRef,
client,
isAdmin,
update,
}: ModalProps<{
client?: OAuth2ClientListItem;
update: () => void;
isAdmin: boolean;
}>) => {
const formRef = useRef<HTMLFormElement>(null);
const scopeReq = useSWR<string[]>('/api/admin/oauth2/scopes');
const grantReq = useSWR<string[]>('/api/admin/oauth2/grants');
const { formData, handleInputChange, handleSubmit } = useForm<
Partial<OAuth2ClientListItem>
>(client || { grants: 'authorization_code' }, () => {
toast
.promise(
publishJSON(
client
? `/api/admin/oauth2/clients/${client.id}`
: '/api/admin/oauth2/clients',
client ? 'PATCH' : 'POST',
{ activated: true, ...formData }
),
{
loading: 'Saving client...',
success: 'Client saved!',
error: (err) => `Saving the client failed: ${err.message}`,
}
)
.then((data) => {
if (data) {
close(true);
update();
}
});
return false;
});
return (
<Modal modalRef={modalRef}>
<ModalHeader>
<h3>{client ? 'Edit OAuth2 Client' : 'New OAuth2 Client'}</h3>
</ModalHeader>
<ModalBody>
<FormWrapper>
<form ref={formRef} onSubmit={handleSubmit} autoComplete="off">
<FormControl>
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
name="title"
value={formData.title || ''}
onChange={handleInputChange}
/>
</FormControl>
<FormControl>
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
value={formData.description || ''}
onChange={handleInputChange}
/>
</FormControl>
<FormControl>
<label htmlFor="scope">Allowed scopes</label>
<input
id="scope"
name="scope"
value={formData.scope || ''}
onChange={handleInputChange}
/>
{scopeReq.data && (
<span>
<b>Available:</b>{' '}
{scopeReq.data
.filter(
(item) => !formData.scope?.split(' ').includes(item)
)
.join(', ') || 'None'}
</span>
)}
</FormControl>
<FormControl>
<label htmlFor="grants">Allowed grant types</label>
<input
id="grants"
name="grants"
value={formData.grants || ''}
onChange={handleInputChange}
/>
{grantReq.data && (
<span>
<b>Available:</b>{' '}
{grantReq.data
.filter(
(item) => !formData.grants?.split(' ').includes(item)
)
.join(', ') || 'None'}
</span>
)}
</FormControl>
{isAdmin && (
<>
<FormControl inline={true}>
<label htmlFor="activated">Activated</label>
<input
id="activated"
name="activated"
type="checkbox"
checked={formData.activated ?? true}
onChange={handleInputChange}
/>
</FormControl>
<FormControl inline={true}>
<label htmlFor="verified">Verified</label>
<input
id="verified"
name="verified"
type="checkbox"
checked={formData.verified ?? false}
onChange={handleInputChange}
/>
</FormControl>
</>
)}
<LinkEdit
formData={formData}
handleInputChange={handleInputChange}
linkType={OAuth2ClientURLType.REDIRECT_URI}
></LinkEdit>
<LinkEdit
formData={formData}
handleInputChange={handleInputChange}
linkType={OAuth2ClientURLType.WEBSITE}
></LinkEdit>
<LinkEdit
formData={formData}
handleInputChange={handleInputChange}
linkType={OAuth2ClientURLType.PRIVACY}
></LinkEdit>
<LinkEdit
formData={formData}
handleInputChange={handleInputChange}
linkType={OAuth2ClientURLType.TERMS}
></LinkEdit>
</form>
</FormWrapper>
</ModalBody>
<ModalFooter>
<Button onClick={() => close(true)}>Cancel</Button>
{client && (
<Button
onClick={() =>
toast
.promise(
publishJSON(
`/api/admin/oauth2/clients/${client!.id}/new-secret`,
'POST'
),
{
loading: 'Generating new secret...',
success: 'New secret generated.',
error: 'Failed to generate new secret.',
}
)
.then(() => update())
}
>
New secret
</Button>
)}
<Button onClick={() => handleSubmit()} variant="primary">
Submit
</Button>
</ModalFooter>
</Modal>
);
};
const OAuth2ClientCard = ({
client,
isAdmin,
update,
}: {
client: OAuth2ClientListItem;
isAdmin: boolean;
update: () => void;
}) => (
<div className={styles.clientCard}>
<div className={styles.pictureWrapper}>
{client.picture ? (
<Image
src={`${UPLOADS_URL}/${client.picture.file}`}
width={128}
height={128}
alt=""
/>
) : (
<Image src={application} alt="" width={128} height={128} />
)}
</div>
<div className={styles.clientInfo}>
<div className={styles.titleWrap}>
<h2>{client.title}</h2>
<Dropdown opens="right">
<button
onClick={() =>
ModalService.open(EditClientModal, {
client,
isAdmin,
update,
})
}
>
Edit client
</button>
<button
onClick={() => {
toast.promise(
navigator.clipboard.writeText(client.client_secret),
{
loading: 'Copying',
success: 'Copied to clipboard',
error: 'Copying to clipboard failed.',
}
);
}}
>
Copy secret
</button>
{!client.activated && (
<button
onClick={() => {
toast
.promise(
publishJSON(
`/api/admin/oauth2/clients/${client.id}`,
'DELETE'
),
{
loading: 'Deleting client...',
success: 'Client deleted!',
error: (err) =>
`Deleting the client failed: ${err.message}`,
}
)
.then((data) => {
if (data) {
update();
}
});
}}
>
Delete client
</button>
)}
</Dropdown>
</div>
<span className={styles.clientDescription}>{client.description}</span>
<dl>
<dt>Client ID</dt>
<dd>{client.client_id}</dd>
<dt>Allowed scopes</dt>
<dd>{client.scope?.split(' ').join(', ')}</dd>
<dt>Allowed grant types</dt>
<dd>{client.grants?.split(' ').join(', ')}</dd>
{isAdmin && (
<>
<dt>Activated</dt>
<dd>{client.activated ? 'Yes' : <b>NOT ACTIVATED</b>}</dd>
<dt>Verified</dt>
<dd>{client.verified ? 'Yes' : 'No'}</dd>
</>
)}
{client.owner ? (
<>
<dt>Owner</dt>
<dd>
{client.owner.uuid} ({client.owner.username})
</dd>
</>
) : undefined}
<dt>Created</dt>
<dd>{new Date(client.created_at).toDateString()}</dd>
{client.urls?.length ? (
<>
<dt>URLs</dt>
<dd>
<div className={styles.urls}>
{client.urls.map((url) => (
<a
key={url.id}
href={url.url}
target="_blank"
rel="noreferrer"
>
{LINK_NAMES[url.type]} &lt;{url.url}&gt;
</a>
))}
</div>
</dd>
</>
) : undefined}
</dl>
</div>
</div>
);
const OAuth2ClientList = ({ isAdmin }: { isAdmin: boolean }) => {
const [pageIndex, setPageIndex] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const { data, mutate } = useSWR<PaginatedResponse<OAuth2ClientListItem>>(
`/api/admin/oauth2/clients?page=${pageIndex}${
searchTerm ? `&q=${searchTerm}` : ''
}&pageSize=8`
);
return (
<>
<div className={styles.header}>
<h1>OAuth2 clients</h1>
<Button
onClick={() =>
ModalService.open(EditClientModal, { isAdmin, update: mutate })
}
>
Create new
</Button>
</div>
<FormWrapper>
<FormControl>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search titles, descriptions, IDs.."
/>
</FormControl>
</FormWrapper>
<br />
{data ? (
<div className={styles.clientList}>
{data.list.map((client) => (
<OAuth2ClientCard
client={client}
key={client.client_id}
isAdmin={isAdmin}
update={mutate}
/>
))}
{data?.pagination && (
<Paginator
setPage={setPageIndex}
pagination={data.pagination}
></Paginator>
)}
</div>
) : (
<span>Nothing found</span>
)}
</>
);
};
export const OAuth2Page = () => {
const { user } = useUser({ redirectTo: '/login' });
const isAdmin = userHasPrivileges(user, 'admin:oauth2');
return (
<>
<Header user={user}></Header>
<Container>
<OAuth2ClientList isAdmin={isAdmin} />
</Container>
</>
);
};