mirror of
https://github.com/rybbit-io/rybbit.git
synced 2025-05-11 20:35:39 +02:00
init cloud version (#46)
* init cloud version * remove unused endpoints * implement deletion * Add organization creation * add getSitesUserHasAccessTo * Add permission gating for sites * add organization control * remove username login * support creating new sites for organizations * Delete unused page * wip
This commit is contained in:
parent
28eb6fe459
commit
2d22dc6fee
46 changed files with 2120 additions and 642 deletions
16
client/package-lock.json
generated
16
client/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -9,12 +9,20 @@ import {
|
|||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { addSite, useGetSites } from "../../hooks/api";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { AlertCircle, AppWindow, Building2 } from "lucide-react";
|
||||
import { authClient } from "../../lib/auth";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
|
||||
/**
|
||||
* A simple domain validation function:
|
||||
|
@ -30,18 +38,33 @@ function isValidDomain(domain: string): boolean {
|
|||
|
||||
export function AddSite() {
|
||||
const { data: sites, refetch } = useGetSites();
|
||||
const { data: organizations } = authClient.useListOrganizations();
|
||||
|
||||
const existingSites = sites?.data?.map((site) => site.domain);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [domain, setDomain] = useState("");
|
||||
const [selectedOrganizationId, setSelectedOrganizationId] =
|
||||
useState<string>("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Set the first organization as the default selection when organizations are loaded
|
||||
useEffect(() => {
|
||||
if (organizations && organizations.length > 0 && !selectedOrganizationId) {
|
||||
setSelectedOrganizationId(organizations[0].id);
|
||||
}
|
||||
}, [organizations, selectedOrganizationId]);
|
||||
|
||||
const domainMatchesExistingSites = existingSites?.includes(domain);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
|
||||
if (!selectedOrganizationId) {
|
||||
setError("Please select an organization");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate before attempting to add
|
||||
if (!isValidDomain(domain)) {
|
||||
setError(
|
||||
|
@ -50,10 +73,10 @@ export function AddSite() {
|
|||
return;
|
||||
}
|
||||
|
||||
const response = await addSite(domain, domain);
|
||||
const response = await addSite(domain, domain, selectedOrganizationId);
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.json();
|
||||
setError(errorMessage.error);
|
||||
const errorData = await response.json();
|
||||
setError(errorData.error || "Failed to add site");
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
|
@ -68,6 +91,10 @@ export function AddSite() {
|
|||
setOpen(isOpen);
|
||||
setDomain("");
|
||||
setError("");
|
||||
// Reset organization to first one when opening dialog
|
||||
if (isOpen && organizations && organizations.length > 0) {
|
||||
setSelectedOrganizationId(organizations[0].id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
|
@ -75,16 +102,54 @@ export function AddSite() {
|
|||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Website</DialogTitle>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AppWindow className="h-6 w-6" />
|
||||
Add Website
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Track analytics for a new website in your organization
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="domain">Domain</Label>
|
||||
<Input
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
placeholder="example.com or sub.example.com"
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="domain">Domain</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
placeholder="example.com or sub.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid w-full items-center gap-1.5">
|
||||
<Label htmlFor="organization">Organization</Label>
|
||||
<Select
|
||||
value={selectedOrganizationId}
|
||||
onValueChange={setSelectedOrganizationId}
|
||||
disabled={!organizations || organizations.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select an organization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations?.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
<div className="flex items-center">
|
||||
<Building2 className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
{org.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!organizations || organizations.length === 0) && (
|
||||
<SelectItem value="no-org" disabled>
|
||||
No organizations available
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
@ -94,16 +159,19 @@ export function AddSite() {
|
|||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
variant={"ghost"}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"success"}
|
||||
onClick={handleSubmit}
|
||||
disabled={!domain || domainMatchesExistingSites}
|
||||
disabled={
|
||||
!domain || domainMatchesExistingSites || !selectedOrganizationId
|
||||
}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
|
|
183
client/src/app/components/CreateOrganizationDialog.tsx
Normal file
183
client/src/app/components/CreateOrganizationDialog.tsx
Normal file
|
@ -0,0 +1,183 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Dialog } from "../../components/ui/dialog";
|
||||
import { DialogContent } from "../../components/ui/dialog";
|
||||
import { DialogHeader } from "../../components/ui/dialog";
|
||||
import { DialogTitle } from "../../components/ui/dialog";
|
||||
import { DialogTrigger } from "../../components/ui/dialog";
|
||||
import { DialogFooter } from "../../components/ui/dialog";
|
||||
import { DialogDescription } from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Building2 } from "lucide-react";
|
||||
import { authClient } from "../../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
interface CreateOrganizationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CreateOrganizationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
trigger,
|
||||
}: CreateOrganizationDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
// Generate slug from name when name changes
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (value) {
|
||||
const generatedSlug = value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
setSlug(generatedSlug);
|
||||
}
|
||||
};
|
||||
|
||||
// Create organization mutation
|
||||
const createOrgMutation = useMutation({
|
||||
mutationFn: async ({ name, slug }: { name: string; slug: string }) => {
|
||||
// Create organization
|
||||
const { data, error } = await authClient.organization.create({
|
||||
name,
|
||||
slug,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to create organization");
|
||||
}
|
||||
|
||||
if (!data?.id) {
|
||||
throw new Error("No organization ID returned");
|
||||
}
|
||||
|
||||
// Set as active organization
|
||||
await authClient.organization.setActive({
|
||||
organizationId: data.id,
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Organization created successfully");
|
||||
setName("");
|
||||
setSlug("");
|
||||
setError("");
|
||||
onOpenChange(false);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setError(error.message);
|
||||
toast.error("Failed to create organization");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name || !slug) {
|
||||
setError("Organization name and slug are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setError("");
|
||||
createOrgMutation.mutate({ name, slug });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl flex items-center gap-2">
|
||||
<Building2 className="h-6 w-6" />
|
||||
Create Your Organization
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set up your organization to get started with Frogstats
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Organization Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Acme Inc."
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">
|
||||
Organization Slug
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(URL identifier)
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
type="text"
|
||||
placeholder="acme-inc"
|
||||
value={slug}
|
||||
onChange={(e) =>
|
||||
setSlug(
|
||||
e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "")
|
||||
)
|
||||
}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This will be used in your URL: frogstats.io/{slug}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/15 text-destructive p-3 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="success"
|
||||
disabled={createOrgMutation.isPending || !name || !slug}
|
||||
>
|
||||
{createOrgMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -29,13 +29,18 @@ export default function RootLayout({
|
|||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !user && !publicRoutes.includes(pathname)) {
|
||||
if (
|
||||
!isPending &&
|
||||
!user &&
|
||||
!publicRoutes.includes(pathname) &&
|
||||
pathname !== "/signup"
|
||||
) {
|
||||
redirect("/login");
|
||||
}
|
||||
}, [isPending, user, pathname]);
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full dark" suppressHydrationWarning>
|
||||
<html lang="en" className="h-full dark">
|
||||
<body
|
||||
className={`${inter.className} h-full bg-background text-foreground`}
|
||||
>
|
||||
|
@ -52,22 +57,20 @@ export default function RootLayout({
|
|||
defer
|
||||
src="https://cdn.jsdelivr.net/npm/ldrs/dist/auto/ping.js"
|
||||
></script>
|
||||
<ThemeProvider>
|
||||
<QueryProvider>
|
||||
{pathname === "/login" ? (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-full">
|
||||
<TopBar />
|
||||
<main className="flex min-h-screen flex-col items-center p-4">
|
||||
<div className="w-full max-w-6xl">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
<QueryProvider>
|
||||
{pathname === "/login" || pathname === "/signup" ? (
|
||||
<div className="min-h-full flex items-center justify-center">
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-full">
|
||||
<TopBar />
|
||||
<main className="flex min-h-screen flex-col items-center p-4">
|
||||
<div className="w-full max-w-6xl">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</QueryProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
@ -4,15 +4,17 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "../../lib/auth";
|
||||
import { userStore } from "../../lib/userStore";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
|
||||
import { authClient } from "../../lib/auth";
|
||||
import { IS_CLOUD } from "../../lib/const";
|
||||
import { userStore } from "../../lib/userStore";
|
||||
|
||||
export default function Page() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
@ -24,8 +26,8 @@ export default function Page() {
|
|||
|
||||
setError("");
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.username({
|
||||
username,
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (data?.user) {
|
||||
|
@ -40,8 +42,20 @@ export default function Page() {
|
|||
}
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSocialSignIn = async (
|
||||
provider: "google" | "github" | "twitter"
|
||||
) => {
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/",
|
||||
});
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -57,14 +71,14 @@ export default function Page() {
|
|||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor={"email"}>Email</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="username"
|
||||
placeholder="username"
|
||||
id={"email"}
|
||||
type={"email"}
|
||||
placeholder={"email"}
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
@ -89,6 +103,41 @@ export default function Page() {
|
|||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Logging in..." : "Login"}
|
||||
</Button>
|
||||
|
||||
{IS_CLOUD && (
|
||||
<>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleSocialSignIn("google")}
|
||||
>
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleSocialSignIn("github")}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleSocialSignIn("twitter")}
|
||||
>
|
||||
X (Twitter)
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
@ -96,6 +145,15 @@ export default function Page() {
|
|||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{IS_CLOUD && (
|
||||
<div className="text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" className="underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
|
|
@ -1,12 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { SiteCard } from "../components/SiteCard";
|
||||
import { useGetSites } from "../hooks/api";
|
||||
import { authClient } from "../lib/auth";
|
||||
import { AddSite } from "./components/AddSite";
|
||||
import { CreateOrganizationDialog } from "./components/CreateOrganizationDialog";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../components/ui/card";
|
||||
import { Plus, Building } from "lucide-react";
|
||||
|
||||
export default function Home() {
|
||||
const { data: sites } = useGetSites();
|
||||
const { data: sites, refetch: refetchSites } = useGetSites();
|
||||
const userOrganizations = authClient.useListOrganizations();
|
||||
const [createOrgDialogOpen, setCreateOrgDialogOpen] = useState(false);
|
||||
|
||||
// Check if the user has no organizations and is not in a loading state
|
||||
const hasNoOrganizations =
|
||||
!userOrganizations.isPending &&
|
||||
Array.isArray(userOrganizations.data) &&
|
||||
userOrganizations.data.length === 0;
|
||||
|
||||
// Handle successful organization creation
|
||||
const handleOrganizationCreated = () => {
|
||||
userOrganizations.refetch();
|
||||
refetchSites();
|
||||
};
|
||||
|
||||
// If the user has no organizations, show a welcome message with the option to create one
|
||||
if (hasNoOrganizations) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Building className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Welcome to Frogstats</CardTitle>
|
||||
<CardDescription>
|
||||
You need to create an organization to get started
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center">
|
||||
<p className="text-muted-foreground text-center mb-6">
|
||||
Organizations help you organize your websites and collaborate with
|
||||
your team.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setCreateOrgDialogOpen(true)}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create an Organization
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateOrganizationDialog
|
||||
open={createOrgDialogOpen}
|
||||
onOpenChange={setCreateOrgDialogOpen}
|
||||
onSuccess={handleOrganizationCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, show the normal dashboard with sites
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col pt-1">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
|
@ -24,6 +89,15 @@ export default function Home() {
|
|||
/>
|
||||
);
|
||||
})}
|
||||
{(!sites?.data || sites.data.length === 0) &&
|
||||
!userOrganizations.isPending && (
|
||||
<Card className="col-span-full p-6 flex flex-col items-center text-center">
|
||||
<CardTitle className="mb-2 text-xl">No websites yet</CardTitle>
|
||||
<CardDescription>
|
||||
Add your first website to start tracking analytics
|
||||
</CardDescription>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { authClient } from "@/lib/auth";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
@ -6,13 +9,9 @@ import {
|
|||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { ChangePassword } from "./ChangePassword";
|
||||
import { DeleteAccount } from "./DeleteAccount";
|
||||
import { toast } from "sonner";
|
||||
import { changeUsername, changeEmail } from "@/hooks/api";
|
||||
import { IS_CLOUD } from "../../../lib/const";
|
||||
|
||||
export function Account({
|
||||
session,
|
||||
|
@ -21,8 +20,38 @@ export function Account({
|
|||
}) {
|
||||
const [username, setUsername] = useState(session.data?.user.username ?? "");
|
||||
const [email, setEmail] = useState(session.data?.user.email ?? "");
|
||||
const [name, setName] = useState(session.data?.user.name ?? "");
|
||||
const [isUpdatingUsername, setIsUpdatingUsername] = useState(false);
|
||||
const [isUpdatingEmail, setIsUpdatingEmail] = useState(false);
|
||||
const [isUpdatingName, setIsUpdatingName] = useState(false);
|
||||
|
||||
const handleNameUpdate = async () => {
|
||||
if (!name) {
|
||||
toast.error("Name cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUpdatingName(true);
|
||||
const response = await authClient.updateUser({
|
||||
name,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to update name");
|
||||
}
|
||||
|
||||
toast.success("Name updated successfully");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Error updating name:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to update name"
|
||||
);
|
||||
} finally {
|
||||
setIsUpdatingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUsernameUpdate = async () => {
|
||||
if (!username) {
|
||||
|
@ -32,11 +61,12 @@ export function Account({
|
|||
|
||||
try {
|
||||
setIsUpdatingUsername(true);
|
||||
const response = await changeUsername(username);
|
||||
const response = await authClient.updateUser({
|
||||
username,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to update username");
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to update username");
|
||||
}
|
||||
|
||||
toast.success("Username updated successfully");
|
||||
|
@ -66,11 +96,12 @@ export function Account({
|
|||
|
||||
try {
|
||||
setIsUpdatingEmail(true);
|
||||
const response = await changeEmail(email);
|
||||
const response = await authClient.changeEmail({
|
||||
newEmail: email,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to update email");
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message || "Failed to update email");
|
||||
}
|
||||
|
||||
toast.success("Email updated successfully");
|
||||
|
@ -94,29 +125,54 @@ export function Account({
|
|||
<CardTitle className="text-xl">Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Username</h4>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Update your username displayed across the platform
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={({ target }) => setUsername(target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUsernameUpdate}
|
||||
disabled={
|
||||
isUpdatingUsername || username === session.data?.user.username
|
||||
}
|
||||
>
|
||||
{isUpdatingUsername ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
{IS_CLOUD ? (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Name</h4>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Update your name displayed across the platform
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={({ target }) => setName(target.value)}
|
||||
placeholder="name"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNameUpdate}
|
||||
disabled={isUpdatingName || name === session.data?.user.name}
|
||||
>
|
||||
{isUpdatingName ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Username</h4>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Update your username displayed across the platform
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={({ target }) => setUsername(target.value)}
|
||||
placeholder="username"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleUsernameUpdate}
|
||||
disabled={
|
||||
isUpdatingUsername ||
|
||||
username === session.data?.user.username
|
||||
}
|
||||
>
|
||||
{isUpdatingUsername ? "Updating..." : "Update"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">Email</h4>
|
||||
|
|
|
@ -14,22 +14,48 @@ import {
|
|||
import { authClient } from "../../../lib/auth";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { useRouter } from "next/navigation";
|
||||
export function DeleteAccount() {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const handleAccountDeletion = async () => {
|
||||
if (!password) {
|
||||
setPasswordError("Password is required to delete your account");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const response = await authClient.deleteUser({});
|
||||
setPasswordError("");
|
||||
const response = await authClient.deleteUser({
|
||||
password,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
toast.error(`Failed to delete account: ${response.error.message}`);
|
||||
toast.error(
|
||||
`Failed to delete account: ${
|
||||
response.error.message || "Unknown error"
|
||||
}`
|
||||
);
|
||||
if (
|
||||
response.error.message &&
|
||||
response.error.message.toLowerCase().includes("password")
|
||||
) {
|
||||
setPasswordError("Incorrect password");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Account successfully deleted");
|
||||
// The user will be redirected to the login page by the auth system
|
||||
setIsOpen(false);
|
||||
|
||||
router.push("/login");
|
||||
} catch (error) {
|
||||
toast.error(`Failed to delete account: ${error}`);
|
||||
} finally {
|
||||
|
@ -37,30 +63,66 @@ export function DeleteAccount() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setPassword("");
|
||||
setPasswordError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" color="hsl(var(--red-500))" />
|
||||
Delete your account?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
account and remove all of your data from our servers.
|
||||
account and remove all your data from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleAccountDeletion}
|
||||
|
||||
<div className="py-1">
|
||||
<Label htmlFor="password" className="text-sm font-medium">
|
||||
Enter your password to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`mt-1 ${passwordError ? "border-red-500" : ""}`}
|
||||
disabled={isDeleting}
|
||||
/>
|
||||
{passwordError && (
|
||||
<p className="text-sm text-red-500 mt-1">{passwordError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAccountDeletion();
|
||||
}}
|
||||
variant="destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Yes, delete my account"}
|
||||
{isDeleting ? "Deleting..." : "Delete Account"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Dialog } from "../../../components/ui/dialog";
|
||||
import { DialogContent } from "../../../components/ui/dialog";
|
||||
import { DialogHeader } from "../../../components/ui/dialog";
|
||||
import { DialogTitle } from "../../../components/ui/dialog";
|
||||
import { DialogTrigger } from "../../../components/ui/dialog";
|
||||
import { DialogFooter } from "../../../components/ui/dialog";
|
||||
import { DialogDescription } from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { AlertTriangle, Trash } from "lucide-react";
|
||||
import { authClient } from "../../../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Organization } from "./Organizations";
|
||||
|
||||
interface DeleteOrganizationDialogProps {
|
||||
organization: Organization;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function DeleteOrganizationDialog({
|
||||
organization,
|
||||
onSuccess,
|
||||
}: DeleteOrganizationDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirmText !== organization.name) {
|
||||
toast.error("Please type the organization name to confirm deletion");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authClient.organization.delete({
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
toast.success("Organization deleted successfully");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to delete organization");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive" className="ml-2">
|
||||
<Trash className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Delete Organization
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
organization and remove all associated data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<p>
|
||||
Please type <strong>{organization.name}</strong> to confirm.
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setConfirmText(e.target.value)
|
||||
}
|
||||
placeholder={organization.name}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isLoading || confirmText !== organization.name}
|
||||
>
|
||||
{isLoading ? "Deleting..." : "Delete Organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Dialog } from "../../../components/ui/dialog";
|
||||
import { DialogContent } from "../../../components/ui/dialog";
|
||||
import { DialogHeader } from "../../../components/ui/dialog";
|
||||
import { DialogTitle } from "../../../components/ui/dialog";
|
||||
import { DialogTrigger } from "../../../components/ui/dialog";
|
||||
import { DialogFooter } from "../../../components/ui/dialog";
|
||||
import { DialogDescription } from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Edit } from "lucide-react";
|
||||
import { authClient } from "../../../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Organization } from "./Organizations";
|
||||
|
||||
interface EditOrganizationDialogProps {
|
||||
organization: Organization;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function EditOrganizationDialog({
|
||||
organization,
|
||||
onSuccess,
|
||||
}: EditOrganizationDialogProps) {
|
||||
const [name, setName] = useState(organization.name);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!name) {
|
||||
toast.error("Organization name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Using the appropriate method and parameters based on Better Auth API
|
||||
await authClient.organization.update({
|
||||
organizationId: organization.id,
|
||||
data: { name },
|
||||
});
|
||||
|
||||
toast.success("Organization updated successfully");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to update organization");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="ml-2">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the details of your organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Organization Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setName(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={isLoading} variant="success">
|
||||
{isLoading ? "Updating..." : "Update Organization"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
116
client/src/app/settings/organizations/InviteMemberDialog.tsx
Normal file
116
client/src/app/settings/organizations/InviteMemberDialog.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Dialog } from "../../../components/ui/dialog";
|
||||
import { DialogContent } from "../../../components/ui/dialog";
|
||||
import { DialogHeader } from "../../../components/ui/dialog";
|
||||
import { DialogTitle } from "../../../components/ui/dialog";
|
||||
import { DialogTrigger } from "../../../components/ui/dialog";
|
||||
import { DialogFooter } from "../../../components/ui/dialog";
|
||||
import { DialogDescription } from "../../../components/ui/dialog";
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Select } from "../../../components/ui/select";
|
||||
import { SelectContent } from "../../../components/ui/select";
|
||||
import { SelectItem } from "../../../components/ui/select";
|
||||
import { SelectTrigger } from "../../../components/ui/select";
|
||||
import { SelectValue } from "../../../components/ui/select";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { authClient } from "../../../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface InviteMemberDialogProps {
|
||||
organizationId: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function InviteMemberDialog({
|
||||
organizationId,
|
||||
onSuccess,
|
||||
}: InviteMemberDialogProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState("member");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!email) {
|
||||
toast.error("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authClient.organization.inviteMember({
|
||||
email,
|
||||
role: role as "admin" | "member" | "owner",
|
||||
organizationId,
|
||||
});
|
||||
|
||||
toast.success(`Invitation sent to ${email}`);
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
setEmail("");
|
||||
setRole("member");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to send invitation");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="ml-2">
|
||||
<UserPlus className="h-4 w-4 mr-1" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite a new member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send an invitation to add someone to this organization.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
value={email}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setEmail(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleInvite} disabled={isLoading} variant="success">
|
||||
{isLoading ? "Sending..." : "Send Invitation"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
186
client/src/app/settings/organizations/Organizations.tsx
Normal file
186
client/src/app/settings/organizations/Organizations.tsx
Normal file
|
@ -0,0 +1,186 @@
|
|||
"use client";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { useOrganizationMembers } from "../../../hooks/api";
|
||||
import { authClient } from "../../../lib/auth";
|
||||
|
||||
// Import the separated dialog components
|
||||
import { DeleteOrganizationDialog } from "./DeleteOrganizationDialog";
|
||||
import { EditOrganizationDialog } from "./EditOrganizationDialog";
|
||||
import { RemoveMemberDialog } from "./RemoveMemberDialog";
|
||||
import { InviteMemberDialog } from "./InviteMemberDialog";
|
||||
|
||||
// Types for our component
|
||||
export type Organization = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type Member = {
|
||||
id: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// Organization Component with Members Table
|
||||
function Organization({ org }: { org: Organization }) {
|
||||
const { data: members, refetch } = useOrganizationMembers(org.id);
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
const isOwner = members?.data.find(
|
||||
(member) => member.role === "owner" && member.userId === data?.user?.id
|
||||
);
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xl">
|
||||
{org.name}
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({org.slug})
|
||||
</span>
|
||||
</CardTitle>
|
||||
|
||||
<div className="flex items-center">
|
||||
{isOwner && (
|
||||
<>
|
||||
<InviteMemberDialog
|
||||
organizationId={org.id}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
<EditOrganizationDialog
|
||||
organization={org}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
<DeleteOrganizationDialog
|
||||
organization={org}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
{isOwner && <TableHead className="w-12">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members?.data?.map((member: any) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell>{member.user?.name || "—"}</TableCell>
|
||||
<TableCell>{member.user?.email}</TableCell>
|
||||
<TableCell className="capitalize">{member.role}</TableCell>
|
||||
<TableCell>
|
||||
{DateTime.fromISO(member.createdAt).toLocaleString(
|
||||
DateTime.DATE_SHORT
|
||||
)}
|
||||
</TableCell>
|
||||
{isOwner && (
|
||||
<TableCell className="text-right">
|
||||
{member.role !== "owner" && (
|
||||
<RemoveMemberDialog
|
||||
member={member}
|
||||
organizationId={org.id}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
{(!members?.data || members.data.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isOwner ? 5 : 4}
|
||||
className="text-center py-6 text-muted-foreground"
|
||||
>
|
||||
No members found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// OrganizationsInner component
|
||||
function OrganizationsInner({
|
||||
organizations,
|
||||
}: {
|
||||
organizations: Organization[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{organizations?.map((organization) => (
|
||||
<Organization key={organization.id} org={organization} />
|
||||
))}
|
||||
{organizations.length === 0 && (
|
||||
<Card className="p-6 text-center text-muted-foreground">
|
||||
You don't have any organizations yet.
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main Organizations component
|
||||
export function Organizations() {
|
||||
const userOrganizations = authClient.useListOrganizations();
|
||||
|
||||
if (userOrganizations.isPending) {
|
||||
return (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-pulse">Loading organizations...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!userOrganizations.data) {
|
||||
return (
|
||||
<Card className="p-6 text-center text-muted-foreground">
|
||||
No organizations found
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return <OrganizationsInner organizations={userOrganizations.data as any} />;
|
||||
}
|
86
client/src/app/settings/organizations/RemoveMemberDialog.tsx
Normal file
86
client/src/app/settings/organizations/RemoveMemberDialog.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { Dialog } from "../../../components/ui/dialog";
|
||||
import { DialogContent } from "../../../components/ui/dialog";
|
||||
import { DialogHeader } from "../../../components/ui/dialog";
|
||||
import { DialogTitle } from "../../../components/ui/dialog";
|
||||
import { DialogTrigger } from "../../../components/ui/dialog";
|
||||
import { DialogFooter } from "../../../components/ui/dialog";
|
||||
import { DialogDescription } from "../../../components/ui/dialog";
|
||||
import { UserMinus } from "lucide-react";
|
||||
import { authClient } from "../../../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Member } from "./Organizations";
|
||||
|
||||
interface RemoveMemberDialogProps {
|
||||
member: Member;
|
||||
organizationId: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function RemoveMemberDialog({
|
||||
member,
|
||||
organizationId,
|
||||
onSuccess,
|
||||
}: RemoveMemberDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Using the appropriate method and parameters based on Better Auth API
|
||||
await authClient.organization.removeMember({
|
||||
memberIdOrEmail: member.id,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
toast.success("Member removed successfully");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to remove member");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to remove this member from the organization?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p>
|
||||
You are about to remove{" "}
|
||||
<strong>{member.user.name || member.user.email}</strong> from this
|
||||
organization.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleRemove}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Removing..." : "Remove Member"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -8,14 +8,14 @@ import { GearSix, User, Users as Users_ } from "@phosphor-icons/react";
|
|||
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Account } from "./account/Account";
|
||||
import { Users } from "./users/Users";
|
||||
import { Organizations } from "./organizations/Organizations";
|
||||
import { Settings } from "./settings/settings";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const session = authClient.useSession();
|
||||
const router = useRouter();
|
||||
const [selectedTab, setSelectedTab] = useState<
|
||||
"account" | "settings" | "users"
|
||||
"account" | "settings" | "organizations"
|
||||
>("account");
|
||||
|
||||
return (
|
||||
|
@ -39,18 +39,18 @@ export default function SettingsPage() {
|
|||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTab === "users" ? "default" : "ghost"}
|
||||
onClick={() => setSelectedTab("users")}
|
||||
variant={selectedTab === "organizations" ? "default" : "ghost"}
|
||||
onClick={() => setSelectedTab("organizations")}
|
||||
className="justify-start"
|
||||
>
|
||||
<Users_ size={16} weight="bold" />
|
||||
Users
|
||||
Organizations
|
||||
</Button>
|
||||
</div>
|
||||
{selectedTab === "account" && session.data?.user && (
|
||||
<Account session={session} />
|
||||
)}
|
||||
{selectedTab === "users" && <Users />}
|
||||
{selectedTab === "organizations" && <Organizations />}
|
||||
{selectedTab === "settings" && <Settings />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { DateTime } from "luxon";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { authClient } from "../../../lib/auth";
|
||||
import { AddUser } from "./AddUser";
|
||||
import { DeleteUser } from "./DeleteUser";
|
||||
|
||||
export function Users() {
|
||||
const { data: users, refetch } = useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
const users = await authClient.admin.listUsers({ query: { limit: 100 } });
|
||||
return users;
|
||||
},
|
||||
});
|
||||
|
||||
if (users?.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="p-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex justify-between items-center">
|
||||
Users
|
||||
<AddUser refetch={refetch} />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<Table>
|
||||
{/* <TableCaption>A list of your recent invoices.</TableCaption> */}
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users?.data?.users?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.role || "admin"}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
{DateTime.fromJSDate(user.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.name !== "admin" && (
|
||||
<DeleteUser user={user} refetch={refetch} />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
@ -11,50 +10,159 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
|
||||
import { authClient } from "../../lib/auth";
|
||||
import { userStore } from "../../lib/userStore";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// Sign up with email and password
|
||||
const { data, error } = await authClient.signUp.email({
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
});
|
||||
|
||||
if (data?.user) {
|
||||
userStore.setState({
|
||||
user: data.user,
|
||||
});
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSocialSignIn = async (
|
||||
provider: "google" | "github" | "twitter"
|
||||
) => {
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/",
|
||||
});
|
||||
} catch (error) {
|
||||
setError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6")}>
|
||||
<Card>
|
||||
<div className="flex justify-center items-center h-screen w-full">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Login</CardTitle>
|
||||
<CardTitle className="text-2xl">Create an account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account
|
||||
Enter your details below to create your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="username"
|
||||
placeholder="username"
|
||||
required
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" required />
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Login
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Creating account..." : "Sign Up"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{""}
|
||||
<a href="/signup" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleSocialSignIn("google")}
|
||||
>
|
||||
Google
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleSocialSignIn("github")}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleSocialSignIn("twitter")}
|
||||
>
|
||||
X (Twitter)
|
||||
</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error Creating Account</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -1,68 +0,0 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<"div">) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Login</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email below to login to your account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="m@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<a
|
||||
href="#"
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
<Input id="password" type="password" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Login
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full">
|
||||
Login with Google
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm">
|
||||
Don't have an account?{""}
|
||||
<a href="#" className="underline underline-offset-4">
|
||||
Sign up
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -243,12 +243,13 @@ export function useGetSites() {
|
|||
return useGenericQuery<GetSitesResponse>("get-sites");
|
||||
}
|
||||
|
||||
export function addSite(domain: string, name: string) {
|
||||
export function addSite(domain: string, name: string, organizationId: string) {
|
||||
return authedFetch(`${BACKEND_URL}/add-site`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
domain,
|
||||
name,
|
||||
organizationId,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
@ -279,30 +280,6 @@ export function useSiteHasData(siteId: string) {
|
|||
return useGenericQuery<boolean>(`site-has-data/${siteId}`);
|
||||
}
|
||||
|
||||
export function changeUsername(newUsername: string) {
|
||||
return authedFetch(`${BACKEND_URL}/change-username`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
newUsername,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function changeEmail(newEmail: string) {
|
||||
return authedFetch(`${BACKEND_URL}/change-email`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
newEmail,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Updated type for grouped sessions from the API
|
||||
export type UserSessionsResponse = {
|
||||
session_id: string;
|
||||
|
@ -388,3 +365,29 @@ export function useGetSessionsInfinite() {
|
|||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
type GetOrganizationMembersResponse = {
|
||||
data: {
|
||||
id: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
createdAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export const useOrganizationMembers = (organizationId: string) => {
|
||||
return useQuery<GetOrganizationMembersResponse>({
|
||||
queryKey: ["organization-members", organizationId],
|
||||
queryFn: () =>
|
||||
authedFetch(
|
||||
`${BACKEND_URL}/list-organization-members/${organizationId}`
|
||||
).then((res) => res.json()),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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..."
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import "dotenv/config";
|
||||
import dotenv from "dotenv";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./src/db/postgres/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
host: "5.78.110.218",
|
||||
// host: "postgres",
|
||||
host: process.env.POSTGRES_HOST || "postgres",
|
||||
port: 5432,
|
||||
database: "analytics",
|
||||
user: "frog",
|
||||
|
@ -15,5 +16,4 @@ export default defineConfig({
|
|||
ssl: false,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
|
|
335
server/package-lock.json
generated
335
server/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -1,88 +0,0 @@
|
|||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/postgres/postgres.js";
|
||||
import { users } from "../db/postgres/schema.js";
|
||||
import { auth } from "../lib/auth.js";
|
||||
|
||||
interface ChangeEmailRequest {
|
||||
Body: {
|
||||
newEmail: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function changeEmail(
|
||||
request: FastifyRequest<ChangeEmailRequest>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { newEmail } = request.body;
|
||||
|
||||
// Validate input
|
||||
if (!newEmail || newEmail.trim() === "") {
|
||||
return reply.status(400).send({
|
||||
error: "New email cannot be empty",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(newEmail)) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid email format",
|
||||
});
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth!.api.getSession({
|
||||
headers: fromNodeHeaders(request.headers),
|
||||
});
|
||||
|
||||
if (!session?.user.id) {
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check if the email is already taken
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, newEmail))
|
||||
.execute();
|
||||
|
||||
if (existingUser.length > 0 && existingUser[0].id !== userId) {
|
||||
return reply.status(409).send({ error: "Email already exists" });
|
||||
}
|
||||
|
||||
// Update the user's email
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
email: newEmail,
|
||||
updatedAt: new Date(),
|
||||
// Note: In a production app, you might want to reset emailVerified to false here
|
||||
// and send a verification email to the new address
|
||||
// emailVerified: false,
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.execute();
|
||||
|
||||
return reply.status(200).send({
|
||||
message: "Email updated successfully",
|
||||
email: newEmail,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error changing email:", error);
|
||||
|
||||
// Handle specific errors
|
||||
if (
|
||||
error.message?.includes("unique constraint") &&
|
||||
error.message.includes("email")
|
||||
) {
|
||||
return reply.status(409).send({ error: "Email already exists" });
|
||||
}
|
||||
|
||||
return reply.status(500).send({ error: "Failed to update email" });
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/postgres/postgres.js";
|
||||
import { users } from "../db/postgres/schema.js";
|
||||
import { auth } from "../lib/auth.js";
|
||||
|
||||
interface ChangeUsernameRequest {
|
||||
Body: {
|
||||
newUsername: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function changeUsername(
|
||||
request: FastifyRequest<ChangeUsernameRequest>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { newUsername } = request.body;
|
||||
|
||||
// Validate input
|
||||
if (!newUsername || newUsername.trim() === "") {
|
||||
return reply.status(400).send({
|
||||
error: "New username cannot be empty",
|
||||
});
|
||||
}
|
||||
|
||||
// Get current user session
|
||||
const session = await auth!.api.getSession({
|
||||
headers: fromNodeHeaders(request.headers),
|
||||
});
|
||||
|
||||
if (!session?.user.id) {
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check if the username is already taken
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.username, newUsername))
|
||||
.execute();
|
||||
|
||||
if (existingUser.length > 0 && existingUser[0].id !== userId) {
|
||||
return reply.status(409).send({ error: "Username already exists" });
|
||||
}
|
||||
|
||||
// Update the user's username
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
username: newUsername,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.execute();
|
||||
|
||||
return reply.status(200).send({
|
||||
message: "Username updated successfully",
|
||||
username: newUsername,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error changing username:", error);
|
||||
|
||||
// Handle specific errors
|
||||
if (
|
||||
error.message?.includes("unique constraint") &&
|
||||
error.message.includes("username")
|
||||
) {
|
||||
return reply.status(409).send({ error: "Username already exists" });
|
||||
}
|
||||
|
||||
return reply.status(500).send({ error: "Failed to update username" });
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
getTimeStatement,
|
||||
processResults,
|
||||
} from "./utils.js";
|
||||
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
|
||||
|
||||
type GetOverviewResponse = {
|
||||
sessions: number;
|
||||
|
@ -127,55 +128,17 @@ const getQuery = ({
|
|||
};
|
||||
|
||||
export async function getOverview(
|
||||
{
|
||||
query: { startDate, endDate, timezone, site, filters, past24Hours },
|
||||
}: FastifyRequest<GenericRequest & { Querystring: { past24Hours: boolean } }>,
|
||||
req: FastifyRequest<
|
||||
GenericRequest & { Querystring: { past24Hours: boolean } }
|
||||
>,
|
||||
res: FastifyReply
|
||||
) {
|
||||
const filterStatement = getFilterStatement(filters);
|
||||
// const query = `SELECT
|
||||
// session_stats.sessions,
|
||||
// session_stats.pages_per_session,
|
||||
// session_stats.bounce_rate * 100 AS bounce_rate,
|
||||
// session_stats.session_duration,
|
||||
// page_stats.pageviews,
|
||||
// page_stats.users
|
||||
// FROM
|
||||
// (
|
||||
// -- Session-level metrics
|
||||
// SELECT
|
||||
// COUNT() AS sessions,
|
||||
// AVG(pages_in_session) AS pages_per_session,
|
||||
// sumIf(1, pages_in_session = 1) / COUNT() AS bounce_rate,
|
||||
// AVG(end_time - start_time) AS session_duration
|
||||
// FROM
|
||||
// (
|
||||
// -- Build a summary row per session
|
||||
// SELECT
|
||||
// session_id,
|
||||
// MIN(timestamp) AS start_time,
|
||||
// MAX(timestamp) AS end_time,
|
||||
// COUNT(*) AS pages_in_session
|
||||
// FROM pageviews
|
||||
// WHERE
|
||||
// site_id = ${site}
|
||||
// ${filterStatement}
|
||||
// ${getTimeStatement(startDate, endDate, timezone)}
|
||||
// GROUP BY session_id
|
||||
// )
|
||||
// ) AS session_stats
|
||||
// CROSS JOIN
|
||||
// (
|
||||
// -- Page-level and user-level metrics
|
||||
// SELECT
|
||||
// COUNT(*) AS pageviews,
|
||||
// COUNT(DISTINCT user_id) AS users
|
||||
// FROM pageviews
|
||||
// WHERE
|
||||
// site_id = ${site}
|
||||
// ${filterStatement}
|
||||
// ${getTimeStatement(startDate, endDate, timezone)}
|
||||
// ) AS page_stats`;
|
||||
const { startDate, endDate, timezone, site, filters, past24Hours } =
|
||||
req.query;
|
||||
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
|
||||
if (!userHasAccessToSite) {
|
||||
return res.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const query = getQuery({
|
||||
startDate,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
getTimeStatement,
|
||||
processResults,
|
||||
} from "./utils.js";
|
||||
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
|
||||
|
||||
type GetSessionsResponse = {
|
||||
session_id: string;
|
||||
|
@ -32,11 +33,15 @@ export interface GetSessionsRequest {
|
|||
}
|
||||
|
||||
export async function getSessions(
|
||||
{
|
||||
query: { startDate, endDate, timezone, site, filters, page },
|
||||
}: FastifyRequest<GetSessionsRequest>,
|
||||
req: FastifyRequest<GetSessionsRequest>,
|
||||
res: FastifyReply
|
||||
) {
|
||||
const { startDate, endDate, timezone, site, filters, page } = req.query;
|
||||
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
|
||||
if (!userHasAccessToSite) {
|
||||
return res.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const filterStatement = getFilterStatement(filters);
|
||||
|
||||
const query = `
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
getTimeStatement,
|
||||
processResults,
|
||||
} from "./utils.js";
|
||||
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
|
||||
|
||||
type GetSingleColResponse = {
|
||||
value: string;
|
||||
|
@ -15,11 +16,17 @@ type GetSingleColResponse = {
|
|||
}[];
|
||||
|
||||
export async function getSingleCol(
|
||||
{
|
||||
query: { startDate, endDate, timezone, site, filters, parameter, limit },
|
||||
}: FastifyRequest<GenericRequest>,
|
||||
req: FastifyRequest<GenericRequest>,
|
||||
res: FastifyReply
|
||||
) {
|
||||
const { startDate, endDate, timezone, site, filters, parameter, limit } =
|
||||
req.query;
|
||||
|
||||
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
|
||||
if (!userHasAccessToSite) {
|
||||
return res.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const filterStatement = getFilterStatement(filters);
|
||||
|
||||
const query = `
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
getTimeStatement,
|
||||
processResults,
|
||||
} from "./utils.js";
|
||||
import { getUserHasAccessToSite } from "../lib/auth-utils.js";
|
||||
|
||||
// Individual pageview type
|
||||
type Pageview = {
|
||||
|
@ -53,12 +54,17 @@ export interface GetUserSessionsRequest {
|
|||
}
|
||||
|
||||
export async function getUserSessions(
|
||||
{
|
||||
query: { startDate, endDate, timezone, site, filters },
|
||||
params: { userId },
|
||||
}: FastifyRequest<GetUserSessionsRequest>,
|
||||
req: FastifyRequest<GetUserSessionsRequest>,
|
||||
res: FastifyReply
|
||||
) {
|
||||
const { startDate, endDate, timezone, site, filters } = req.query;
|
||||
const userId = req.params.userId;
|
||||
|
||||
const userHasAccessToSite = await getUserHasAccessToSite(req, site);
|
||||
if (!userHasAccessToSite) {
|
||||
return res.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
|
||||
const filterStatement = getFilterStatement(filters);
|
||||
|
||||
const query = `
|
||||
|
|
96
server/src/api/listOrganizationMembers.ts
Normal file
96
server/src/api/listOrganizationMembers.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
import { FastifyRequest, FastifyReply } from "fastify";
|
||||
import { db } from "../db/postgres/postgres.js";
|
||||
import { member, users } from "../db/postgres/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth.js";
|
||||
|
||||
interface ListOrganizationMembersRequest {
|
||||
Params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Define user interface based on schema
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export async function listOrganizationMembers(
|
||||
request: FastifyRequest<ListOrganizationMembersRequest>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const { organizationId } = request.params;
|
||||
|
||||
// Get current user's session
|
||||
const headers = new Headers(request.headers as any);
|
||||
const session = await auth!.api.getSession({ headers });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return reply.status(401).send({
|
||||
error: "Unauthorized",
|
||||
message: "You must be logged in to access this resource",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is a member of this organization
|
||||
const userMembership = await db.query.member.findFirst({
|
||||
where: and(
|
||||
eq(member.userId, session.user.id),
|
||||
eq(member.organizationId, organizationId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMembership) {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "You do not have access to this organization",
|
||||
});
|
||||
}
|
||||
|
||||
// User has access, fetch all members of the organization
|
||||
// Use a direct SQL query approach instead of relations
|
||||
const organizationMembers = await db
|
||||
.select({
|
||||
id: member.id,
|
||||
role: member.role,
|
||||
userId: member.userId,
|
||||
organizationId: member.organizationId,
|
||||
createdAt: member.createdAt,
|
||||
// User fields
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
userImage: users.image,
|
||||
userActualId: users.id,
|
||||
})
|
||||
.from(member)
|
||||
.leftJoin(users, eq(member.userId, users.id))
|
||||
.where(eq(member.organizationId, organizationId));
|
||||
|
||||
// Transform the results to the expected format
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: organizationMembers.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
userId: m.userId,
|
||||
organizationId: m.organizationId,
|
||||
createdAt: m.createdAt,
|
||||
user: {
|
||||
id: m.userActualId,
|
||||
name: m.userName,
|
||||
email: m.userEmail,
|
||||
},
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error listing organization members:", error);
|
||||
return reply.status(500).send({
|
||||
error: "InternalServerError",
|
||||
message: "An error occurred while listing organization members",
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) });
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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);
|
||||
|
|
63
server/src/lib/auth-utils.ts
Normal file
63
server/src/lib/auth-utils.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { FastifyRequest } from "fastify";
|
||||
import { auth } from "./auth.js";
|
||||
import { sites, member } from "../db/postgres/schema.js";
|
||||
import { inArray, eq } from "drizzle-orm";
|
||||
import { db } from "../db/postgres/postgres.js";
|
||||
|
||||
export function mapHeaders(headers: any) {
|
||||
const entries = Object.entries(headers);
|
||||
const map = new Map();
|
||||
for (const [headerKey, headerValue] of entries) {
|
||||
if (headerValue != null) {
|
||||
map.set(headerKey, headerValue);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function getSitesUserHasAccessTo(req: FastifyRequest) {
|
||||
const headers = new Headers(req.headers as any);
|
||||
const session = await auth!.api.getSession({ headers });
|
||||
|
||||
const userId = session?.user.id;
|
||||
|
||||
if (!userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the user's organization IDs directly from the database
|
||||
const memberRecords = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId));
|
||||
|
||||
if (!memberRecords || memberRecords.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract organization IDs
|
||||
const organizationIds = memberRecords.map(
|
||||
(record) => record.organizationId
|
||||
);
|
||||
|
||||
// Get sites for these organizations
|
||||
const siteRecords = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(inArray(sites.organizationId, organizationIds));
|
||||
|
||||
return siteRecords;
|
||||
} catch (error) {
|
||||
console.error("Error getting sites user has access to:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserHasAccessToSite(
|
||||
req: FastifyRequest,
|
||||
siteId: string
|
||||
) {
|
||||
const sites = await getSitesUserHasAccessTo(req);
|
||||
return sites.some((site) => site.siteId === Number(siteId));
|
||||
}
|
|
@ -4,12 +4,35 @@ import dotenv from "dotenv";
|
|||
import pg from "pg";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db } from "../db/postgres/postgres.js";
|
||||
import { IS_CLOUD } from "./const.js";
|
||||
import * as schema from "../db/postgres/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
type AuthType = ReturnType<typeof betterAuth> | null;
|
||||
|
||||
const pluginList = IS_CLOUD
|
||||
? [
|
||||
admin(),
|
||||
organization({
|
||||
// Allow users to create organizations
|
||||
allowUserToCreateOrganization: true,
|
||||
// Set the creator role to owner
|
||||
creatorRole: "owner",
|
||||
}),
|
||||
]
|
||||
: [
|
||||
username(),
|
||||
admin(),
|
||||
organization({
|
||||
// Allow users to create organizations
|
||||
allowUserToCreateOrganization: true,
|
||||
// Set the creator role to owner
|
||||
creatorRole: "owner",
|
||||
}),
|
||||
];
|
||||
|
||||
export let auth: AuthType | null = betterAuth({
|
||||
basePath: "/auth",
|
||||
database: new pg.Pool({
|
||||
|
@ -25,7 +48,12 @@ export let auth: AuthType | null = betterAuth({
|
|||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [username(), admin(), organization()],
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
plugins: pluginList,
|
||||
trustedOrigins: ["http://localhost:3002"],
|
||||
advanced: {
|
||||
useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev
|
||||
|
@ -35,7 +63,6 @@ export let auth: AuthType | null = betterAuth({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function initAuth(allowedOrigins: string[]) {
|
||||
auth = betterAuth({
|
||||
basePath: "/auth",
|
||||
|
@ -58,18 +85,90 @@ export function initAuth(allowedOrigins: string[]) {
|
|||
},
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
// Disable email verification for now
|
||||
requireEmailVerification: false,
|
||||
},
|
||||
// socialProviders: {
|
||||
// google: {
|
||||
// clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
// clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
// },
|
||||
// github: {
|
||||
// clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
// clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
// },
|
||||
// twitter: {
|
||||
// clientId: process.env.TWITTER_CLIENT_ID!,
|
||||
// clientSecret: process.env.TWITTER_CLIENT_SECRET!,
|
||||
// },
|
||||
// },
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [username(), admin(), organization()],
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true,
|
||||
// Add a hook to run before deleting a user
|
||||
// i dont think this works
|
||||
beforeDelete: async (user) => {
|
||||
// Delete all memberships for this user first
|
||||
console.log(
|
||||
`Cleaning up memberships for user ${user.id} before deletion`
|
||||
);
|
||||
try {
|
||||
// Delete member records for this user
|
||||
await db
|
||||
.delete(schema.member)
|
||||
.where(eq(schema.member.userId, user.id));
|
||||
|
||||
console.log(`Successfully removed memberships for user ${user.id}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error removing memberships for user ${user.id}:`,
|
||||
error
|
||||
);
|
||||
throw error; // Re-throw to prevent user deletion if cleanup fails
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: pluginList,
|
||||
trustedOrigins: allowedOrigins,
|
||||
advanced: {
|
||||
useSecureCookies: process.env.NODE_ENV === "production", // don't mark Secure in dev
|
||||
useSecureCookies: process.env.NODE_ENV === "production",
|
||||
defaultCookieAttributes: {
|
||||
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
// Use database hooks to create an organization after user signup
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
// Create an organization for the new user
|
||||
console.info(user);
|
||||
// if (auth) {
|
||||
// try {
|
||||
// const orgName = user.name || user.username || "My Organization";
|
||||
// await auth.api.organization.createOrganization({
|
||||
// body: {
|
||||
// name: orgName,
|
||||
// },
|
||||
// headers: {
|
||||
// "x-user-id": user.id,
|
||||
// },
|
||||
// });
|
||||
// } catch (error) {
|
||||
// console.error(
|
||||
// "Error creating organization for new user:",
|
||||
// error
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
export function mapHeaders(headers: any) {
|
||||
const entries = Object.entries(headers);
|
||||
const map = new Map();
|
||||
for (const [headerKey, headerValue] of entries) {
|
||||
if (headerValue != null) {
|
||||
map.set(headerKey, headerValue);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
5
server/src/lib/const.ts
Normal file
5
server/src/lib/const.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const IS_CLOUD = process.env.CLOUD === "true";
|
Loading…
Add table
Add a link
Reference in a new issue