mirror of https://github.com/lukechilds/sensei.git
John Cantrell
3 years ago
13 changed files with 432 additions and 20 deletions
@ -0,0 +1,88 @@ |
|||||
|
import { useNavigate } from "react-router"; |
||||
|
import { Form, FORM_ERROR, Input, Select } from "../../components/form"; |
||||
|
import * as z from "zod"; |
||||
|
import createAccessToken from "../mutations/createAccessToken"; |
||||
|
import addMinutes from "date-fns/addMinutes"; |
||||
|
|
||||
|
export const CreateTokenInput = z.object({ |
||||
|
name: z.string(), |
||||
|
scope: z.string(), |
||||
|
expiresAt: z.string(), |
||||
|
singleUse: z.string(), |
||||
|
}); |
||||
|
|
||||
|
const NewTokenForm = () => { |
||||
|
let navigate = useNavigate(); |
||||
|
|
||||
|
let singleUseOptions = [ |
||||
|
{ value: "false", text: "Unlimited Use" }, |
||||
|
{ value: "true", text: "Single Use" }, |
||||
|
]; |
||||
|
|
||||
|
let scopeOptions = [ |
||||
|
{ value: "*", text: "All Scopes" }, |
||||
|
{ value: "nodes/create", text: "Create Nodes" }, |
||||
|
{ |
||||
|
value: "nodes/create,nodes/list,nodes/delete,nodes/stop,nodes/start", |
||||
|
text: "Node Management", |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
let expirationOptions = [ |
||||
|
{ value: `0`, text: "Never" }, |
||||
|
{ value: `5`, text: "5 minutes" }, |
||||
|
{ value: `60`, text: "1 hour" }, |
||||
|
{ value: `360`, text: "6 hours" }, |
||||
|
{ value: `${24 * 60}`, text: "24 hours" }, |
||||
|
{ value: `${7 * 24 * 60}`, text: "1 week" }, |
||||
|
{ value: `${2 * 7 * 24 * 60}`, text: "2 weeks" }, |
||||
|
{ value: `${30 * 24 * 60}`, text: "30 days" }, |
||||
|
{ value: `${60 * 24 * 60}`, text: "60 days" }, |
||||
|
{ value: `${90 * 24 * 60}`, text: "90 days" }, |
||||
|
{ value: `${180 * 24 * 60}`, text: "180 days" }, |
||||
|
{ value: `${365 * 24 * 60}`, text: "1 year" }, |
||||
|
{ value: `${2 * 365 * 24 * 60}`, text: "2 years" }, |
||||
|
{ value: `${3 * 365 * 24 * 60}`, text: "3 years" }, |
||||
|
]; |
||||
|
|
||||
|
return ( |
||||
|
<Form |
||||
|
submitText="Create Token" |
||||
|
schema={CreateTokenInput} |
||||
|
initialValues={{ |
||||
|
name: "", |
||||
|
singleUse: "false", |
||||
|
expiresAt: "0", |
||||
|
scope: "*", |
||||
|
}} |
||||
|
noticePosition="top" |
||||
|
layout="default" |
||||
|
onSubmit={async ({ name, scope, expiresAt, singleUse }) => { |
||||
|
let expiresAtInt = parseInt(expiresAt, 10); |
||||
|
const expiresAtActual = |
||||
|
expiresAtInt === 0 |
||||
|
? 0 |
||||
|
: addMinutes(new Date(), expiresAtInt).getTime(); |
||||
|
|
||||
|
try { |
||||
|
await createAccessToken( |
||||
|
name, |
||||
|
scope, |
||||
|
expiresAtActual, |
||||
|
singleUse === "true" |
||||
|
); |
||||
|
navigate("/admin/tokens"); |
||||
|
} catch (e) { |
||||
|
// TODO: handle error
|
||||
|
} |
||||
|
}} |
||||
|
> |
||||
|
<Input label="Name" name="name" /> |
||||
|
<Select label="Scope" name="scope" options={scopeOptions} /> |
||||
|
<Select label="Expiration" name="expiresAt" options={expirationOptions} /> |
||||
|
<Select label="Usage Limit" name="singleUse" options={singleUseOptions} /> |
||||
|
</Form> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default NewTokenForm; |
@ -0,0 +1,226 @@ |
|||||
|
import { truncateMiddle } from "../../utils/capitalize"; |
||||
|
import SearchableTable from "../../components/tables/SearchableTable"; |
||||
|
import getAccessTokens from "../queries/getAccessTokens"; |
||||
|
import deleteAccessToken from "../mutations/deleteAccessToken"; |
||||
|
import { ClipboardCopyIcon, PlusCircleIcon } from "@heroicons/react/outline"; |
||||
|
import copy from "copy-to-clipboard"; |
||||
|
import { useState } from "react"; |
||||
|
import { useModal } from "../../contexts/modal"; |
||||
|
import { TrashIcon } from "@heroicons/react/outline"; |
||||
|
import { useConfirm } from "../../contexts/confirm"; |
||||
|
import { useQueryClient } from "react-query"; |
||||
|
import { Link } from "react-router-dom"; |
||||
|
import { AccessToken } from "@l2-technology/sensei-client"; |
||||
|
import formatDistanceToNow from "date-fns/formatDistanceToNow"; |
||||
|
|
||||
|
const SimpleColumn = ({ value, className }) => { |
||||
|
return ( |
||||
|
<td |
||||
|
className={`px-6 py-4 whitespace-nowrap text-sm leading-5 font-medium text-light-plum ${className}`} |
||||
|
> |
||||
|
{value} |
||||
|
</td> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const ActionsColumn = ({ value, token, className }) => { |
||||
|
const { showConfirm } = useConfirm(); |
||||
|
const queryClient = useQueryClient(); |
||||
|
|
||||
|
const deleteTokenClicked = () => { |
||||
|
showConfirm({ |
||||
|
title: "Are you sure you want to delete this token?", |
||||
|
description: |
||||
|
"A deleted token can no longer be used to make authenticated requests", |
||||
|
ctaText: "Yes, delete it", |
||||
|
callback: async () => { |
||||
|
await deleteAccessToken(token.id); |
||||
|
queryClient.invalidateQueries("tokens"); |
||||
|
}, |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<td |
||||
|
className={`px-6 py-4 whitespace-nowrap text-sm leading-5 font-medium text-light-plum ${className}`} |
||||
|
> |
||||
|
<TrashIcon |
||||
|
className="inline-block h-6 cursor-pointer" |
||||
|
onClick={deleteTokenClicked} |
||||
|
/> |
||||
|
</td> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const StatusColumn = ({ token, value, className }) => { |
||||
|
const expiresAt = parseInt(token.expiresAt, 10); |
||||
|
const now = new Date().getTime(); |
||||
|
const expired = expiresAt > 0 && expiresAt < now; |
||||
|
|
||||
|
return ( |
||||
|
<td |
||||
|
className={`px-6 py-4 whitespace-nowrap text-sm leading-5 font-medium text-light-plum ${className}`} |
||||
|
> |
||||
|
{expired && ( |
||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-200 text-red-800"> |
||||
|
Expired |
||||
|
</span> |
||||
|
)} |
||||
|
{!expired && expiresAt === 0 && ( |
||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> |
||||
|
Active |
||||
|
</span> |
||||
|
)} |
||||
|
{!expired && expiresAt > 0 && ( |
||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> |
||||
|
Expires in {formatDistanceToNow(expiresAt)} |
||||
|
</span> |
||||
|
)} |
||||
|
</td> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const SingleUseColumn = ({ value, className }) => { |
||||
|
return ( |
||||
|
<td |
||||
|
className={`px-6 py-4 whitespace-nowrap text-sm leading-5 font-medium text-light-plum ${className}`} |
||||
|
> |
||||
|
{value && <span className="">Single</span>} |
||||
|
{!value && <span className="">Unlimited</span>} |
||||
|
</td> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const TokenColumn = ({ token, value, className }) => { |
||||
|
let [copied, setCopied] = useState(false); |
||||
|
|
||||
|
const copyClicked = () => { |
||||
|
copy(token.token); |
||||
|
setCopied(true); |
||||
|
setTimeout(() => { |
||||
|
setCopied(false); |
||||
|
}, 1000); |
||||
|
}; |
||||
|
|
||||
|
return copied ? ( |
||||
|
<td |
||||
|
className={`px-6 py-4 whitespace-nowrap text-sm leading-5 font-medium text-light-plum ${className}`} |
||||
|
> |
||||
|
Copied! |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
</td> |
||||
|
) : ( |
||||
|
<td |
||||
|
onClick={copyClicked} |
||||
|
className={`group cursor-pointer px-6 py-4 whitespace-nowrap text-sm leading-5 font-medium text-light-plum ${className}`} |
||||
|
> |
||||
|
{truncateMiddle(value, 10)}{" "} |
||||
|
<span className="inline-block group-hover:hidden"> |
||||
|
|
||||
|
</span> |
||||
|
<ClipboardCopyIcon className="w-4 text-gray-500 hidden group-hover:inline-block" /> |
||||
|
</td> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const TokenRow = ({ result, extraClass, attributes }) => { |
||||
|
let columnKeyComponentMap = { |
||||
|
singleUse: SingleUseColumn, |
||||
|
token: TokenColumn, |
||||
|
actions: ActionsColumn, |
||||
|
status: StatusColumn, |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<tr className={`border-b border-plum-200 ${extraClass}`}> |
||||
|
{attributes.map(({ key, label, className }) => { |
||||
|
let value = result[key]; |
||||
|
let ColumnComponent = columnKeyComponentMap[key] |
||||
|
? columnKeyComponentMap[key] |
||||
|
: SimpleColumn; |
||||
|
|
||||
|
return ( |
||||
|
<ColumnComponent |
||||
|
key={key} |
||||
|
token={result} |
||||
|
value={value} |
||||
|
className={className} |
||||
|
/> |
||||
|
); |
||||
|
})} |
||||
|
</tr> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const TokensListCard = () => { |
||||
|
const emptyTableHeadline = "No tokens found"; |
||||
|
const emptyTableSubtext = "Try changing the search term"; |
||||
|
const searchBarPlaceholder = "Search"; |
||||
|
|
||||
|
const attributes = [ |
||||
|
{ |
||||
|
key: "name", |
||||
|
label: "Name", |
||||
|
}, |
||||
|
{ |
||||
|
key: "token", |
||||
|
label: "Token", |
||||
|
}, |
||||
|
{ |
||||
|
key: "scope", |
||||
|
label: "Scope", |
||||
|
}, |
||||
|
{ |
||||
|
key: "singleUse", |
||||
|
label: "Usage Limit", |
||||
|
}, |
||||
|
{ |
||||
|
key: "status", |
||||
|
label: "Status", |
||||
|
}, |
||||
|
{ |
||||
|
key: "actions", |
||||
|
label: "Actions", |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
const transformResults = (tokens: AccessToken[]) => { |
||||
|
return tokens.map((token) => { |
||||
|
return { |
||||
|
...token, |
||||
|
actions: "Action", |
||||
|
status: "Status", |
||||
|
}; |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const queryFunction = async ({ queryKey }) => { |
||||
|
const [_key, { page, searchTerm, take }] = queryKey; |
||||
|
const response = await getAccessTokens({ page, searchTerm, take }); |
||||
|
return { |
||||
|
results: transformResults(response.tokens), |
||||
|
hasMore: response.pagination.hasMore, |
||||
|
total: response.pagination.total, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<SearchableTable |
||||
|
attributes={attributes} |
||||
|
queryKey="tokens" |
||||
|
queryFunction={queryFunction} |
||||
|
emptyTableHeadline={emptyTableHeadline} |
||||
|
emptyTableSubtext={emptyTableSubtext} |
||||
|
searchBarPlaceholder={searchBarPlaceholder} |
||||
|
hasHeader |
||||
|
itemsPerPage={5} |
||||
|
RowComponent={TokenRow} |
||||
|
/> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default TokensListCard; |
@ -0,0 +1,7 @@ |
|||||
|
import sensei from "../../utils/sensei" |
||||
|
|
||||
|
const createAccessToken = async (name: string, scope: string, expiresAt: number, singleUse: boolean) => { |
||||
|
return sensei.createAccessToken({ name, scope, expiresAt, singleUse }) |
||||
|
} |
||||
|
|
||||
|
export default createAccessToken |
@ -0,0 +1,7 @@ |
|||||
|
import sensei from "../../utils/sensei"; |
||||
|
|
||||
|
const deleteAccessToken = async (id: number) => { |
||||
|
return await sensei.deleteAccessToken(id) |
||||
|
} |
||||
|
|
||||
|
export default deleteAccessToken |
@ -0,0 +1,34 @@ |
|||||
|
import NewTokenForm from "../components/NewTokenForm"; |
||||
|
import { Link } from "react-router-dom"; |
||||
|
|
||||
|
const NewTokenPage = () => { |
||||
|
return ( |
||||
|
<div className="py-12"> |
||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
||||
|
<div className="pb-5 border-b border-gray-400 sm:flex sm:items-center sm:justify-between"> |
||||
|
<h3 className="text-2xl leading-6 font-medium text-light-plum"> |
||||
|
Create Token |
||||
|
</h3> |
||||
|
|
||||
|
<div className="mt-3 sm:mt-0 sm:ml-4"> |
||||
|
<Link |
||||
|
to="/admin/tokens" |
||||
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-gray-900 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-50" |
||||
|
> |
||||
|
Cancel |
||||
|
</Link> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8"> |
||||
|
<div className="py-4"> |
||||
|
<div className="bg-plum-100 text-light-plum shadow p-4 rounded-lg"> |
||||
|
<NewTokenForm /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default NewTokenPage; |
@ -0,0 +1,31 @@ |
|||||
|
import TokensListCard from "../components/TokensList"; |
||||
|
import { Link } from "react-router-dom"; |
||||
|
|
||||
|
const TokensPage = () => { |
||||
|
return ( |
||||
|
<div className="py-12"> |
||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
||||
|
<div className="pb-5 border-b border-plum-200 sm:flex sm:items-center sm:justify-between"> |
||||
|
<h3 className="text-2xl leading-6 font-medium text-light-plum"> |
||||
|
Tokens |
||||
|
</h3> |
||||
|
<div className="mt-3 sm:mt-0 sm:ml-4"> |
||||
|
<Link |
||||
|
to="/admin/tokens/new" |
||||
|
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-orange hover:bg-orange-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-200" |
||||
|
> |
||||
|
Create new token |
||||
|
</Link> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8"> |
||||
|
<div className="py-4"> |
||||
|
<TokensListCard /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
export default TokensPage; |
@ -0,0 +1,7 @@ |
|||||
|
import sensei from "../../utils/sensei" |
||||
|
|
||||
|
const getAccesstokens = async ({ page, searchTerm, take }) => { |
||||
|
return await sensei.getAccessTokens({page, searchTerm, take }) |
||||
|
} |
||||
|
|
||||
|
export default getAccesstokens |
Loading…
Reference in new issue