From 279437ba082ffe7315dbffa8af7f714ede8c3f84 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Sat, 15 Jan 2022 22:09:15 -0500 Subject: [PATCH] List users on admin page (#28) * Admin page using gridjs * Move hook into separate file * Link to each user's Manifold and Firestore /user entry * Gate admin access to Austin/James/Stephen * Don't leak the existence of /admin * Add a custom 404 page that directs to Discord. * Fix broken window.location.href on NextJS server --- web/hooks/use-users.ts | 12 ++++ web/lib/firebase/users.ts | 14 ++++ web/package.json | 2 + web/pages/404.tsx | 24 +++++++ web/pages/[username]/[contractSlug].tsx | 3 +- web/pages/[username]/index.tsx | 6 +- web/pages/admin.tsx | 91 +++++++++++++++++++++++++ web/yarn.lock | 20 +++++- 8 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 web/hooks/use-users.ts create mode 100644 web/pages/404.tsx create mode 100644 web/pages/admin.tsx diff --git a/web/hooks/use-users.ts b/web/hooks/use-users.ts new file mode 100644 index 00000000..6d17be86 --- /dev/null +++ b/web/hooks/use-users.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react' +import { listenForAllUsers, User } from '../lib/firebase/users' + +export const useUsers = () => { + const [users, setUsers] = useState([]) + + useEffect(() => { + listenForAllUsers(setUsers) + }, []) + + return users +} diff --git a/web/lib/firebase/users.ts b/web/lib/firebase/users.ts index 7ee32430..1311bdd4 100644 --- a/web/lib/firebase/users.ts +++ b/web/lib/firebase/users.ts @@ -20,6 +20,7 @@ import { } from 'firebase/auth' import { User } from '../../../common/user' +import { listenForValues } from './utils' export type { User } export const STARTING_BALANCE = 1000 @@ -111,3 +112,16 @@ export async function uploadData( await uploadBytes(uploadRef, data, metadata) return await getDownloadURL(uploadRef) } + +export async function listAllUsers() { + const userCollection = collection(db, 'users') + const q = query(userCollection) + const docs = await getDocs(q) + return docs.docs.map((doc) => doc.data() as User) +} + +export function listenForAllUsers(setUsers: (users: User[]) => void) { + const userCollection = collection(db, 'users') + const q = query(userCollection) + listenForValues(q, setUsers) +} diff --git a/web/package.json b/web/package.json index a13eee2b..9bcede3d 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,8 @@ "daisyui": "1.16.4", "dayjs": "1.10.7", "firebase": "9.6.0", + "gridjs": "^5.0.2", + "gridjs-react": "^5.0.2", "lodash": "4.17.21", "next": "12.0.7", "react": "17.0.2", diff --git a/web/pages/404.tsx b/web/pages/404.tsx new file mode 100644 index 00000000..efc4eb10 --- /dev/null +++ b/web/pages/404.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'gridjs' +import { Page } from '../components/page' +import { Title } from '../components/title' + +export default function Custom404() { + return ( + +
+ + <p>Nothing exists at this location.</p> + <p>If you didn't expect this, let us know on Discord!</p> + <br /> + <iframe + src="https://discord.com/widget?id=915138780216823849&theme=dark" + width="350" + height="500" + allowTransparency={true} + frameBorder="0" + sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts" + ></iframe> + </div> + </Page> + ) +} diff --git a/web/pages/[username]/[contractSlug].tsx b/web/pages/[username]/[contractSlug].tsx index 105ec9c3..2d192390 100644 --- a/web/pages/[username]/[contractSlug].tsx +++ b/web/pages/[username]/[contractSlug].tsx @@ -21,6 +21,7 @@ import { Page } from '../../components/page' import { contractTextDetails } from '../../components/contract-card' import { Bet, listAllBets } from '../../lib/firebase/bets' import { Comment, listAllComments } from '../../lib/firebase/comments' +import Custom404 from '../404' export async function getStaticProps(props: { params: any }) { const { username, contractSlug } = props.params @@ -62,7 +63,7 @@ export default function ContractPage(props: { const { bets, comments } = props if (!contract) { - return <div>Contract not found...</div> + return <Custom404 /> } const { creatorId, isResolved, resolution, question } = contract diff --git a/web/pages/[username]/index.tsx b/web/pages/[username]/index.tsx index 8ff821f4..de88d52a 100644 --- a/web/pages/[username]/index.tsx +++ b/web/pages/[username]/index.tsx @@ -1,10 +1,10 @@ import { useRouter } from 'next/router' import React, { useEffect, useState } from 'react' -import Error from 'next/error' import { getUserByUsername, User } from '../../lib/firebase/users' import { UserPage } from '../../components/user-page' import { useUser } from '../../hooks/use-user' +import Custom404 from '../404' export default function UserProfile() { const router = useRouter() @@ -18,13 +18,11 @@ export default function UserProfile() { const currentUser = useUser() - const errorMessage = `Who is this "${username}" you speak of..` - if (user === 'loading') return <></> return user ? ( <UserPage user={user} currentUser={currentUser || undefined} /> ) : ( - <Error statusCode={404} title={errorMessage} /> + <Custom404 /> ) } diff --git a/web/pages/admin.tsx b/web/pages/admin.tsx new file mode 100644 index 00000000..baea0c8b --- /dev/null +++ b/web/pages/admin.tsx @@ -0,0 +1,91 @@ +import { Page } from '../components/page' +import { Grid } from 'gridjs-react' +import 'gridjs/dist/theme/mermaid.css' +import { html } from 'gridjs' +import dayjs from 'dayjs' +import { useUsers } from '../hooks/use-users' +import { useUser } from '../hooks/use-user' +import Error from 'next/error' +import Custom404 from './404' + +function avatarHtml(avatarUrl: string) { + return `<img + class="h-10 w-10 rounded-full bg-gray-400 flex items-center justify-center" + src="${avatarUrl}" + alt="" +/>` +} + +function UsersTable() { + let users = useUsers() + // Sort users by createdTime descending, by default + users = users.sort((a, b) => b.createdTime - a.createdTime) + + return ( + <Grid + data={users} + columns={[ + { + id: 'avatarUrl', + name: 'Avatar', + formatter: (cell) => html(avatarHtml(cell as string)), + }, + { + id: 'username', + name: 'Username', + formatter: (cell) => + html(`<a + class="hover:underline hover:decoration-indigo-400 hover:decoration-2" + href="/${cell}">@${cell}</a>`), + }, + 'Email', + { + id: 'createdTime', + name: 'Created Time', + formatter: (cell) => + html( + `<span class="whitespace-nowrap">${dayjs(cell as number).format( + 'MMM D, h:mma' + )}</span>` + ), + }, + { + id: 'balance', + name: 'Balance', + formatter: (cell) => (cell as number).toFixed(0), + }, + { + id: 'id', + name: 'ID', + formatter: (cell) => + html(`<a + class="hover:underline hover:decoration-indigo-400 hover:decoration-2" + href="https://console.firebase.google.com/project/mantic-markets/firestore/data/~2Fusers~2F${cell}">${cell}</a>`), + }, + ]} + search={true} + sort={true} + pagination={{ + enabled: true, + limit: 25, + }} + /> + ) +} + +export default function Admin() { + const user = useUser() + const adminIds = [ + 'igi2zGXsfxYPgB0DJTXVJVmwCOr2', // Austin + '5LZ4LgYuySdL1huCWe7bti02ghx2', // James + 'tlmGNz9kjXc2EteizMORes4qvWl2', // Stephen + ] + const isAdmin = adminIds.includes(user?.id || '') + return isAdmin ? ( + <Page wide> + <UsersTable /> + </Page> + ) : ( + <Custom404 /> + ) +} diff --git a/web/yarn.lock b/web/yarn.lock index abff3d3e..7f9cd557 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -2597,6 +2597,19 @@ graceful-fs@^4.1.2: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== +gridjs-react@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/gridjs-react/-/gridjs-react-5.0.2.tgz#5c561b89a391615fc8242223649e56808fcdd824" + integrity sha512-K8bEUL8MuRRbu6o9hCc/bn+H7UfstN0yRIb/UMzVIbJ7ROR0ns9UA63mr4xcMP5keqolCPuLuM1manlH17BZCA== + +gridjs@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/gridjs/-/gridjs-5.0.2.tgz#27c4bd7399d07770e68b45bcca20c3b1fea828b6" + integrity sha512-7EaMc4/IhqRcSbdOYvHOlHETciWmbbhMU6rDr2e0UfzIXSkb7X3Lhf9+bGv+v4JaWnJW5GBillgIIHrAvIIoFg== + dependencies: + preact "^10.5.12" + tslib "^2.0.1" + has-bigints@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113" @@ -3771,6 +3784,11 @@ postcss@^8.1.6, postcss@^8.3.7: picocolors "^1.0.0" source-map-js "^1.0.1" +preact@^10.5.12: + version "10.6.4" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.6.4.tgz#ad12c409ff1b4316158486e0a7b8d43636f7ced8" + integrity sha512-WyosM7pxGcndU8hY0OQlLd54tOU+qmG45QXj2dAYrL11HoyU/EzOSTlpJsirbBr1QW7lICxSsVJJmcmUglovHQ== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -4621,7 +4639,7 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.3, tslib@^2.1.0: +tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==