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