icynet-admin/components/OAuth2Page/OAuth2Page.tsx

545 lines
15 KiB
TypeScript

import useSWR 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, useEffect, 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';
import Head from 'next/head';
const LINK_NAMES: Record<string, string> = {
redirect_uri: 'Redirect URI',
terms: 'Terms of Service',
privacy: 'Privacy Policy',
website: 'Website',
};
const REDIRECT_URI_COUNT = 3;
const LinkEdit = ({
link,
onChange,
onRemove,
}: {
link: Partial<OAuth2ClientURL>;
onChange: () => void;
onRemove: () => void;
}) => {
return (
<FormControl>
<label htmlFor={link.type}>{LINK_NAMES[link.type!]}</label>
<div className={styles.urlWrapper}>
<input
id={link.type}
name={link.type}
value={link.url || ''}
onChange={(e) => {
link.url = e.target.value;
onChange();
}}
/>
<Button variant="link" onClick={() => onRemove()} type="button">
Remove
</Button>
</div>
{link.type === OAuth2ClientURLType.REDIRECT_URI && (
<span>Wildcards are NOT allowed!</span>
)}
</FormControl>
);
};
const LinkEditor = ({
formData,
handleInputChange,
}: {
formData: Partial<OAuth2ClientListItem>;
handleInputChange: (
e?: ChangeEvent,
setValue?: any,
formField?: string
) => void;
}) => {
const [links, setLinks] = useState<Partial<OAuth2ClientURL>[]>(
formData.urls || []
);
const [addNewSelection, setAddNewSelection] = useState<OAuth2ClientURLType>();
const availableTypes = useMemo(
() =>
Object.values(OAuth2ClientURLType).filter((type) =>
type === 'redirect_uri'
? links.filter(
(link) => link.type === OAuth2ClientURLType.REDIRECT_URI
).length < REDIRECT_URI_COUNT
: !links.some((link) => link.type === type)
),
[links]
);
useEffect(() => {
if (
(!addNewSelection ||
availableTypes.indexOf(addNewSelection as OAuth2ClientURLType) ===
-1) &&
availableTypes.length
) {
setAddNewSelection(availableTypes[0]);
}
}, [availableTypes, addNewSelection]);
return (
<>
<h3>Client URLs</h3>
{links.map((link, index) => (
<LinkEdit
key={index}
link={link}
onChange={() => {
setLinks(links);
handleInputChange(undefined, links, 'urls');
}}
onRemove={() => {
const clone = links.slice();
clone.splice(index, 1);
setLinks(clone);
handleInputChange(undefined, clone, 'urls');
}}
/>
))}
<FormControl>
{availableTypes.length > 0 && (
<div className={styles.urlWrapper}>
<select
value={addNewSelection}
onChange={(e) =>
setAddNewSelection(e.target.value as OAuth2ClientURLType)
}
>
{availableTypes.map((value, index) => (
<option value={value} key={index}>
{LINK_NAMES[value]}
</option>
))}
</select>
<Button
variant="default"
type="button"
onClick={() => {
setLinks([
...links,
{
type: addNewSelection,
url: '',
},
]);
}}
>
Add
</Button>
</div>
)}
</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>
</>
)}
<LinkEditor
formData={formData}
handleInputChange={handleInputChange}
></LinkEditor>
</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 [clientCount, setClientCount] = useState<number>();
const [searchTerm, setSearchTerm] = useState('');
const { data, mutate } = useSWR<PaginatedResponse<OAuth2ClientListItem>>(
`/api/admin/oauth2/clients?page=${pageIndex}${
searchTerm ? `&q=${searchTerm}` : ''
}&pageSize=8`
);
useEffect(() => {
if (data?.pagination?.rowCount) {
setClientCount(data?.pagination.rowCount);
}
}, [data]);
return (
<>
<div className={styles.header}>
<h1>OAuth2 clients{clientCount && ` (${clientCount})`}</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 (
<>
<Head>
<title>OAuth2 | Icy Network Administration</title>
</Head>
<Header user={user}></Header>
<Container>
<OAuth2ClientList isAdmin={isAdmin} />
</Container>
</>
);
};