From 61d64c1293a53dd15035f0027d9ef40d6887e432 Mon Sep 17 00:00:00 2001 From: Bill Yang <45103519+goldflag@users.noreply.github.com> Date: Wed, 19 Feb 2025 21:00:55 -0500 Subject: [PATCH] add new user (#18) --- client/package-lock.json | 1 + client/package.json | 1 + client/src/app/settings/page.tsx | 41 ++--- client/src/app/settings/settings/settings.tsx | 44 +++++ client/src/app/settings/users/AddUser.tsx | 154 ++++++++++++++++++ client/src/app/settings/users/Users.tsx | 79 +++++++++ client/src/components/TopBar.tsx | 61 ++++++- client/src/components/ui/switch.tsx | 29 ++++ client/src/components/ui/table.tsx | 127 +++++++++++++++ client/src/hooks/api.ts | 15 ++ client/src/lib/auth.ts | 4 +- client/tailwind.config.ts | 2 +- server/src/actions/sites/addSite.ts | 1 - server/src/api/listUsers.ts | 23 +++ server/src/db/postgres/postgres.ts | 6 +- server/src/index.ts | 10 +- server/src/lib/auth.ts | 30 +++- 17 files changed, 586 insertions(+), 42 deletions(-) create mode 100644 client/src/app/settings/settings/settings.tsx create mode 100644 client/src/app/settings/users/AddUser.tsx create mode 100644 client/src/app/settings/users/Users.tsx create mode 100644 client/src/components/ui/switch.tsx create mode 100644 client/src/components/ui/table.tsx create mode 100644 server/src/api/listUsers.ts diff --git a/client/package-lock.json b/client/package-lock.json index 56ef082..7bdac3d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -18,6 +18,7 @@ "@radix-ui/react-popover": "^1.1.5", "@radix-ui/react-select": "^2.1.5", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/themes": "^3.2.0", "@tanstack/react-query": "^5.64.2", diff --git a/client/package.json b/client/package.json index 5ea2eb0..2b09e96 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-popover": "^1.1.5", "@radix-ui/react-select": "^2.1.5", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/themes": "^3.2.0", "@tanstack/react-query": "^5.64.2", diff --git a/client/src/app/settings/page.tsx b/client/src/app/settings/page.tsx index adb22fc..70ca2ad 100644 --- a/client/src/app/settings/page.tsx +++ b/client/src/app/settings/page.tsx @@ -4,10 +4,12 @@ import { Button } from "@/components/ui/button"; import { authClient } from "../../lib/auth"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { GearSix, User } from "@phosphor-icons/react"; +import { GearSix, User, Users as Users_ } from "@phosphor-icons/react"; import { Input } from "../../components/ui/input"; import { Account } from "./account/Account"; +import { Users } from "./users/Users"; +import { Settings } from "./settings/settings"; export default function SettingsPage() { const session = authClient.useSession(); @@ -36,38 +38,21 @@ export default function SettingsPage() { Settings + {selectedTab === "account" && session.data?.user && ( )} + {selectedTab === "users" && } + {selectedTab === "settings" && } - {/*
-
-

Settings

-

- Manage your analytics preferences and configurations. -

-
- - -
- - - - - - - - -
-
*/} ); } diff --git a/client/src/app/settings/settings/settings.tsx b/client/src/app/settings/settings/settings.tsx new file mode 100644 index 0000000..cee08b0 --- /dev/null +++ b/client/src/app/settings/settings/settings.tsx @@ -0,0 +1,44 @@ +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; + +export function Settings() { + return ( +
+ + + Settings + + + {/*
+ + setUsername(target.value)} + /> +
+
+ + setEmail(target.value)} + /> +
*/} +
+ {/* + + */} +
+
+ ); +} diff --git a/client/src/app/settings/users/AddUser.tsx b/client/src/app/settings/users/AddUser.tsx new file mode 100644 index 0000000..260cab7 --- /dev/null +++ b/client/src/app/settings/users/AddUser.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { Button } from "../../../components/ui/button"; +import { authClient } from "../../../lib/auth"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { Label } from "../../../components/ui/label"; +import { Input } from "../../../components/ui/input"; +import { Switch } from "../../../components/ui/switch"; + +export function AddUser() { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [isAdmin, setIsAdmin] = useState(false); + + const [error, setError] = useState(""); + + const passwordsMatch = password === confirmPassword; + + const handleSubmit = async () => { + setError(""); + + if (password !== confirmPassword) { + setError("New password and confirm password do not match"); + return; + } + try { + const response = await authClient.admin.createUser({ + password, + email, + name, + role: isAdmin ? "admin" : "user", + }); + + if (response.error) { + setError(String(response.error.message)); + return; + } + + setOpen(false); + } catch (error) { + setError(String(error)); + } + }; + + return ( +
+ { + setOpen(isOpen); + setPassword(""); + setConfirmPassword(""); + setName(""); + setEmail(""); + setError(""); + setIsAdmin(false); + }} + > + + + + + + Add User + +
+ + setName(e.target.value)} + placeholder="Name" + /> +
+
+ + setEmail(e.target.value)} + placeholder="Email" + /> +
+
+ setIsAdmin(!isAdmin)} + /> + +
+
+ + setPassword(e.target.value)} + type="password" + /> +
+
+ + setConfirmPassword(e.target.value)} + type="password" + /> +
+ {error && ( + + + Error Creating User + {error} + + )} + + + + +
+
+
+ ); +} diff --git a/client/src/app/settings/users/Users.tsx b/client/src/app/settings/users/Users.tsx new file mode 100644 index 0000000..abb06f2 --- /dev/null +++ b/client/src/app/settings/users/Users.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { DateTime } from "luxon"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { useListUsers } from "../../../hooks/api"; +import { AddUser } from "./AddUser"; + +export function Users() { + const { data: users, refetch } = useListUsers(); + + return ( +
+ + + + Users + + + + + + {/* A list of your recent invoices. */} + + + Name + Role + Email + Created + + + + + {users?.data?.map((user) => ( + + {user.name} + {user.role || "admin"} + {user.email} + + {DateTime.fromISO(user.createdAt).toLocaleString()} + + + {user.name !== "admin" && ( + + )} + + + ))} + {/* + Paid + Credit Card + */} + +
+
+ {/* + + */} +
+
+ ); +} diff --git a/client/src/components/TopBar.tsx b/client/src/components/TopBar.tsx index d86818f..d6288ef 100644 --- a/client/src/components/TopBar.tsx +++ b/client/src/components/TopBar.tsx @@ -1,8 +1,33 @@ +"use client"; + import { GearSix } from "@phosphor-icons/react/dist/ssr"; import Link from "next/link"; import { ThemeToggle } from "./ThemeToggle"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "./ui/navigation-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { authClient } from "../lib/auth"; +import { Button } from "./ui/button"; +import { User } from "@phosphor-icons/react"; +import { useRouter } from "next/navigation"; export function TopBar() { + const session = authClient.useSession(); + const router = useRouter(); return (
@@ -11,7 +36,18 @@ export function TopBar() { 🐸 Frogstats
- */} + + + + {session.data?.user.name} + + + + + Settings + + + { + await authClient.signOut(); + router.push("/login"); + }} + > + Sign out + + +
); diff --git a/client/src/components/ui/switch.tsx b/client/src/components/ui/switch.tsx new file mode 100644 index 0000000..9f9cc0c --- /dev/null +++ b/client/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/client/src/components/ui/table.tsx b/client/src/components/ui/table.tsx new file mode 100644 index 0000000..f0d7cef --- /dev/null +++ b/client/src/components/ui/table.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)); +Table.displayName = "Table"; + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableHeader.displayName = "TableHeader"; + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableBody.displayName = "TableBody"; + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0 dark:bg-neutral-800/50", + className + )} + {...props} + /> +)); +TableFooter.displayName = "TableFooter"; + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)); +TableRow.displayName = "TableRow"; + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
[role=checkbox]]:translate-y-[2px] dark:text-neutral-400", + className + )} + {...props} + /> +)); +TableHead.displayName = "TableHead"; + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> +)); +TableCell.displayName = "TableCell"; + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TableCaption.displayName = "TableCaption"; + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +}; diff --git a/client/src/hooks/api.ts b/client/src/hooks/api.ts index 410acbe..19bbb63 100644 --- a/client/src/hooks/api.ts +++ b/client/src/hooks/api.ts @@ -173,3 +173,18 @@ export function deleteSite(siteId: string) { method: "POST", }); } + +export type ListUsersResponse = { + id: string; + name: string; + username: string; + email: string; + emailVerified: boolean; + role: string; + createdAt: string; + updatedAt: string; +}[]; + +export function useListUsers() { + return useGenericQuery("list-users"); +} diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 275f050..dc5c01f 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -1,9 +1,9 @@ -import { usernameClient } from "better-auth/client/plugins"; +import { usernameClient, adminClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, - plugins: [usernameClient()], + plugins: [usernameClient(), adminClient()], fetchOptions: { credentials: "include", }, diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index e2144f7..a611f48 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -17,7 +17,7 @@ module.exports = { }, extend: { colors: { - border: "hsl(var(--border))", + border: "red", input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", diff --git a/server/src/actions/sites/addSite.ts b/server/src/actions/sites/addSite.ts index 4f61094..1cfdb7a 100644 --- a/server/src/actions/sites/addSite.ts +++ b/server/src/actions/sites/addSite.ts @@ -31,7 +31,6 @@ export async function addSite( } try { await sql`INSERT INTO sites (domain, name, created_by) VALUES (${domain}, ${name}, ${session?.user.id})`; - await loadAllowedDomains(); return reply.status(200).send(); } catch (err) { diff --git a/server/src/api/listUsers.ts b/server/src/api/listUsers.ts new file mode 100644 index 0000000..b3b6ee9 --- /dev/null +++ b/server/src/api/listUsers.ts @@ -0,0 +1,23 @@ +import { FastifyReply, FastifyRequest } from "fastify"; +import { sql } from "../db/postgres/postgres.js"; + +type ListUsersResponse = { + id: string; + name: string; + username: string; + email: string; + emailVerified: boolean; + role: string; + createdAt: string; + updatedAt: string; +}[]; + +export async function listUsers(_: FastifyRequest, res: FastifyReply) { + try { + const users = await sql`SELECT * FROM "user"`; + return res.send({ data: users }); + } catch (error) { + console.error("Error fetching users:", error); + return res.status(500).send({ error: "Failed to fetch users" }); + } +} diff --git a/server/src/db/postgres/postgres.ts b/server/src/db/postgres/postgres.ts index 6c1e8b2..f64a8d3 100644 --- a/server/src/db/postgres/postgres.ts +++ b/server/src/db/postgres/postgres.ts @@ -21,11 +21,13 @@ export async function initializePostgres() { CREATE TABLE IF NOT EXISTS "user" ( "id" text not null primary key, "name" text not null, + "username" text not null unique, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamp not null, - "updatedAt" timestamp not null + "updatedAt" timestamp not null, + "role" text not null default 'user' ); `, @@ -120,6 +122,8 @@ export async function initializePostgres() { }); } + await sql`UPDATE "user" SET "role" = 'admin' WHERE username = 'admin'`; + console.log("Tables created successfully."); } catch (err) { console.error("Error creating tables:", err); diff --git a/server/src/index.ts b/server/src/index.ts index 712d524..d633fd3 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -20,12 +20,12 @@ import { getPages } from "./api/getPages.js"; import { getPageViews } from "./api/getPageViews.js"; import { getReferrers } from "./api/getReferrers.js"; import { initializeClickhouse } from "./db/clickhouse/clickhouse.js"; -import { initializePostgres, sql } from "./db/postgres/postgres.js"; +import { initializePostgres } from "./db/postgres/postgres.js"; import { cleanupOldSessions } from "./db/postgres/session-cleanup.js"; -import { auth, initAuth } from "./lib/auth.js"; +import { allowList, loadAllowedDomains } from "./lib/allowedDomains.js"; +import { auth } from "./lib/auth.js"; import { mapHeaders } from "./lib/betterAuth.js"; -import { allowList } from "./lib/allowedDomains.js"; -import { loadAllowedDomains } from "./lib/allowedDomains.js"; +import { listUsers } from "./api/listUsers.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -128,9 +128,11 @@ server.get("/pages", getPages); server.get("/referrers", getReferrers); server.get("/pageviews", getPageViews); +// Administrative server.post("/add-site", addSite); server.post("/delete-site/:id", deleteSite); server.get("/get-sites", getSites); +server.get("/list-users", listUsers); // Track pageview endpoint server.post("/track/pageview", trackPageView); diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts index d16eb4c..a17f588 100644 --- a/server/src/lib/auth.ts +++ b/server/src/lib/auth.ts @@ -1,5 +1,5 @@ import { betterAuth } from "better-auth"; -import { username } from "better-auth/plugins"; +import { username, admin } from "better-auth/plugins"; import dotenv from "dotenv"; import pg from "pg"; @@ -7,7 +7,31 @@ dotenv.config(); type AuthType = ReturnType | null; -export let auth: AuthType | null = null; +export let auth: AuthType | null = betterAuth({ + basePath: "/auth", + database: new pg.Pool({ + host: process.env.POSTGRES_HOST || "postgres", + port: parseInt(process.env.POSTGRES_PORT || "5432", 10), + database: process.env.POSTGRES_DB, + user: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + }), + emailAndPassword: { + enabled: true, + }, + deleteUser: { + enabled: true, + }, + plugins: [username(), admin()], + trustedOrigins: [], + advanced: { + useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev + defaultCookieAttributes: { + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + path: "/", + }, + }, +}); export const initAuth = (allowList: string[]) => { auth = betterAuth({ @@ -25,7 +49,7 @@ export const initAuth = (allowList: string[]) => { deleteUser: { enabled: true, }, - plugins: [username()], + plugins: [username(), admin()], trustedOrigins: allowList, advanced: { useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev