wireadmin/src/pages/[serverId]/index.tsx

369 lines
12 KiB
TypeScript

import { Button, Card, List } from "antd";
import BasePage from "@ui/pages/BasePage";
import PageRouter from "@ui/pages/PageRouter";
import React from "react";
import { PlusOutlined } from "@ant-design/icons";
import useSWR from "swr";
import { APIResponse, Peer, WgServer } from "@lib/typings";
import useSWRMutation from "swr/mutation";
import { useRouter } from "next/router";
import { MiddleEllipsis } from "@ui/MiddleEllipsis";
import StatusBadge from "@ui/StatusBadge";
import { SmartModalRef } from "@ui/Modal/SmartModal";
import CreateClientModal from "@ui/Modal/CreateClientModal";
import { twMerge } from "tailwind-merge";
import QRCodeModal from "@ui/Modal/QRCodeModal";
import { getPeerConf } from "@lib/wireguard-utils";
import EditableText from "@ui/EditableText";
import { RLS_NAME_INPUT } from "@lib/form-rules";
import { UPDATE_CLIENT } from "@lib/swr-fetch";
import CopiableWrapper from "@ui/CopiableWrapper";
export async function getServerSideProps(context: any) {
return {
props: {
serverId: context.params.serverId
}
}
}
type PageProps = {
serverId: string
}
export default function ServerPage(props: PageProps) {
const router = useRouter()
const createClientRef = React.useRef<SmartModalRef | null>(null)
const { data, error, isLoading, mutate: refresh } = useSWR(
`/api/wireguard/${props.serverId}`,
async (url: string) => {
const resp = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
})
if (resp.status === 404) {
router.replace('/').catch()
return false
}
const data = await resp.json() as APIResponse<WgServer>
if (!data.ok) throw new Error('Server responded with error status')
return data.result
}
)
const { isMutating: isChangingStatus, trigger: changeStatus } = useSWRMutation(
`/api/wireguard/${props.serverId}`,
async (url: string, { arg }: { arg: string }) => {
const resp = await fetch(url, {
method: arg === 'remove' ? 'DELETE' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: arg === 'remove' ? undefined : JSON.stringify({ status: arg })
})
if (resp.status === 404) {
router.replace('/').catch()
return false
}
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
},
{
onSuccess: async () => await refresh(),
onError: async () => await refresh(),
}
)
const lastChangeStatus = React.useRef<string | null>(null)
return (
<BasePage>
<CreateClientModal
ref={createClientRef}
serverId={props.serverId}
refreshTrigger={() => refresh()}
/>
<PageRouter
route={[
{ title: data ? data.name.toString() : 'LOADING...' }
]}
/>
{error || (!isLoading && !data) ? (
<Card className={'flex items-center justify-center p-4'}>
! ERROR !
</Card>
) : isLoading ? (
<Card className={'flex items-center justify-center p-4'}>
Loading...
</Card>
) : data && (
<div className={'space-y-4'}>
<Card className={'[&>.ant-card-body]:max-md:p1-2'}>
<List>
<Row label={'IP address'}>
<pre> {data.address}/24 </pre>
</Row>
<Row label={'Listen Port'}>
<pre> {data.listen} </pre>
</Row>
<Row label={'Status'}>
<StatusBadge status={data.status} />
</Row>
<Row label={'Public Key'}>
<CopiableWrapper content={data.publicKey}>
<MiddleEllipsis
content={data.publicKey}
maxLength={16}
/>
</CopiableWrapper>
</Row>
</List>
<div className={'flex flex-wrap items-center gap-2 mt-6'}>
{data.status === 'up' ? (
<React.Fragment>
<Button
className={'max-md:col-span-12'}
loading={isChangingStatus && lastChangeStatus.current === 'restart'}
disabled={isChangingStatus}
onClick={() => changeStatus('restart')}
> Restart </Button>
<Button
danger={true}
className={'max-md:col-span-12'}
loading={isChangingStatus && lastChangeStatus.current === 'stop'}
disabled={isChangingStatus}
onClick={() => changeStatus('stop')}
> Stop </Button>
</React.Fragment>
) : (
<React.Fragment>
<Button
type={'primary'}
className={'max-md:col-span-12 bg-green-500'}
loading={isChangingStatus && lastChangeStatus.current === 'start'}
disabled={isChangingStatus}
onClick={() => changeStatus('start')}
> Start </Button>
<Button
danger={true}
className={'max-md:col-span-12'}
loading={isChangingStatus && lastChangeStatus.current === 'remove'}
disabled={isChangingStatus}
onClick={() => {
changeStatus('remove').finally(() => {
lastChangeStatus.current = null
router.replace('/').catch()
})
}}
> Remove </Button>
</React.Fragment>
)}
</div>
</Card>
<Card
className={'[&>.ant-card-body]:p-0'}
title={(
<div className={'flex items-center justify-between'}>
<span> Clients </span>
{data && data.peers.length > 0 && (
<div>
<Button
type={'primary'}
icon={<PlusOutlined />}
onClick={() => createClientRef.current?.open()}
>
Add a client
</Button>
</div>
)}
</div>
)}
>
{data && data.peers.length > 0 ? (
<List>
{data.peers.map((s) => (
<Client
key={s.id}
{...s}
serverId={props.serverId}
serverPublicKey={data?.publicKey}
dns={data?.dns}
listenPort={data?.listen}
refreshTrigger={() => refresh()}
/>
))}
</List>
) : (
<div className={'flex flex-col items-center justify-center gap-y-4 py-8'}>
<p className={'text-gray-400 text-md'}>
There are no clients yet!
</p>
<Button type={'primary'} icon={<PlusOutlined />} onClick={() => createClientRef.current?.open()}>
Add a client
</Button>
</div>
)}
</Card>
</div>
)}
</BasePage>
);
}
interface ClientProps extends Peer, Pick<WgServer, 'dns'> {
serverId: string
serverPublicKey: string
listenPort: number
refreshTrigger: () => void
}
function Client(props: ClientProps) {
const qrcodeRef = React.useRef<SmartModalRef | null>(null)
const [ conf, setConf ] = React.useState<string | null>(null)
React.useEffect(() => {
getPeerConf({
...props,
serverPublicKey: props.serverPublicKey,
port: props.listenPort,
dns: props.dns,
})
.then((s) => setConf(s))
}, [ props ])
const RefreshOptions = {
onSuccess: () => props.refreshTrigger(),
onError: () => props.refreshTrigger()
}
const { isMutating: removingClient, trigger: removeClient } = useSWRMutation(
`/api/wireguard/${props.serverId}/${props.id}`,
async (url: string,) => {
const resp = await fetch(url, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
})
const data = await resp.json() as APIResponse<any>
if (!data.ok) throw new Error('Server responded with error status')
return true
},
RefreshOptions
)
const { isMutating, trigger } = useSWRMutation(
`/api/wireguard/${props.serverId}/${props.id}`,
UPDATE_CLIENT,
RefreshOptions
)
return (
<List.Item key={props.id} className={'flex items-center justify-between p-4'}>
<QRCodeModal ref={qrcodeRef} content={conf?.trim() || 'null'} />
<div className={'w-full flex flex-row items-center gap-x-2'}>
<div
className={'w-12 aspect-square flex items-center justify-center mr-4 rounded-full bg-gray-200 max-md:hidden'}>
<i className={'fas fa-user text-gray-400 text-lg'} />
</div>
<div className={'flex flex-col items-start justify-between'}>
<EditableText
disabled={isMutating}
rules={RLS_NAME_INPUT}
rootClassName={'font-medium col-span-4'}
inputClassName={'w-20'}
content={props.name}
onChange={(v) => trigger({ name: v })}
/>
<CopiableWrapper content={props.allowedIps} className={'text-sm'} showInHover={true}>
<span className={'font-mono text-gray-400 text-xs'}> {props.allowedIps} </span>
</CopiableWrapper>
</div>
</div>
<div className={'flex items-center justify-center gap-x-3'}>
{/* QRCode */}
<ClientBaseButton disabled={removingClient} onClick={() => {
qrcodeRef.current?.open()
}}>
<i className={'fal text-neutral-700 group-hover:text-primary fa-qrcode'} />
</ClientBaseButton>
{/* Download */}
<ClientBaseButton disabled={removingClient} onClick={() => {
if (!conf) {
console.error('conf is null')
return
}
console.log('conf', conf)
// create a blob
const blob = new Blob([ conf ], { type: 'text/plain' })
// create a link
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = `${props.name}.conf`
// click the link
link.click()
// remove the link
link.remove()
}}>
<i className={'fal text-neutral-700 group-hover:text-primary fa-download'} />
</ClientBaseButton>
{/* Remove */}
<ClientBaseButton loading={removingClient} onClick={() => removeClient()}>
<i className={'fal text-neutral-700 group-hover:text-primary text-lg fa-trash-can'} />
</ClientBaseButton>
</div>
</List.Item>
)
}
function ClientBaseButton(props: {
onClick: () => void
loading?: boolean
disabled?: boolean
children: React.ReactNode
}) {
return (
<div
className={twMerge(
'group flex items-center justify-center w-10 aspect-square rounded-md',
'bg-gray-200/80 hover:bg-gray-100/50',
'border border-transparent hover:border-primary',
'transition-colors duration-200 ease-in-out',
'cursor-pointer',
props.disabled && 'opacity-50 cursor-not-allowed',
props.loading && 'animate-pulse'
)}
onClick={props.onClick}
>
{props.children}
</div>
)
}
function Row(props: {
label: string
children: React.ReactNode
}) {
return (
<List.Item className={'flex flex-wrap items-center gap-2 leading-none relative overflow-ellipsis'}>
<div className={'flex items-center text-gray-400 text-sm col-span-12 md:col-span-3'}>
{props.label}
</div>
<div className={'flex items-center gap-x-2 col-span-12 md:col-span-9'}>
{props.children}
</div>
</List.Item>
)
}