diff --git a/client/package-lock.json b/client/package-lock.json index 15bc952..0f3590a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index a1593bb..21bf0dd 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/app/components/AddSite.tsx b/client/src/app/components/AddSite.tsx index a93448c..9d9ae46 100644 --- a/client/src/app/components/AddSite.tsx +++ b/client/src/app/components/AddSite.tsx @@ -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(""); 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); + } }} > @@ -75,16 +102,54 @@ export function AddSite() { - Add Website + + + Add Website + + + Track analytics for a new website in your organization + -
- - setDomain(e.target.value)} - placeholder="example.com or sub.example.com" - /> + +
+
+ + setDomain(e.target.value)} + placeholder="example.com or sub.example.com" + /> +
+
+ + +
+ {error && ( @@ -94,16 +159,19 @@ export function AddSite() { )} diff --git a/client/src/app/components/CreateOrganizationDialog.tsx b/client/src/app/components/CreateOrganizationDialog.tsx new file mode 100644 index 0000000..af7c105 --- /dev/null +++ b/client/src/app/components/CreateOrganizationDialog.tsx @@ -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(""); + + // 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 ( + + {trigger && {trigger}} + + + + + Create Your Organization + + + Set up your organization to get started with Frogstats + + +
+
+
+ + handleNameChange(e.target.value)} + required + /> +
+ +
+ + + setSlug( + e.target.value + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, "") + ) + } + required + /> +

+ This will be used in your URL: frogstats.io/{slug} +

+
+ + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+
+ ); +} diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 8553f64..9805e9d 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -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 ( - + @@ -52,22 +57,20 @@ export default function RootLayout({ defer src="https://cdn.jsdelivr.net/npm/ldrs/dist/auto/ping.js" > - - - {pathname === "/login" ? ( -
- {children} -
- ) : ( -
- -
-
{children}
-
-
- )} -
-
+ + {pathname === "/login" || pathname === "/signup" ? ( +
+ {children} +
+ ) : ( +
+ +
+
{children}
+
+
+ )} +
); diff --git a/client/src/app/login/page.tsx b/client/src/app/login/page.tsx index c707c94..6fb82f9 100644 --- a/client/src/app/login/page.tsx +++ b/client/src/app/login/page.tsx @@ -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(); @@ -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() {
- + setUsername(e.target.value)} + value={email} + onChange={(e) => setEmail(e.target.value)} />
@@ -89,6 +103,41 @@ export default function Page() { + + {IS_CLOUD && ( + <> +
+ + Or continue with + +
+ +
+ + + +
+ + )} + {error && ( @@ -96,6 +145,15 @@ export default function Page() { {error} )} + + {IS_CLOUD && ( +
+ Don't have an account?{" "} + + Sign up + +
+ )}
diff --git a/client/src/app/page.tsx b/client/src/app/page.tsx index b4635ed..0ff638e 100644 --- a/client/src/app/page.tsx +++ b/client/src/app/page.tsx @@ -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 ( +
+ + +
+ +
+ Welcome to Frogstats + + You need to create an organization to get started + +
+ +

+ Organizations help you organize your websites and collaborate with + your team. +

+ +
+
+ + +
+ ); + } + + // Otherwise, show the normal dashboard with sites return (
@@ -24,6 +89,15 @@ export default function Home() { /> ); })} + {(!sites?.data || sites.data.length === 0) && + !userOrganizations.isPending && ( + + No websites yet + + Add your first website to start tracking analytics + + + )}
); diff --git a/client/src/app/settings/account/Account.tsx b/client/src/app/settings/account/Account.tsx index 0f26b2d..3e1ffea 100644 --- a/client/src/app/settings/account/Account.tsx +++ b/client/src/app/settings/account/Account.tsx @@ -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({ Account -
-

Username

-

- Update your username displayed across the platform -

-
- setUsername(target.value)} - placeholder="username" - /> - + {IS_CLOUD ? ( +
+

Name

+

+ Update your name displayed across the platform +

+
+ setName(target.value)} + placeholder="name" + /> + +
-
+ ) : ( +
+

Username

+

+ Update your username displayed across the platform +

+
+ setUsername(target.value)} + placeholder="username" + /> + +
+
+ )}

Email

diff --git a/client/src/app/settings/account/DeleteAccount.tsx b/client/src/app/settings/account/DeleteAccount.tsx index bad6171..71c448e 100644 --- a/client/src/app/settings/account/DeleteAccount.tsx +++ b/client/src/app/settings/account/DeleteAccount.tsx @@ -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 ( - + - - Are you absolutely sure? + + + Delete your account? + 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. - - Cancel - + + setPassword(e.target.value)} + className={`mt-1 ${passwordError ? "border-red-500" : ""}`} disabled={isDeleting} + /> + {passwordError && ( +

{passwordError}

+ )} +
+ + + + Cancel + + { + e.preventDefault(); + handleAccountDeletion(); + }} variant="destructive" + disabled={isDeleting} > - {isDeleting ? "Deleting..." : "Yes, delete my account"} + {isDeleting ? "Deleting..." : "Delete Account"} diff --git a/client/src/app/settings/users/AddUser.tsx b/client/src/app/settings/organizations/AddUser.tsx similarity index 100% rename from client/src/app/settings/users/AddUser.tsx rename to client/src/app/settings/organizations/AddUser.tsx diff --git a/client/src/app/settings/organizations/DeleteOrganizationDialog.tsx b/client/src/app/settings/organizations/DeleteOrganizationDialog.tsx new file mode 100644 index 0000000..b90a488 --- /dev/null +++ b/client/src/app/settings/organizations/DeleteOrganizationDialog.tsx @@ -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 ( + + + + + + + + + Delete Organization + + + This action cannot be undone. This will permanently delete the + organization and remove all associated data. + + +
+

+ Please type {organization.name} to confirm. +

+ ) => + setConfirmText(e.target.value) + } + placeholder={organization.name} + /> +
+ + + + +
+
+ ); +} diff --git a/client/src/app/settings/users/DeleteUser.tsx b/client/src/app/settings/organizations/DeleteUser.tsx similarity index 100% rename from client/src/app/settings/users/DeleteUser.tsx rename to client/src/app/settings/organizations/DeleteUser.tsx diff --git a/client/src/app/settings/organizations/EditOrganizationDialog.tsx b/client/src/app/settings/organizations/EditOrganizationDialog.tsx new file mode 100644 index 0000000..b7f208f --- /dev/null +++ b/client/src/app/settings/organizations/EditOrganizationDialog.tsx @@ -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 ( + + + + + + + Edit Organization + + Update the details of your organization. + + +
+
+ + ) => + setName(e.target.value) + } + /> +
+
+ + + + +
+
+ ); +} diff --git a/client/src/app/settings/organizations/InviteMemberDialog.tsx b/client/src/app/settings/organizations/InviteMemberDialog.tsx new file mode 100644 index 0000000..8292c84 --- /dev/null +++ b/client/src/app/settings/organizations/InviteMemberDialog.tsx @@ -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 ( + + + + + + + Invite a new member + + Send an invitation to add someone to this organization. + + +
+
+ + ) => + setEmail(e.target.value) + } + /> +
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/client/src/app/settings/organizations/Organizations.tsx b/client/src/app/settings/organizations/Organizations.tsx new file mode 100644 index 0000000..95d53dc --- /dev/null +++ b/client/src/app/settings/organizations/Organizations.tsx @@ -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 ( + + +
+ + {org.name} + + ({org.slug}) + + + +
+ {isOwner && ( + <> + + + + + )} +
+
+
+ + + + + Name + Email + Role + Joined + {isOwner && Actions} + + + + {members?.data?.map((member: any) => ( + + {member.user?.name || "—"} + {member.user?.email} + {member.role} + + {DateTime.fromISO(member.createdAt).toLocaleString( + DateTime.DATE_SHORT + )} + + {isOwner && ( + + {member.role !== "owner" && ( + + )} + + )} + + ))} + {(!members?.data || members.data.length === 0) && ( + + + No members found + + + )} + +
+
+
+ ); +} + +// OrganizationsInner component +function OrganizationsInner({ + organizations, +}: { + organizations: Organization[]; +}) { + return ( +
+ {organizations?.map((organization) => ( + + ))} + {organizations.length === 0 && ( + + You don't have any organizations yet. + + )} +
+ ); +} + +// Main Organizations component +export function Organizations() { + const userOrganizations = authClient.useListOrganizations(); + + if (userOrganizations.isPending) { + return ( +
+
Loading organizations...
+
+ ); + } + + if (!userOrganizations.data) { + return ( + + No organizations found + + ); + } + + return ; +} diff --git a/client/src/app/settings/organizations/RemoveMemberDialog.tsx b/client/src/app/settings/organizations/RemoveMemberDialog.tsx new file mode 100644 index 0000000..4e97a08 --- /dev/null +++ b/client/src/app/settings/organizations/RemoveMemberDialog.tsx @@ -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 ( + + + + + + + Remove Member + + Are you sure you want to remove this member from the organization? + + +
+

+ You are about to remove{" "} + {member.user.name || member.user.email} from this + organization. +

+
+ + + + +
+
+ ); +} diff --git a/client/src/app/settings/page.tsx b/client/src/app/settings/page.tsx index 70ca2ad..5d97dce 100644 --- a/client/src/app/settings/page.tsx +++ b/client/src/app/settings/page.tsx @@ -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
{selectedTab === "account" && session.data?.user && ( )} - {selectedTab === "users" && } + {selectedTab === "organizations" && } {selectedTab === "settings" && }
diff --git a/client/src/app/settings/users/Users.tsx b/client/src/app/settings/users/Users.tsx deleted file mode 100644 index 2977ed8..0000000 --- a/client/src/app/settings/users/Users.tsx +++ /dev/null @@ -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 ( -
- - - - Users - - - - - - {/* A list of your recent invoices. */} - - - Name - Role - Email - Created - - - - - {users?.data?.users?.map((user) => ( - - {user.name} - {user.role || "admin"} - {user.email} - - {DateTime.fromJSDate(user.createdAt).toLocaleString()} - - - {user.name !== "admin" && ( - - )} - - - ))} - -
-
-
-
- ); -} diff --git a/client/src/app/signup/page.tsx b/client/src/app/signup/page.tsx index d1646e1..a5601d3 100644 --- a/client/src/app/signup/page.tsx +++ b/client/src/app/signup/page.tsx @@ -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(); + 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 ( -
- +
+ - Login + Create an account - Enter your email below to login to your account + Enter your details below to create your account -
+
- + setName(e.target.value)} />
- - + + setEmail(e.target.value)} + />
- -
-
- Don't have an account?{""} - - Sign up - + +
+ + Or continue with + +
+ +
+ + + +
+ {error && ( + + + Error Creating Account + {error} + + )} + +
+ Already have an account?{" "} + + Log in + +
diff --git a/client/src/components/TopBar.tsx b/client/src/components/TopBar.tsx index d6288ef..ee3161d 100644 --- a/client/src/components/TopBar.tsx +++ b/client/src/components/TopBar.tsx @@ -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(); diff --git a/client/src/components/login-form.tsx b/client/src/components/login-form.tsx deleted file mode 100644 index 2000f69..0000000 --- a/client/src/components/login-form.tsx +++ /dev/null @@ -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 ( -
- - - Login - - Enter your email below to login to your account - - - -
-
-
- - -
-
- - -
- - -
-
- Don't have an account?{""} - - Sign up - -
-
-
-
-
- ) -} diff --git a/client/src/hooks/api.ts b/client/src/hooks/api.ts index e8de9d1..aae655d 100644 --- a/client/src/hooks/api.ts +++ b/client/src/hooks/api.ts @@ -243,12 +243,13 @@ export function useGetSites() { return useGenericQuery("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(`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({ + queryKey: ["organization-members", organizationId], + queryFn: () => + authedFetch( + `${BACKEND_URL}/list-organization-members/${organizationId}` + ).then((res) => res.json()), + staleTime: Infinity, + }); +}; diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index bb9fedd..5fc00ca 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -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"], }); diff --git a/client/src/lib/const.ts b/client/src/lib/const.ts index e760911..b0d2b4a 100644 --- a/client/src/lib/const.ts +++ b/client/src/lib/const.ts @@ -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"; diff --git a/server/docker-entrypoint.sh b/server/docker-entrypoint.sh index e3e3488..cd1ecff 100644 --- a/server/docker-entrypoint.sh +++ b/server/docker-entrypoint.sh @@ -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..." diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts index cae7c45..8400951 100644 --- a/server/drizzle.config.ts +++ b/server/drizzle.config.ts @@ -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, }); diff --git a/server/package-lock.json b/server/package-lock.json index efbd2c0..66ecb0c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index a78392d..2dc14b7 100644 --- a/server/package.json +++ b/server/package.json @@ -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" }, diff --git a/server/src/api/changeEmail.ts b/server/src/api/changeEmail.ts deleted file mode 100644 index 1ce8c00..0000000 --- a/server/src/api/changeEmail.ts +++ /dev/null @@ -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, - 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" }); - } -} diff --git a/server/src/api/changeUsername.ts b/server/src/api/changeUsername.ts deleted file mode 100644 index fb401ca..0000000 --- a/server/src/api/changeUsername.ts +++ /dev/null @@ -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, - 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" }); - } -} diff --git a/server/src/api/getLiveUsercount.ts b/server/src/api/getLiveUsercount.ts index 6810d33..22d2a7f 100644 --- a/server/src/api/getLiveUsercount.ts +++ b/server/src/api/getLiveUsercount.ts @@ -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) diff --git a/server/src/api/getOverview.ts b/server/src/api/getOverview.ts index 5c1a586..06180ee 100644 --- a/server/src/api/getOverview.ts +++ b/server/src/api/getOverview.ts @@ -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, + 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, diff --git a/server/src/api/getOverviewBucketed.ts b/server/src/api/getOverviewBucketed.ts index 5a1ab56..6ece2be 100644 --- a/server/src/api/getOverviewBucketed.ts +++ b/server/src/api/getOverviewBucketed.ts @@ -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, diff --git a/server/src/api/getSessions.ts b/server/src/api/getSessions.ts index e7671cd..632fdfa 100644 --- a/server/src/api/getSessions.ts +++ b/server/src/api/getSessions.ts @@ -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, + req: FastifyRequest, 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 = ` diff --git a/server/src/api/getSingleCol.ts b/server/src/api/getSingleCol.ts index a7f327d..1fa654e 100644 --- a/server/src/api/getSingleCol.ts +++ b/server/src/api/getSingleCol.ts @@ -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, + req: FastifyRequest, 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 = ` diff --git a/server/src/api/getUserSessions.ts b/server/src/api/getUserSessions.ts index b7b5089..520ff15 100644 --- a/server/src/api/getUserSessions.ts +++ b/server/src/api/getUserSessions.ts @@ -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, + req: FastifyRequest, 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 = ` diff --git a/server/src/api/listOrganizationMembers.ts b/server/src/api/listOrganizationMembers.ts new file mode 100644 index 0000000..9b69298 --- /dev/null +++ b/server/src/api/listOrganizationMembers.ts @@ -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, + 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", + }); + } +} diff --git a/server/src/api/sites/addSite.ts b/server/src/api/sites/addSite.ts index b43d7c0..6779c93 100644 --- a/server/src/api/sites/addSite.ts +++ b/server/src/api/sites/addSite.ts @@ -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", + }); } } diff --git a/server/src/api/sites/getSites.ts b/server/src/api/sites/getSites.ts index 7366ac3..70af686 100644 --- a/server/src/api/sites/getSites.ts +++ b/server/src/api/sites/getSites.ts @@ -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) }); diff --git a/server/src/db/postgres/postgres.ts b/server/src/db/postgres/postgres.ts index 9a29daf..7f69161 100644 --- a/server/src/db/postgres/postgres.ts +++ b/server/src/db/postgres/postgres.ts @@ -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); diff --git a/server/src/db/postgres/schema.ts b/server/src/db/postgres/schema.ts index 4952d77..dc91be9 100644 --- a/server/src/db/postgres/schema.ts +++ b/server/src/db/postgres/schema.ts @@ -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"), diff --git a/server/src/index.ts b/server/src/index.ts index 9c87c1b..fdfaab7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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); diff --git a/server/src/lib/auth-utils.ts b/server/src/lib/auth-utils.ts new file mode 100644 index 0000000..c864a40 --- /dev/null +++ b/server/src/lib/auth-utils.ts @@ -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)); +} diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts index 253c290..98969b1 100644 --- a/server/src/lib/auth.ts +++ b/server/src/lib/auth.ts @@ -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 | 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 + // ); + // } + // } + }, + }, + }, + }, }); } diff --git a/server/src/lib/betterAuth.ts b/server/src/lib/betterAuth.ts deleted file mode 100644 index 2675ba9..0000000 --- a/server/src/lib/betterAuth.ts +++ /dev/null @@ -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; -} diff --git a/server/src/lib/const.ts b/server/src/lib/const.ts new file mode 100644 index 0000000..1f5471c --- /dev/null +++ b/server/src/lib/const.ts @@ -0,0 +1,5 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +export const IS_CLOUD = process.env.CLOUD === "true";