From dda3513d9ce3b784bf5962a4aede7f0eaac1d98b Mon Sep 17 00:00:00 2001 From: Thomas Schoffelen Date: Sun, 6 Oct 2024 12:45:30 +0100 Subject: [PATCH] feat: finish user management --- packages/api/src/routes/auth/index.js | 1 + packages/api/src/routes/users/index.js | 122 ++++++++++- packages/dashboard/package.json | 1 + .../src/components/dialogs/create-user.tsx | 96 +++++++++ .../src/components/dialogs/delete-user.tsx | 76 +++++++ .../dashboard/src/components/ui/toast.tsx | 127 ++++++++++++ .../dashboard/src/components/ui/toaster.tsx | 33 +++ packages/dashboard/src/hooks/use-toast.ts | 191 ++++++++++++++++++ packages/dashboard/src/lib/api.ts | 21 +- packages/dashboard/src/main.tsx | 2 + .../dashboard/src/routes/users/columns.tsx | 15 +- packages/dashboard/src/routes/users/page.tsx | 4 +- yarn.lock | 107 ++++++++++ 13 files changed, 778 insertions(+), 18 deletions(-) create mode 100644 packages/dashboard/src/components/dialogs/create-user.tsx create mode 100644 packages/dashboard/src/components/dialogs/delete-user.tsx create mode 100644 packages/dashboard/src/components/ui/toast.tsx create mode 100644 packages/dashboard/src/components/ui/toaster.tsx create mode 100644 packages/dashboard/src/hooks/use-toast.ts diff --git a/packages/api/src/routes/auth/index.js b/packages/api/src/routes/auth/index.js index 8a4edfc..93d4f26 100644 --- a/packages/api/src/routes/auth/index.js +++ b/packages/api/src/routes/auth/index.js @@ -33,6 +33,7 @@ app.post("/login", async (c) => { await put( { pk: `access-token#${id}`, + type: 'access-token', sk: user.pk, accessTokenType: "dashboard", }, diff --git a/packages/api/src/routes/users/index.js b/packages/api/src/routes/users/index.js index e86b532..536725f 100644 --- a/packages/api/src/routes/users/index.js +++ b/packages/api/src/routes/users/index.js @@ -1,10 +1,23 @@ import { Hono } from "hono"; -import { queryAll } from "../../lib/database"; +import bcrypt from "bcryptjs"; + +import { deleteItem, put, query, queryAll, update } from "../../lib/database"; const app = new Hono(); -app.get("/", async (c) => { - const items = await queryAll({ +const getUser = async (username) => { + const { Items } = await query({ + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { + ":pk": `user#${username}`, + }, + }); + + return Items?.[0]; +}; + +const getAllUsers = async () => + queryAll({ KeyConditionExpression: "#type = :type", ExpressionAttributeNames: { "#type": "type", @@ -15,6 +28,23 @@ app.get("/", async (c) => { IndexName: "type-sk", }); +const getAllAccessTokens = (username) => + queryAll({ + KeyConditionExpression: "#type = :type AND #sk = :sk", + ExpressionAttributeNames: { + "#type": "type", + "#sk": "sk", + }, + ExpressionAttributeValues: { + ":type": "access-token", + ":sk": `user#${username}`, + }, + IndexName: "type-sk", + }); + +app.get("/", async (c) => { + const items = await getAllUsers(); + return c.json( items.map((item) => ({ ...item, @@ -25,15 +55,95 @@ app.get("/", async (c) => { }); app.post("/", async (c) => { - return c.json({ message: "Hello, World!" }); + const body = await c.req.json(); + + if (!body.username) { + return c.json({ error: "Username is required" }, 400); + } + if (!body.password) { + return c.json({ error: "Password is required" }, 400); + } + if (body.password.length < 5) { + return c.json({ error: "Password must be at least 5 characters" }, 400); + } + + const existingUser = await getUser(body.username); + if (existingUser) { + return c.json({ error: "A user with this username already exists" }, 400); + } + + await put({ + pk: `user#${body.username}`, + sk: "user", + type: "user", + name: body.username, + passwordHash: bcrypt.hashSync(body.password), + }); + + return c.json({ ok: true }, 201); }); app.post("/:userId", async (c) => { - return c.json({ message: "Hello, World!" }); + const body = await c.req.json(); + + const existingUser = await getUser(body.username); + if (!existingUser) { + return c.json({ error: "User not found" }, 400); + } + + if (!body.password) { + return c.json({ error: "Password is required" }, 400); + } + if (body.password.length < 5) { + return c.json({ error: "Password must be at least 5 characters" }, 400); + } + + await update({ + Key: { + pk: existingUser.pk, + sk: existingUser.sk, + }, + UpdateExpression: "SET #passwordHash = :passwordHash", + ExpressionAttributeValues: { + ":passwordHash": bcrypt.hashSync(body.password), + }, + ExpressionAttributeNames: { + "#passwordHash": "passwordHash", + }, + }); + + return c.json({ ok: true }); }); app.delete("/:userId", async (c) => { - return c.json({ message: "Hello, World!" }); + const existingUser = await getUser(c.req.param("userId")); + if (!existingUser) { + return c.json({ error: "This user does not exist" }, 404); + } + + const allUsers = await getAllUsers(); + if (allUsers.length < 2) { + return c.json({ error: "You cannot delete the last user" }, 400); + } + + // Delete user + await deleteItem({ + pk: existingUser.pk, + sk: existingUser.sk, + }); + + // Delete their access tokens + const accessTokens = await getAllAccessTokens(existingUser.name); + console.log("accessTokens", accessTokens); + + for (const token of accessTokens) { + await deleteItem({ + pk: token.pk, + sk: token.sk, + }); + } + + return c.json({ ok: true }); }); export default app; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index d893d2e..8b66d1c 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.2", "@tanstack/react-table": "^8.20.5", "@xyflow/react": "^12.3.0", diff --git a/packages/dashboard/src/components/dialogs/create-user.tsx b/packages/dashboard/src/components/dialogs/create-user.tsx new file mode 100644 index 0000000..47e7906 --- /dev/null +++ b/packages/dashboard/src/components/dialogs/create-user.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import { useToast } from "@/hooks/use-toast"; +import { authenticatedFetch } from "@/lib/api"; + +export function CreateUser({ mutate }) { + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const { toast } = useToast(); + + const submit = async (event) => { + event.preventDefault(); + + const username = event.target.querySelector("#username").value; + const password = event.target.querySelector("#password").value; + + setLoading(true); + try { + await authenticatedFetch("/users", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + + await mutate(); + + toast({ description: "User created" }); + setOpen(false); + } catch (error: any) { + toast({ + title: "An error occurred", + description: error.message, + variant: "destructive", + }); + } + setLoading(false); + }; + + return ( + + + + + +
+ + Create user + Add a new user to your team. + +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ ); +} diff --git a/packages/dashboard/src/components/dialogs/delete-user.tsx b/packages/dashboard/src/components/dialogs/delete-user.tsx new file mode 100644 index 0000000..d985433 --- /dev/null +++ b/packages/dashboard/src/components/dialogs/delete-user.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { TrashIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +import { useToast } from "@/hooks/use-toast"; +import { authenticatedFetch, useData } from "@/lib/api"; + +export function DeleteUser({ id }) { + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + + const { mutate } = useData(`../users`); + const { toast } = useToast(); + + const submit = async (event) => { + event.preventDefault(); + + setLoading(true); + try { + await authenticatedFetch(`/users/${id}`, { + method: "DELETE", + }); + + await mutate(); + setOpen(false); + + toast({ description: "User deleted" }); + } catch (error: any) { + toast({ + title: "An error occurred", + description: error.message, + variant: "destructive", + }); + } + setLoading(false); + }; + + return ( + + +
+ +
+
+ +
+ + Are you absolutely sure? + + Are you sure you want to remove this user? This action can't be + undone. + + + + + +
+
+
+ ); +} diff --git a/packages/dashboard/src/components/ui/toast.tsx b/packages/dashboard/src/components/ui/toast.tsx new file mode 100644 index 0000000..a822477 --- /dev/null +++ b/packages/dashboard/src/components/ui/toast.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/packages/dashboard/src/components/ui/toaster.tsx b/packages/dashboard/src/components/ui/toaster.tsx new file mode 100644 index 0000000..6c67edf --- /dev/null +++ b/packages/dashboard/src/components/ui/toaster.tsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/packages/dashboard/src/hooks/use-toast.ts b/packages/dashboard/src/hooks/use-toast.ts new file mode 100644 index 0000000..2c14125 --- /dev/null +++ b/packages/dashboard/src/hooks/use-toast.ts @@ -0,0 +1,191 @@ +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/packages/dashboard/src/lib/api.ts b/packages/dashboard/src/lib/api.ts index 08c9cd2..987607c 100644 --- a/packages/dashboard/src/lib/api.ts +++ b/packages/dashboard/src/lib/api.ts @@ -8,12 +8,19 @@ const BASE_URL = window.location.href.includes("localhost:") export const API_URL = `${BASE_URL}/api/explore`; export const API_AUTH_URL = `${BASE_URL}/api/auth/login`; -export const authenticatedFetch = async (url: string, options: RequestInit = {}) => { +export const authenticatedFetch = async ( + url: string, + options: RequestInit = {}, +) => { const token = localStorage.getItem("token"); if (!token) { throw new Error("Unauthorized"); } + if (url.startsWith("/")) { + url = `${BASE_URL}/api` + url; + } + const res = await fetch(url, { ...options, headers: { @@ -22,11 +29,21 @@ export const authenticatedFetch = async (url: string, options: RequestInit = {}) }, }); - if(res.status === 401) { + if (res.status === 401) { localStorage.removeItem("token"); throw new Error("Unauthorized"); } + if (res.status === 400 || res.status === 500 || res.status === 404) { + let json; + try { + json = await res.json(); + } catch (e) { + return res; + } + if (json.error) throw new Error(json.error); + } + return res; }; diff --git a/packages/dashboard/src/main.tsx b/packages/dashboard/src/main.tsx index 9dbf7ed..7e47bc0 100644 --- a/packages/dashboard/src/main.tsx +++ b/packages/dashboard/src/main.tsx @@ -18,6 +18,7 @@ import Users from "@/routes/users/page"; import { ThemeProvider } from "@/components/layout/theme-provider"; import { DateRangeProvider } from "@/components/layout/date-picker"; import { PrivateRoutes } from "@/components/auth/private-routes"; +import { Toaster } from "@/components/ui/toaster"; const router = createBrowserRouter([ { @@ -59,5 +60,6 @@ createRoot(document.getElementById("root")).render( + , ); diff --git a/packages/dashboard/src/routes/users/columns.tsx b/packages/dashboard/src/routes/users/columns.tsx index 7ec9dde..83f7b4e 100644 --- a/packages/dashboard/src/routes/users/columns.tsx +++ b/packages/dashboard/src/routes/users/columns.tsx @@ -1,8 +1,7 @@ import { ColumnDef } from "@tanstack/react-table"; -import { TrashIcon } from "lucide-react"; import { format } from "date-fns"; -import { Button } from "@/components/ui/button"; +import { DeleteUser } from "@/components/dialogs/delete-user"; export type UserItem = { name: string; @@ -16,7 +15,7 @@ export const columns: ColumnDef[] = [ header: "Username", cell: ({ row }) => { const name = row.getValue("name") as string; - return {name}; + return {name}; }, filterFn: (row, id, value) => row.getValue("name")?.toLowerCase().includes(value.toLowerCase()), @@ -25,6 +24,9 @@ export const columns: ColumnDef[] = [ accessorKey: "lastSeen", header: "Last seen", cell: ({ row }) => { + const lastSeen = row.getValue("lastSeen"); + + if (!lastSeen) return "Never"; return format(new Date(row.getValue("lastSeen")), "MMM d, yyyy"); }, }, @@ -34,12 +36,7 @@ export const columns: ColumnDef[] = [ enableSorting: false, cell: ({ row }) => { return ( -
- -
+ ); }, }, diff --git a/packages/dashboard/src/routes/users/page.tsx b/packages/dashboard/src/routes/users/page.tsx index 2ce4650..7843318 100644 --- a/packages/dashboard/src/routes/users/page.tsx +++ b/packages/dashboard/src/routes/users/page.tsx @@ -1,9 +1,10 @@ import { DataTable } from "@/components/tables/data-table"; import { columns } from "./columns"; import { useData } from "@/lib/api"; +import { CreateUser } from "@/components/dialogs/create-user"; const Users = () => { - const { data } = useData(`../users`, { suspense: true }); + const { data, mutate } = useData(`../users`, { suspense: true }); return (
@@ -11,6 +12,7 @@ const Users = () => {

Manage who has access to the TraceStack dashboard.

+