init cloud version (#46)

* init cloud version

* remove unused endpoints

* implement deletion

* Add organization creation

* add getSitesUserHasAccessTo

* Add permission gating for sites

* add organization control

* remove username login

* support creating new sites for organizations

* Delete unused page

* wip
This commit is contained in:
Bill Yang 2025-03-09 18:06:48 -07:00 committed by GitHub
parent 28eb6fe459
commit 2d22dc6fee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2120 additions and 642 deletions

View file

@ -8,6 +8,7 @@
"name": "client",
"version": "0.1.0",
"dependencies": {
"@better-auth/stripe": "^1.2.3",
"@nivo/core": "^0.88.0",
"@nivo/line": "^0.88.0",
"@phosphor-icons/react": "^2.1.7",
@ -207,6 +208,15 @@
"node": ">=6.9.0"
}
},
"node_modules/@better-auth/stripe": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@better-auth/stripe/-/stripe-1.2.3.tgz",
"integrity": "sha512-bXXH9rsPKPEJ6sNanqPuH1pqHgXwQsfW5Sy6/heaQoPpI9p2Ah3/5AocTdN3zgdduDMzNRdTR1mshcsLjN2e7w==",
"dependencies": {
"better-auth": "^1.2.3",
"zod": "^3.24.1"
}
},
"node_modules/@better-auth/utils": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.2.3.tgz",
@ -3820,9 +3830,9 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/better-auth": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.2.tgz",
"integrity": "sha512-zsynKwkMKeuKq1QQy80zLV9UehcM8yG0fjJSlGsb7oXWwgfgek5RVBptBFckZcq7z1e84WIqDvtypcgXx0xmlg==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.3.tgz",
"integrity": "sha512-y97/ah2SOWaW81IRg36m7xMSMVl7ATaHie/nhQ0in/reVlEX/6juVPszNqq0gcTwQtFsB8oe15wQKgdf4yHP9Q==",
"dependencies": {
"@better-auth/utils": "0.2.3",
"@better-fetch/fetch": "^1.1.15",

View file

@ -9,6 +9,7 @@
"lint": "next lint"
},
"dependencies": {
"@better-auth/stripe": "^1.2.3",
"@nivo/core": "^0.88.0",
"@nivo/line": "^0.88.0",
"@phosphor-icons/react": "^2.1.7",

View file

@ -9,12 +9,20 @@ import {
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Label } from "../../components/ui/label";
import { Input } from "../../components/ui/input";
import { addSite, useGetSites } from "../../hooks/api";
import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
import { AlertCircle } from "lucide-react";
import { AlertCircle, AppWindow, Building2 } from "lucide-react";
import { authClient } from "../../lib/auth";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
/**
* A simple domain validation function:
@ -30,18 +38,33 @@ function isValidDomain(domain: string): boolean {
export function AddSite() {
const { data: sites, refetch } = useGetSites();
const { data: organizations } = authClient.useListOrganizations();
const existingSites = sites?.data?.map((site) => site.domain);
const [open, setOpen] = useState(false);
const [domain, setDomain] = useState("");
const [selectedOrganizationId, setSelectedOrganizationId] =
useState<string>("");
const [error, setError] = useState("");
// Set the first organization as the default selection when organizations are loaded
useEffect(() => {
if (organizations && organizations.length > 0 && !selectedOrganizationId) {
setSelectedOrganizationId(organizations[0].id);
}
}, [organizations, selectedOrganizationId]);
const domainMatchesExistingSites = existingSites?.includes(domain);
const handleSubmit = async () => {
setError("");
if (!selectedOrganizationId) {
setError("Please select an organization");
return;
}
// Validate before attempting to add
if (!isValidDomain(domain)) {
setError(
@ -50,10 +73,10 @@ export function AddSite() {
return;
}
const response = await addSite(domain, domain);
const response = await addSite(domain, domain, selectedOrganizationId);
if (!response.ok) {
const errorMessage = await response.json();
setError(errorMessage.error);
const errorData = await response.json();
setError(errorData.error || "Failed to add site");
return;
}
setOpen(false);
@ -68,6 +91,10 @@ export function AddSite() {
setOpen(isOpen);
setDomain("");
setError("");
// Reset organization to first one when opening dialog
if (isOpen && organizations && organizations.length > 0) {
setSelectedOrganizationId(organizations[0].id);
}
}}
>
<DialogTrigger asChild>
@ -75,16 +102,54 @@ export function AddSite() {
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Website</DialogTitle>
<DialogTitle className="flex items-center gap-2">
<AppWindow className="h-6 w-6" />
Add Website
</DialogTitle>
<DialogDescription>
Track analytics for a new website in your organization
</DialogDescription>
</DialogHeader>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="domain">Domain</Label>
<Input
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="example.com or sub.example.com"
/>
<div className="grid gap-4 py-2">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="domain">Domain</Label>
<Input
id="domain"
value={domain}
onChange={(e) => setDomain(e.target.value)}
placeholder="example.com or sub.example.com"
/>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="organization">Organization</Label>
<Select
value={selectedOrganizationId}
onValueChange={setSelectedOrganizationId}
disabled={!organizations || organizations.length === 0}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
{organizations?.map((org) => (
<SelectItem key={org.id} value={org.id}>
<div className="flex items-center">
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
{org.name}
</div>
</SelectItem>
))}
{(!organizations || organizations.length === 0) && (
<SelectItem value="no-org" disabled>
No organizations available
</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
@ -94,16 +159,19 @@ export function AddSite() {
)}
<DialogFooter>
<Button
type="submit"
type="button"
onClick={() => setOpen(false)}
variant={"ghost"}
variant="outline"
>
Cancel
</Button>
<Button
type="submit"
variant={"success"}
onClick={handleSubmit}
disabled={!domain || domainMatchesExistingSites}
disabled={
!domain || domainMatchesExistingSites || !selectedOrganizationId
}
>
Add
</Button>

View file

@ -0,0 +1,183 @@
"use client";
import { useState } from "react";
import { Button } from "../../components/ui/button";
import { Dialog } from "../../components/ui/dialog";
import { DialogContent } from "../../components/ui/dialog";
import { DialogHeader } from "../../components/ui/dialog";
import { DialogTitle } from "../../components/ui/dialog";
import { DialogTrigger } from "../../components/ui/dialog";
import { DialogFooter } from "../../components/ui/dialog";
import { DialogDescription } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { Building2 } from "lucide-react";
import { authClient } from "../../lib/auth";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
interface CreateOrganizationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
trigger?: React.ReactNode;
}
export function CreateOrganizationDialog({
open,
onOpenChange,
onSuccess,
trigger,
}: CreateOrganizationDialogProps) {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [error, setError] = useState<string>("");
// Generate slug from name when name changes
const handleNameChange = (value: string) => {
setName(value);
if (value) {
const generatedSlug = value
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
setSlug(generatedSlug);
}
};
// Create organization mutation
const createOrgMutation = useMutation({
mutationFn: async ({ name, slug }: { name: string; slug: string }) => {
// Create organization
const { data, error } = await authClient.organization.create({
name,
slug,
});
if (error) {
throw new Error(error.message || "Failed to create organization");
}
if (!data?.id) {
throw new Error("No organization ID returned");
}
// Set as active organization
await authClient.organization.setActive({
organizationId: data.id,
});
return data;
},
onSuccess: () => {
toast.success("Organization created successfully");
setName("");
setSlug("");
setError("");
onOpenChange(false);
if (onSuccess) {
onSuccess();
}
},
onError: (error: Error) => {
setError(error.message);
toast.error("Failed to create organization");
},
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !slug) {
setError("Organization name and slug are required");
return;
}
setError("");
createOrgMutation.mutate({ name, slug });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl flex items-center gap-2">
<Building2 className="h-6 w-6" />
Create Your Organization
</DialogTitle>
<DialogDescription>
Set up your organization to get started with Frogstats
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Organization Name</Label>
<Input
id="name"
type="text"
placeholder="Acme Inc."
value={name}
onChange={(e) => handleNameChange(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="slug">
Organization Slug
<span className="text-xs text-muted-foreground ml-2">
(URL identifier)
</span>
</Label>
<Input
id="slug"
type="text"
placeholder="acme-inc"
value={slug}
onChange={(e) =>
setSlug(
e.target.value
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
)
}
required
/>
<p className="text-xs text-muted-foreground">
This will be used in your URL: frogstats.io/{slug}
</p>
</div>
{error && (
<div className="bg-destructive/15 text-destructive p-3 rounded-md text-sm">
{error}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
variant="success"
disabled={createOrgMutation.isPending || !name || !slug}
>
{createOrgMutation.isPending
? "Creating..."
: "Create Organization"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -29,13 +29,18 @@ export default function RootLayout({
const pathname = usePathname();
useEffect(() => {
if (!isPending && !user && !publicRoutes.includes(pathname)) {
if (
!isPending &&
!user &&
!publicRoutes.includes(pathname) &&
pathname !== "/signup"
) {
redirect("/login");
}
}, [isPending, user, pathname]);
return (
<html lang="en" className="h-full dark" suppressHydrationWarning>
<html lang="en" className="h-full dark">
<body
className={`${inter.className} h-full bg-background text-foreground`}
>
@ -52,22 +57,20 @@ export default function RootLayout({
defer
src="https://cdn.jsdelivr.net/npm/ldrs/dist/auto/ping.js"
></script>
<ThemeProvider>
<QueryProvider>
{pathname === "/login" ? (
<div className="min-h-full flex items-center justify-center">
{children}
</div>
) : (
<div className="min-h-full">
<TopBar />
<main className="flex min-h-screen flex-col items-center p-4">
<div className="w-full max-w-6xl">{children}</div>
</main>
</div>
)}
</QueryProvider>
</ThemeProvider>
<QueryProvider>
{pathname === "/login" || pathname === "/signup" ? (
<div className="min-h-full flex items-center justify-center">
{children}
</div>
) : (
<div className="min-h-full">
<TopBar />
<main className="flex min-h-screen flex-col items-center p-4">
<div className="w-full max-w-6xl">{children}</div>
</main>
</div>
)}
</QueryProvider>
</body>
</html>
);

View file

@ -4,15 +4,17 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { authClient } from "../../lib/auth";
import { userStore } from "../../lib/userStore";
import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
import { authClient } from "../../lib/auth";
import { IS_CLOUD } from "../../lib/const";
import { userStore } from "../../lib/userStore";
export default function Page() {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
@ -24,8 +26,8 @@ export default function Page() {
setError("");
try {
const { data, error } = await authClient.signIn.username({
username,
const { data, error } = await authClient.signIn.email({
email,
password,
});
if (data?.user) {
@ -40,8 +42,20 @@ export default function Page() {
}
} catch (error) {
setError(String(error));
} finally {
setIsLoading(false);
}
setIsLoading(false);
};
const handleSocialSignIn = async (
provider: "google" | "github" | "twitter"
) => {
try {
await authClient.signIn.social({
provider,
callbackURL: "/",
});
} catch (error) {
setError(String(error));
}
};
@ -57,14 +71,14 @@ export default function Page() {
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor={"email"}>Email</Label>
<Input
id="username"
type="username"
placeholder="username"
id={"email"}
type={"email"}
placeholder={"email"}
required
value={username}
onChange={(e) => setUsername(e.target.value)}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
@ -89,6 +103,41 @@ export default function Page() {
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Login"}
</Button>
{IS_CLOUD && (
<>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
<div className="flex flex-col gap-2">
<Button
type="button"
variant="outline"
onClick={() => handleSocialSignIn("google")}
>
Google
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleSocialSignIn("github")}
>
GitHub
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleSocialSignIn("twitter")}
>
X (Twitter)
</Button>
</div>
</>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
@ -96,6 +145,15 @@ export default function Page() {
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{IS_CLOUD && (
<div className="text-center text-sm">
Don't have an account?{" "}
<Link href="/signup" className="underline">
Sign up
</Link>
</div>
)}
</div>
</form>
</CardContent>

View file

@ -1,12 +1,77 @@
"use client";
import { useState, useEffect } from "react";
import { SiteCard } from "../components/SiteCard";
import { useGetSites } from "../hooks/api";
import { authClient } from "../lib/auth";
import { AddSite } from "./components/AddSite";
import { CreateOrganizationDialog } from "./components/CreateOrganizationDialog";
import { Button } from "../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../components/ui/card";
import { Plus, Building } from "lucide-react";
export default function Home() {
const { data: sites } = useGetSites();
const { data: sites, refetch: refetchSites } = useGetSites();
const userOrganizations = authClient.useListOrganizations();
const [createOrgDialogOpen, setCreateOrgDialogOpen] = useState(false);
// Check if the user has no organizations and is not in a loading state
const hasNoOrganizations =
!userOrganizations.isPending &&
Array.isArray(userOrganizations.data) &&
userOrganizations.data.length === 0;
// Handle successful organization creation
const handleOrganizationCreated = () => {
userOrganizations.refetch();
refetchSites();
};
// If the user has no organizations, show a welcome message with the option to create one
if (hasNoOrganizations) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Building className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">Welcome to Frogstats</CardTitle>
<CardDescription>
You need to create an organization to get started
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<p className="text-muted-foreground text-center mb-6">
Organizations help you organize your websites and collaborate with
your team.
</p>
<Button
onClick={() => setCreateOrgDialogOpen(true)}
className="w-full max-w-xs"
>
<Plus className="mr-2 h-4 w-4" />
Create an Organization
</Button>
</CardContent>
</Card>
<CreateOrganizationDialog
open={createOrgDialogOpen}
onOpenChange={setCreateOrgDialogOpen}
onSuccess={handleOrganizationCreated}
/>
</div>
);
}
// Otherwise, show the normal dashboard with sites
return (
<div className="flex min-h-screen flex-col pt-1">
<div className="flex justify-between items-center mb-4">
@ -24,6 +89,15 @@ export default function Home() {
/>
);
})}
{(!sites?.data || sites.data.length === 0) &&
!userOrganizations.isPending && (
<Card className="col-span-full p-6 flex flex-col items-center text-center">
<CardTitle className="mb-2 text-xl">No websites yet</CardTitle>
<CardDescription>
Add your first website to start tracking analytics
</CardDescription>
</Card>
)}
</div>
</div>
);

View file

@ -1,4 +1,7 @@
import { authClient } from "@/lib/auth";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
@ -6,13 +9,9 @@ import {
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import { ChangePassword } from "./ChangePassword";
import { DeleteAccount } from "./DeleteAccount";
import { toast } from "sonner";
import { changeUsername, changeEmail } from "@/hooks/api";
import { IS_CLOUD } from "../../../lib/const";
export function Account({
session,
@ -21,8 +20,38 @@ export function Account({
}) {
const [username, setUsername] = useState(session.data?.user.username ?? "");
const [email, setEmail] = useState(session.data?.user.email ?? "");
const [name, setName] = useState(session.data?.user.name ?? "");
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
const [isUpdatingEmail, setIsUpdatingEmail] = useState(false);
const [isUpdatingName, setIsUpdatingName] = useState(false);
const handleNameUpdate = async () => {
if (!name) {
toast.error("Name cannot be empty");
return;
}
try {
setIsUpdatingName(true);
const response = await authClient.updateUser({
name,
});
if (response.error) {
throw new Error(response.error.message || "Failed to update name");
}
toast.success("Name updated successfully");
window.location.reload();
} catch (error) {
console.error("Error updating name:", error);
toast.error(
error instanceof Error ? error.message : "Failed to update name"
);
} finally {
setIsUpdatingName(false);
}
};
const handleUsernameUpdate = async () => {
if (!username) {
@ -32,11 +61,12 @@ export function Account({
try {
setIsUpdatingUsername(true);
const response = await changeUsername(username);
const response = await authClient.updateUser({
username,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to update username");
if (response.error) {
throw new Error(response.error.message || "Failed to update username");
}
toast.success("Username updated successfully");
@ -66,11 +96,12 @@ export function Account({
try {
setIsUpdatingEmail(true);
const response = await changeEmail(email);
const response = await authClient.changeEmail({
newEmail: email,
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to update email");
if (response.error) {
throw new Error(response.error.message || "Failed to update email");
}
toast.success("Email updated successfully");
@ -94,29 +125,54 @@ export function Account({
<CardTitle className="text-xl">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<h4 className="text-sm font-medium">Username</h4>
<p className="text-xs text-neutral-500">
Update your username displayed across the platform
</p>
<div className="flex space-x-2">
<Input
id="username"
value={username}
onChange={({ target }) => setUsername(target.value)}
placeholder="username"
/>
<Button
variant="outline"
onClick={handleUsernameUpdate}
disabled={
isUpdatingUsername || username === session.data?.user.username
}
>
{isUpdatingUsername ? "Updating..." : "Update"}
</Button>
{IS_CLOUD ? (
<div className="space-y-2">
<h4 className="text-sm font-medium">Name</h4>
<p className="text-xs text-neutral-500">
Update your name displayed across the platform
</p>
<div className="flex space-x-2">
<Input
id="name"
value={name}
onChange={({ target }) => setName(target.value)}
placeholder="name"
/>
<Button
variant="outline"
onClick={handleNameUpdate}
disabled={isUpdatingName || name === session.data?.user.name}
>
{isUpdatingName ? "Updating..." : "Update"}
</Button>
</div>
</div>
</div>
) : (
<div className="space-y-2">
<h4 className="text-sm font-medium">Username</h4>
<p className="text-xs text-neutral-500">
Update your username displayed across the platform
</p>
<div className="flex space-x-2">
<Input
id="username"
value={username}
onChange={({ target }) => setUsername(target.value)}
placeholder="username"
/>
<Button
variant="outline"
onClick={handleUsernameUpdate}
disabled={
isUpdatingUsername ||
username === session.data?.user.username
}
>
{isUpdatingUsername ? "Updating..." : "Update"}
</Button>
</div>
</div>
)}
<div className="space-y-2">
<h4 className="text-sm font-medium">Email</h4>

View file

@ -14,22 +14,48 @@ import {
import { authClient } from "../../../lib/auth";
import { AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { useRouter } from "next/navigation";
export function DeleteAccount() {
const [isDeleting, setIsDeleting] = useState(false);
const [password, setPassword] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [passwordError, setPasswordError] = useState("");
const router = useRouter();
const handleAccountDeletion = async () => {
if (!password) {
setPasswordError("Password is required to delete your account");
return;
}
try {
setIsDeleting(true);
const response = await authClient.deleteUser({});
setPasswordError("");
const response = await authClient.deleteUser({
password,
});
if (response.error) {
toast.error(`Failed to delete account: ${response.error.message}`);
toast.error(
`Failed to delete account: ${
response.error.message || "Unknown error"
}`
);
if (
response.error.message &&
response.error.message.toLowerCase().includes("password")
) {
setPasswordError("Incorrect password");
}
return;
}
toast.success("Account successfully deleted");
// The user will be redirected to the login page by the auth system
setIsOpen(false);
router.push("/login");
} catch (error) {
toast.error(`Failed to delete account: ${error}`);
} finally {
@ -37,30 +63,66 @@ export function DeleteAccount() {
}
};
const handleClose = () => {
setIsOpen(false);
setPassword("");
setPasswordError("");
};
return (
<AlertDialog>
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
<AlertTriangle className="h-4 w-4 mr-2" />
<Button
variant="destructive"
className="w-full"
onClick={() => setIsOpen(true)}
>
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" color="hsl(var(--red-500))" />
Delete your account?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove all of your data from our servers.
account and remove all your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleAccountDeletion}
<div className="py-1">
<Label htmlFor="password" className="text-sm font-medium">
Enter your password to confirm
</Label>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={`mt-1 ${passwordError ? "border-red-500" : ""}`}
disabled={isDeleting}
/>
{passwordError && (
<p className="text-sm text-red-500 mt-1">{passwordError}</p>
)}
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleClose} disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleAccountDeletion();
}}
variant="destructive"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Yes, delete my account"}
{isDeleting ? "Deleting..." : "Delete Account"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View file

@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import { Dialog } from "../../../components/ui/dialog";
import { DialogContent } from "../../../components/ui/dialog";
import { DialogHeader } from "../../../components/ui/dialog";
import { DialogTitle } from "../../../components/ui/dialog";
import { DialogTrigger } from "../../../components/ui/dialog";
import { DialogFooter } from "../../../components/ui/dialog";
import { DialogDescription } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { AlertTriangle, Trash } from "lucide-react";
import { authClient } from "../../../lib/auth";
import { toast } from "sonner";
import { Organization } from "./Organizations";
interface DeleteOrganizationDialogProps {
organization: Organization;
onSuccess: () => void;
}
export function DeleteOrganizationDialog({
organization,
onSuccess,
}: DeleteOrganizationDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const handleDelete = async () => {
if (confirmText !== organization.name) {
toast.error("Please type the organization name to confirm deletion");
return;
}
setIsLoading(true);
try {
await authClient.organization.delete({
organizationId: organization.id,
});
toast.success("Organization deleted successfully");
setOpen(false);
onSuccess();
} catch (error: any) {
toast.error(error.message || "Failed to delete organization");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="destructive" className="ml-2">
<Trash className="h-4 w-4 mr-1" />
Delete
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="text-destructive flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Delete Organization
</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
organization and remove all associated data.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<p>
Please type <strong>{organization.name}</strong> to confirm.
</p>
<Input
value={confirmText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmText(e.target.value)
}
placeholder={organization.name}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isLoading || confirmText !== organization.name}
>
{isLoading ? "Deleting..." : "Delete Organization"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,94 @@
"use client";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import { Dialog } from "../../../components/ui/dialog";
import { DialogContent } from "../../../components/ui/dialog";
import { DialogHeader } from "../../../components/ui/dialog";
import { DialogTitle } from "../../../components/ui/dialog";
import { DialogTrigger } from "../../../components/ui/dialog";
import { DialogFooter } from "../../../components/ui/dialog";
import { DialogDescription } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Edit } from "lucide-react";
import { authClient } from "../../../lib/auth";
import { toast } from "sonner";
import { Organization } from "./Organizations";
interface EditOrganizationDialogProps {
organization: Organization;
onSuccess: () => void;
}
export function EditOrganizationDialog({
organization,
onSuccess,
}: EditOrganizationDialogProps) {
const [name, setName] = useState(organization.name);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const handleUpdate = async () => {
if (!name) {
toast.error("Organization name is required");
return;
}
setIsLoading(true);
try {
// Using the appropriate method and parameters based on Better Auth API
await authClient.organization.update({
organizationId: organization.id,
data: { name },
});
toast.success("Organization updated successfully");
setOpen(false);
onSuccess();
} catch (error: any) {
toast.error(error.message || "Failed to update organization");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-2">
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Edit Organization</DialogTitle>
<DialogDescription>
Update the details of your organization.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Organization Name</Label>
<Input
id="name"
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setName(e.target.value)
}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleUpdate} disabled={isLoading} variant="success">
{isLoading ? "Updating..." : "Update Organization"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import { Dialog } from "../../../components/ui/dialog";
import { DialogContent } from "../../../components/ui/dialog";
import { DialogHeader } from "../../../components/ui/dialog";
import { DialogTitle } from "../../../components/ui/dialog";
import { DialogTrigger } from "../../../components/ui/dialog";
import { DialogFooter } from "../../../components/ui/dialog";
import { DialogDescription } from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Select } from "../../../components/ui/select";
import { SelectContent } from "../../../components/ui/select";
import { SelectItem } from "../../../components/ui/select";
import { SelectTrigger } from "../../../components/ui/select";
import { SelectValue } from "../../../components/ui/select";
import { UserPlus } from "lucide-react";
import { authClient } from "../../../lib/auth";
import { toast } from "sonner";
interface InviteMemberDialogProps {
organizationId: string;
onSuccess: () => void;
}
export function InviteMemberDialog({
organizationId,
onSuccess,
}: InviteMemberDialogProps) {
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const handleInvite = async () => {
if (!email) {
toast.error("Email is required");
return;
}
setIsLoading(true);
try {
await authClient.organization.inviteMember({
email,
role: role as "admin" | "member" | "owner",
organizationId,
});
toast.success(`Invitation sent to ${email}`);
setOpen(false);
onSuccess();
setEmail("");
setRole("member");
} catch (error: any) {
toast.error(error.message || "Failed to send invitation");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-2">
<UserPlus className="h-4 w-4 mr-1" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Invite a new member</DialogTitle>
<DialogDescription>
Send an invitation to add someone to this organization.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="email@example.com"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setEmail(e.target.value)
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleInvite} disabled={isLoading} variant="success">
{isLoading ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,186 @@
"use client";
import { DateTime } from "luxon";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { useOrganizationMembers } from "../../../hooks/api";
import { authClient } from "../../../lib/auth";
// Import the separated dialog components
import { DeleteOrganizationDialog } from "./DeleteOrganizationDialog";
import { EditOrganizationDialog } from "./EditOrganizationDialog";
import { RemoveMemberDialog } from "./RemoveMemberDialog";
import { InviteMemberDialog } from "./InviteMemberDialog";
// Types for our component
export type Organization = {
id: string;
name: string;
createdAt: string;
slug: string;
};
export type Member = {
id: string;
role: string;
userId: string;
organizationId: string;
createdAt: string;
user: {
id: string;
name: string | null;
email: string;
image: string | null;
};
};
// Organization Component with Members Table
function Organization({ org }: { org: Organization }) {
const { data: members, refetch } = useOrganizationMembers(org.id);
const { data } = authClient.useSession();
const isOwner = members?.data.find(
(member) => member.role === "owner" && member.userId === data?.user?.id
);
const handleRefresh = () => {
refetch();
};
return (
<Card className="w-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-xl">
{org.name}
<span className="text-sm font-normal text-muted-foreground ml-2">
({org.slug})
</span>
</CardTitle>
<div className="flex items-center">
{isOwner && (
<>
<InviteMemberDialog
organizationId={org.id}
onSuccess={handleRefresh}
/>
<EditOrganizationDialog
organization={org}
onSuccess={handleRefresh}
/>
<DeleteOrganizationDialog
organization={org}
onSuccess={handleRefresh}
/>
</>
)}
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Joined</TableHead>
{isOwner && <TableHead className="w-12">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{members?.data?.map((member: any) => (
<TableRow key={member.id}>
<TableCell>{member.user?.name || "—"}</TableCell>
<TableCell>{member.user?.email}</TableCell>
<TableCell className="capitalize">{member.role}</TableCell>
<TableCell>
{DateTime.fromISO(member.createdAt).toLocaleString(
DateTime.DATE_SHORT
)}
</TableCell>
{isOwner && (
<TableCell className="text-right">
{member.role !== "owner" && (
<RemoveMemberDialog
member={member}
organizationId={org.id}
onSuccess={handleRefresh}
/>
)}
</TableCell>
)}
</TableRow>
))}
{(!members?.data || members.data.length === 0) && (
<TableRow>
<TableCell
colSpan={isOwner ? 5 : 4}
className="text-center py-6 text-muted-foreground"
>
No members found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
// OrganizationsInner component
function OrganizationsInner({
organizations,
}: {
organizations: Organization[];
}) {
return (
<div className="flex flex-col gap-6">
{organizations?.map((organization) => (
<Organization key={organization.id} org={organization} />
))}
{organizations.length === 0 && (
<Card className="p-6 text-center text-muted-foreground">
You don't have any organizations yet.
</Card>
)}
</div>
);
}
// Main Organizations component
export function Organizations() {
const userOrganizations = authClient.useListOrganizations();
if (userOrganizations.isPending) {
return (
<div className="flex justify-center py-8">
<div className="animate-pulse">Loading organizations...</div>
</div>
);
}
if (!userOrganizations.data) {
return (
<Card className="p-6 text-center text-muted-foreground">
No organizations found
</Card>
);
}
return <OrganizationsInner organizations={userOrganizations.data as any} />;
}

View file

@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import { Dialog } from "../../../components/ui/dialog";
import { DialogContent } from "../../../components/ui/dialog";
import { DialogHeader } from "../../../components/ui/dialog";
import { DialogTitle } from "../../../components/ui/dialog";
import { DialogTrigger } from "../../../components/ui/dialog";
import { DialogFooter } from "../../../components/ui/dialog";
import { DialogDescription } from "../../../components/ui/dialog";
import { UserMinus } from "lucide-react";
import { authClient } from "../../../lib/auth";
import { toast } from "sonner";
import { Member } from "./Organizations";
interface RemoveMemberDialogProps {
member: Member;
organizationId: string;
onSuccess: () => void;
}
export function RemoveMemberDialog({
member,
organizationId,
onSuccess,
}: RemoveMemberDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const handleRemove = async () => {
setIsLoading(true);
try {
// Using the appropriate method and parameters based on Better Auth API
await authClient.organization.removeMember({
memberIdOrEmail: member.id,
organizationId,
});
toast.success("Member removed successfully");
setOpen(false);
onSuccess();
} catch (error: any) {
toast.error(error.message || "Failed to remove member");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="ghost" className="text-destructive">
<UserMinus className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Remove Member</DialogTitle>
<DialogDescription>
Are you sure you want to remove this member from the organization?
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p>
You are about to remove{" "}
<strong>{member.user.name || member.user.email}</strong> from this
organization.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleRemove}
disabled={isLoading}
>
{isLoading ? "Removing..." : "Remove Member"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -8,14 +8,14 @@ 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 { Organizations } from "./organizations/Organizations";
import { Settings } from "./settings/settings";
export default function SettingsPage() {
const session = authClient.useSession();
const router = useRouter();
const [selectedTab, setSelectedTab] = useState<
"account" | "settings" | "users"
"account" | "settings" | "organizations"
>("account");
return (
@ -39,18 +39,18 @@ export default function SettingsPage() {
Settings
</Button>
<Button
variant={selectedTab === "users" ? "default" : "ghost"}
onClick={() => setSelectedTab("users")}
variant={selectedTab === "organizations" ? "default" : "ghost"}
onClick={() => setSelectedTab("organizations")}
className="justify-start"
>
<Users_ size={16} weight="bold" />
Users
Organizations
</Button>
</div>
{selectedTab === "account" && session.data?.user && (
<Account session={session} />
)}
{selectedTab === "users" && <Users />}
{selectedTab === "organizations" && <Organizations />}
{selectedTab === "settings" && <Settings />}
</div>
</div>

View file

@ -1,78 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { DateTime } from "luxon";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { authClient } from "../../../lib/auth";
import { AddUser } from "./AddUser";
import { DeleteUser } from "./DeleteUser";
export function Users() {
const { data: users, refetch } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const users = await authClient.admin.listUsers({ query: { limit: 100 } });
return users;
},
});
if (users?.error) {
return null;
}
return (
<div className="flex flex-col gap-4">
<Card className="p-2">
<CardHeader>
<CardTitle className="text-xl flex justify-between items-center">
Users
<AddUser refetch={refetch} />
</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?.users?.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.role || "admin"}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{DateTime.fromJSDate(user.createdAt).toLocaleString()}
</TableCell>
<TableCell>
{user.name !== "admin" && (
<DeleteUser user={user} refetch={refetch} />
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,6 +1,5 @@
"use client";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
@ -11,50 +10,159 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
import { authClient } from "../../lib/auth";
import { userStore } from "../../lib/userStore";
export default function SignupPage() {
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string>();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
// Sign up with email and password
const { data, error } = await authClient.signUp.email({
email,
name,
password,
});
if (data?.user) {
userStore.setState({
user: data.user,
});
router.push("/");
}
if (error) {
setError(error.message);
}
} catch (error) {
setError(String(error));
} finally {
setIsLoading(false);
}
};
const handleSocialSignIn = async (
provider: "google" | "github" | "twitter"
) => {
try {
await authClient.signIn.social({
provider,
callbackURL: "/",
});
} catch (error) {
setError(String(error));
}
};
export default function Page() {
return (
<div className={cn("flex flex-col gap-6")}>
<Card>
<div className="flex justify-center items-center h-screen w-full">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardTitle className="text-2xl">Create an account</CardTitle>
<CardDescription>
Enter your email below to login to your account
Enter your details below to create your account
</CardDescription>
</CardHeader>
<CardContent>
<form>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="name">Name</Label>
<Input
id="username"
type="username"
placeholder="username"
required
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" required />
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="email@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Button type="submit" className="w-full">
Login
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Sign Up"}
</Button>
</div>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{""}
<a href="/signup" className="underline underline-offset-4">
Sign up
</a>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
<div className="flex flex-col gap-2">
<Button
type="button"
variant="outline"
onClick={() => handleSocialSignIn("google")}
>
Google
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleSocialSignIn("github")}
>
GitHub
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleSocialSignIn("twitter")}
>
X (Twitter)
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error Creating Account</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="text-center text-sm">
Already have an account?{" "}
<Link
href="/login"
className="underline underline-offset-4 hover:text-primary"
>
Log in
</Link>
</div>
</div>
</form>
</CardContent>

View file

@ -1,29 +1,21 @@
"use client";
import { GearSix } from "@phosphor-icons/react/dist/ssr";
import { User } from "@phosphor-icons/react";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "./ui/navigation-menu";
import { useRouter } from "next/navigation";
import { authClient } from "../lib/auth";
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";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
} from "./ui/navigation-menu";
export function TopBar() {
const session = authClient.useSession();

View file

@ -1,68 +0,0 @@
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function LoginForm({
className,
...props
}: React.ComponentPropsWithoutRef<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<form>
<div className="flex flex-col gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input id="password" type="password" required />
</div>
<Button type="submit" className="w-full">
Login
</Button>
<Button variant="outline" className="w-full">
Login with Google
</Button>
</div>
<div className="mt-4 text-center text-sm">
Don&apos;t have an account?{""}
<a href="#" className="underline underline-offset-4">
Sign up
</a>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View file

@ -243,12 +243,13 @@ export function useGetSites() {
return useGenericQuery<GetSitesResponse>("get-sites");
}
export function addSite(domain: string, name: string) {
export function addSite(domain: string, name: string, organizationId: string) {
return authedFetch(`${BACKEND_URL}/add-site`, {
method: "POST",
body: JSON.stringify({
domain,
name,
organizationId,
}),
headers: {
"Content-Type": "application/json",
@ -279,30 +280,6 @@ export function useSiteHasData(siteId: string) {
return useGenericQuery<boolean>(`site-has-data/${siteId}`);
}
export function changeUsername(newUsername: string) {
return authedFetch(`${BACKEND_URL}/change-username`, {
method: "POST",
body: JSON.stringify({
newUsername,
}),
headers: {
"Content-Type": "application/json",
},
});
}
export function changeEmail(newEmail: string) {
return authedFetch(`${BACKEND_URL}/change-email`, {
method: "POST",
body: JSON.stringify({
newEmail,
}),
headers: {
"Content-Type": "application/json",
},
});
}
// Updated type for grouped sessions from the API
export type UserSessionsResponse = {
session_id: string;
@ -388,3 +365,29 @@ export function useGetSessionsInfinite() {
staleTime: Infinity,
});
}
type GetOrganizationMembersResponse = {
data: {
id: string;
role: string;
userId: string;
organizationId: string;
createdAt: string;
user: {
id: string;
name: string | null;
email: string;
};
}[];
};
export const useOrganizationMembers = (organizationId: string) => {
return useQuery<GetOrganizationMembersResponse>({
queryKey: ["organization-members", organizationId],
queryFn: () =>
authedFetch(
`${BACKEND_URL}/list-organization-members/${organizationId}`
).then((res) => res.json()),
staleTime: Infinity,
});
};

View file

@ -1,7 +1,7 @@
import {
usernameClient,
adminClient,
organizationClient,
usernameClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
@ -11,4 +11,5 @@ export const authClient = createAuthClient({
fetchOptions: {
credentials: "include",
},
socialProviders: ["google", "github", "twitter"],
});

View file

@ -2,3 +2,4 @@ export const BACKEND_URL =
process.env.NEXT_PUBLIC_BACKEND_URL === "http://localhost:3001"
? "http://localhost:3001"
: `${process.env.NEXT_PUBLIC_BACKEND_URL}/api`;
export const IS_CLOUD = process.env.NEXT_PUBLIC_CLOUD === "true";

View file

@ -6,7 +6,7 @@ set -e
# Run migrations explicitly using the npm script
echo "Running database migrations...."
# npm run db:migrate
npm run db:push
# Start the application
echo "Starting application..."

View file

@ -1,13 +1,14 @@
import "dotenv/config";
import dotenv from "dotenv";
import { defineConfig } from "drizzle-kit";
dotenv.config();
export default defineConfig({
schema: "./src/db/postgres/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
host: "5.78.110.218",
// host: "postgres",
host: process.env.POSTGRES_HOST || "postgres",
port: 5432,
database: "analytics",
user: "frog",
@ -15,5 +16,4 @@ export default defineConfig({
ssl: false,
},
verbose: true,
strict: true,
});

335
server/package-lock.json generated
View file

@ -8,13 +8,13 @@
"name": "analytics-backend",
"version": "1.0.0",
"dependencies": {
"@better-auth/stripe": "^1.2.3",
"@clickhouse/client": "^1.10.1",
"@fastify/cors": "^10.0.2",
"@fastify/one-line-logger": "^1.4.0",
"@fastify/static": "^8.0.4",
"better-auth": "^1.2.2",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.5",
"drizzle-orm": "^0.40.0",
"fastify": "^5.1.0",
"fastify-better-auth": "^1.0.1",
@ -22,6 +22,7 @@
"node-cron": "^3.0.3",
"pg": "^8.13.3",
"postgres": "^3.4.5",
"stripe": "^17.7.0",
"ua-parser-js": "^2.0.0",
"undici": "^7.3.0"
},
@ -30,11 +31,21 @@
"@types/node": "^20.10.0",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.11.11",
"drizzle-kit": "^0.30.5",
"ts-node-dev": "^2.0.0",
"tsx": "^4.19.3",
"typescript": "^5.7.3"
}
},
"node_modules/@better-auth/stripe": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@better-auth/stripe/-/stripe-1.2.3.tgz",
"integrity": "sha512-bXXH9rsPKPEJ6sNanqPuH1pqHgXwQsfW5Sy6/heaQoPpI9p2Ah3/5AocTdN3zgdduDMzNRdTR1mshcsLjN2e7w==",
"dependencies": {
"better-auth": "^1.2.3",
"zod": "^3.24.1"
}
},
"node_modules/@better-auth/utils": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.2.3.tgz",
@ -79,13 +90,15 @@
"node_modules/@drizzle-team/brocli": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="
"integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==",
"dev": true
},
"node_modules/@esbuild-kit/core-utils": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
"integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==",
"deprecated": "Merged into tsx: https://tsx.is",
"dev": true,
"dependencies": {
"esbuild": "~0.18.20",
"source-map-support": "^0.5.21"
@ -98,6 +111,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -113,6 +127,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -128,6 +143,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -143,6 +159,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -158,6 +175,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -173,6 +191,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -188,6 +207,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -203,6 +223,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -218,6 +239,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -233,6 +255,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -248,6 +271,7 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -263,6 +287,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -278,6 +303,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -293,6 +319,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -308,6 +335,7 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -323,6 +351,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -338,6 +367,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@ -353,6 +383,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@ -368,6 +399,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@ -383,6 +415,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -398,6 +431,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -413,6 +447,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -425,6 +460,7 @@
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
"integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@ -462,6 +498,7 @@
"resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz",
"integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==",
"deprecated": "Merged into tsx: https://tsx.is",
"dev": true,
"dependencies": {
"@esbuild-kit/core-utils": "^3.3.2",
"get-tsconfig": "^4.7.0"
@ -474,6 +511,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
@ -489,6 +527,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -504,6 +543,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -519,6 +559,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -534,6 +575,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -549,6 +591,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -564,6 +607,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -579,6 +623,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -594,6 +639,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -609,6 +655,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -624,6 +671,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -639,6 +687,7 @@
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -654,6 +703,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -669,6 +719,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -684,6 +735,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -699,6 +751,7 @@
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -714,6 +767,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -745,6 +799,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
@ -776,6 +831,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
@ -791,6 +847,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
@ -806,6 +863,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -821,6 +879,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -836,6 +895,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -1140,7 +1200,8 @@
"node_modules/@petamoriken/float16": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.1.tgz",
"integrity": "sha512-j+ejhYwY6PeB+v1kn7lZFACUIG97u90WxMuGosILFsl9d4Ovi0sjk0GlPfoEcx+FzvXZDAfioD+NGnnPamXgMA=="
"integrity": "sha512-j+ejhYwY6PeB+v1kn7lZFACUIG97u90WxMuGosILFsl9d4Ovi0sjk0GlPfoEcx+FzvXZDAfioD+NGnnPamXgMA==",
"devOptional": true
},
"node_modules/@simplewebauthn/browser": {
"version": "13.1.0",
@ -1198,7 +1259,6 @@
"version": "20.10.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.0.tgz",
"integrity": "sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==",
"devOptional": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@ -1456,9 +1516,9 @@
]
},
"node_modules/better-auth": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.2.tgz",
"integrity": "sha512-zsynKwkMKeuKq1QQy80zLV9UehcM8yG0fjJSlGsb7oXWwgfgek5RVBptBFckZcq7z1e84WIqDvtypcgXx0xmlg==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.2.3.tgz",
"integrity": "sha512-y97/ah2SOWaW81IRg36m7xMSMVl7ATaHie/nhQ0in/reVlEX/6juVPszNqq0gcTwQtFsB8oe15wQKgdf4yHP9Q==",
"dependencies": {
"@better-auth/utils": "0.2.3",
"@better-fetch/fetch": "^1.1.15",
@ -1544,7 +1604,35 @@
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
@ -1647,6 +1735,7 @@
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"devOptional": true,
"dependencies": {
"ms": "^2.1.3"
},
@ -1723,6 +1812,7 @@
"version": "0.30.5",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.5.tgz",
"integrity": "sha512-l6dMSE100u7sDaTbLczibrQZjA35jLsHNqIV+jmhNVO3O8jzM6kywMOmV9uOz9ZVSCMPQhAZEFjL/qDPVrqpUA==",
"dev": true,
"dependencies": {
"@drizzle-team/brocli": "^0.10.2",
"@esbuild-kit/esm-loader": "^2.5.5",
@ -1854,6 +1944,19 @@
}
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/dynamic-dedupe": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz",
@ -1885,6 +1988,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
"devOptional": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
@ -1892,10 +1996,38 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
@ -1933,6 +2065,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"dev": true,
"dependencies": {
"debug": "^4.3.4"
},
@ -2159,7 +2292,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -2168,6 +2300,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/gel/-/gel-2.0.0.tgz",
"integrity": "sha512-Oq3Fjay71s00xzDc0BF/mpcLmnA+uRqMEJK8p5K4PaZjUEsxaeo+kR9OHBVAf289/qPd+0OcLOLUN0UhqiUCog==",
"devOptional": true,
"dependencies": {
"@petamoriken/float16": "^3.8.7",
"debug": "^4.3.4",
@ -2187,6 +2320,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz",
"integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==",
"devOptional": true,
"engines": {
"node": ">=16"
}
@ -2195,6 +2329,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
"integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
"devOptional": true,
"dependencies": {
"isexe": "^3.1.1"
},
@ -2205,10 +2340,46 @@
"node": "^16.13.0 || >=18.0.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-tsconfig": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.0.tgz",
"integrity": "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==",
"dev": true,
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
@ -2250,11 +2421,32 @@
"node": ">= 6"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@ -2517,6 +2709,14 @@
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
@ -2581,7 +2781,8 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"devOptional": true
},
"node_modules/nanostores": {
"version": "0.11.3",
@ -2617,6 +2818,17 @@
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
@ -2964,6 +3176,20 @@
"node": ">=6.0.0"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@ -3036,6 +3262,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
@ -3227,6 +3454,75 @@
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"devOptional": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
@ -3257,6 +3553,7 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -3265,6 +3562,7 @@
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dev": true,
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
@ -3400,6 +3698,18 @@
"node": ">=0.10.0"
}
},
"node_modules/stripe": {
"version": "17.7.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
"integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
"dependencies": {
"@types/node": ">=8.1.0",
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@ -4056,8 +4366,7 @@
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"devOptional": true
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
},
"node_modules/uuid": {
"version": "8.3.2",

View file

@ -15,6 +15,7 @@
"db:check": "drizzle-kit check --config=drizzle.config.ts"
},
"dependencies": {
"@better-auth/stripe": "^1.2.3",
"@clickhouse/client": "^1.10.1",
"@fastify/cors": "^10.0.2",
"@fastify/one-line-logger": "^1.4.0",
@ -28,6 +29,7 @@
"node-cron": "^3.0.3",
"pg": "^8.13.3",
"postgres": "^3.4.5",
"stripe": "^17.7.0",
"ua-parser-js": "^2.0.0",
"undici": "^7.3.0"
},

View file

@ -1,88 +0,0 @@
import { fromNodeHeaders } from "better-auth/node";
import { FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm";
import { db } from "../db/postgres/postgres.js";
import { users } from "../db/postgres/schema.js";
import { auth } from "../lib/auth.js";
interface ChangeEmailRequest {
Body: {
newEmail: string;
};
}
export async function changeEmail(
request: FastifyRequest<ChangeEmailRequest>,
reply: FastifyReply
) {
try {
const { newEmail } = request.body;
// Validate input
if (!newEmail || newEmail.trim() === "") {
return reply.status(400).send({
error: "New email cannot be empty",
});
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(newEmail)) {
return reply.status(400).send({
error: "Invalid email format",
});
}
// Get current user session
const session = await auth!.api.getSession({
headers: fromNodeHeaders(request.headers),
});
if (!session?.user.id) {
return reply.status(401).send({ error: "Unauthorized" });
}
const userId = session.user.id;
// Check if the email is already taken
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, newEmail))
.execute();
if (existingUser.length > 0 && existingUser[0].id !== userId) {
return reply.status(409).send({ error: "Email already exists" });
}
// Update the user's email
await db
.update(users)
.set({
email: newEmail,
updatedAt: new Date(),
// Note: In a production app, you might want to reset emailVerified to false here
// and send a verification email to the new address
// emailVerified: false,
})
.where(eq(users.id, userId))
.execute();
return reply.status(200).send({
message: "Email updated successfully",
email: newEmail,
});
} catch (error: any) {
console.error("Error changing email:", error);
// Handle specific errors
if (
error.message?.includes("unique constraint") &&
error.message.includes("email")
) {
return reply.status(409).send({ error: "Email already exists" });
}
return reply.status(500).send({ error: "Failed to update email" });
}
}

View file

@ -1,77 +0,0 @@
import { fromNodeHeaders } from "better-auth/node";
import { FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm";
import { db } from "../db/postgres/postgres.js";
import { users } from "../db/postgres/schema.js";
import { auth } from "../lib/auth.js";
interface ChangeUsernameRequest {
Body: {
newUsername: string;
};
}
export async function changeUsername(
request: FastifyRequest<ChangeUsernameRequest>,
reply: FastifyReply
) {
try {
const { newUsername } = request.body;
// Validate input
if (!newUsername || newUsername.trim() === "") {
return reply.status(400).send({
error: "New username cannot be empty",
});
}
// Get current user session
const session = await auth!.api.getSession({
headers: fromNodeHeaders(request.headers),
});
if (!session?.user.id) {
return reply.status(401).send({ error: "Unauthorized" });
}
const userId = session.user.id;
// Check if the username is already taken
const existingUser = await db
.select()
.from(users)
.where(eq(users.username, newUsername))
.execute();
if (existingUser.length > 0 && existingUser[0].id !== userId) {
return reply.status(409).send({ error: "Username already exists" });
}
// Update the user's username
await db
.update(users)
.set({
username: newUsername,
updatedAt: new Date(),
})
.where(eq(users.id, userId))
.execute();
return reply.status(200).send({
message: "Username updated successfully",
username: newUsername,
});
} catch (error: any) {
console.error("Error changing username:", error);
// Handle specific errors
if (
error.message?.includes("unique constraint") &&
error.message.includes("username")
) {
return reply.status(409).send({ error: "Username already exists" });
}
return reply.status(500).send({ error: "Failed to update username" });
}
}

View file

@ -2,11 +2,18 @@ import { FastifyReply, FastifyRequest } from "fastify";
import { db } from "../db/postgres/postgres.js";
import { activeSessions } from "../db/postgres/schema.js";
import { eq, count } from "drizzle-orm";
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
export const getLiveUsercount = async (
{ params: { site } }: FastifyRequest<{ Params: { site: string } }>,
req: FastifyRequest<{ Params: { site: string } }>,
res: FastifyReply
) => {
const { site } = req.params;
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
if (!userHasAccessToSite) {
return res.status(403).send({ error: "Forbidden" });
}
const result = await db
.select({ count: count() })
.from(activeSessions)

View file

@ -6,6 +6,7 @@ import {
getTimeStatement,
processResults,
} from "./utils.js";
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
type GetOverviewResponse = {
sessions: number;
@ -127,55 +128,17 @@ const getQuery = ({
};
export async function getOverview(
{
query: { startDate, endDate, timezone, site, filters, past24Hours },
}: FastifyRequest<GenericRequest & { Querystring: { past24Hours: boolean } }>,
req: FastifyRequest<
GenericRequest & { Querystring: { past24Hours: boolean } }
>,
res: FastifyReply
) {
const filterStatement = getFilterStatement(filters);
// const query = `SELECT
// session_stats.sessions,
// session_stats.pages_per_session,
// session_stats.bounce_rate * 100 AS bounce_rate,
// session_stats.session_duration,
// page_stats.pageviews,
// page_stats.users
// FROM
// (
// -- Session-level metrics
// SELECT
// COUNT() AS sessions,
// AVG(pages_in_session) AS pages_per_session,
// sumIf(1, pages_in_session = 1) / COUNT() AS bounce_rate,
// AVG(end_time - start_time) AS session_duration
// FROM
// (
// -- Build a summary row per session
// SELECT
// session_id,
// MIN(timestamp) AS start_time,
// MAX(timestamp) AS end_time,
// COUNT(*) AS pages_in_session
// FROM pageviews
// WHERE
// site_id = ${site}
// ${filterStatement}
// ${getTimeStatement(startDate, endDate, timezone)}
// GROUP BY session_id
// )
// ) AS session_stats
// CROSS JOIN
// (
// -- Page-level and user-level metrics
// SELECT
// COUNT(*) AS pageviews,
// COUNT(DISTINCT user_id) AS users
// FROM pageviews
// WHERE
// site_id = ${site}
// ${filterStatement}
// ${getTimeStatement(startDate, endDate, timezone)}
// ) AS page_stats`;
const { startDate, endDate, timezone, site, filters, past24Hours } =
req.query;
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
if (!userHasAccessToSite) {
return res.status(403).send({ error: "Forbidden" });
}
const query = getQuery({
startDate,

View file

@ -5,6 +5,7 @@ import {
getTimeStatement,
processResults,
} from "./utils.js";
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
const TimeBucketToFn = {
minute: "toStartOfMinute",
@ -205,9 +206,7 @@ type TimeBucket = "hour" | "day" | "week" | "month";
type getOverviewBucketed = { time: string; pageviews: number }[];
export async function getOverviewBucketed(
{
query: { startDate, endDate, timezone, bucket, site, filters, past24Hours },
}: FastifyRequest<{
req: FastifyRequest<{
Querystring: {
startDate: string;
endDate: string;
@ -220,6 +219,14 @@ export async function getOverviewBucketed(
}>,
res: FastifyReply
) {
const { startDate, endDate, timezone, bucket, site, filters, past24Hours } =
req.query;
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
if (!userHasAccessToSite) {
return res.status(403).send({ error: "Forbidden" });
}
const query = getQuery({
startDate,
endDate,

View file

@ -5,6 +5,7 @@ import {
getTimeStatement,
processResults,
} from "./utils.js";
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
type GetSessionsResponse = {
session_id: string;
@ -32,11 +33,15 @@ export interface GetSessionsRequest {
}
export async function getSessions(
{
query: { startDate, endDate, timezone, site, filters, page },
}: FastifyRequest<GetSessionsRequest>,
req: FastifyRequest<GetSessionsRequest>,
res: FastifyReply
) {
const { startDate, endDate, timezone, site, filters, page } = req.query;
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
if (!userHasAccessToSite) {
return res.status(403).send({ error: "Forbidden" });
}
const filterStatement = getFilterStatement(filters);
const query = `

View file

@ -7,6 +7,7 @@ import {
getTimeStatement,
processResults,
} from "./utils.js";
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
type GetSingleColResponse = {
value: string;
@ -15,11 +16,17 @@ type GetSingleColResponse = {
}[];
export async function getSingleCol(
{
query: { startDate, endDate, timezone, site, filters, parameter, limit },
}: FastifyRequest<GenericRequest>,
req: FastifyRequest<GenericRequest>,
res: FastifyReply
) {
const { startDate, endDate, timezone, site, filters, parameter, limit } =
req.query;
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
if (!userHasAccessToSite) {
return res.status(403).send({ error: "Forbidden" });
}
const filterStatement = getFilterStatement(filters);
const query = `

View file

@ -5,6 +5,7 @@ import {
getTimeStatement,
processResults,
} from "./utils.js";
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
// Individual pageview type
type Pageview = {
@ -53,12 +54,17 @@ export interface GetUserSessionsRequest {
}
export async function getUserSessions(
{
query: { startDate, endDate, timezone, site, filters },
params: { userId },
}: FastifyRequest<GetUserSessionsRequest>,
req: FastifyRequest<GetUserSessionsRequest>,
res: FastifyReply
) {
const { startDate, endDate, timezone, site, filters } = req.query;
const userId = req.params.userId;
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
if (!userHasAccessToSite) {
return res.status(403).send({ error: "Forbidden" });
}
const filterStatement = getFilterStatement(filters);
const query = `

View file

@ -0,0 +1,96 @@
import { FastifyRequest, FastifyReply } from "fastify";
import { db } from "../db/postgres/postgres.js";
import { member, users } from "../db/postgres/schema.js";
import { eq, and } from "drizzle-orm";
import { auth } from "../lib/auth.js";
interface ListOrganizationMembersRequest {
Params: {
organizationId: string;
};
}
// Define user interface based on schema
interface UserInfo {
id: string;
name: string | null;
email: string;
image: string | null;
}
export async function listOrganizationMembers(
request: FastifyRequest<ListOrganizationMembersRequest>,
reply: FastifyReply
) {
try {
const { organizationId } = request.params;
// Get current user's session
const headers = new Headers(request.headers as any);
const session = await auth!.api.getSession({ headers });
if (!session?.user?.id) {
return reply.status(401).send({
error: "Unauthorized",
message: "You must be logged in to access this resource",
});
}
// Check if user is a member of this organization
const userMembership = await db.query.member.findFirst({
where: and(
eq(member.userId, session.user.id),
eq(member.organizationId, organizationId)
),
});
if (!userMembership) {
return reply.status(403).send({
error: "Forbidden",
message: "You do not have access to this organization",
});
}
// User has access, fetch all members of the organization
// Use a direct SQL query approach instead of relations
const organizationMembers = await db
.select({
id: member.id,
role: member.role,
userId: member.userId,
organizationId: member.organizationId,
createdAt: member.createdAt,
// User fields
userName: users.name,
userEmail: users.email,
userImage: users.image,
userActualId: users.id,
})
.from(member)
.leftJoin(users, eq(member.userId, users.id))
.where(eq(member.organizationId, organizationId));
// Transform the results to the expected format
return reply.send({
success: true,
data: organizationMembers.map((m) => ({
id: m.id,
role: m.role,
userId: m.userId,
organizationId: m.organizationId,
createdAt: m.createdAt,
user: {
id: m.userActualId,
name: m.userName,
email: m.userEmail,
},
})),
});
} catch (error) {
console.error("Error listing organization members:", error);
return reply.status(500).send({
error: "InternalServerError",
message: "An error occurred while listing organization members",
});
}
}

View file

@ -6,10 +6,12 @@ import { loadAllowedDomains } from "../../lib/allowedDomains.js";
import { auth } from "../../lib/auth.js";
export async function addSite(
request: FastifyRequest<{ Body: { domain: string; name: string } }>,
request: FastifyRequest<{
Body: { domain: string; name: string; organizationId: string };
}>,
reply: FastifyReply
) {
const { domain, name } = request.body;
const { domain, name, organizationId } = request.body;
// Validate domain format using regex
const domainRegex =
@ -21,24 +23,78 @@ export async function addSite(
});
}
const session = await auth!.api.getSession({
headers: fromNodeHeaders(request.headers),
});
if (!session?.user.id) {
return reply.status(500).send({ error: "Could not find user id" });
}
try {
await db.insert(sites).values({
domain,
name,
createdBy: session.user.id,
// Get the current user's session
const headers = new Headers(request.headers as any);
const session = await auth!.api.getSession({ headers });
if (!session?.user?.id) {
return reply.status(401).send({
error: "Unauthorized",
message: "You must be logged in to add a site",
});
}
// Check if the organization exists
if (!organizationId) {
return reply.status(400).send({
error: "Organization ID is required",
});
}
// Check if the user is an owner or admin of the organization
// First, get the user's role in the organization
const member = await db.query.member.findFirst({
where: (member, { and, eq }) =>
and(
eq(member.userId, session.user.id),
eq(member.organizationId, organizationId)
),
});
if (!member) {
return reply.status(403).send({
error: "You are not a member of this organization",
});
}
// Check if the user's role is admin or owner
if (member.role !== "admin" && member.role !== "owner") {
return reply.status(403).send({
error:
"You must be an admin or owner to add sites to this organization",
});
}
// Check if site already exists
const existingSite = await db.query.sites.findFirst({
where: (sites, { eq }) => eq(sites.domain, domain),
});
if (existingSite) {
return reply.status(400).send({
error: "Site already exists",
});
}
// Create the new site
const newSite = await db
.insert(sites)
.values({
domain,
name,
createdBy: session.user.id,
organizationId,
})
.returning();
await loadAllowedDomains();
return reply.status(200).send();
} catch (err) {
return reply.status(500).send({ error: String(err) });
return reply.status(201).send(newSite[0]);
} catch (error) {
console.error("Error adding site:", error);
return reply.status(500).send({
error: "Internal server error",
});
}
}

View file

@ -1,11 +1,9 @@
import { FastifyReply } from "fastify";
import { FastifyRequest } from "fastify";
import { db } from "../../db/postgres/postgres.js";
import { sites } from "../../db/postgres/schema.js";
import { FastifyReply, FastifyRequest } from "fastify";
import { getSitesUserHasAccessTo } from "../../lib/auth-utils.js";
export async function getSites(_: FastifyRequest, reply: FastifyReply) {
export async function getSites(req: FastifyRequest, reply: FastifyReply) {
try {
const sitesData = await db.select().from(sites);
const sitesData = await getSitesUserHasAccessTo(req);
return reply.status(200).send({ data: sitesData });
} catch (err) {
return reply.status(500).send({ error: String(err) });

View file

@ -1,7 +1,6 @@
import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { auth } from "../../lib/auth.js";
import * as schema from "./schema.js";
dotenv.config();
@ -25,29 +24,6 @@ export const sql = client;
export async function initializePostgres() {
try {
console.log("Initializing PostgreSQL database...");
// Assume migrations have been run manually with 'npm run db:migrate'
// No automatic migrations during application startup
// Check if admin user exists, if not create one
const [{ count }]: { count: number }[] =
await client`SELECT count(*) FROM "user" WHERE username = 'admin'`;
if (Number(count) === 0) {
// Create admin user
console.log("Creating admin user");
await auth!.api.signUpEmail({
body: {
email: "admin@example.com",
username: "admin",
password: "admin123",
name: "Admin User",
},
});
}
await client`UPDATE "user" SET "role" = 'admin' WHERE username = 'admin'`;
console.log("PostgreSQL initialization completed successfully.");
} catch (error) {
console.error("Error initializing PostgreSQL:", error);

View file

@ -12,7 +12,7 @@ import {
export const users = pgTable("user", {
id: text("id").primaryKey().notNull(),
name: text("name").notNull(),
username: text("username").notNull().unique(),
username: text("username").unique(),
email: text("email").notNull().unique(),
emailVerified: boolean("emailVerified").notNull(),
image: text("image"),

View file

@ -6,12 +6,13 @@ import cron from "node-cron";
import { dirname, join } from "path";
import { Headers, HeadersInit } from "undici";
import { fileURLToPath } from "url";
import { changeEmail } from "./api/changeEmail.js";
import { changeUsername } from "./api/changeUsername.js";
import { createAccount } from "./api/createAccount.js";
import { getLiveUsercount } from "./api/getLiveUsercount.js";
import { getOverview } from "./api/getOverview.js";
import { getOverviewBucketed } from "./api/getOverviewBucketed.js";
import { getSessions } from "./api/getSessions.js";
import { getSingleCol } from "./api/getSingleCol.js";
import { getUserSessions } from "./api/getUserSessions.js";
import { listUsers } from "./api/listUsers.js";
import { addSite } from "./api/sites/addSite.js";
import { changeSiteDomain } from "./api/sites/changeSiteDomain.js";
@ -23,11 +24,9 @@ import { initializePostgres } from "./db/postgres/postgres.js";
import { cleanupOldSessions } from "./db/postgres/session-cleanup.js";
import { allowList, loadAllowedDomains } from "./lib/allowedDomains.js";
import { auth } from "./lib/auth.js";
import { mapHeaders } from "./lib/betterAuth.js";
import { mapHeaders } from "./lib/auth-utils.js";
import { trackPageView } from "./tracker/trackPageView.js";
import { createAccount } from "./api/createAccount.js";
import { getSessions } from "./api/getSessions.js";
import { getUserSessions } from "./api/getUserSessions.js";
import { listOrganizationMembers } from "./api/listOrganizationMembers.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -133,10 +132,10 @@ server.post("/delete-site/:id", deleteSite);
server.get("/get-sites", getSites);
server.get("/list-users", listUsers);
server.post("/create-account", createAccount);
// User management
server.post("/change-username", changeUsername);
server.post("/change-email", changeEmail);
server.get(
"/list-organization-members/:organizationId",
listOrganizationMembers
);
// Track pageview endpoint
server.post("/track/pageview", trackPageView);

View file

@ -0,0 +1,63 @@
import { FastifyRequest } from "fastify";
import { auth } from "./auth.js";
import { sites, member } from "../db/postgres/schema.js";
import { inArray, eq } from "drizzle-orm";
import { db } from "../db/postgres/postgres.js";
export function mapHeaders(headers: any) {
const entries = Object.entries(headers);
const map = new Map();
for (const [headerKey, headerValue] of entries) {
if (headerValue != null) {
map.set(headerKey, headerValue);
}
}
return map;
}
export async function getSitesUserHasAccessTo(req: FastifyRequest) {
const headers = new Headers(req.headers as any);
const session = await auth!.api.getSession({ headers });
const userId = session?.user.id;
if (!userId) {
return [];
}
try {
// Get the user's organization IDs directly from the database
const memberRecords = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, userId));
if (!memberRecords || memberRecords.length === 0) {
return [];
}
// Extract organization IDs
const organizationIds = memberRecords.map(
(record) => record.organizationId
);
// Get sites for these organizations
const siteRecords = await db
.select()
.from(sites)
.where(inArray(sites.organizationId, organizationIds));
return siteRecords;
} catch (error) {
console.error("Error getting sites user has access to:", error);
return [];
}
}
export async function getUserHasAccessToSite(
req: FastifyRequest,
siteId: string
) {
const sites = await getSitesUserHasAccessTo(req);
return sites.some((site) => site.siteId === Number(siteId));
}

View file

@ -4,12 +4,35 @@ import dotenv from "dotenv";
import pg from "pg";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "../db/postgres/postgres.js";
import { IS_CLOUD } from "./const.js";
import * as schema from "../db/postgres/schema.js";
import { eq } from "drizzle-orm";
dotenv.config();
type AuthType = ReturnType<typeof betterAuth> | null;
const pluginList = IS_CLOUD
? [
admin(),
organization({
// Allow users to create organizations
allowUserToCreateOrganization: true,
// Set the creator role to owner
creatorRole: "owner",
}),
]
: [
username(),
admin(),
organization({
// Allow users to create organizations
allowUserToCreateOrganization: true,
// Set the creator role to owner
creatorRole: "owner",
}),
];
export let auth: AuthType | null = betterAuth({
basePath: "/auth",
database: new pg.Pool({
@ -25,7 +48,12 @@ export let auth: AuthType | null = betterAuth({
deleteUser: {
enabled: true,
},
plugins: [username(), admin(), organization()],
user: {
deleteUser: {
enabled: true,
},
},
plugins: pluginList,
trustedOrigins: ["http://localhost:3002"],
advanced: {
useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev
@ -35,7 +63,6 @@ export let auth: AuthType | null = betterAuth({
},
},
});
export function initAuth(allowedOrigins: string[]) {
auth = betterAuth({
basePath: "/auth",
@ -58,18 +85,90 @@ export function initAuth(allowedOrigins: string[]) {
},
emailAndPassword: {
enabled: true,
// Disable email verification for now
requireEmailVerification: false,
},
// socialProviders: {
// google: {
// clientId: process.env.GOOGLE_CLIENT_ID!,
// clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
// },
// github: {
// clientId: process.env.GITHUB_CLIENT_ID!,
// clientSecret: process.env.GITHUB_CLIENT_SECRET!,
// },
// twitter: {
// clientId: process.env.TWITTER_CLIENT_ID!,
// clientSecret: process.env.TWITTER_CLIENT_SECRET!,
// },
// },
deleteUser: {
enabled: true,
},
plugins: [username(), admin(), organization()],
user: {
deleteUser: {
enabled: true,
// Add a hook to run before deleting a user
// i dont think this works
beforeDelete: async (user) => {
// Delete all memberships for this user first
console.log(
`Cleaning up memberships for user ${user.id} before deletion`
);
try {
// Delete member records for this user
await db
.delete(schema.member)
.where(eq(schema.member.userId, user.id));
console.log(`Successfully removed memberships for user ${user.id}`);
} catch (error) {
console.error(
`Error removing memberships for user ${user.id}:`,
error
);
throw error; // Re-throw to prevent user deletion if cleanup fails
}
},
},
},
plugins: pluginList,
trustedOrigins: allowedOrigins,
advanced: {
useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev
useSecureCookies: process.env.NODE_ENV === "production",
defaultCookieAttributes: {
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
path: "/",
},
},
// Use database hooks to create an organization after user signup
databaseHooks: {
user: {
create: {
after: async (user) => {
// Create an organization for the new user
console.info(user);
// if (auth) {
// try {
// const orgName = user.name || user.username || "My Organization";
// await auth.api.organization.createOrganization({
// body: {
// name: orgName,
// },
// headers: {
// "x-user-id": user.id,
// },
// });
// } catch (error) {
// console.error(
// "Error creating organization for new user:",
// error
// );
// }
// }
},
},
},
},
});
}

View file

@ -1,10 +0,0 @@
export function mapHeaders(headers: any) {
const entries = Object.entries(headers);
const map = new Map();
for (const [headerKey, headerValue] of entries) {
if (headerValue != null) {
map.set(headerKey, headerValue);
}
}
return map;
}

5
server/src/lib/const.ts Normal file
View file

@ -0,0 +1,5 @@
import dotenv from "dotenv";
dotenv.config();
export const IS_CLOUD = process.env.CLOUD === "true";