Browse Source

add simple token management in web admin

patch-1
John Cantrell 3 years ago
parent
commit
502ae9f920
  1. 3
      src/grpc/admin.rs
  2. 2
      src/main.rs
  3. 14
      web-admin/package-lock.json
  4. 2
      web-admin/package.json
  5. 4
      web-admin/src/App.tsx
  6. 19
      web-admin/src/layouts/AppLayout.tsx
  7. 88
      web-admin/src/tokens/components/NewTokenForm.tsx
  8. 226
      web-admin/src/tokens/components/TokensList.tsx
  9. 7
      web-admin/src/tokens/mutations/createAccessToken.ts
  10. 7
      web-admin/src/tokens/mutations/deleteAccessToken.ts
  11. 34
      web-admin/src/tokens/pages/NewTokenPage.tsx
  12. 31
      web-admin/src/tokens/pages/TokensPage.tsx
  13. 7
      web-admin/src/tokens/queries/getAccessTokens.ts

3
src/grpc/admin.rs

@ -316,6 +316,9 @@ pub fn get_scope_from_request(request: &AdminRequest) -> Option<&'static str> {
AdminRequest::ListNodes { .. } => Some("nodes/list"),
AdminRequest::DeleteNode { .. } => Some("nodes/delete"),
AdminRequest::StopNode { .. } => Some("nodes/stop"),
AdminRequest::ListTokens { .. } => Some("tokens/list"),
AdminRequest::CreateToken { .. } => Some("tokens/create"),
AdminRequest::DeleteToken { .. } => Some("tokens/delete"),
_ => None,
}
}

2
src/main.rs

@ -166,7 +166,7 @@ async fn main() {
let router = add_node_routes(router);
let origins = vec![
"http://localhost:3000".parse().unwrap(),
"http://localhost:3001".parse().unwrap(),
"http://localhost:5401".parse().unwrap(),
];
let http_service = router

14
web-admin/package-lock.json

@ -10,7 +10,7 @@
"dependencies": {
"@headlessui/react": "^1.4.2",
"@heroicons/react": "^1.0.5",
"@l2-technology/sensei-client": "^0.1.4",
"@l2-technology/sensei-client": "^0.1.7",
"@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.0",
@ -3188,9 +3188,9 @@
}
},
"node_modules/@l2-technology/sensei-client": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@l2-technology/sensei-client/-/sensei-client-0.1.4.tgz",
"integrity": "sha512-G1/1EmSB3J18MpMG0WJ6gOyHTAPSe8xaGVEOmJ5IyZ8npDCcRzCl0JZC6dMAQzzkdO2umo9xU0QLeW3rCgn/Ug==",
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@l2-technology/sensei-client/-/sensei-client-0.1.7.tgz",
"integrity": "sha512-qEZGbxTu7MhWU9UKBa4EVWYCMLao5u/ztbmNF4mihPXQiW0vp9z320O1zLsOwV1KH+t+agpX9Mi2E0SIOJvxjw==",
"dependencies": {
"cross-fetch": "^3.1.5"
}
@ -25727,9 +25727,9 @@
}
},
"@l2-technology/sensei-client": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@l2-technology/sensei-client/-/sensei-client-0.1.4.tgz",
"integrity": "sha512-G1/1EmSB3J18MpMG0WJ6gOyHTAPSe8xaGVEOmJ5IyZ8npDCcRzCl0JZC6dMAQzzkdO2umo9xU0QLeW3rCgn/Ug==",
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/@l2-technology/sensei-client/-/sensei-client-0.1.7.tgz",
"integrity": "sha512-qEZGbxTu7MhWU9UKBa4EVWYCMLao5u/ztbmNF4mihPXQiW0vp9z320O1zLsOwV1KH+t+agpX9Mi2E0SIOJvxjw==",
"requires": {
"cross-fetch": "^3.1.5"
}

2
web-admin/package.json

@ -6,7 +6,7 @@
"dependencies": {
"@headlessui/react": "^1.4.2",
"@heroicons/react": "^1.0.5",
"@l2-technology/sensei-client": "^0.1.4",
"@l2-technology/sensei-client": "^0.1.7",
"@tailwindcss/aspect-ratio": "^0.4.0",
"@tailwindcss/forms": "^0.4.0",
"@tailwindcss/typography": "^0.5.0",

4
web-admin/src/App.tsx

@ -23,6 +23,8 @@ import ErrorModal from "./components/layout/app/ErrorModal";
import NotificationContainer from "./components/layout/app/NotificationContainer";
import OpenChannelPage from "./channels/pages/OpenChannelPage";
import LogoutPage from "./auth/pages/LogoutPage";
import TokensPage from "./tokens/pages/TokensPage";
import NewTokenPage from "./tokens/pages/NewTokenPage";
function App() {
const { isLoading, data } = useQuery("status", getStatus, {
@ -58,6 +60,8 @@ function App() {
<Route path="/admin/peers" element={<PeersPage />} />
<Route path="/admin/nodes" element={<NodesPage />} />
<Route path="/admin/nodes/new" element={<NewNodePage />} />
<Route path="/admin/tokens" element={<TokensPage />} />
<Route path="/admin/tokens/new" element={<NewTokenPage />} />
</Route>
</Routes>
<Modal />

19
web-admin/src/layouts/AppLayout.tsx

@ -12,7 +12,8 @@ import {
CollectionIcon,
ShoppingCartIcon,
QrcodeIcon,
LinkIcon
LinkIcon,
KeyIcon,
} from "@heroicons/react/outline";
import { NavLink } from "react-router-dom";
@ -23,7 +24,7 @@ import { useAuth } from "../contexts/auth";
const AppLayout = () => {
const auth = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const navigation = []
const navigation = [];
if (auth.isAdmin()) {
navigation.push({
@ -37,15 +38,13 @@ const AppLayout = () => {
name: "Fund Node",
href: "/admin/fund",
icon: QrcodeIcon,
})
});
navigation.push(
{
navigation.push({
name: "Chain",
href: "/admin/chain",
icon: LinkIcon,
},
);
});
navigation.push({
name: "Channels",
@ -65,6 +64,12 @@ const AppLayout = () => {
icon: CashIcon,
});
navigation.push({
name: "Access Tokens",
href: "/admin/tokens",
icon: KeyIcon,
});
navigation.push({ name: "Logout", href: "/admin/logout", icon: LogoutIcon });
return (

88
web-admin/src/tokens/components/NewTokenForm.tsx

@ -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;

226
web-admin/src/tokens/components/TokensList.tsx

@ -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! &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</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">
&nbsp;&nbsp;&nbsp;&nbsp;
</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;

7
web-admin/src/tokens/mutations/createAccessToken.ts

@ -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

7
web-admin/src/tokens/mutations/deleteAccessToken.ts

@ -0,0 +1,7 @@
import sensei from "../../utils/sensei";
const deleteAccessToken = async (id: number) => {
return await sensei.deleteAccessToken(id)
}
export default deleteAccessToken

34
web-admin/src/tokens/pages/NewTokenPage.tsx

@ -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;

31
web-admin/src/tokens/pages/TokensPage.tsx

@ -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;

7
web-admin/src/tokens/queries/getAccessTokens.ts

@ -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…
Cancel
Save