add new user (#18)

This commit is contained in:
Bill Yang 2025-02-19 21:00:55 -05:00 committed by GitHub
parent 8ada9b8c16
commit 61d64c1293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 586 additions and 42 deletions

View file

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

View file

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

View file

@ -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() {
<GearSix size={16} weight="bold" />
Settings
</Button>
<Button
variant={selectedTab === "users" ? "default" : "ghost"}
onClick={() => setSelectedTab("users")}
className="justify-start"
>
<Users_ size={16} weight="bold" />
Users
</Button>
</div>
{selectedTab === "account" && session.data?.user && (
<Account session={session} />
)}
{selectedTab === "users" && <Users />}
{selectedTab === "settings" && <Settings />}
</div>
{/* <div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Settings</h3>
<p className="text-sm text-muted-foreground">
Manage your analytics preferences and configurations.
</p>
</div>
<Button
onClick={async () => {
await authClient.signOut();
router.push("/login");
}}
>
Signout
</Button>
<div className="flex gap-2 flex-col">
<Button>Test</Button>
<Button variant={"destructive"}>Test</Button>
<Button variant={"accent"}>Test</Button>
<Button variant={"warning"}>Test</Button>
<Button variant={"ghost"}>Test</Button>
<Button variant={"link"}>Test</Button>
<Button variant={"outline"}>Test</Button>
<Button variant={"secondary"}>Test</Button>
</div>
</div> */}
</div>
);
}

View file

@ -0,0 +1,44 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
export function Settings() {
return (
<div className="flex flex-col gap-4">
<Card className="p-2">
<CardHeader>
<CardTitle className="text-xl">Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* <div className="grid w-full items-center gap-1.5">
<Label htmlFor="username">Username</Label>
<Input
className="w-60"
id="username"
value={username}
onChange={({ target }) => setUsername(target.value)}
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="email">Email</Label>
<Input
className="w-60"
id="email"
type="email"
value={email}
onChange={({ target }) => setEmail(target.value)}
/>
</div> */}
</CardContent>
{/* <CardFooter className="flex justify-end">
<Button variant={"accent"} disabled={!hasChanges}>
Save Changes
</Button>
</CardFooter> */}
</Card>
</div>
);
}

View file

@ -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 (
<div>
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
setPassword("");
setConfirmPassword("");
setName("");
setEmail("");
setError("");
setIsAdmin(false);
}}
>
<DialogTrigger asChild>
<Button>Add User</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
</DialogHeader>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="email">Email</Label>
<Input
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is-admin"
checked={isAdmin}
onCheckedChange={() => setIsAdmin(!isAdmin)}
/>
<Label htmlFor="is-admin">Admin</Label>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="password"> Password</Label>
<Input
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
type="password"
/>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Creating User</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button
type="submit"
onClick={() => setOpen(false)}
variant={"ghost"}
>
Cancel
</Button>
<Button
type="submit"
onClick={handleSubmit}
disabled={
!password || !confirmPassword || !passwordsMatch || !name
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -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 (
<div className="flex flex-col gap-4">
<Card className="p-2">
<CardHeader>
<CardTitle className="text-xl flex justify-between items-center">
Users
<AddUser />
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Table>
{/* <TableCaption>A list of your recent invoices.</TableCaption> */}
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Email</TableHead>
<TableHead>Created</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users?.data?.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.role || "admin"}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{DateTime.fromISO(user.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{user.name !== "admin" && (
<Button variant={"destructive"} size={"sm"}>
Delete
</Button>
)}
</TableCell>
</TableRow>
))}
{/* <TableRow>
<TableCell>Paid</TableCell>
<TableCell>Credit Card</TableCell>
</TableRow> */}
</TableBody>
</Table>
</CardContent>
{/* <CardFooter className="flex justify-end">
<Button variant={"accent"} disabled={!hasChanges}>
Save Changes
</Button>
</CardFooter> */}
</Card>
</div>
);
}

View file

@ -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 (
<div className="flex pt-2 items-center w-full pb-4 bg-neutral-900 justify-center">
<div className="flex items-center justify-between max-w-6xl flex-1">
@ -11,7 +36,18 @@ export function TopBar() {
🐸 Frogstats
</Link>
</div>
<nav className="ml-auto flex items-center space-x-6 text-sm">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<Link href="/" legacyBehavior passHref>
<NavigationMenuLink className="text-neutral-100 flex items-center gap-1 text-sm font-medium">
Websites
</NavigationMenuLink>
</Link>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
{/* <nav className="ml-auto flex items-center space-x-6 text-sm">
<Link href="/" className="text-neutral-100">
Websites
</Link>
@ -23,7 +59,28 @@ export function TopBar() {
Settings
</Link>
<ThemeToggle />
</nav>
</nav> */}
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-1 text-sm font-medium">
<User size={16} weight="bold" />
{session.data?.user.name}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Link href="/settings" legacyBehavior passHref>
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
await authClient.signOut();
router.push("/login");
}}
>
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);

View file

@ -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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 dark:bg-neutral-950"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -0,0 +1,127 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn("[&_tr]:border-b-neutral-600", className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-neutral-100/50 font-medium [&>tr]:last:border-b-0 dark:bg-neutral-800/50",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-b-neutral-700 transition-colors hover:bg-neutral-100/50 data-[state=selected]:bg-neutral-100 dark:hover:bg-neutral-800/50 dark:data-[state=selected]:bg-neutral-800",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-neutral-500 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] dark:text-neutral-400",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn(
"mt-4 text-sm text-neutral-500 dark:text-neutral-400",
className
)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View file

@ -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<ListUsersResponse>("list-users");
}

View file

@ -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",
},

View file

@ -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))",

View file

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

View file

@ -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<ListUsersResponse[]>`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" });
}
}

View file

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

View file

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

View file

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