-
Dashboard
+
+
+
Teams
+
+
+ {showCreateForm && (
+
+ )}
+
+
+ {teams?.length === 0 ? (
+
+ No teams created yet. Create your first team to get started.
+
+ ) : (
+ teams?.map((team) => (
+
+
+
+ {team.name}
+
+
+ Organization: {team.organization.name}
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
+
+function ApplicationsView() {
+ const router = useRouter();
+ const utils = trpc.useUtils();
+ const createAppMutation = trpc.createApplication.useMutation({
+ onSuccess: () => {
+ toast.success("Application created successfully");
+ utils.listApplications.invalidate();
+ },
+ onError: (error) => {
+ toast.error(error.message || "Failed to create application");
+ },
+ });
+
+ const { data: applications, isLoading: isLoadingApps } =
+ trpc.listApplications.useQuery();
+ const { data: teams, isLoading: isLoadingTeams } = trpc.listTeams.useQuery();
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [newApp, setNewApp] = useState({
+ name: "",
+ description: "",
+ teamId: "",
+ domains: [] as string[],
+ });
+
+ const [newDomain, setNewDomain] = useState("");
+
+ const handleAddDomain = useCallback(() => {
+ if (newDomain && !newApp.domains.includes(newDomain)) {
+ const validationResult = validateDomains([...newApp.domains, newDomain]);
+
+ if (!validationResult.valid) {
+ toast.error(validationResult.error);
+ return;
+ }
+
+ setNewApp((prev) => ({
+ ...prev,
+ domains: [...prev.domains, newDomain],
+ }));
+ setNewDomain("");
+ }
+ }, [newDomain, newApp.domains]);
+
+ const handleRemoveDomain = useCallback((domain: string) => {
+ setNewApp((prev) => ({
+ ...prev,
+ domains: prev.domains.filter((d) => d !== domain),
+ }));
+ }, []);
+
+ const handleCreateApp = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ // Show loading toast
+ const loadingToast = toast.loading("Creating application...");
+
+ try {
+ await createAppMutation.mutateAsync({
+ name: newApp.name,
+ description: newApp.description || undefined,
+ teamId: newApp.teamId,
+ domains: newApp.domains,
+ });
+ setNewApp({ name: "", description: "", teamId: "", domains: [] });
+ setShowCreateForm(false);
+ } catch {
+ //
+ } finally {
+ toast.dismiss(loadingToast);
+ }
+ },
+ [newApp, createAppMutation]
+ );
+
+ if (isLoadingApps || isLoadingTeams) {
+ return (
+
+
+ Loading applications...
+
+
+ );
+ }
+
+ return (
+
+
+
Applications
-
Welcome, user with ID: {user.id}
-
+
+ {showCreateForm && (
+
+ )}
+
+
+ {applications?.length === 0 ? (
+
+ No applications created yet. Create your first application to get
+ started.
+
+ ) : (
+ applications?.map((app) => (
+
+
+
+ {app.name}
+
+
Team: {app.team.name}
+ {app.description && (
+
+ {app.description}
+
+ )}
+
+
+
+ ))
+ )}
+
- )
+ );
}
-
diff --git a/app/dashboard/settings/page.tsx b/app/dashboard/settings/page.tsx
new file mode 100644
index 0000000..70bb3da
--- /dev/null
+++ b/app/dashboard/settings/page.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { ProtectedApiInteraction } from "@/client/components/ProtectedApiInteraction";
+import { useAuth } from "@/client/hooks/useAuth";
+import { supabase } from "@/client/utils/supabase/supabase-client-client";
+
+export default function SettingsPage() {
+ const { user } = useAuth();
+
+ const handleRefresh = async () => {
+ await supabase.auth.refreshSession();
+ };
+
+ if (!user) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ User Profile
+
+
ID: {user.id}
+ {user.email && (
+
Email: {user.email}
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/dashboard/teams/[id]/page.tsx b/app/dashboard/teams/[id]/page.tsx
new file mode 100644
index 0000000..cac1b35
--- /dev/null
+++ b/app/dashboard/teams/[id]/page.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { trpc } from "@/client/utils/trpc/trpc-client";
+import { toast } from "sonner";
+import {
+ LoadingSpinner,
+ LoadingPage,
+} from "@/client/components/LoadingSpinner";
+import { Modal } from "@/client/components/Modal";
+import { PageHeader } from "@/client/components/PageHeader";
+
+const inputStyles =
+ "mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-base py-2.5 px-3";
+
+export default function TeamDetailsPage({
+ params,
+}: {
+ params: { id: string };
+}) {
+ const router = useRouter();
+ const utils = trpc.useUtils();
+ const { data: team, isLoading } = trpc.getTeam.useQuery({ id: params.id });
+ const [isEditing, setIsEditing] = useState(false);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [editForm, setEditForm] = useState({
+ name: "",
+ });
+
+ const updateTeamMutation = trpc.updateTeam.useMutation({
+ onSuccess: () => {
+ toast.success("Team updated successfully");
+ setIsEditing(false);
+ utils.getTeam.invalidate({ id: params.id });
+ },
+ onError: (error) => {
+ toast.error(error.message || "Failed to update team");
+ },
+ });
+
+ const deleteTeamMutation = trpc.deleteTeam.useMutation({
+ onSuccess: () => {
+ toast.success("Team deleted successfully");
+ router.push("/dashboard?tab=teams");
+ utils.listTeams.invalidate();
+ },
+ onError: (error) => {
+ toast.error(error.message || "Failed to delete team");
+ },
+ });
+
+ if (isLoading) {
+ return
;
+ }
+
+ if (!team) {
+ return (
+
+ );
+ }
+
+ const handleEdit = () => {
+ setEditForm({ name: team.name });
+ setIsEditing(true);
+ };
+
+ const handleUpdate = async (e: React.FormEvent) => {
+ e.preventDefault();
+ await updateTeamMutation.mutateAsync({
+ id: team.id,
+ name: editForm.name,
+ });
+ };
+
+ const handleDelete = async () => {
+ await deleteTeamMutation.mutateAsync({ id: team.id });
+ };
+
+ const headerActions = (
+ <>
+
+
+ >
+ );
+
+ return (
+
+
+
+
+
+
+ {/* Team Stats */}
+
+
+
Members
+
+ {team._count.memberships}
+
+
+
+
Applications
+
+ {team._count.applications}
+
+
+
+
+ {/* Edit Form */}
+ {isEditing && (
+
+ )}
+
+ {/* Delete Confirmation Modal */}
+
setShowDeleteConfirm(false)}
+ title="Delete Team"
+ >
+
+
+ Are you sure you want to delete this team? This action cannot
+ be undone.
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/favicon.ico b/app/favicon.ico
deleted file mode 100644
index 718d6fe..0000000
Binary files a/app/favicon.ico and /dev/null differ
diff --git a/app/globals.css b/app/globals.css
index ce8bf91..f3f7c18 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,3 +1,22 @@
-body {
- font-family: Arial, Helvetica, sans-serif;
-}
\ No newline at end of file
+@import "tailwindcss";
+
+html {
+ scrollbar-gutter: stable;
+}
+
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #f1f1f1;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #666;
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 7e8db0b..da1a2f9 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,14 +1,28 @@
-"use client"
+import "./globals.css";
+import { Metadata } from "next";
+import AuthProvider from "@/client/components/AuthProvider";
+import AuthGuard from "@/client/components/AuthGuard";
-import { trpc } from "@/client/utils/trpc/trpc-client"
+export const metadata: Metadata = {
+ title: "Wander Embedded",
+ description: "Set up teams and apps for your embedded wallet",
+ icons: {
+ icon: "favicon.png",
+ },
+};
-function RootLayout({ children }: { children: React.ReactNode }) {
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
return (
- {children}
+
+
+ {children}
+
+
- )
+ );
}
-
-export default trpc.withTRPC(RootLayout)
-
diff --git a/app/page.tsx b/app/page.tsx
index 3fce276..221a702 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,58 +1,265 @@
-"use client"
+"use client";
-import { useEffect, useState } from "react"
-import { useRouter } from "next/navigation"
-import { trpc } from "@/client/utils/trpc/trpc-client"
-import { useAuth } from "@/client/hooks/useAuth"
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { trpc } from "@/client/utils/trpc/trpc-client";
+import { useAuth } from "@/client/hooks/useAuth";
+import { AuthHeader } from "@/client/components/AuthHeader";
+import { FcGoogle } from "react-icons/fc";
+import { FaFacebook, FaApple, FaTwitter } from "react-icons/fa";
+import { MdEmail, MdArrowBack, MdExpandMore } from "react-icons/md";
+
+type AuthProvider = "GOOGLE" | "FACEBOOK" | "X" | "APPLE" | "EMAIL_N_PASSWORD";
+
+const initialProviders = [
+ {
+ id: "GOOGLE" as AuthProvider,
+ name: "Google",
+ icon:
,
+ },
+];
+
+const additionalProviders = [
+ {
+ id: "FACEBOOK" as AuthProvider,
+ name: "Facebook",
+ icon:
,
+ },
+ {
+ id: "X" as AuthProvider,
+ name: "X",
+ icon:
,
+ },
+ {
+ id: "APPLE" as AuthProvider,
+ name: "Apple",
+ icon:
,
+ },
+];
+
+const inputStyles =
+ "mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-base py-2.5 px-3 h-[42px]";
export default function Login() {
const router = useRouter();
const { user, isLoading: isAuthLoading } = useAuth();
const loginMutation = trpc.authenticate.useMutation();
const [isLoading, setIsLoading] = useState(false);
+ const [showEmailForm, setShowEmailForm] = useState(false);
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [showAllProviders, setShowAllProviders] = useState(false);
- const handleGoogleSignIn = async () => {
+ const handleProviderSignIn = async (provider: AuthProvider) => {
try {
setIsLoading(true);
+ const { url } = await loginMutation.mutateAsync({
+ authProviderType: provider,
+ });
+ if (url) window.location.href = url;
+ else console.error("No URL returned from authenticate");
+ } catch (error) {
+ console.error(`${provider} sign-in failed:`, error);
+ setIsLoading(false);
+ }
+ };
- const { url } = await loginMutation.mutateAsync({ authProviderType: "GOOGLE" });
-
- if (url) {
- // Redirect to Google's OAuth page
- window.location.href = url
- } else {
- console.error("No URL returned from authenticate")
- }
+ const handleEmailSignIn = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ setIsLoading(true);
+ // await loginMutation.mutateAsync({
+ // authProviderType: "EMAIL_N_PASSWORD",
+ // email,
+ // password,
+ // });
} catch (error) {
- console.error("Google sign-in failed:", error)
+ console.error("Email sign-in failed:", error);
setIsLoading(false);
}
- }
+ };
useEffect(() => {
if (user) {
- router.push("/dashboard")
+ router.push("/dashboard");
}
- }, [user, router])
+ }, [user, router]);
-
- if (isAuthLoading || isLoading || user) return
Authenticating...
+ if (isAuthLoading || isLoading || user) {
+ return (
+ <>
+
+
+ >
+ );
+ }
return (
-
-
Welcome to Our App
+ <>
+
+
+
+
+
+
+
+ Welcome to Wander Embedded
+
+
+ Set up teams and apps for your embedded wallet
+
+
-
+
+ {!showEmailForm ? (
+ <>
+ {initialProviders.map((provider) => (
+
+ ))}
- { loginMutation.error ? (
{ loginMutation.error.message }
) : null }
+ {!showAllProviders ? (
+
+ ) : (
+ <>
+ {additionalProviders.map((provider) => (
+
+ ))}
+ >
+ )}
-
- )
-}
+
+
+
+ >
+ ) : (
+
+ )}
+ {loginMutation.error && (
+
+
+ {loginMutation.error.message}
+
+
+ )}
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/client/components/AuthGuard.tsx b/client/components/AuthGuard.tsx
new file mode 100644
index 0000000..3516d29
--- /dev/null
+++ b/client/components/AuthGuard.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useState } from "react";
+import { useAuth } from "../hooks/useAuth";
+import { supabase } from "../utils/supabase/supabase-client-client";
+import { trpc } from "../utils/trpc/trpc-client";
+import Header from "./Header";
+
+export default function AuthGuard({ children }: { children: React.ReactNode }) {
+ const { user, isLoading: isAuthLoading } = useAuth();
+ const logoutMutation = trpc.logout.useMutation();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleLogout = async () => {
+ try {
+ setIsLoading(true);
+ await logoutMutation.mutateAsync();
+ await supabase.auth.signOut();
+ window.location.href = "/";
+ } catch (error) {
+ console.error("Logout failed:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ if (isAuthLoading) {
+ return
;
+ }
+
+ if (isLoading) {
+ return
;
+ }
+
+ return (
+
+ {user && }
+ {children}
+
+ );
+}
+
+function LoadingScreen({ message }: { message: string }) {
+ return (
+
+ );
+}
diff --git a/client/components/AuthHeader.tsx b/client/components/AuthHeader.tsx
new file mode 100644
index 0000000..ead8209
--- /dev/null
+++ b/client/components/AuthHeader.tsx
@@ -0,0 +1,28 @@
+import Image from "next/image";
+
+export function AuthHeader() {
+ return (
+
+
+
+
+
+
+ Wander Embedded
+
+
+
+
+
+
+ );
+}
diff --git a/client/components/AuthProvider.tsx b/client/components/AuthProvider.tsx
new file mode 100644
index 0000000..b646423
--- /dev/null
+++ b/client/components/AuthProvider.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { setAuthToken, trpc } from "@/client/utils/trpc/trpc-client";
+import { User } from "@supabase/supabase-js";
+import { supabase } from "@/client/utils/supabase/supabase-client-client";
+import { useRouter, usePathname } from "next/navigation";
+import { AuthContext, AuthContextType } from "../hooks/useAuth";
+import { Toaster } from "sonner";
+
+type AuthProviderProps = {
+ children: React.ReactNode;
+};
+
+const AuthProvider = ({ children }: AuthProviderProps) => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [token, setToken] = useState
(null);
+ const router = useRouter();
+ const pathname = usePathname();
+
+ useEffect(() => {
+ const checkSession = async () => {
+ const {
+ data: { session },
+ } = await supabase.auth.getSession();
+
+ const accessToken = session?.access_token ?? null;
+
+ setToken(accessToken);
+ setAuthToken(accessToken);
+ setIsLoading(false);
+
+ if (!session && pathname !== "/") {
+ router.replace("/");
+ }
+ };
+
+ checkSession();
+
+ const {
+ data: { subscription },
+ } = supabase.auth.onAuthStateChange((_event, session) => {
+ const accessToken = session?.access_token ?? null;
+
+ setToken(accessToken);
+ setAuthToken(accessToken);
+ setIsLoading(false);
+
+ if (!session && pathname !== "/") {
+ router.replace("/");
+ }
+ });
+
+ return () => subscription.unsubscribe();
+ }, [router, pathname]);
+
+ const {
+ data,
+ isLoading: isUserLoading,
+ error: userError,
+ } = trpc.getUser.useQuery(undefined, {
+ enabled: !!token,
+ retry: false,
+ });
+
+ const user: User | null = data?.user || null;
+
+ useEffect(() => {
+ if (!isLoading && !user && pathname !== "/") {
+ router.replace("/");
+ }
+ }, [isLoading, user, router, pathname]);
+
+ const value = token
+ ? {
+ token,
+ user,
+ isLoading: isLoading || isUserLoading,
+ error: userError,
+ }
+ : {
+ token: null,
+ user: null,
+ isLoading,
+ error: null,
+ };
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+export default trpc.withTRPC(AuthProvider);
diff --git a/client/components/CopyButton.tsx b/client/components/CopyButton.tsx
new file mode 100644
index 0000000..c43de01
--- /dev/null
+++ b/client/components/CopyButton.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useState } from "react";
+import { toast } from "sonner";
+
+export function CopyButton({ text, label }: { text: string; label?: string }) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ toast.success("Copied to clipboard!");
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ toast.error("Failed to copy to clipboard");
+ }
+ };
+
+ return (
+
+
{label || text}
+
+
+ );
+}
diff --git a/client/components/Header.tsx b/client/components/Header.tsx
new file mode 100644
index 0000000..954f38c
--- /dev/null
+++ b/client/components/Header.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { User } from "@supabase/supabase-js";
+import { useRouter } from "next/navigation";
+import Image from "next/image";
+
+interface HeaderProps {
+ user: User;
+ onLogout: () => void;
+}
+
+export default function Header({ user, onLogout }: HeaderProps) {
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const NavLink = ({
+ href,
+ children,
+ }: {
+ href: string;
+ children: React.ReactNode;
+ }) => {
+ const isActive = pathname === href;
+ return (
+
+ {children}
+
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/client/components/LoadingSpinner.tsx b/client/components/LoadingSpinner.tsx
new file mode 100644
index 0000000..673ac06
--- /dev/null
+++ b/client/components/LoadingSpinner.tsx
@@ -0,0 +1,43 @@
+export function LoadingSpinner({
+ className = "h-5 w-5",
+}: {
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+export function LoadingPage() {
+ return (
+
+ );
+}
diff --git a/client/components/Modal.tsx b/client/components/Modal.tsx
new file mode 100644
index 0000000..76c0158
--- /dev/null
+++ b/client/components/Modal.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useEffect } from "react";
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ children: React.ReactNode;
+}
+
+export function Modal({ isOpen, onClose, title, children }: ModalProps) {
+ useEffect(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ if (isOpen) {
+ document.addEventListener("keydown", handleEscape);
+ document.body.style.overflow = "hidden";
+ }
+
+ return () => {
+ document.removeEventListener("keydown", handleEscape);
+ document.body.style.overflow = "unset";
+ };
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal */}
+
+
e.stopPropagation()}
+ >
+
+
{title}
+
+
{children}
+
+
+
+ );
+}
diff --git a/client/components/PageHeader.tsx b/client/components/PageHeader.tsx
new file mode 100644
index 0000000..4f869ba
--- /dev/null
+++ b/client/components/PageHeader.tsx
@@ -0,0 +1,41 @@
+import { MdArrowBack } from "react-icons/md";
+import Link from "next/link";
+
+interface PageHeaderProps {
+ title: string;
+ subtitle?: string;
+ actions?: React.ReactNode;
+ backUrl?: string;
+}
+
+export function PageHeader({
+ title,
+ subtitle,
+ actions,
+ backUrl,
+}: PageHeaderProps) {
+ return (
+
+
+
+
+ {backUrl && (
+
+
+ Back
+
+ )}
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+ {actions &&
{actions}
}
+
+
+
+ );
+}
diff --git a/client/components/ProtectedApiInteraction.tsx b/client/components/ProtectedApiInteraction.tsx
index 70c9bd0..4453195 100644
--- a/client/components/ProtectedApiInteraction.tsx
+++ b/client/components/ProtectedApiInteraction.tsx
@@ -1,34 +1,133 @@
-"use client"
+"use client";
-import { getAuthToken, trpc } from "@/client/utils/trpc/trpc-client"
+import { getAuthToken, trpc } from "@/client/utils/trpc/trpc-client";
import { jwtDecode } from "jwt-decode";
+import { useState } from "react";
export function ProtectedApiInteraction() {
- const { data, isLoading, error } = trpc.debugSession.useQuery()
+ const { data, isLoading, error, refetch } = trpc.debugSession.useQuery();
+ const [activeTab, setActiveTab] = useState<"jwt" | "user" | "session">("jwt");
if (isLoading) {
- return Loading...
+ return (
+
+ );
}
if (error) {
- return { error.message }
+ return (
+
+
+
+
{error.message}
+
+
+ );
}
+ const TabButton = ({
+ tab,
+ label,
+ }: {
+ tab: typeof activeTab;
+ label: string;
+ }) => (
+
+ );
+
+ const renderContent = () => {
+ let content;
+ switch (activeTab) {
+ case "jwt":
+ content = jwtDecode(getAuthToken() || "");
+ break;
+ case "user":
+ content = data?.user;
+ break;
+ case "session":
+ content = data?.session;
+ break;
+ }
+
+ return (
+
+
+ {JSON.stringify(content, null, 2)}
+
+
+ );
+ };
+
return (
-
- {data && (
-
-
JWT Token
-
{JSON.stringify(jwtDecode(getAuthToken() || ""), null, " ")}
+
+
+
+
+
+
+
-
auth.users
-
{JSON.stringify(data.user, null, " ")}
+
+
-
Session
-
{JSON.stringify(data.session, null, " ")}
+
+
+
+ {activeTab === "jwt" && "JWT Token Details"}
+ {activeTab === "user" && "User Information"}
+ {activeTab === "session" && "Session Details"}
+
+
+ {activeTab === "jwt" && "Decoded JWT token content"}
+ {activeTab === "user" && "Current user data from auth.users"}
+ {activeTab === "session" && "Active session information"}
+
- )}
+
+ {renderContent()}
+
- )
+ );
}
-
diff --git a/client/hooks/useAuth.ts b/client/hooks/useAuth.ts
index 5503ffd..0d3d319 100644
--- a/client/hooks/useAuth.ts
+++ b/client/hooks/useAuth.ts
@@ -1,65 +1,21 @@
-import { useState, useEffect } from "react"
-import { setAuthToken, trpc } from "@/client/utils/trpc/trpc-client"
-import { User } from "@supabase/supabase-js"
-import { supabase } from "@/client/utils/supabase/supabase-client-client"
-
-export function useAuth() {
- const [isLoading, setIsLoading] = useState(true)
- const [token, setToken] = useState
(null)
-
- useEffect(() => {
- const checkSession = async () => {
- const {
- data: { session },
- } = await supabase.auth.getSession();
-
- console.log("Session init =", session);
-
- const accessToken = session?.access_token ?? null;
-
- setToken(accessToken);
- setAuthToken(accessToken);
- setIsLoading(false)
- }
-
- checkSession()
-
- const {
- data: { subscription },
- } = supabase.auth.onAuthStateChange((_event, session) => {
- console.log("Session change =", session);
-
- const accessToken = session?.access_token ?? null;
-
- setToken(accessToken);
- setAuthToken(accessToken);
- setIsLoading(false)
- });
-
- return () => subscription.unsubscribe()
- }, [])
-
- const {
- data,
- isLoading: isUserLoading,
- error: userError,
- } = trpc.getUser.useQuery(undefined, {
- enabled: !!token,
- retry: false,
- });
+import { User } from "@supabase/supabase-js";
+import { createContext, useContext } from "react";
+
+export interface AuthContextType {
+ token: string | null;
+ user: User | null;
+ isLoading: boolean;
+ error: Error | null;
+}
- const user: User | null = data?.user || null;
+export const AuthContext = createContext(
+ undefined
+);
- return token ? {
- token,
- user,
- isLoading: isLoading || isUserLoading,
- error: userError,
- } : {
- token: null,
- user: null,
- isLoading,
- error: null,
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error("useAuth must be used within an AuthProvider");
}
+ return context;
}
-
diff --git a/client/utils/trpc/trpc-client.ts b/client/utils/trpc/trpc-client.ts
index 74854fb..2e19192 100644
--- a/client/utils/trpc/trpc-client.ts
+++ b/client/utils/trpc/trpc-client.ts
@@ -59,5 +59,5 @@ export const trpc = createTRPCNext({
],
};
},
- ssr: false,
+ ssr: true,
});
diff --git a/package.json b/package.json
index 4c65107..ed51cf3 100644
--- a/package.json
+++ b/package.json
@@ -30,20 +30,26 @@
"dotenv": "^16.4.7",
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^4.0.0",
+ "nanoid": "^5.1.2",
"next": "14.2.16",
"react": "^18",
"react-dom": "^18",
+ "react-icons": "^5.5.0",
+ "sonner": "^2.0.1",
"superjson": "^2.2.2",
"zod": "^3.24.1"
},
"devDependencies": {
+ "@tailwindcss/postcss": "^4.0.9",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.16",
+ "postcss": "^8.5.3",
"prisma": "6.2.1",
+ "tailwindcss": "^4.0.9",
"ts-node": "^10.9.2",
"tsup": "^8.3.6",
"typescript": "^5.7.3"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ee97c51..a658a31 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
+ nanoid:
+ specifier: ^5.1.2
+ version: 5.1.2
next:
specifier: 14.2.16
version: 14.2.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -53,6 +56,12 @@ importers:
react-dom:
specifier: ^18
version: 18.3.1(react@18.3.1)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.5.0(react@18.3.1)
+ sonner:
+ specifier: ^2.0.1
+ version: 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
superjson:
specifier: ^2.2.2
version: 2.2.2
@@ -60,6 +69,9 @@ importers:
specifier: ^3.24.1
version: 3.24.2
devDependencies:
+ '@tailwindcss/postcss':
+ specifier: ^4.0.9
+ version: 4.0.9
'@types/jsonwebtoken':
specifier: ^9.0.7
version: 9.0.9
@@ -78,21 +90,31 @@ importers:
eslint-config-next:
specifier: 14.2.16
version: 14.2.16(eslint@8.57.1)(typescript@5.8.2)
+ postcss:
+ specifier: ^8.5.3
+ version: 8.5.3
prisma:
specifier: 6.2.1
version: 6.2.1
+ tailwindcss:
+ specifier: ^4.0.9
+ version: 4.0.9
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.17.23)(typescript@5.8.2)
tsup:
specifier: ^8.3.6
- version: 8.4.0(postcss@8.4.31)(typescript@5.8.2)
+ version: 8.4.0(jiti@2.4.2)(postcss@8.5.3)(typescript@5.8.2)
typescript:
specifier: ^5.7.3
version: 5.8.2
packages:
+ '@alloc/quick-lru@5.2.0':
+ resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
+ engines: {node: '>=10'}
+
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -541,6 +563,82 @@ packages:
'@swc/helpers@0.5.5':
resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==}
+ '@tailwindcss/node@4.0.9':
+ resolution: {integrity: sha512-tOJvdI7XfJbARYhxX+0RArAhmuDcczTC46DGCEziqxzzbIaPnfYaIyRT31n4u8lROrsO7Q6u/K9bmQHL2uL1bQ==}
+
+ '@tailwindcss/oxide-android-arm64@4.0.9':
+ resolution: {integrity: sha512-YBgy6+2flE/8dbtrdotVInhMVIxnHJPbAwa7U1gX4l2ThUIaPUp18LjB9wEH8wAGMBZUb//SzLtdXXNBHPUl6Q==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.0.9':
+ resolution: {integrity: sha512-pWdl4J2dIHXALgy2jVkwKBmtEb73kqIfMpYmcgESr7oPQ+lbcQ4+tlPeVXaSAmang+vglAfFpXQCOvs/aGSqlw==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.0.9':
+ resolution: {integrity: sha512-4Dq3lKp0/C7vrRSkNPtBGVebEyWt9QPPlQctxJ0H3MDyiQYvzVYf8jKow7h5QkWNe8hbatEqljMj/Y0M+ERYJg==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.0.9':
+ resolution: {integrity: sha512-k7U1RwRODta8x0uealtVt3RoWAWqA+D5FAOsvVGpYoI6ObgmnzqWW6pnVwz70tL8UZ/QXjeMyiICXyjzB6OGtQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.9':
+ resolution: {integrity: sha512-NDDjVweHz2zo4j+oS8y3KwKL5wGCZoXGA9ruJM982uVJLdsF8/1AeKvUwKRlMBpxHt1EdWJSAh8a0Mfhl28GlQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.0.9':
+ resolution: {integrity: sha512-jk90UZ0jzJl3Dy1BhuFfRZ2KP9wVKMXPjmCtY4U6fF2LvrjP5gWFJj5VHzfzHonJexjrGe1lMzgtjriuZkxagg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.0.9':
+ resolution: {integrity: sha512-3eMjyTC6HBxh9nRgOHzrc96PYh1/jWOwHZ3Kk0JN0Kl25BJ80Lj9HEvvwVDNTgPg154LdICwuFLuhfgH9DULmg==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.0.9':
+ resolution: {integrity: sha512-v0D8WqI/c3WpWH1kq/HP0J899ATLdGZmENa2/emmNjubT0sWtEke9W9+wXeEoACuGAhF9i3PO5MeyditpDCiWQ==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.0.9':
+ resolution: {integrity: sha512-Kvp0TCkfeXyeehqLJr7otsc4hd/BUPfcIGrQiwsTVCfaMfjQZCG7DjI+9/QqPZha8YapLA9UoIcUILRYO7NE1Q==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [linux]
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.0.9':
+ resolution: {integrity: sha512-m3+60T/7YvWekajNq/eexjhV8z10rswcz4BC9bioJ7YaN+7K8W2AmLmG0B79H14m6UHE571qB0XsPus4n0QVgQ==}
+ engines: {node: '>= 10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.0.9':
+ resolution: {integrity: sha512-dpc05mSlqkwVNOUjGu/ZXd5U1XNch1kHFJ4/cHkZFvaW1RzbHmRt24gvM8/HC6IirMxNarzVw4IXVtvrOoZtxA==}
+ engines: {node: '>= 10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.0.9':
+ resolution: {integrity: sha512-eLizHmXFqHswJONwfqi/WZjtmWZpIalpvMlNhTM99/bkHtUs6IqgI1XQ0/W5eO2HiRQcIlXUogI2ycvKhVLNcA==}
+ engines: {node: '>= 10'}
+
+ '@tailwindcss/postcss@4.0.9':
+ resolution: {integrity: sha512-BT/E+pdMqulavEAVM5NCpxmGEwHiLDPpkmg/c/X25ZBW+izTe+aZ+v1gf/HXTrihRoCxrUp5U4YyHsBTzspQKQ==}
+
'@tanstack/query-core@4.36.1':
resolution: {integrity: sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==}
@@ -936,6 +1034,11 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ detect-libc@1.0.3:
+ resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
+ engines: {node: '>=0.10'}
+ hasBin: true
+
diff@4.0.2:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
@@ -1426,6 +1529,10 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
+ jiti@2.4.2:
+ resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
+ hasBin: true
+
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
@@ -1482,6 +1589,70 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ lightningcss-darwin-arm64@1.29.1:
+ resolution: {integrity: sha512-HtR5XJ5A0lvCqYAoSv2QdZZyoHNttBpa5EP9aNuzBQeKGfbyH5+UipLWvVzpP4Uml5ej4BYs5I9Lco9u1fECqw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
+ lightningcss-darwin-x64@1.29.1:
+ resolution: {integrity: sha512-k33G9IzKUpHy/J/3+9MCO4e+PzaFblsgBjSGlpAaFikeBFm8B/CkO3cKU9oI4g+fjS2KlkLM/Bza9K/aw8wsNA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
+ lightningcss-freebsd-x64@1.29.1:
+ resolution: {integrity: sha512-0SUW22fv/8kln2LnIdOCmSuXnxgxVC276W5KLTwoehiO0hxkacBxjHOL5EtHD8BAXg2BvuhsJPmVMasvby3LiQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
+ lightningcss-linux-arm-gnueabihf@1.29.1:
+ resolution: {integrity: sha512-sD32pFvlR0kDlqsOZmYqH/68SqUMPNj+0pucGxToXZi4XZgZmqeX/NkxNKCPsswAXU3UeYgDSpGhu05eAufjDg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
+ lightningcss-linux-arm64-gnu@1.29.1:
+ resolution: {integrity: sha512-0+vClRIZ6mmJl/dxGuRsE197o1HDEeeRk6nzycSy2GofC2JsY4ifCRnvUWf/CUBQmlrvMzt6SMQNMSEu22csWQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-arm64-musl@1.29.1:
+ resolution: {integrity: sha512-UKMFrG4rL/uHNgelBsDwJcBqVpzNJbzsKkbI3Ja5fg00sgQnHw/VrzUTEc4jhZ+AN2BvQYz/tkHu4vt1kLuJyw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+
+ lightningcss-linux-x64-gnu@1.29.1:
+ resolution: {integrity: sha512-u1S+xdODy/eEtjADqirA774y3jLcm8RPtYztwReEXoZKdzgsHYPl0s5V52Tst+GKzqjebkULT86XMSxejzfISw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-linux-x64-musl@1.29.1:
+ resolution: {integrity: sha512-L0Tx0DtaNUTzXv0lbGCLB/c/qEADanHbu4QdcNOXLIe1i8i22rZRpbT3gpWYsCh9aSL9zFujY/WmEXIatWvXbw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+
+ lightningcss-win32-arm64-msvc@1.29.1:
+ resolution: {integrity: sha512-QoOVnkIEFfbW4xPi+dpdft/zAKmgLgsRHfJalEPYuJDOWf7cLQzYg0DEh8/sn737FaeMJxHZRc1oBreiwZCjog==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
+ lightningcss-win32-x64-msvc@1.29.1:
+ resolution: {integrity: sha512-NygcbThNBe4JElP+olyTI/doBNGJvLs3bFCRPdvuCcxZCcCZ71B858IHpdm7L1btZex0FvCmM17FK98Y9MRy1Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
+ lightningcss@1.29.1:
+ resolution: {integrity: sha512-FmGoeD4S05ewj+AkhTY+D+myDvXI6eL27FjHIjoyUkO/uw7WZD1fBVs0QxeYWa7E17CUHJaYX/RUGISCtcrG4Q==}
+ engines: {node: '>= 12.0.0'}
+
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -1574,6 +1745,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ nanoid@5.1.2:
+ resolution: {integrity: sha512-b+CiXQCNMUGe0Ri64S9SXFcP9hogjAJ2Rd6GdVxhPLRm7mhGaM7VgOvCAJ1ZshfHbqVDI3uqTI5C8/GaKuLI7g==}
+ engines: {node: ^18 || >=20}
+ hasBin: true
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -1713,6 +1889,10 @@ packages:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
+ postcss@8.5.3:
+ resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
+ engines: {node: ^10 || ^12 || >=14}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -1737,6 +1917,11 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-icons@5.5.0:
+ resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==}
+ peerDependencies:
+ react: '*'
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -1868,6 +2053,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ sonner@2.0.1:
+ resolution: {integrity: sha512-FRBphaehZ5tLdLcQ8g2WOIRE+Y7BCfWi5Zyd8bCvBjiW8TxxAyoWZIxS661Yz6TGPqFQ4VLzOF89WEYhfynSFQ==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1960,6 +2151,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ tailwindcss@4.0.9:
+ resolution: {integrity: sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==}
+
tapable@2.2.1:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'}
@@ -2163,6 +2357,8 @@ packages:
snapshots:
+ '@alloc/quick-lru@5.2.0': {}
+
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -2499,6 +2695,68 @@ snapshots:
'@swc/counter': 0.1.3
tslib: 2.8.1
+ '@tailwindcss/node@4.0.9':
+ dependencies:
+ enhanced-resolve: 5.18.1
+ jiti: 2.4.2
+ tailwindcss: 4.0.9
+
+ '@tailwindcss/oxide-android-arm64@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.0.9':
+ optional: true
+
+ '@tailwindcss/oxide@4.0.9':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.0.9
+ '@tailwindcss/oxide-darwin-arm64': 4.0.9
+ '@tailwindcss/oxide-darwin-x64': 4.0.9
+ '@tailwindcss/oxide-freebsd-x64': 4.0.9
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.0.9
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.0.9
+ '@tailwindcss/oxide-linux-arm64-musl': 4.0.9
+ '@tailwindcss/oxide-linux-x64-gnu': 4.0.9
+ '@tailwindcss/oxide-linux-x64-musl': 4.0.9
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.0.9
+ '@tailwindcss/oxide-win32-x64-msvc': 4.0.9
+
+ '@tailwindcss/postcss@4.0.9':
+ dependencies:
+ '@alloc/quick-lru': 5.2.0
+ '@tailwindcss/node': 4.0.9
+ '@tailwindcss/oxide': 4.0.9
+ lightningcss: 1.29.1
+ postcss: 8.5.3
+ tailwindcss: 4.0.9
+
'@tanstack/query-core@4.36.1': {}
'@tanstack/react-query@4.36.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
@@ -2920,6 +3178,8 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ detect-libc@1.0.3: {}
+
diff@4.0.2: {}
doctrine@2.1.0:
@@ -3089,8 +3349,8 @@ snapshots:
'@typescript-eslint/parser': 8.26.0(eslint@8.57.1)(typescript@5.8.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.1)
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.4(eslint@8.57.1)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1)
@@ -3109,7 +3369,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.1):
+ eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.0
@@ -3120,22 +3380,22 @@ snapshots:
stable-hash: 0.0.4
tinyglobby: 0.2.12
optionalDependencies:
- eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1)
+ eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.26.0(eslint@8.57.1)(typescript@5.8.2)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0)(eslint@8.57.1)
+ eslint-import-resolver-typescript: 3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1):
+ eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@@ -3146,7 +3406,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3)(eslint@8.57.1)
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.8.3(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.0(eslint@8.57.1)(typescript@5.8.2))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -3610,6 +3870,8 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
+ jiti@2.4.2: {}
+
joycon@3.1.1: {}
js-tokens@4.0.0: {}
@@ -3676,6 +3938,51 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ lightningcss-darwin-arm64@1.29.1:
+ optional: true
+
+ lightningcss-darwin-x64@1.29.1:
+ optional: true
+
+ lightningcss-freebsd-x64@1.29.1:
+ optional: true
+
+ lightningcss-linux-arm-gnueabihf@1.29.1:
+ optional: true
+
+ lightningcss-linux-arm64-gnu@1.29.1:
+ optional: true
+
+ lightningcss-linux-arm64-musl@1.29.1:
+ optional: true
+
+ lightningcss-linux-x64-gnu@1.29.1:
+ optional: true
+
+ lightningcss-linux-x64-musl@1.29.1:
+ optional: true
+
+ lightningcss-win32-arm64-msvc@1.29.1:
+ optional: true
+
+ lightningcss-win32-x64-msvc@1.29.1:
+ optional: true
+
+ lightningcss@1.29.1:
+ dependencies:
+ detect-libc: 1.0.3
+ optionalDependencies:
+ lightningcss-darwin-arm64: 1.29.1
+ lightningcss-darwin-x64: 1.29.1
+ lightningcss-freebsd-x64: 1.29.1
+ lightningcss-linux-arm-gnueabihf: 1.29.1
+ lightningcss-linux-arm64-gnu: 1.29.1
+ lightningcss-linux-arm64-musl: 1.29.1
+ lightningcss-linux-x64-gnu: 1.29.1
+ lightningcss-linux-x64-musl: 1.29.1
+ lightningcss-win32-arm64-msvc: 1.29.1
+ lightningcss-win32-x64-msvc: 1.29.1
+
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -3745,6 +4052,8 @@ snapshots:
nanoid@3.3.8: {}
+ nanoid@5.1.2: {}
+
natural-compare@1.4.0: {}
next@14.2.16(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -3869,11 +4178,12 @@ snapshots:
possible-typed-array-names@1.1.0: {}
- postcss-load-config@6.0.1(postcss@8.4.31):
+ postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.3):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
- postcss: 8.4.31
+ jiti: 2.4.2
+ postcss: 8.5.3
postcss@8.4.31:
dependencies:
@@ -3881,6 +4191,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postcss@8.5.3:
+ dependencies:
+ nanoid: 3.3.8
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
prelude-ls@1.2.1: {}
prisma@6.2.1:
@@ -3905,6 +4221,10 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-icons@5.5.0(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+
react-is@16.13.1: {}
react-ssr-prepass@1.6.0(react@18.3.1):
@@ -4079,6 +4399,11 @@ snapshots:
signal-exit@4.1.0: {}
+ sonner@2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
source-map-js@1.2.1: {}
source-map@0.8.0-beta.0:
@@ -4188,6 +4513,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ tailwindcss@4.0.9: {}
+
tapable@2.2.1: {}
text-table@0.2.0: {}
@@ -4252,7 +4579,7 @@ snapshots:
tslib@2.8.1: {}
- tsup@8.4.0(postcss@8.4.31)(typescript@5.8.2):
+ tsup@8.4.0(jiti@2.4.2)(postcss@8.5.3)(typescript@5.8.2):
dependencies:
bundle-require: 5.1.0(esbuild@0.25.0)
cac: 6.7.14
@@ -4262,7 +4589,7 @@ snapshots:
esbuild: 0.25.0
joycon: 3.1.1
picocolors: 1.1.1
- postcss-load-config: 6.0.1(postcss@8.4.31)
+ postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.3)
resolve-from: 5.0.0
rollup: 4.34.9
source-map: 0.8.0-beta.0
@@ -4271,7 +4598,7 @@ snapshots:
tinyglobby: 0.2.12
tree-kill: 1.2.2
optionalDependencies:
- postcss: 8.4.31
+ postcss: 8.5.3
typescript: 5.8.2
transitivePeerDependencies:
- jiti
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..7059fe9
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,6 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+export default config;
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000..d4b51b6
Binary files /dev/null and b/public/favicon.png differ
diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 0000000..aa8b1e9
Binary files /dev/null and b/public/icon.png differ
diff --git a/server/routers/_app.ts b/server/routers/_app.ts
index 0d128a2..0b3db00 100644
--- a/server/routers/_app.ts
+++ b/server/routers/_app.ts
@@ -24,11 +24,13 @@ import { rotateAuthShare } from "@/server/routers/work-shares/rotateAuthShare";
import { registerWalletExport } from "@/server/routers/backup/registerWalletExport";
import { authenticateRouter } from "@/server/routers/authenticate";
import { validationRouter } from "./validation";
+import { dashboardRouter } from "./dashboard";
// import { supabase } from '@/utils/supabaseClient';
export const appRouter = router({
...authenticateRouter,
...validationRouter,
+ ...dashboardRouter,
// Wallets:
fetchWallets,
diff --git a/server/routers/dashboard.ts b/server/routers/dashboard.ts
new file mode 100644
index 0000000..22ab46d
--- /dev/null
+++ b/server/routers/dashboard.ts
@@ -0,0 +1,482 @@
+import { protectedProcedure } from "../trpc";
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { Prisma } from "@prisma/client";
+import { customAlphabet } from "nanoid";
+import { domainValidator } from "@/shared/validators/domains";
+
+// Create a custom nanoid function that uses lowercase letters and numbers
+const generateSlug = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 12);
+
+export const dashboardRouter = {
+ // Teams
+ createTeam: protectedProcedure
+ .input(
+ z.object({
+ name: z.string().min(1).max(100),
+ slug: z
+ .string()
+ .min(1)
+ .max(50)
+ .regex(/^[a-z0-9-]+$/),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ // Find or create default organization for the user
+ let org = await ctx.prisma.organization.findFirst({
+ where: {
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ select: { id: true },
+ });
+
+ if (!org) {
+ const slug = generateSlug();
+ const orgName =
+ (ctx.user?.email?.split("@")[0] || slug) + "'s Organization";
+
+ org = await ctx.prisma.organization.create({
+ data: {
+ name: orgName,
+ slug: `org-${slug}`,
+ },
+ select: { id: true },
+ });
+ }
+
+ try {
+ return await ctx.prisma.team.create({
+ data: {
+ name: input.name,
+ slug: input.slug,
+ organizationId: org.id,
+ memberships: {
+ create: {
+ userId: ctx.user.id,
+ role: "OWNER",
+ organizationId: org.id,
+ },
+ },
+ },
+ select: {
+ id: true,
+ memberships: true,
+ },
+ });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
+ if (error.code === "P2002") {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "Team with this slug already exists in the organization",
+ });
+ }
+ }
+ throw error;
+ }
+ }),
+
+ listTeams: protectedProcedure
+ .input(
+ z
+ .object({
+ organizationId: z.string().uuid().optional(),
+ })
+ .optional()
+ )
+ .query(async ({ ctx, input }) => {
+ return ctx.prisma.team.findMany({
+ where: {
+ organizationId: input?.organizationId,
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ organization: {
+ select: {
+ name: true,
+ },
+ },
+ _count: {
+ select: {
+ memberships: true,
+ applications: true,
+ },
+ },
+ },
+ });
+ }),
+
+ // Applications
+ createApplication: protectedProcedure
+ .input(
+ z.object({
+ name: z.string().min(1).max(100),
+ description: z.string().max(255).optional(),
+ teamId: z.string().uuid(),
+ domains: domainValidator,
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const membership = await ctx.prisma.membership.findFirst({
+ where: {
+ userId: ctx.user.id,
+ teamId: input.teamId,
+ role: {
+ in: ["OWNER", "ADMIN"],
+ },
+ },
+ });
+
+ if (!membership) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message:
+ "You don't have permission to create applications in this team",
+ });
+ }
+
+ return ctx.prisma.application.create({
+ data: {
+ name: input.name,
+ description: input.description,
+ domains: input.domains || [],
+ teamId: input.teamId,
+ settings: {},
+ },
+ select: {
+ id: true,
+ name: true,
+ team: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ });
+ }),
+
+ listApplications: protectedProcedure
+ .input(
+ z
+ .object({
+ teamId: z.string().uuid().optional(),
+ })
+ .optional()
+ )
+ .query(async ({ ctx, input }) => {
+ return ctx.prisma.application.findMany({
+ where: {
+ teamId: input?.teamId,
+ team: {
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ description: true,
+ clientId: true,
+ team: {
+ select: {
+ name: true,
+ },
+ },
+ },
+ });
+ }),
+
+ // Stats for dashboard
+ getStats: protectedProcedure.query(async ({ ctx }) => {
+ const [teams, applications] = await Promise.all([
+ ctx.prisma.team.count({
+ where: {
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ }),
+ ctx.prisma.application.count({
+ where: {
+ team: {
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ },
+ }),
+ ]);
+
+ return {
+ teams,
+ applications,
+ };
+ }),
+
+ getTeam: protectedProcedure
+ .input(z.object({ id: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const team = await ctx.prisma.team.findFirst({
+ where: {
+ id: input.id,
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ organization: {
+ select: {
+ name: true,
+ },
+ },
+ _count: {
+ select: {
+ memberships: true,
+ applications: true,
+ },
+ },
+ },
+ });
+
+ if (!team) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Team not found",
+ });
+ }
+
+ return team;
+ }),
+
+ updateTeam: protectedProcedure
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ name: z.string().min(1).max(100),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const team = await ctx.prisma.team.findFirst({
+ where: {
+ id: input.id,
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ role: {
+ in: ["OWNER", "ADMIN"],
+ },
+ },
+ },
+ },
+ });
+
+ if (!team) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Team not found or you don't have permission to update it",
+ });
+ }
+
+ return ctx.prisma.team.update({
+ where: { id: input.id },
+ data: {
+ name: input.name,
+ },
+ });
+ }),
+
+ deleteTeam: protectedProcedure
+ .input(z.object({ id: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const team = await ctx.prisma.team.findFirst({
+ where: {
+ id: input.id,
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ role: "OWNER",
+ },
+ },
+ },
+ include: {
+ organization: {
+ select: {
+ name: true,
+ teams: {
+ select: {
+ id: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!team) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Team not found or insufficient permissions",
+ });
+ }
+
+ if (team.organization.teams.length === 1) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Cannot delete the organization's only team",
+ });
+ }
+
+ const otherMemberships = await ctx.prisma.membership.findFirst({
+ where: {
+ userId: ctx.user.id,
+ teamId: {
+ not: input.id,
+ },
+ },
+ });
+
+ if (!otherMemberships) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Cannot delete your only team membership",
+ });
+ }
+
+ return ctx.prisma.team.delete({ where: { id: input.id } });
+ }),
+
+ getApplication: protectedProcedure
+ .input(z.object({ id: z.string().uuid() }))
+ .query(async ({ ctx, input }) => {
+ const application = await ctx.prisma.application.findFirst({
+ where: {
+ id: input.id,
+ team: {
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ },
+ },
+ },
+ },
+ select: {
+ id: true,
+ name: true,
+ description: true,
+ domains: true,
+ team: {
+ select: {
+ name: true,
+ },
+ },
+ clientId: true,
+ },
+ });
+
+ if (!application) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Application not found",
+ });
+ }
+
+ return application;
+ }),
+
+ updateApplication: protectedProcedure
+ .input(
+ z.object({
+ id: z.string().uuid(),
+ name: z.string().min(1).max(100),
+ description: z.string().max(255).optional(),
+ domains: domainValidator,
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const application = await ctx.prisma.application.findFirst({
+ where: {
+ id: input.id,
+ team: {
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ role: {
+ in: ["OWNER", "ADMIN"],
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!application) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message:
+ "Application not found or you don't have permission to update it",
+ });
+ }
+
+ return ctx.prisma.application.update({
+ where: { id: input.id },
+ data: {
+ name: input.name,
+ description: input.description,
+ domains: input.domains,
+ },
+ });
+ }),
+
+ deleteApplication: protectedProcedure
+ .input(z.object({ id: z.string().uuid() }))
+ .mutation(async ({ ctx, input }) => {
+ const application = await ctx.prisma.application.findFirst({
+ where: {
+ id: input.id,
+ team: {
+ memberships: {
+ some: {
+ userId: ctx.user.id,
+ role: {
+ in: ["OWNER", "ADMIN"],
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!application) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message:
+ "Application not found or you don't have permission to delete it",
+ });
+ }
+
+ return ctx.prisma.application.delete({
+ where: { id: input.id },
+ });
+ }),
+};
diff --git a/shared/validators/domains.ts b/shared/validators/domains.ts
new file mode 100644
index 0000000..3612682
--- /dev/null
+++ b/shared/validators/domains.ts
@@ -0,0 +1,71 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+
+export function getBaseDomain(url: string): string {
+ try {
+ let normalizedUrl = url;
+ if (!url.includes("://")) {
+ normalizedUrl = `http://${url}`;
+ }
+ const urlObj = new URL(normalizedUrl);
+ return urlObj.hostname;
+ } catch {
+ throw new Error(`Invalid domain format: ${url}`);
+ }
+}
+
+export function areDomainsRelated(domain1: string, domain2: string): boolean {
+ try {
+ const base1 = getBaseDomain(domain1);
+ const base2 = getBaseDomain(domain2);
+
+ if (base1 === base2) return true;
+ return base1.endsWith(`.${base2}`) || base2.endsWith(`.${base1}`);
+ } catch {
+ return false;
+ }
+}
+
+export function validateDomains(domains: string[]): {
+ valid: boolean;
+ error?: string;
+} {
+ if (domains.length === 0) return { valid: true };
+
+ try {
+ const firstDomain = domains[0];
+ const isValid = domains.every((domain) =>
+ areDomainsRelated(firstDomain, domain)
+ );
+
+ return {
+ valid: isValid,
+ error: isValid
+ ? undefined
+ : "All domains must be subdomains or ports of the same base domain",
+ };
+ } catch {
+ return {
+ valid: false,
+ error: "Invalid domain format provided",
+ };
+ }
+}
+
+// For backend TRPC validation
+export const domainValidator = z
+ .array(z.string().max(255))
+ .superRefine((domains, ctx) => {
+ const result = validateDomains(domains);
+
+ if (!result.valid) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: result.error!,
+ });
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: result.error!,
+ });
+ }
+ });