diff --git a/web/.env.example b/web/.env.example
index 8aae850..ba369f9 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -1,2 +1,27 @@
# Sentry DSN for error tracking (optional; leave empty to disable)
NEXT_PUBLIC_SENTRY_DSN=
+
+# NextAuth.js — required for session management
+AUTH_SECRET= # openssl rand -base64 32
+
+# Firebase Admin SDK — server-side token verification
+# Set FIREBASE_SERVICE_ACCOUNT_JSON to the full service account JSON (single line, no newlines).
+# If omitted, set FIREBASE_PROJECT_ID and rely on Application Default Credentials (GCP only).
+FIREBASE_PROJECT_ID= # same as NEXT_PUBLIC_FIREBASE_PROJECT_ID, server-side only
+FIREBASE_SERVICE_ACCOUNT_JSON= # {"type":"service_account","project_id":"..."}
+
+# Firebase — client-side config (copy from Firebase Console > Project settings)
+NEXT_PUBLIC_FIREBASE_API_KEY=
+NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
+NEXT_PUBLIC_FIREBASE_PROJECT_ID=
+NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
+NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
+NEXT_PUBLIC_FIREBASE_APP_ID=
+NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= # optional
+
+# Firebase Cloud Messaging — VAPID key from Firebase Console > Cloud Messaging
+NEXT_PUBLIC_FIREBASE_VAPID_KEY=
+
+# Go backend base URL
+NEXT_PUBLIC_API_URL=http://localhost:8080
+
diff --git a/web/__mocks__/next/image.tsx b/web/__mocks__/next/image.tsx
index 5ccbf89..442be2b 100644
--- a/web/__mocks__/next/image.tsx
+++ b/web/__mocks__/next/image.tsx
@@ -2,6 +2,7 @@ import type { ImageProps } from 'next/image'
export default function MockImage({ src, alt, width, height, ...props }: ImageProps) {
return (
+ // eslint-disable-next-line @next/next/no-img-element
({
+ useSession: vi.fn(() => ({ data: null, status: 'unauthenticated' })),
+ SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}))
+
describe('Home page', () => {
it('renders without crashing', () => {
const { container } = render()
diff --git a/web/app/(auth)/login/page.tsx b/web/app/(auth)/login/page.tsx
new file mode 100644
index 0000000..85596b9
--- /dev/null
+++ b/web/app/(auth)/login/page.tsx
@@ -0,0 +1,44 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { LoginForm } from '@/features/auth/components/LoginForm'
+import { GoogleSignInButton } from '@/features/auth/components/GoogleSignInButton'
+import { Separator } from '@/components/ui/separator'
+import Link from 'next/link'
+
+export default function LoginPage() {
+ return (
+
+
+
+ Sign in
+
+ Enter your credentials to access your account
+
+
+
+
+
+
+ or
+
+
+
+
+ Don't have an account?{' '}
+
+ Sign up
+
+
+
+
+
+ )
+}
diff --git a/web/app/(auth)/register/page.tsx b/web/app/(auth)/register/page.tsx
new file mode 100644
index 0000000..68f5595
--- /dev/null
+++ b/web/app/(auth)/register/page.tsx
@@ -0,0 +1,42 @@
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card'
+import { RegisterForm } from '@/features/auth/components/RegisterForm'
+import { GoogleSignInButton } from '@/features/auth/components/GoogleSignInButton'
+import { Separator } from '@/components/ui/separator'
+import Link from 'next/link'
+
+export default function RegisterPage() {
+ return (
+
+
+
+ Create account
+ Sign up to get started
+
+
+
+
+
+ or
+
+
+
+
+ Already have an account?{' '}
+
+ Sign in
+
+
+
+
+
+ )
+}
diff --git a/web/app/(dashboard)/dashboard/page.tsx b/web/app/(dashboard)/dashboard/page.tsx
new file mode 100644
index 0000000..d5334ec
--- /dev/null
+++ b/web/app/(dashboard)/dashboard/page.tsx
@@ -0,0 +1,18 @@
+import { auth } from '@/auth'
+import { redirect } from 'next/navigation'
+
+export default async function DashboardPage() {
+ const session = await auth()
+ if (!session) redirect('/login')
+
+ return (
+
+
+
Welcome back
+
+ {session.user?.name ?? session.user?.email ?? 'User'}
+
+
+
+ )
+}
diff --git a/web/app/(dashboard)/layout.tsx b/web/app/(dashboard)/layout.tsx
new file mode 100644
index 0000000..78fb412
--- /dev/null
+++ b/web/app/(dashboard)/layout.tsx
@@ -0,0 +1,20 @@
+import { cookies } from 'next/headers'
+import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
+import { AppSidebar } from '@/components/layout/AppSidebar'
+
+export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
+ const cookieStore = await cookies()
+ const sidebarOpen = cookieStore.get('sidebar_state')?.value !== 'false'
+
+ return (
+
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/web/app/(dashboard)/settings/page.tsx b/web/app/(dashboard)/settings/page.tsx
new file mode 100644
index 0000000..7dbe94d
--- /dev/null
+++ b/web/app/(dashboard)/settings/page.tsx
@@ -0,0 +1,25 @@
+import { auth } from '@/auth'
+import { redirect } from 'next/navigation'
+
+export default async function SettingsPage() {
+ const session = await auth()
+ if (!session) redirect('/login')
+
+ return (
+
+
+
Settings
+
Manage your account settings.
+
+
+
Account
+
+ {session.user?.name && (
+ {session.user.name}
+ )}
+ {session.user?.email}
+
+
+
+ )
+}
diff --git a/web/app/api/auth/[...nextauth]/route.ts b/web/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..42e2953
--- /dev/null
+++ b/web/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,2 @@
+import { handlers } from "@/auth"
+export const { GET, POST } = handlers
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index 5068e0a..c206450 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -1,10 +1,10 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import { Manrope, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
-const geistSans = Geist({
- variable: "--font-geist-sans",
+const manrope = Manrope({
+ variable: "--font-sans",
subsets: ["latin"],
});
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: "App",
+ description: "Fullstack template",
};
export default function RootLayout({
@@ -26,7 +26,7 @@ export default function RootLayout({
return (
{children}
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 5237f3a..362fe5d 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -1,11 +1,15 @@
import About from "@/components/home/about";
import Hero from "@/components/home/hero";
+import Footer from "@/components/layout/footer";
+import Header from "@/components/layout/header";
const page = () => {
return (
);
};
diff --git a/web/app/providers.tsx b/web/app/providers.tsx
index 12464c3..5d0ff12 100644
--- a/web/app/providers.tsx
+++ b/web/app/providers.tsx
@@ -1,7 +1,14 @@
'use client'
import { TRPCProvider } from '@/lib/trpc/client'
+import { SessionProvider } from 'next-auth/react'
+import { Toaster } from '@/components/ui/sonner'
export function Providers({ children }: { children: React.ReactNode }) {
- return {children}
+ return (
+
+ {children}
+
+
+ )
}
diff --git a/web/auth.ts b/web/auth.ts
new file mode 100644
index 0000000..7907043
--- /dev/null
+++ b/web/auth.ts
@@ -0,0 +1,32 @@
+import NextAuth from "next-auth"
+import Credentials from "next-auth/providers/credentials"
+import { z } from "zod"
+import { verifyFirebaseToken } from "@/lib/firebase-admin"
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+ providers: [
+ Credentials({
+ credentials: { idToken: {} },
+ async authorize(credentials) {
+ const parsed = z.object({ idToken: z.string().min(1) }).safeParse(credentials)
+ if (!parsed.success) return null
+
+ try {
+ const decoded = await verifyFirebaseToken(parsed.data.idToken)
+ if (!decoded.sub) return null
+
+ return {
+ id: decoded.sub,
+ email: decoded.email ?? null,
+ name: decoded.name ?? null,
+ image: decoded.picture ?? null,
+ }
+ } catch {
+ return null
+ }
+ },
+ }),
+ ],
+ pages: { signIn: "/login" },
+ session: { strategy: "jwt" },
+})
diff --git a/web/components/layout/AppSidebar.tsx b/web/components/layout/AppSidebar.tsx
new file mode 100644
index 0000000..a98295a
--- /dev/null
+++ b/web/components/layout/AppSidebar.tsx
@@ -0,0 +1,54 @@
+import Link from 'next/link'
+import { LayoutDashboard, Settings } from 'lucide-react'
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem,
+} from '@/components/ui/sidebar'
+import { UserMenu } from '@/features/auth/components/UserMenu'
+
+const navItems = [
+ { title: 'Dashboard', url: '/dashboard', icon: LayoutDashboard },
+ { title: 'Settings', url: '/settings', icon: Settings },
+]
+
+export function AppSidebar() {
+ return (
+
+
+
+ App
+
+
+
+
+ Menu
+
+
+ {navItems.map((item) => (
+
+
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/components/layout/NavAuth.tsx b/web/components/layout/NavAuth.tsx
new file mode 100644
index 0000000..e55a128
--- /dev/null
+++ b/web/components/layout/NavAuth.tsx
@@ -0,0 +1,19 @@
+"use client"
+
+import Link from "next/link"
+import { useSession } from "@/features/auth/hooks/useSession"
+import { UserMenu } from "@/features/auth/components/UserMenu"
+import { Button } from "@/components/ui/button"
+
+export function NavAuth() {
+ const { isAuthenticated, isLoading } = useSession()
+
+ if (isLoading) return
+ if (isAuthenticated) return
+
+ return (
+
+ )
+}
diff --git a/web/components/layout/__tests__/NavAuth.test.tsx b/web/components/layout/__tests__/NavAuth.test.tsx
new file mode 100644
index 0000000..dab7801
--- /dev/null
+++ b/web/components/layout/__tests__/NavAuth.test.tsx
@@ -0,0 +1,38 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { NavAuth } from '../NavAuth'
+
+vi.mock('@/features/auth/hooks/useSession', () => ({
+ useSession: vi.fn(),
+}))
+
+vi.mock('@/features/auth/components/UserMenu', () => ({
+ UserMenu: () => ,
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}))
+
+import { useSession } from '@/features/auth/hooks/useSession'
+const mockUseSession = vi.mocked(useSession)
+
+describe('NavAuth', () => {
+ it('shows a loading skeleton while session is loading', () => {
+ mockUseSession.mockReturnValue({ isLoading: true, isAuthenticated: false, user: null })
+ const { container } = render()
+ expect(container.querySelector('.animate-pulse')).toBeInTheDocument()
+ })
+
+ it('renders UserMenu when authenticated', () => {
+ mockUseSession.mockReturnValue({ isLoading: false, isAuthenticated: true, user: { email: 'a@b.com' } })
+ render()
+ expect(screen.getByTestId('user-menu')).toBeInTheDocument()
+ })
+
+ it('renders a sign-in link when unauthenticated', () => {
+ mockUseSession.mockReturnValue({ isLoading: false, isAuthenticated: false, user: null })
+ render()
+ expect(screen.getByRole('link', { name: /sign in/i })).toBeInTheDocument()
+ })
+})
diff --git a/web/components/layout/footer.tsx b/web/components/layout/footer.tsx
new file mode 100644
index 0000000..3a0e0ab
--- /dev/null
+++ b/web/components/layout/footer.tsx
@@ -0,0 +1,7 @@
+const Footer = () => {
+ return (
+
+ );
+};
+
+export default Footer;
\ No newline at end of file
diff --git a/web/components/layout/header.tsx b/web/components/layout/header.tsx
new file mode 100644
index 0000000..a47a50e
--- /dev/null
+++ b/web/components/layout/header.tsx
@@ -0,0 +1,16 @@
+import Link from "next/link"
+import { NavAuth } from "./NavAuth"
+
+export default function Header() {
+ return (
+
+ )
+}
diff --git a/web/components/ui/form.tsx b/web/components/ui/form.tsx
new file mode 100644
index 0000000..fe3d786
--- /dev/null
+++ b/web/components/ui/form.tsx
@@ -0,0 +1,177 @@
+"use client"
+
+import * as React from "react"
+import { Label as LabelPrimitive, Slot } from "radix-ui"
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from "react-hook-form"
+
+import { cn } from "@/lib/utils"
+import { Label } from "@/components/ui/label"
+
+const Form = FormProvider
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName
+}
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ )
+}
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext)
+ const itemContext = React.useContext(FormItemContext)
+ const { getFieldState, formState } = useFormContext()
+
+ const fieldState = getFieldState(fieldContext.name, formState)
+
+ if (!fieldContext) {
+ throw new Error("useFormField should be used within ")
+ }
+
+ const { id } = itemContext
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ }
+}
+
+type FormItemContextValue = {
+ id: string
+}
+
+const FormItemContext = React.createContext(
+ {} as FormItemContextValue
+)
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const id = React.useId()
+
+ return (
+
+
+
+ )
+})
+FormItem.displayName = "FormItem"
+
+const FormLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { error, formItemId } = useFormField()
+
+ return (
+
+ )
+})
+FormLabel.displayName = "FormLabel"
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
+
+ return (
+
+ )
+})
+FormControl.displayName = "FormControl"
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField()
+
+ return (
+
+ )
+})
+FormDescription.displayName = "FormDescription"
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : children
+
+ if (!body) {
+ return null
+ }
+
+ return (
+
+ {body}
+
+ )
+})
+FormMessage.displayName = "FormMessage"
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/web/components/ui/sheet.tsx b/web/components/ui/sheet.tsx
new file mode 100644
index 0000000..8d0e1d3
--- /dev/null
+++ b/web/components/ui/sheet.tsx
@@ -0,0 +1,147 @@
+"use client"
+
+import * as React from "react"
+import { Dialog as SheetPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left"
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+
+ )}
+
+
+ )
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Sheet,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+}
diff --git a/web/components/ui/sidebar.tsx b/web/components/ui/sidebar.tsx
new file mode 100644
index 0000000..ea78b50
--- /dev/null
+++ b/web/components/ui/sidebar.tsx
@@ -0,0 +1,702 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "radix-ui"
+
+import { useIsMobile } from "@/hooks/use-mobile"
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { PanelLeftIcon } from "lucide-react"
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state"
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
+const SIDEBAR_WIDTH = "16rem"
+const SIDEBAR_WIDTH_MOBILE = "18rem"
+const SIDEBAR_WIDTH_ICON = "3rem"
+const SIDEBAR_KEYBOARD_SHORTCUT = "b"
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed"
+ open: boolean
+ setOpen: (open: boolean) => void
+ openMobile: boolean
+ setOpenMobile: (open: boolean) => void
+ isMobile: boolean
+ toggleSidebar: () => void
+}
+
+const SidebarContext = React.createContext(null)
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext)
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.")
+ }
+
+ return context
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}) {
+ const isMobile = useIsMobile()
+ const [openMobile, setOpenMobile] = React.useState(false)
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen)
+ const open = openProp ?? _open
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value
+ if (setOpenProp) {
+ setOpenProp(openState)
+ } else {
+ _setOpen(openState)
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
+ },
+ [setOpenProp, open]
+ )
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
+ }, [isMobile, setOpen, setOpenMobile])
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault()
+ toggleSidebar()
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [toggleSidebar])
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed"
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ dir,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right"
+ variant?: "sidebar" | "floating" | "inset"
+ collapsible?: "offcanvas" | "icon" | "none"
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ )
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ )
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ )
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar()
+
+ return (
+
+ )
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ )
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "div"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function SidebarMenuButton({
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+} & VariantProps) {
+ const Comp = asChild ? Slot.Root : "button"
+ const { isMobile, state } = useSidebar()
+
+ const button = (
+
+ )
+
+ if (!tooltip) {
+ return button
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ }
+ }
+
+ return (
+
+ {button}
+
+
+ )
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean
+ showOnHover?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "button"
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ showOnHover &&
+ "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean
+}) {
+ // Random width between 50 to 90%.
+ const [width] = React.useState(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`
+ })
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ )
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ )
+}
+
+function SidebarMenuSubButton({
+ asChild = false,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean
+ size?: "sm" | "md"
+ isActive?: boolean
+}) {
+ const Comp = asChild ? Slot.Root : "a"
+
+ return (
+ span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+}
diff --git a/web/components/ui/tooltip.tsx b/web/components/ui/tooltip.tsx
new file mode 100644
index 0000000..bb1ea52
--- /dev/null
+++ b/web/components/ui/tooltip.tsx
@@ -0,0 +1,57 @@
+"use client"
+
+import * as React from "react"
+import { Tooltip as TooltipPrimitive } from "radix-ui"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
diff --git a/web/docs/_index.md b/web/docs/_index.md
index c6590df..ef559b6 100644
--- a/web/docs/_index.md
+++ b/web/docs/_index.md
@@ -15,3 +15,4 @@ The `docs` agent reads this index first to locate the right file.
| WebSocket hook (useWebSocket, reconnect, auth) | [websocket.md](websocket.md) | `lib/useWebSocket.ts`, `lib/useWebSocket.test.ts` |
| Firebase Cloud Messaging — permission, token, service worker, useFCM hook | [fcm.md](fcm.md) | `lib/fcm.ts`, `lib/useFCM.ts`, `public/firebase-messaging-sw.js`, `lib/fcm.test.ts` |
| Object storage (Cloudflare R2) — presign utility, uploadToR2, useUpload hook | [storage.md](storage.md) | `lib/storage.ts`, `lib/useUpload.ts` |
+| Authentication (NextAuth v5) — providers, session, proxy, forms, hooks | [auth.md](auth.md) | `auth.ts`, `proxy.ts`, `features/auth/` |
diff --git a/web/docs/auth.md b/web/docs/auth.md
new file mode 100644
index 0000000..74690b1
--- /dev/null
+++ b/web/docs/auth.md
@@ -0,0 +1,307 @@
+---
+topic: auth
+last_verified: 2026-06-24
+sources:
+ - auth.ts
+ - lib/firebase.ts
+ - lib/firebase-admin.ts
+ - app/api/auth/[...nextauth]/route.ts
+ - proxy.ts
+ - server/trpc.ts
+ - server/routers/auth.ts
+ - app/providers.tsx
+ - features/auth/types.ts
+ - features/auth/validation.ts
+ - features/auth/components/LoginForm.tsx
+ - features/auth/components/RegisterForm.tsx
+ - features/auth/components/GoogleSignInButton.tsx
+ - features/auth/components/UserMenu.tsx
+ - features/auth/hooks/useSession.ts
+ - features/auth/hooks/useSignOut.ts
+ - app/(auth)/login/page.tsx
+ - app/(auth)/register/page.tsx
+ - app/(dashboard)/dashboard/page.tsx
+---
+
+# Authentication
+
+Firebase-first auth with NextAuth v5 (Auth.js) for session management.
+
+**Flow overview:**
+1. Client signs in with Firebase Auth (Google popup or email/password).
+2. Client retrieves a Firebase ID token (`user.getIdToken()`).
+3. Client calls NextAuth's Credentials provider with `{ idToken }`.
+4. `auth.ts` verifies the token with Firebase Admin SDK (`verifyIdToken`), extracts claims, and returns a NextAuth user.
+5. NextAuth issues a JWT session cookie.
+
+## Packages
+
+```json
+"next-auth": "beta",
+"firebase": "^12.x",
+"firebase-admin": "^14.x",
+"react-hook-form": "^7.x",
+"@hookform/resolvers": "^5.x"
+```
+
+## Config (`auth.ts`)
+
+Lives at the web root. Exports four named values used throughout the app:
+
+```ts
+export const { handlers, auth, signIn, signOut } = NextAuth({ ... })
+```
+
+- **`handlers`** — `{ GET, POST }` for the route handler.
+- **`auth()`** — async function; call in Server Components, Server Actions, and proxy to read the session.
+- **`signIn(provider, options)`** — trigger sign-in from server context.
+- **`signOut(options)`** — trigger sign-out from server context.
+
+### Provider
+
+Single **Credentials** provider that accepts `{ idToken: string }`.
+
+`authorize()`:
+1. Validates `idToken` is a non-empty string with Zod.
+2. Calls `verifyFirebaseToken(idToken)` (Firebase Admin SDK) — verifies signature, expiry, audience, and issuer.
+3. Returns `{ id, email, name, image }` from the decoded claims, or `null` on any failure.
+
+### Session strategy
+
+```ts
+session: { strategy: 'jwt' }
+```
+
+### Custom sign-in page
+
+```ts
+pages: { signIn: '/login' }
+```
+
+## Firebase Admin SDK (`lib/firebase-admin.ts`)
+
+Initialises the Admin app once (singleton) and exports `verifyFirebaseToken`:
+
+```ts
+export async function verifyFirebaseToken(idToken: string) {
+ return getAuth(getAdminApp()).verifyIdToken(idToken)
+}
+```
+
+Initialization precedence:
+1. `FIREBASE_SERVICE_ACCOUNT_JSON` (full service account JSON string) — works everywhere.
+2. `FIREBASE_PROJECT_ID` alone — works on GCP with Application Default Credentials.
+
+## Firebase Client SDK (`lib/firebase.ts`)
+
+Exports `getFirebaseAuth()` for use in client components. Initialises the Firebase app once using `NEXT_PUBLIC_FIREBASE_*` env vars.
+
+## Route handler (`app/api/auth/[...nextauth]/route.ts`)
+
+```ts
+import { handlers } from "@/auth"
+export const { GET, POST } = handlers
+```
+
+All NextAuth HTTP endpoints (`/api/auth/callback/*`, `/api/auth/session`, etc.) are handled here.
+
+## Route protection
+
+### Proxy (`proxy.ts`)
+
+```ts
+import { auth } from "@/auth"
+
+export default auth((req: NextAuthRequest) => {
+ if (!req.auth) {
+ const loginUrl = new URL("/login", req.url)
+ loginUrl.searchParams.set("callbackUrl", req.nextUrl.pathname + req.nextUrl.search)
+ return NextResponse.redirect(loginUrl)
+ }
+})
+
+export const config = {
+ matcher: ["/dashboard/:path*", "/settings/:path*"],
+}
+```
+
+Unauthenticated requests to `/dashboard/*` or `/settings/*` are redirected to `/login?callbackUrl=`.
+
+### Additional guard in Server Components
+
+Protected pages call `auth()` directly and redirect if no session is returned:
+
+```ts
+// app/(dashboard)/dashboard/page.tsx
+const session = await auth()
+if (!session) redirect('/login')
+```
+
+## Reading the session
+
+### In a Server Component
+
+```ts
+import { auth } from '@/auth'
+
+const session = await auth()
+// session is Session | null
+```
+
+### In a Client Component
+
+Use the `useSession` hook from `features/auth/hooks/useSession`:
+
+```ts
+import { useSession } from '@/features/auth/hooks/useSession'
+
+const { user, isAuthenticated, isLoading } = useSession()
+```
+
+Returns `{ user: AuthUser | null, isAuthenticated: boolean, isLoading: boolean }`.
+Internally wraps `useSession` from `next-auth/react` and casts to the local `AuthSession` type.
+
+## SessionProvider (`app/providers.tsx`)
+
+`` from `next-auth/react` wraps ``:
+
+```tsx
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+ )
+}
+```
+
+Required for `useSession()` to work in Client Components.
+
+## Types (`features/auth/types.ts`)
+
+```ts
+export interface AuthUser {
+ id?: string | null
+ name?: string | null
+ email?: string | null
+ image?: string | null
+}
+
+export interface AuthSession {
+ user: AuthUser
+ expires: string
+}
+```
+
+## Validation (`features/auth/validation.ts`)
+
+Zod v4 schemas:
+
+- **`loginSchema`** — `{ email: z.email(), password: string (min 1) }`.
+- **`registerSchema`** — `{ name: string (min 2), email: z.email(), password: string (min 8), confirmPassword: string }` with `.refine` for password match.
+- Exports inferred types `LoginFormValues` and `RegisterFormValues`.
+
+Used with `standardSchemaResolver` from `@hookform/resolvers/standard-schema` (required for Zod v4).
+
+## Auth components (`features/auth/components/`)
+
+All are `'use client'` components. Errors are surfaced via Sonner toasts, not inline error state.
+
+### `LoginForm`
+
+`react-hook-form` with `standardSchemaResolver(loginSchema)`. On submit:
+1. `signInWithEmailAndPassword(getFirebaseAuth(), email, password)` via Firebase.
+2. Retrieves ID token from the credential.
+3. `signIn('credentials', { idToken, redirect: false })` via NextAuth.
+4. Pushes to `/dashboard` on success; shows `toast.error` on failure.
+
+### `RegisterForm`
+
+Same form setup with `registerSchema`. On submit:
+1. `createUserWithEmailAndPassword(getFirebaseAuth(), email, password)`.
+2. `updateProfile(user, { displayName: name })`.
+3. Retrieves ID token.
+4. `signIn('credentials', { idToken, redirect: false })`.
+5. Pushes to `/dashboard` on success.
+Handles `auth/email-already-in-use` with a specific toast message.
+
+### `GoogleSignInButton`
+
+On click:
+1. `signInWithPopup(getFirebaseAuth(), new GoogleAuthProvider())`.
+2. Retrieves ID token.
+3. `signIn('credentials', { idToken, redirect: false })`.
+4. Pushes to `/dashboard` on success.
+Popup-dismissed errors (`auth/popup-closed-by-user`, `auth/cancelled-popup-request`) are silently ignored. Button is disabled while in-flight.
+
+### `UserMenu`
+
+Reads `{ user, isAuthenticated }` from `useSession()`. Returns `null` when not authenticated. Renders an `Avatar` inside a `DropdownMenu` showing the user's name, email, and a "Sign out" item that calls `useSignOut().signOut`.
+
+## Auth hooks (`features/auth/hooks/`)
+
+### `useSession`
+
+Wraps `useSession` from `next-auth/react`. Returns `UseSessionReturn`:
+
+```ts
+{ user: AuthUser | null, isAuthenticated: boolean, isLoading: boolean }
+```
+
+### `useSignOut`
+
+Signs out of both NextAuth and Firebase in parallel, then navigates to `/login`:
+
+```ts
+await Promise.all([
+ nextAuthSignOut({ redirect: false }),
+ firebaseSignOut(getFirebaseAuth()),
+])
+router.push('/login')
+```
+
+## Pages
+
+| Route | File | Type |
+|---|---|---|
+| `/login` | `app/(auth)/login/page.tsx` | Server Component |
+| `/register` | `app/(auth)/register/page.tsx` | Server Component |
+| `/dashboard` | `app/(dashboard)/dashboard/page.tsx` | Server Component |
+| `/settings` | `app/(dashboard)/settings/page.tsx` | Server Component |
+
+Login and Register pages are layout-less Server Components that render a centered `Card` containing `GoogleSignInButton`, a divider, the form, and a link to the other page.
+
+## Environment variables
+
+| Variable | Description |
+|---|---|
+| `AUTH_SECRET` | Secret used to sign JWTs. Generate with `openssl rand -base64 32`. |
+| `FIREBASE_PROJECT_ID` | Firebase project ID (server-side). Used when `FIREBASE_SERVICE_ACCOUNT_JSON` is absent and ADC is available (GCP). |
+| `FIREBASE_SERVICE_ACCOUNT_JSON` | Full service account JSON as a single-line string. Required outside GCP for `verifyIdToken`. |
+| `NEXT_PUBLIC_FIREBASE_API_KEY` | Firebase web API key. |
+| `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN` | Firebase auth domain. |
+| `NEXT_PUBLIC_FIREBASE_PROJECT_ID` | Firebase project ID (client-side). |
+| `NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET` | Firebase storage bucket. |
+| `NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID` | Firebase messaging sender ID. |
+| `NEXT_PUBLIC_FIREBASE_APP_ID` | Firebase app ID. |
+
+## Testing
+
+`createTRPCContext` calls `auth()` internally. Any test that instantiates a context must mock `@/auth`:
+
+```ts
+vi.mock('@/auth', () => ({
+ auth: vi.fn(),
+}))
+
+import { auth } from '@/auth'
+const mockAuth = vi.mocked(auth)
+
+// provide a session:
+mockAuth.mockResolvedValue(validSession)
+// or simulate unauthenticated:
+mockAuth.mockResolvedValue(null)
+```
+
+See `server/routers/__tests__/auth.test.ts` for the full test pattern.
diff --git a/web/docs/data-fetching.md b/web/docs/data-fetching.md
index f801264..3e7e416 100644
--- a/web/docs/data-fetching.md
+++ b/web/docs/data-fetching.md
@@ -9,6 +9,7 @@ sources:
- server/routers/health.ts
- lib/trpc/client.tsx
- lib/trpc/server.ts
+ - lib/trpc/utils.ts
- app/providers.tsx
---
@@ -64,7 +65,7 @@ import { createUser } from './actions';
## When client-side fetching is acceptable
Only when data depends on runtime browser state (e.g., user interaction, live updates).
-Use `SWR` or `TanStack Query` for client-side data — never bare `useEffect + fetch`.
+Use tRPC with React Query (via `trpc...useQuery()`) for client-side data — never bare `useEffect + fetch`.
## Backend URL
Backend runs at `http://localhost:8080` in development.
diff --git a/web/docs/trpc.md b/web/docs/trpc.md
index e74b286..a7c59bf 100644
--- a/web/docs/trpc.md
+++ b/web/docs/trpc.md
@@ -11,6 +11,8 @@ sources:
- lib/trpc/client.tsx
- lib/trpc/server.ts
- app/providers.tsx
+ - auth.ts
+ - app/api/auth/[...nextauth]/route.ts
---
# tRPC
@@ -32,22 +34,21 @@ tRPC v11 with React Query v5, wired into Next.js App Router.
```ts
export interface TRPCContext {
req: NextRequest
- // session will be added when auth is implemented
+ session: Session | null
}
export async function createTRPCContext({ req }: { req: NextRequest }): Promise
```
-`createTRPCContext` takes a `NextRequest` and returns the context object passed to every procedure.
+`createTRPCContext` calls `auth()` from NextAuth (`@/auth`) to resolve the current session, then returns `{ req, session }`.
### Procedure types
**`publicProcedure`** — alias for `t.procedure`. No auth check.
**`protectedProcedure`** — middleware runs before the handler:
-- Reads `Authorization` header: passes if it starts with `Bearer `.
-- Reads `Cookie` header: passes if it contains `__session=`.
-- Throws `TRPCError({ code: 'UNAUTHORIZED' })` if neither is present.
+- Checks `ctx.session?.user`.
+- Throws `TRPCError({ code: 'UNAUTHORIZED' })` if `session` is `null` or `session.user` is absent.
**`createCallerFactory`** — exported from `t.createCallerFactory`; used by `lib/trpc/server.ts` to build server-side callers.
@@ -73,12 +74,10 @@ Uses `publicProcedure`. Fetches `GET ${BACKEND_URL}/health` (defaults to `http:/
### `auth` router (`server/routers/auth.ts`)
-Uses `protectedProcedure`. Current stubs:
-- `session` — query, returns `{ authenticated: true }`.
+Uses `protectedProcedure`.
+- `session` — query, returns `{ authenticated: true, user: ctx.session?.user ?? null }`.
- `signOut` — mutation, returns `{ success: true }`.
-Both will be replaced when auth is implemented.
-
### `notifications` router (`server/routers/notifications.ts`)
Uses `protectedProcedure` with Zod input validation. Current stubs:
@@ -150,14 +149,19 @@ export default async function HealthPage() {
## Provider wiring (`app/providers.tsx` + `app/layout.tsx`)
-`app/providers.tsx` is a thin `'use client'` wrapper:
+`app/providers.tsx` is a thin `'use client'` wrapper. `SessionProvider` (from `next-auth/react`) wraps `TRPCProvider` so both session and React Query contexts are available to all Client Components:
```tsx
'use client'
import { TRPCProvider } from '@/lib/trpc/client'
+import { SessionProvider } from 'next-auth/react'
export function Providers({ children }: { children: React.ReactNode }) {
- return {children}
+ return (
+
+ {children}
+
+ )
}
```
@@ -248,35 +252,52 @@ export const appRouter = router({
Procedures are tested directly via `appRouter.createCaller(ctx)` — no HTTP server needed.
-Pattern from `server/routers/__tests__/health.test.ts` and `auth.test.ts`:
+`createTRPCContext` calls `auth()` from NextAuth, so **every test file that calls `createTRPCContext` must mock `@/auth`**:
+
+```ts
+vi.mock('@/auth', () => ({
+ auth: vi.fn(),
+}))
+
+import { auth } from '@/auth'
+const mockAuth = vi.mocked(auth)
+```
+
+Pattern from `server/routers/__tests__/auth.test.ts`:
```ts
+import { describe, it, expect, vi, beforeEach } from 'vitest'
import { appRouter } from '../_app'
import { createTRPCContext } from '../../trpc'
+import type { Session } from 'next-auth'
-function makeContext(reqHeaders: Record = {}) {
- const req = new Request('http://localhost/api/trpc', { headers: reqHeaders }) as NextRequest
- return createTRPCContext({ req })
+vi.mock('@/auth', () => ({ auth: vi.fn() }))
+
+import { auth } from '@/auth'
+const mockAuth = vi.mocked(auth)
+
+const validSession: Session = {
+ user: { id: '123', email: 'test@example.com', name: 'Test User' },
+ expires: '2099-01-01T00:00:00.000Z',
}
-it('returns data', async () => {
- const ctx = await makeContext()
- const caller = appRouter.createCaller(ctx)
- const result = await caller.health.query()
- expect(result).toEqual({ status: 'ok', database: 'ok' })
-})
+function makeContext(session: Session | null = null) {
+ const req = new Request('http://localhost/api/trpc') as NextRequest
+ mockAuth.mockResolvedValue(session)
+ return createTRPCContext({ req })
+}
-it('throws UNAUTHORIZED without session', async () => {
- const ctx = await makeContext()
+it('throws UNAUTHORIZED when no session present', async () => {
+ const ctx = await makeContext(null)
const caller = appRouter.createCaller(ctx)
await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
})
-it('allows Bearer token', async () => {
- const ctx = await makeContext({ authorization: 'Bearer test-token' })
+it('allows access with valid session', async () => {
+ const ctx = await makeContext(validSession)
const caller = appRouter.createCaller(ctx)
const result = await caller.auth.session()
- expect(result).toEqual({ authenticated: true })
+ expect(result).toMatchObject({ authenticated: true, user: { email: 'test@example.com' } })
})
```
diff --git a/web/features/auth/__tests__/GoogleSignInButton.test.tsx b/web/features/auth/__tests__/GoogleSignInButton.test.tsx
new file mode 100644
index 0000000..49cf415
--- /dev/null
+++ b/web/features/auth/__tests__/GoogleSignInButton.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { GoogleSignInButton } from '../components/GoogleSignInButton'
+
+vi.mock('firebase/auth', () => ({
+ signInWithPopup: vi.fn(),
+ GoogleAuthProvider: class {
+ constructor() {}
+ },
+}))
+
+vi.mock('@/lib/firebase', () => ({
+ getFirebaseAuth: vi.fn(),
+}))
+
+vi.mock('next-auth/react', () => ({
+ signIn: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}))
+
+describe('GoogleSignInButton', () => {
+ it('renders the Google sign-in button', () => {
+ render()
+ expect(screen.getByRole('button', { name: /continue with google/i })).toBeInTheDocument()
+ })
+})
diff --git a/web/features/auth/__tests__/LoginForm.test.tsx b/web/features/auth/__tests__/LoginForm.test.tsx
new file mode 100644
index 0000000..1b135c8
--- /dev/null
+++ b/web/features/auth/__tests__/LoginForm.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { LoginForm } from '../components/LoginForm'
+
+vi.mock('firebase/auth', () => ({
+ signInWithEmailAndPassword: vi.fn(),
+}))
+
+vi.mock('@/lib/firebase', () => ({
+ getFirebaseAuth: vi.fn(),
+}))
+
+vi.mock('next-auth/react', () => ({
+ signIn: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}))
+
+describe('LoginForm', () => {
+ it('renders email and password fields', () => {
+ render()
+ expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
+ })
+
+ it('shows validation error for invalid email', async () => {
+ render()
+ const user = userEvent.setup()
+
+ await user.type(screen.getByLabelText(/email/i), 'not-an-email')
+ await user.type(screen.getByLabelText(/password/i), 'password123')
+ await user.click(screen.getByRole('button', { name: /sign in/i }))
+
+ expect(await screen.findByText(/valid email/i)).toBeInTheDocument()
+ })
+
+ it('shows validation error for empty password', async () => {
+ render()
+ const user = userEvent.setup()
+
+ await user.type(screen.getByLabelText(/email/i), 'test@example.com')
+ await user.click(screen.getByRole('button', { name: /sign in/i }))
+
+ expect(await screen.findByText(/required/i)).toBeInTheDocument()
+ })
+})
diff --git a/web/features/auth/__tests__/RegisterForm.test.tsx b/web/features/auth/__tests__/RegisterForm.test.tsx
new file mode 100644
index 0000000..5dff184
--- /dev/null
+++ b/web/features/auth/__tests__/RegisterForm.test.tsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { RegisterForm } from '../components/RegisterForm'
+
+vi.mock('firebase/auth', () => ({
+ createUserWithEmailAndPassword: vi.fn(),
+ updateProfile: vi.fn(),
+}))
+
+vi.mock('@/lib/firebase', () => ({
+ getFirebaseAuth: vi.fn(),
+}))
+
+vi.mock('next-auth/react', () => ({
+ signIn: vi.fn(),
+}))
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}))
+
+describe('RegisterForm', () => {
+ it('renders all registration fields', () => {
+ render()
+ expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/^email$/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument()
+ })
+
+ it('shows validation error when passwords do not match', async () => {
+ render()
+ const user = userEvent.setup()
+
+ await user.type(screen.getByLabelText(/^name$/i), 'Grace Noble')
+ await user.type(screen.getByLabelText(/^email$/i), 'grace@example.com')
+ await user.type(screen.getByLabelText(/^password$/i), 'password123')
+ await user.type(screen.getByLabelText(/confirm password/i), 'differentpassword')
+ await user.click(screen.getByRole('button', { name: /create account/i }))
+
+ expect(await screen.findByText(/passwords don't match/i)).toBeInTheDocument()
+ })
+
+ it('shows validation error for short password', async () => {
+ render()
+ const user = userEvent.setup()
+
+ await user.type(screen.getByLabelText(/^name$/i), 'Grace Noble')
+ await user.type(screen.getByLabelText(/^email$/i), 'grace@example.com')
+ await user.type(screen.getByLabelText(/^password$/i), 'short')
+ await user.type(screen.getByLabelText(/confirm password/i), 'short')
+ await user.click(screen.getByRole('button', { name: /create account/i }))
+
+ expect(await screen.findByText(/at least 8 characters/i)).toBeInTheDocument()
+ })
+})
diff --git a/web/features/auth/__tests__/useSession.test.ts b/web/features/auth/__tests__/useSession.test.ts
new file mode 100644
index 0000000..231d007
--- /dev/null
+++ b/web/features/auth/__tests__/useSession.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect, vi } from 'vitest'
+import { renderHook } from '@testing-library/react'
+import { useSession } from '../hooks/useSession'
+
+vi.mock('next-auth/react', () => ({
+ useSession: vi.fn(),
+}))
+
+import { useSession as useNextAuthSession } from 'next-auth/react'
+const mockUseSession = vi.mocked(useNextAuthSession)
+
+describe('useSession', () => {
+ it('returns unauthenticated state when no session', () => {
+ mockUseSession.mockReturnValue({
+ data: null,
+ status: 'unauthenticated',
+ update: vi.fn(),
+ })
+
+ const { result } = renderHook(() => useSession())
+
+ expect(result.current.isAuthenticated).toBe(false)
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.user).toBeNull()
+ })
+
+ it('returns loading state', () => {
+ mockUseSession.mockReturnValue({
+ data: null,
+ status: 'loading',
+ update: vi.fn(),
+ })
+
+ const { result } = renderHook(() => useSession())
+
+ expect(result.current.isLoading).toBe(true)
+ expect(result.current.isAuthenticated).toBe(false)
+ })
+
+ it('returns authenticated state with user', () => {
+ mockUseSession.mockReturnValue({
+ data: {
+ user: { id: '1', name: 'Grace Noble', email: 'grace@example.com', image: null },
+ expires: '2099-01-01',
+ },
+ status: 'authenticated',
+ update: vi.fn(),
+ })
+
+ const { result } = renderHook(() => useSession())
+
+ expect(result.current.isAuthenticated).toBe(true)
+ expect(result.current.isLoading).toBe(false)
+ expect(result.current.user?.email).toBe('grace@example.com')
+ expect(result.current.user?.name).toBe('Grace Noble')
+ })
+})
diff --git a/web/features/auth/__tests__/validation.test.ts b/web/features/auth/__tests__/validation.test.ts
new file mode 100644
index 0000000..2018f49
--- /dev/null
+++ b/web/features/auth/__tests__/validation.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from 'vitest'
+import { loginSchema, registerSchema } from '../validation'
+
+describe('loginSchema', () => {
+ it('validates correct credentials', () => {
+ const result = loginSchema.safeParse({ email: 'user@example.com', password: 'password123' })
+ expect(result.success).toBe(true)
+ })
+
+ it('rejects invalid email', () => {
+ const result = loginSchema.safeParse({ email: 'not-an-email', password: 'password123' })
+ expect(result.success).toBe(false)
+ expect(result.error?.issues[0]?.path).toContain('email')
+ })
+
+ it('rejects empty password', () => {
+ const result = loginSchema.safeParse({ email: 'user@example.com', password: '' })
+ expect(result.success).toBe(false)
+ expect(result.error?.issues[0]?.path).toContain('password')
+ })
+
+ it('rejects missing fields', () => {
+ const result = loginSchema.safeParse({})
+ expect(result.success).toBe(false)
+ })
+})
+
+describe('registerSchema', () => {
+ const valid = {
+ name: 'Grace Noble',
+ email: 'grace@example.com',
+ password: 'securepassword',
+ confirmPassword: 'securepassword',
+ }
+
+ it('validates correct registration data', () => {
+ expect(registerSchema.safeParse(valid).success).toBe(true)
+ })
+
+ it('rejects name shorter than 2 characters', () => {
+ const result = registerSchema.safeParse({ ...valid, name: 'G' })
+ expect(result.success).toBe(false)
+ expect(result.error?.issues[0]?.path).toContain('name')
+ })
+
+ it('rejects password shorter than 8 characters', () => {
+ const result = registerSchema.safeParse({
+ ...valid,
+ password: 'short',
+ confirmPassword: 'short',
+ })
+ expect(result.success).toBe(false)
+ expect(result.error?.issues[0]?.path).toContain('password')
+ })
+
+ it('rejects mismatched passwords', () => {
+ const result = registerSchema.safeParse({ ...valid, confirmPassword: 'different' })
+ expect(result.success).toBe(false)
+ expect(result.error?.issues[0]?.path).toContain('confirmPassword')
+ })
+
+ it('rejects invalid email', () => {
+ const result = registerSchema.safeParse({ ...valid, email: 'not-email' })
+ expect(result.success).toBe(false)
+ })
+})
diff --git a/web/features/auth/components/GoogleSignInButton.tsx b/web/features/auth/components/GoogleSignInButton.tsx
new file mode 100644
index 0000000..9c5444e
--- /dev/null
+++ b/web/features/auth/components/GoogleSignInButton.tsx
@@ -0,0 +1,69 @@
+'use client'
+
+import { useState } from 'react'
+import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
+import { signIn } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { getFirebaseAuth } from '@/lib/firebase'
+
+const POPUP_DISMISSED = new Set(['auth/popup-closed-by-user', 'auth/cancelled-popup-request'])
+
+export function GoogleSignInButton() {
+ const router = useRouter()
+ const [isPending, setIsPending] = useState(false)
+
+ async function handleClick() {
+ setIsPending(true)
+ try {
+ const result = await signInWithPopup(getFirebaseAuth(), new GoogleAuthProvider())
+ const idToken = await result.user.getIdToken()
+ const res = await signIn('credentials', { idToken, redirect: false })
+ if (res?.ok) router.push('/dashboard')
+ else toast.error('Sign-in failed. Please try again.')
+ } catch (err) {
+ const code = (err as { code?: string }).code
+ if (!code || !POPUP_DISMISSED.has(code)) {
+ toast.error('Sign-in failed. Please try again.')
+ }
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/web/features/auth/components/LoginForm.tsx b/web/features/auth/components/LoginForm.tsx
new file mode 100644
index 0000000..72dcdc3
--- /dev/null
+++ b/web/features/auth/components/LoginForm.tsx
@@ -0,0 +1,84 @@
+'use client'
+
+import { useForm } from 'react-hook-form'
+import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
+import { signInWithEmailAndPassword } from 'firebase/auth'
+import { signIn } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { getFirebaseAuth } from '@/lib/firebase'
+import { loginSchema, type LoginFormValues } from '../validation'
+
+export function LoginForm() {
+ const router = useRouter()
+
+ const form = useForm({
+ resolver: standardSchemaResolver(loginSchema),
+ defaultValues: { email: '', password: '' },
+ })
+
+ const onSubmit = async (values: LoginFormValues) => {
+ try {
+ const credential = await signInWithEmailAndPassword(
+ getFirebaseAuth(),
+ values.email,
+ values.password,
+ )
+ const idToken = await credential.user.getIdToken()
+ const result = await signIn('credentials', { idToken, redirect: false })
+ if (result?.error) {
+ toast.error('Invalid email or password')
+ return
+ }
+ router.push('/dashboard')
+ } catch {
+ toast.error('Invalid email or password')
+ }
+ }
+
+ return (
+
+
+ )
+}
diff --git a/web/features/auth/components/RegisterForm.tsx b/web/features/auth/components/RegisterForm.tsx
new file mode 100644
index 0000000..e782fff
--- /dev/null
+++ b/web/features/auth/components/RegisterForm.tsx
@@ -0,0 +1,117 @@
+'use client'
+
+import { useForm } from 'react-hook-form'
+import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
+import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'
+import { signIn } from 'next-auth/react'
+import { useRouter } from 'next/navigation'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { getFirebaseAuth } from '@/lib/firebase'
+import { registerSchema, type RegisterFormValues } from '../validation'
+
+export function RegisterForm() {
+ const router = useRouter()
+
+ const form = useForm({
+ resolver: standardSchemaResolver(registerSchema),
+ defaultValues: { name: '', email: '', password: '', confirmPassword: '' },
+ })
+
+ const onSubmit = async (values: RegisterFormValues) => {
+ try {
+ const credential = await createUserWithEmailAndPassword(
+ getFirebaseAuth(),
+ values.email,
+ values.password,
+ )
+ await updateProfile(credential.user, { displayName: values.name })
+ const idToken = await credential.user.getIdToken()
+ const result = await signIn('credentials', { idToken, redirect: false })
+ if (result?.error) {
+ toast.error('Account created but sign-in failed. Please try signing in.')
+ router.push('/login')
+ return
+ }
+ router.push('/dashboard')
+ } catch (err) {
+ const code = (err as { code?: string }).code
+ if (code === 'auth/email-already-in-use') {
+ toast.error('An account with this email already exists.')
+ } else {
+ toast.error('Registration failed. Please try again.')
+ }
+ }
+ }
+
+ return (
+
+
+ )
+}
diff --git a/web/features/auth/components/UserMenu.tsx b/web/features/auth/components/UserMenu.tsx
new file mode 100644
index 0000000..9e3e867
--- /dev/null
+++ b/web/features/auth/components/UserMenu.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+import { useSession } from '../hooks/useSession'
+import { useSignOut } from '../hooks/useSignOut'
+import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Button } from '@/components/ui/button'
+
+export function UserMenu() {
+ const { user, isAuthenticated } = useSession()
+ const { signOut } = useSignOut()
+
+ if (!isAuthenticated || !user) return null
+
+ const initials = user.name
+ ? user.name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase()
+ .slice(0, 2)
+ : (user.email?.[0]?.toUpperCase() ?? '?')
+
+ return (
+
+
+
+
+
+
+
+ {user.name &&
{user.name}
}
+ {user.email && (
+
{user.email}
+ )}
+
+
+
+ Sign out
+
+
+ )
+}
diff --git a/web/features/auth/hooks/useSession.ts b/web/features/auth/hooks/useSession.ts
new file mode 100644
index 0000000..ce07ad5
--- /dev/null
+++ b/web/features/auth/hooks/useSession.ts
@@ -0,0 +1,20 @@
+'use client'
+
+import { useSession as useNextAuthSession } from 'next-auth/react'
+import type { AuthSession, AuthUser } from '../types'
+
+export interface UseSessionReturn {
+ user: AuthUser | null
+ isAuthenticated: boolean
+ isLoading: boolean
+}
+
+export function useSession(): UseSessionReturn {
+ const { data, status } = useNextAuthSession()
+
+ return {
+ user: (data as AuthSession | null)?.user ?? null,
+ isAuthenticated: status === 'authenticated',
+ isLoading: status === 'loading',
+ }
+}
diff --git a/web/features/auth/hooks/useSignOut.ts b/web/features/auth/hooks/useSignOut.ts
new file mode 100644
index 0000000..2c5f025
--- /dev/null
+++ b/web/features/auth/hooks/useSignOut.ts
@@ -0,0 +1,20 @@
+'use client'
+
+import { signOut as nextAuthSignOut } from 'next-auth/react'
+import { signOut as firebaseSignOut } from 'firebase/auth'
+import { useRouter } from 'next/navigation'
+import { getFirebaseAuth } from '@/lib/firebase'
+
+export function useSignOut() {
+ const router = useRouter()
+
+ const signOut = async () => {
+ await Promise.all([
+ nextAuthSignOut({ redirect: false }),
+ firebaseSignOut(getFirebaseAuth()),
+ ])
+ router.push('/login')
+ }
+
+ return { signOut }
+}
diff --git a/web/features/auth/types.ts b/web/features/auth/types.ts
new file mode 100644
index 0000000..4a75190
--- /dev/null
+++ b/web/features/auth/types.ts
@@ -0,0 +1,11 @@
+export interface AuthUser {
+ id?: string | null
+ name?: string | null
+ email?: string | null
+ image?: string | null
+}
+
+export interface AuthSession {
+ user: AuthUser
+ expires: string
+}
diff --git a/web/features/auth/validation.ts b/web/features/auth/validation.ts
new file mode 100644
index 0000000..e90111a
--- /dev/null
+++ b/web/features/auth/validation.ts
@@ -0,0 +1,21 @@
+import { z } from 'zod'
+
+export const loginSchema = z.object({
+ email: z.email('Please enter a valid email address'),
+ password: z.string().min(1, 'Password is required'),
+})
+
+export const registerSchema = z
+ .object({
+ name: z.string().min(2, 'Name must be at least 2 characters'),
+ email: z.email('Please enter a valid email address'),
+ password: z.string().min(8, 'Password must be at least 8 characters'),
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: "Passwords don't match",
+ path: ['confirmPassword'],
+ })
+
+export type LoginFormValues = z.infer
+export type RegisterFormValues = z.infer
diff --git a/web/hooks/use-mobile.ts b/web/hooks/use-mobile.ts
new file mode 100644
index 0000000..d65bd2b
--- /dev/null
+++ b/web/hooks/use-mobile.ts
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+const MOBILE_BREAKPOINT = 768
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(
+ () => typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT,
+ )
+
+ React.useEffect(() => {
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ mql.addEventListener("change", onChange)
+ return () => mql.removeEventListener("change", onChange)
+ }, [])
+
+ return isMobile
+}
diff --git a/web/lib/firebase-admin.ts b/web/lib/firebase-admin.ts
new file mode 100644
index 0000000..e168d5a
--- /dev/null
+++ b/web/lib/firebase-admin.ts
@@ -0,0 +1,25 @@
+import { cert, getApp, getApps, initializeApp } from 'firebase-admin/app'
+import { getAuth } from 'firebase-admin/auth'
+
+function getAdminApp() {
+ if (getApps().length > 0) return getApp()
+
+ const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON
+ if (serviceAccountJson) {
+ return initializeApp({
+ credential: cert(JSON.parse(serviceAccountJson) as object),
+ })
+ }
+
+ const projectId = process.env.FIREBASE_PROJECT_ID
+ if (!projectId) {
+ throw new Error(
+ 'Firebase Admin: set FIREBASE_SERVICE_ACCOUNT_JSON or FIREBASE_PROJECT_ID',
+ )
+ }
+ return initializeApp({ projectId })
+}
+
+export async function verifyFirebaseToken(idToken: string) {
+ return getAuth(getAdminApp()).verifyIdToken(idToken)
+}
diff --git a/web/lib/firebase.ts b/web/lib/firebase.ts
new file mode 100644
index 0000000..02038c2
--- /dev/null
+++ b/web/lib/firebase.ts
@@ -0,0 +1,22 @@
+'use client'
+
+import { initializeApp, getApps, type FirebaseApp } from 'firebase/app'
+import { getAuth } from 'firebase/auth'
+
+const firebaseConfig = {
+ apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
+ authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
+ projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
+ storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
+ messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
+ appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
+}
+
+function getApp(): FirebaseApp {
+ if (getApps().length > 0) return getApps()[0]!
+ return initializeApp(firebaseConfig)
+}
+
+export function getFirebaseAuth() {
+ return getAuth(getApp())
+}
diff --git a/web/lib/trpc/__tests__/server.test.ts b/web/lib/trpc/__tests__/server.test.ts
index 5f2ab79..6b8f7e5 100644
--- a/web/lib/trpc/__tests__/server.test.ts
+++ b/web/lib/trpc/__tests__/server.test.ts
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from 'vitest'
+vi.mock('@/auth', () => ({
+ auth: vi.fn().mockResolvedValue(null),
+}))
+
vi.mock('next/headers', () => ({
headers: vi.fn(async () => new Headers({ 'x-test': 'true' })),
}))
diff --git a/web/next.config.ts b/web/next.config.ts
index 75ca89c..4d83c8c 100644
--- a/web/next.config.ts
+++ b/web/next.config.ts
@@ -2,7 +2,14 @@ import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
- /* config options here */
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "lh3.googleusercontent.com",
+ },
+ ],
+ },
};
export default withSentryConfig(nextConfig, {
diff --git a/web/package.json b/web/package.json
index 4269e72..42e2692 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,6 +12,7 @@
"test:ui": "vitest --ui"
},
"dependencies": {
+ "@hookform/resolvers": "^5.4.0",
"@sentry/nextjs": "^10.57.0",
"@tanstack/react-query": "^5.101.1",
"@trpc/client": "^11.18.0",
@@ -20,12 +21,15 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"firebase": "^12.14.0",
+ "firebase-admin": "^14.0.0",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+ "next-auth": "5.0.0-beta.31",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "react-hook-form": "^7.80.0",
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
@@ -36,6 +40,7 @@
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 443be0f..0521d88 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@hookform/resolvers':
+ specifier: ^5.4.0
+ version: 5.4.0(react-hook-form@7.80.0(react@19.2.4))
'@sentry/nextjs':
specifier: ^10.57.0
version: 10.57.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.107.2)
@@ -32,12 +35,18 @@ importers:
firebase:
specifier: ^12.14.0
version: 12.14.0
+ firebase-admin:
+ specifier: ^14.0.0
+ version: 14.0.0
lucide-react:
specifier: ^1.21.0
version: 1.21.0(react@19.2.4)
next:
specifier: 16.2.9
version: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ next-auth:
+ specifier: 5.0.0-beta.31
+ version: 5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -50,6 +59,9 @@ importers:
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
+ react-hook-form:
+ specifier: ^7.80.0
+ version: 7.80.0(react@19.2.4)
shadcn:
specifier: ^4.11.0
version: 4.11.0(typescript@5.9.3)
@@ -75,6 +87,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: ^20
version: 20.19.43
@@ -130,6 +145,20 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+ '@auth/core@0.41.2':
+ resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
+ peerDependencies:
+ '@simplewebauthn/browser': ^9.0.1
+ '@simplewebauthn/server': ^9.0.2
+ nodemailer: ^7.0.7
+ peerDependenciesMeta:
+ '@simplewebauthn/browser':
+ optional: true
+ '@simplewebauthn/server':
+ optional: true
+ nodemailer:
+ optional: true
+
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -369,6 +398,9 @@ packages:
'@noble/hashes':
optional: true
+ '@fastify/busboy@3.2.0':
+ resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
+
'@firebase/ai@2.13.0':
resolution: {integrity: sha512-nJJDQKqjAcbkZdZGT/5WTVLrGZ+pYhWbwKC90nNzmvtoRTtnOJaNS34fhKSHQeB9SALgD2kxuWT5I4AkytdZ/Q==}
engines: {node: '>=20.0.0'}
@@ -594,6 +626,30 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
+ '@google-cloud/firestore@8.6.0':
+ resolution: {integrity: sha512-TdvZHfwQj5B5CSDEgDqyrhdVqtOSupmBXDQPasMAJiC64tjsGvyMooNiC43fdk1TsUHeklyoZ6/vQ1TjWKVMbg==}
+ engines: {node: '>=18'}
+
+ '@google-cloud/paginator@5.0.2':
+ resolution: {integrity: sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==}
+ engines: {node: '>=14.0.0'}
+
+ '@google-cloud/projectify@4.0.0':
+ resolution: {integrity: sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==}
+ engines: {node: '>=14.0.0'}
+
+ '@google-cloud/promisify@4.0.0':
+ resolution: {integrity: sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==}
+ engines: {node: '>=14'}
+
+ '@google-cloud/storage@7.21.0':
+ resolution: {integrity: sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==}
+ engines: {node: '>=14'}
+
+ '@grpc/grpc-js@1.14.4':
+ resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==}
+ engines: {node: '>=12.10.0'}
+
'@grpc/grpc-js@1.9.16':
resolution: {integrity: sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==}
engines: {node: ^8.13.0 || >=10.10.0}
@@ -603,12 +659,22 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ '@grpc/proto-loader@0.8.1':
+ resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
'@hono/node-server@1.19.14':
resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
+ '@hookform/resolvers@5.4.0':
+ resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==}
+ peerDependencies:
+ react-hook-form: ^7.55.0
+
'@humanfs/core@0.19.2':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -766,6 +832,10 @@ packages:
cpu: [x64]
os: [win32]
+ '@isaacs/cliui@8.0.2':
+ resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
+ engines: {node: '>=12'}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -785,6 +855,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@js-sdsl/ordered-map@4.4.2':
+ resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
+
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
@@ -855,6 +928,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@nodable/entities@2.2.0':
+ resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -910,6 +986,13 @@ packages:
'@oxc-project/types@0.133.0':
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
+ '@panva/hkdf@1.2.1':
+ resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
+
+ '@pkgjs/parseargs@0.11.0':
+ resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
+ engines: {node: '>=14'}
+
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
@@ -2022,6 +2105,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@standard-schema/utils@0.3.0':
+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -2144,6 +2230,16 @@ packages:
'@types/react-dom':
optional: true
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
+ '@tootallnate/once@2.0.1':
+ resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==}
+ engines: {node: '>= 10'}
+
'@trpc/client@11.18.0':
resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==}
hasBin: true
@@ -2175,6 +2271,9 @@ packages:
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+ '@types/caseless@0.12.5':
+ resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==}
+
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -2190,6 +2289,12 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/jsonwebtoken@9.0.10':
+ resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
@@ -2201,6 +2306,12 @@ packages:
'@types/react@19.2.17':
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
+ '@types/request@2.48.13':
+ resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==}
+
+ '@types/tough-cookie@4.0.5':
+ resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -2466,6 +2577,10 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
+ abort-controller@3.0.0:
+ resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
+ engines: {node: '>=6.5'}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -2546,6 +2661,13 @@ packages:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
+ ansi-styles@6.2.3:
+ resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
+ engines: {node: '>=12'}
+
+ anynum@1.0.1:
+ resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -2592,6 +2714,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
+ arrify@2.0.1:
+ resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
+ engines: {node: '>=8'}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -2607,6 +2733,12 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
+ async-retry@1.3.3:
+ resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
+
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
atomically@1.7.0:
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
engines: {node: '>=10.12.0'}
@@ -2630,6 +2762,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
baseline-browser-mapping@2.10.37:
resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==}
engines: {node: '>=6.0.0'}
@@ -2638,6 +2773,9 @@ packages:
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+ bignumber.js@9.3.1:
+ resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
+
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
@@ -2645,6 +2783,9 @@ packages:
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
+ brace-expansion@2.1.1:
+ resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
+
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
@@ -2658,6 +2799,9 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2739,6 +2883,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
commander@11.1.0:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
@@ -2901,6 +3049,10 @@ packages:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -2946,6 +3098,15 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ duplexify@4.1.3:
+ resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
+
+ eastasianwidth@0.2.0:
+ resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
+
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -2965,6 +3126,9 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
+ end-of-stream@1.4.5:
+ resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
+
enhanced-resolve@5.21.6:
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
engines: {node: '>=10.13.0'}
@@ -3177,6 +3341,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
+ event-target-shim@5.0.1:
+ resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
+ engines: {node: '>=6'}
+
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -3211,6 +3379,13 @@ packages:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
+ extend@3.0.2:
+ resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+
+ farmhash-modern@1.1.0:
+ resolution: {integrity: sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==}
+ engines: {node: '>=18.0.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -3231,6 +3406,13 @@ packages:
fast-uri@3.1.2:
resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==}
+ fast-xml-builder@1.2.0:
+ resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
+
+ fast-xml-parser@5.9.3:
+ resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==}
+ hasBin: true
+
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -3275,6 +3457,10 @@ packages:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
+ firebase-admin@14.0.0:
+ resolution: {integrity: sha512-U88/r6VWiBQ05+UlLaF1A1AN4Y3SAGQKcQWawzafEAnXVaCZ21+2KclMPdlIQAAF5pUtN+FkXCSQnJEpc6QDZA==}
+ engines: {node: '>=22'}
+
firebase@12.14.0:
resolution: {integrity: sha512-aEZ/lniDR1hOCYpx/x/V8Nrrqq9pepKDNkqP/4WGZFC69gTv6F59Z4/54W/SUP4L/hFlrRNmWj35aweQq+IHow==}
@@ -3289,6 +3475,14 @@ packages:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
+ foreground-child@3.3.1:
+ resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
+ engines: {node: '>=14'}
+
+ form-data@2.5.6:
+ resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
+ engines: {node: '>= 0.12'}
+
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -3317,12 +3511,39 @@ packages:
resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==}
engines: {node: '>= 0.4'}
+ functional-red-black-tree@1.0.1:
+ resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
+
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
fuzzysort@3.1.0:
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
+ gaxios@6.7.1:
+ resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
+ engines: {node: '>=14'}
+
+ gaxios@7.1.3:
+ resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
+ engines: {node: '>=18'}
+
+ gaxios@7.1.5:
+ resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==}
+ engines: {node: '>=18'}
+
+ gcp-metadata@6.1.1:
+ resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
+ engines: {node: '>=14'}
+
+ gcp-metadata@8.1.2:
+ resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
+ engines: {node: '>=18'}
+
+ gcp-metadata@8.1.3:
+ resolution: {integrity: sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w==}
+ engines: {node: '>=18'}
+
generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
@@ -3381,6 +3602,11 @@ packages:
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
+ glob@10.5.0:
+ resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ hasBin: true
+
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@@ -3397,6 +3623,30 @@ packages:
resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==}
engines: {node: '>= 0.4'}
+ google-auth-library@10.5.0:
+ resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==}
+ engines: {node: '>=18'}
+
+ google-auth-library@10.7.0:
+ resolution: {integrity: sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==}
+ engines: {node: '>=18'}
+
+ google-auth-library@9.15.1:
+ resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
+ engines: {node: '>=14'}
+
+ google-gax@5.0.7:
+ resolution: {integrity: sha512-EhiqaWWJ+9h7sCcKJTsoo6tMcjokVHhWsbSuWCnZJT4vIBP3y4mAoFLnt9SzgkVZeq24ZsFaArr06nnYYku2yA==}
+ engines: {node: '>=18'}
+
+ google-logging-utils@0.0.2:
+ resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
+ engines: {node: '>=14'}
+
+ google-logging-utils@1.1.3:
+ resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
+ engines: {node: '>=14'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -3404,6 +3654,14 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ gtoken@7.1.0:
+ resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
+ engines: {node: '>=14.0.0'}
+
+ gtoken@8.0.0:
+ resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
+ engines: {node: '>=18'}
+
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -3445,6 +3703,9 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ html-entities@2.6.0:
+ resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -3452,6 +3713,14 @@ packages:
http-parser-js@0.5.10:
resolution: {integrity: sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==}
+ http-proxy-agent@5.0.0:
+ resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
+ engines: {node: '>= 6'}
+
+ http-proxy-agent@7.0.2:
+ resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
+ engines: {node: '>= 14'}
+
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
@@ -3680,6 +3949,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
+ is-unsafe@1.0.1:
+ resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==}
+
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -3714,6 +3986,9 @@ packages:
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
engines: {node: '>= 0.4'}
+ jackspeak@3.4.3:
+ resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
+
jest-worker@27.5.1:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
@@ -3746,6 +4021,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ json-bigint@1.0.0:
+ resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
+
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
@@ -3779,10 +4057,24 @@ packages:
jsonfile@6.2.1:
resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
+ jsonwebtoken@9.0.3:
+ resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
+ engines: {node: '>=12', npm: '>=6'}
+
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
+ jwa@2.0.1:
+ resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
+
+ jwks-rsa@4.1.0:
+ resolution: {integrity: sha512-sbkByqyATKYJP5F4RXj03N5TUNC0QLTjCAZvwTzC4BwJZ8e0/cWxN8YROnyUth2g1/ONWi4eSFHeu6oYalrc3Q==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >= 23.0.0}
+
+ jws@4.0.1:
+ resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -3875,6 +4167,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ limiter@1.1.5:
+ resolution: {integrity: sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -3893,9 +4188,33 @@ packages:
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
+ lodash.clonedeep@4.5.0:
+ resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
+
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
log-symbols@6.0.0:
resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==}
engines: {node: '>=18'}
@@ -3907,6 +4226,9 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ lru-cache@10.4.3:
+ resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
+
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
@@ -3914,6 +4236,9 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lru-memoizer@3.0.0:
+ resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
+
lucide-react@1.21.0:
resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==}
peerDependencies:
@@ -3952,14 +4277,27 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
+ mime@3.0.0:
+ resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -3983,6 +4321,10 @@ packages:
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
+ minimatch@9.0.9:
+ resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -4016,6 +4358,22 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
+ next-auth@5.0.0-beta.31:
+ resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
+ peerDependencies:
+ '@simplewebauthn/browser': ^9.0.1
+ '@simplewebauthn/server': ^9.0.2
+ next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
+ nodemailer: ^7.0.7
+ react: ^18.2.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@simplewebauthn/browser':
+ optional: true
+ '@simplewebauthn/server':
+ optional: true
+ nodemailer:
+ optional: true
+
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
@@ -4077,10 +4435,17 @@ packages:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
+ oauth4webapi@3.8.6:
+ resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
+ object-hash@3.0.0:
+ resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
+ engines: {node: '>= 6'}
+
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -4172,6 +4537,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
+ package-json-from-dist@1.0.1:
+ resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -4202,6 +4570,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
+ path-expression-matcher@1.6.1:
+ resolution: {integrity: sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==}
+ engines: {node: '>=14.0.0'}
+
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -4213,6 +4585,10 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ path-scurry@1.11.1:
+ resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
+ engines: {node: '>=16 || 14 >=14.18'}
+
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
@@ -4262,6 +4638,14 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
+ preact-render-to-string@6.5.11:
+ resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
+ peerDependencies:
+ preact: '>=10'
+
+ preact@10.24.3:
+ resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
+
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -4285,6 +4669,10 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ proto3-json-serializer@3.0.4:
+ resolution: {integrity: sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==}
+ engines: {node: '>=18'}
+
protobufjs@7.6.4:
resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==}
engines: {node: '>=12.0.0'}
@@ -4333,6 +4721,12 @@ packages:
peerDependencies:
react: ^19.2.4
+ react-hook-form@7.80.0:
+ resolution: {integrity: sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -4373,6 +4767,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
recast@0.23.11:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
@@ -4417,10 +4815,26 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
+ retry-request@7.0.2:
+ resolution: {integrity: sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==}
+ engines: {node: '>=14'}
+
+ retry-request@8.0.3:
+ resolution: {integrity: sha512-qqoc4kkGgP9cmQDWELlOpAmfgJOg0Yi7MT82ZjiPWu451ayju4itwomjM4/dBEliify8C1b3tSaeCOldugtwPQ==}
+ engines: {node: '>=18'}
+
+ retry@0.13.1:
+ resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
+ engines: {node: '>= 4'}
+
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ rimraf@5.0.10:
+ resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
+ hasBin: true
+
rolldown@1.0.3:
resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -4590,10 +5004,20 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
+ stream-events@1.0.5:
+ resolution: {integrity: sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==}
+
+ stream-shift@1.0.3:
+ resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
+ string-width@5.1.2:
+ resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
+ engines: {node: '>=12'}
+
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
@@ -4621,6 +5045,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
stringify-object@5.0.0:
resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==}
engines: {node: '>=14.16'}
@@ -4653,6 +5080,12 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
+ strnum@2.4.1:
+ resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==}
+
+ stubs@3.0.0:
+ resolution: {integrity: sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==}
+
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -4697,6 +5130,14 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ teeny-request@10.1.3:
+ resolution: {integrity: sha512-5yDliI1uWkYPo7W+Zvrxg6YmoWuj5iC5EydewqrRTvc68nyMTZhlPPlLg6cptUGfbQAb+N9XDPDPzF6N081lug==}
+ engines: {node: '>=18'}
+
+ teeny-request@9.0.0:
+ resolution: {integrity: sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==}
+ engines: {node: '>=14'}
+
terser-webpack-plugin@5.6.1:
resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==}
engines: {node: '>= 10.13.0'}
@@ -4909,6 +5350,11 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ uuid@9.0.1:
+ resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
+ hasBin: true
+
validate-npm-package-name@7.0.2:
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -5095,6 +5541,10 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
+ wrap-ansi@8.1.0:
+ resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
+ engines: {node: '>=12'}
+
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -5106,6 +5556,10 @@ packages:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
+ xml-naming@0.1.0:
+ resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
+ engines: {node: '>=16.0.0'}
+
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
@@ -5179,6 +5633,14 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
+ '@auth/core@0.41.2':
+ dependencies:
+ '@panva/hkdf': 1.2.1
+ jose: 6.2.3
+ oauth4webapi: 3.8.6
+ preact: 10.24.3
+ preact-render-to-string: 6.5.11(preact@10.24.3)
+
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -5485,6 +5947,8 @@ snapshots:
'@exodus/bytes@1.15.1': {}
+ '@fastify/busboy@3.2.0': {}
+
'@firebase/ai@2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)':
dependencies:
'@firebase/app': 0.14.13
@@ -5822,6 +6286,56 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
+ '@google-cloud/firestore@8.6.0':
+ dependencies:
+ '@opentelemetry/api': 1.9.1
+ fast-deep-equal: 3.1.3
+ functional-red-black-tree: 1.0.1
+ google-gax: 5.0.7
+ protobufjs: 7.6.4
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ '@google-cloud/paginator@5.0.2':
+ dependencies:
+ arrify: 2.0.1
+ extend: 3.0.2
+ optional: true
+
+ '@google-cloud/projectify@4.0.0':
+ optional: true
+
+ '@google-cloud/promisify@4.0.0':
+ optional: true
+
+ '@google-cloud/storage@7.21.0':
+ dependencies:
+ '@google-cloud/paginator': 5.0.2
+ '@google-cloud/projectify': 4.0.0
+ '@google-cloud/promisify': 4.0.0
+ abort-controller: 3.0.0
+ async-retry: 1.3.3
+ duplexify: 4.1.3
+ fast-xml-parser: 5.9.3
+ gaxios: 6.7.1
+ google-auth-library: 9.15.1
+ html-entities: 2.6.0
+ mime: 3.0.0
+ p-limit: 3.1.0
+ retry-request: 7.0.2
+ teeny-request: 9.0.0
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ optional: true
+
+ '@grpc/grpc-js@1.14.4':
+ dependencies:
+ '@grpc/proto-loader': 0.8.1
+ '@js-sdsl/ordered-map': 4.4.2
+ optional: true
+
'@grpc/grpc-js@1.9.16':
dependencies:
'@grpc/proto-loader': 0.7.15
@@ -5834,10 +6348,23 @@ snapshots:
protobufjs: 7.6.4
yargs: 17.7.2
+ '@grpc/proto-loader@0.8.1':
+ dependencies:
+ lodash.camelcase: 4.3.0
+ long: 5.3.2
+ protobufjs: 7.6.4
+ yargs: 17.7.2
+ optional: true
+
'@hono/node-server@1.19.14(hono@4.12.27)':
dependencies:
hono: 4.12.27
+ '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.4))':
+ dependencies:
+ '@standard-schema/utils': 0.3.0
+ react-hook-form: 7.80.0(react@19.2.4)
+
'@humanfs/core@0.19.2':
dependencies:
'@humanfs/types': 0.15.0
@@ -5951,6 +6478,16 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
+ '@isaacs/cliui@8.0.2':
+ dependencies:
+ string-width: 5.1.2
+ string-width-cjs: string-width@4.2.3
+ strip-ansi: 7.2.0
+ strip-ansi-cjs: strip-ansi@6.0.1
+ wrap-ansi: 8.1.0
+ wrap-ansi-cjs: wrap-ansi@7.0.0
+ optional: true
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5975,6 +6512,9 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@js-sdsl/ordered-map@4.4.2':
+ optional: true
+
'@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.27)
@@ -6034,6 +6574,9 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.9':
optional: true
+ '@nodable/entities@2.2.0':
+ optional: true
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -6085,6 +6628,11 @@ snapshots:
'@oxc-project/types@0.133.0': {}
+ '@panva/hkdf@1.2.1': {}
+
+ '@pkgjs/parseargs@0.11.0':
+ optional: true
+
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
@@ -7175,6 +7723,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
+ '@standard-schema/utils@0.3.0': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -7285,6 +7835,13 @@ snapshots:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
+ '@tootallnate/once@2.0.1':
+ optional: true
+
'@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@trpc/server': 11.18.0(typescript@5.9.3)
@@ -7315,6 +7872,9 @@ snapshots:
'@types/aria-query@5.0.4': {}
+ '@types/caseless@0.12.5':
+ optional: true
+
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -7328,6 +7888,13 @@ snapshots:
'@types/json5@0.0.29': {}
+ '@types/jsonwebtoken@9.0.10':
+ dependencies:
+ '@types/ms': 2.1.0
+ '@types/node': 20.19.43
+
+ '@types/ms@2.1.0': {}
+
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
@@ -7340,6 +7907,17 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/request@2.48.13':
+ dependencies:
+ '@types/caseless': 0.12.5
+ '@types/node': 20.19.43
+ '@types/tough-cookie': 4.0.5
+ form-data: 2.5.6
+ optional: true
+
+ '@types/tough-cookie@4.0.5':
+ optional: true
+
'@types/validate-npm-package-name@4.0.2': {}
'@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
@@ -7629,6 +8207,11 @@ snapshots:
'@xtuc/long@4.2.2': {}
+ abort-controller@3.0.0:
+ dependencies:
+ event-target-shim: 5.0.1
+ optional: true
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -7695,6 +8278,12 @@ snapshots:
ansi-styles@5.2.0: {}
+ ansi-styles@6.2.3:
+ optional: true
+
+ anynum@1.0.1:
+ optional: true
+
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -7774,6 +8363,9 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
+ arrify@2.0.1:
+ optional: true
+
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
@@ -7784,6 +8376,14 @@ snapshots:
async-function@1.0.0: {}
+ async-retry@1.3.3:
+ dependencies:
+ retry: 0.13.1
+ optional: true
+
+ asynckit@0.4.0:
+ optional: true
+
atomically@1.7.0: {}
available-typed-arrays@1.0.7:
@@ -7798,12 +8398,16 @@ snapshots:
balanced-match@4.0.4: {}
+ base64-js@1.5.1: {}
+
baseline-browser-mapping@2.10.37: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
+ bignumber.js@9.3.1: {}
+
body-parser@2.3.0:
dependencies:
bytes: 3.1.2
@@ -7823,6 +8427,11 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
+ brace-expansion@2.1.1:
+ dependencies:
+ balanced-match: 1.0.2
+ optional: true
+
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.4
@@ -7839,6 +8448,8 @@ snapshots:
node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2)
+ buffer-equal-constant-time@1.0.1: {}
+
buffer-from@1.1.2: {}
bundle-name@4.1.0:
@@ -7909,6 +8520,11 @@ snapshots:
color-name@1.1.4: {}
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+ optional: true
+
commander@11.1.0: {}
commander@14.0.3: {}
@@ -8047,6 +8663,9 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
+ delayed-stream@1.0.0:
+ optional: true
+
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -8079,6 +8698,21 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ duplexify@4.1.3:
+ dependencies:
+ end-of-stream: 1.4.5
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+ stream-shift: 1.0.3
+ optional: true
+
+ eastasianwidth@0.2.0:
+ optional: true
+
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
ee-first@1.1.1: {}
electron-to-chromium@1.5.372: {}
@@ -8091,6 +8725,11 @@ snapshots:
encodeurl@2.0.0: {}
+ end-of-stream@1.4.5:
+ dependencies:
+ once: 1.4.0
+ optional: true
+
enhanced-resolve@5.21.6:
dependencies:
graceful-fs: 4.2.11
@@ -8445,6 +9084,9 @@ snapshots:
etag@1.8.1: {}
+ event-target-shim@5.0.1:
+ optional: true
+
events@3.3.0: {}
eventsource-parser@3.1.0: {}
@@ -8520,6 +9162,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ extend@3.0.2: {}
+
+ farmhash-modern@1.1.0: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.1:
@@ -8544,6 +9190,22 @@ snapshots:
fast-uri@3.1.2: {}
+ fast-xml-builder@1.2.0:
+ dependencies:
+ path-expression-matcher: 1.6.1
+ xml-naming: 0.1.0
+ optional: true
+
+ fast-xml-parser@5.9.3:
+ dependencies:
+ '@nodable/entities': 2.2.0
+ fast-xml-builder: 1.2.0
+ is-unsafe: 1.0.1
+ path-expression-matcher: 1.6.1
+ strnum: 2.4.1
+ xml-naming: 0.1.0
+ optional: true
+
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -8593,6 +9255,23 @@ snapshots:
locate-path: 6.0.0
path-exists: 4.0.0
+ firebase-admin@14.0.0:
+ dependencies:
+ '@fastify/busboy': 3.2.0
+ '@firebase/database-compat': 2.1.4
+ '@firebase/database-types': 1.0.20
+ farmhash-modern: 1.1.0
+ fast-deep-equal: 3.1.3
+ google-auth-library: 10.7.0
+ jsonwebtoken: 9.0.3
+ jwks-rsa: 4.1.0
+ optionalDependencies:
+ '@google-cloud/firestore': 8.6.0
+ '@google-cloud/storage': 7.21.0
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
firebase@12.14.0:
dependencies:
'@firebase/ai': 2.13.0(@firebase/app-types@0.9.5)(@firebase/app@0.14.13)
@@ -8637,6 +9316,22 @@ snapshots:
dependencies:
is-callable: 1.2.7
+ foreground-child@3.3.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ signal-exit: 4.1.0
+ optional: true
+
+ form-data@2.5.6:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.4
+ mime-types: 2.1.35
+ safe-buffer: 5.2.1
+ optional: true
+
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
@@ -8668,10 +9363,70 @@ snapshots:
is-callable: 1.2.7
is-document.all: 1.0.0
+ functional-red-black-tree@1.0.1:
+ optional: true
+
functions-have-names@1.2.3: {}
fuzzysort@3.1.0: {}
+ gaxios@6.7.1:
+ dependencies:
+ extend: 3.0.2
+ https-proxy-agent: 7.0.6
+ is-stream: 2.0.1
+ node-fetch: 2.7.0
+ uuid: 9.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ optional: true
+
+ gaxios@7.1.3:
+ dependencies:
+ extend: 3.0.2
+ https-proxy-agent: 7.0.6
+ node-fetch: 3.3.2
+ rimraf: 5.0.10
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ gaxios@7.1.5:
+ dependencies:
+ extend: 3.0.2
+ https-proxy-agent: 7.0.6
+ node-fetch: 3.3.2
+ transitivePeerDependencies:
+ - supports-color
+
+ gcp-metadata@6.1.1:
+ dependencies:
+ gaxios: 6.7.1
+ google-logging-utils: 0.0.2
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ optional: true
+
+ gcp-metadata@8.1.2:
+ dependencies:
+ gaxios: 7.1.5
+ google-logging-utils: 1.1.3
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ gcp-metadata@8.1.3:
+ dependencies:
+ gaxios: 7.1.3
+ google-logging-utils: 1.1.3
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {}
@@ -8729,6 +9484,16 @@ snapshots:
glob-to-regexp@0.4.1: {}
+ glob@10.5.0:
+ dependencies:
+ foreground-child: 3.3.1
+ jackspeak: 3.4.3
+ minimatch: 9.0.9
+ minipass: 7.1.3
+ package-json-from-dist: 1.0.1
+ path-scurry: 1.11.1
+ optional: true
+
glob@13.0.6:
dependencies:
minimatch: 10.2.5
@@ -8744,10 +9509,86 @@ snapshots:
define-properties: 1.2.1
gopd: 1.2.0
+ google-auth-library@10.5.0:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 7.1.5
+ gcp-metadata: 8.1.3
+ google-logging-utils: 1.1.3
+ gtoken: 8.0.0
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ google-auth-library@10.7.0:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 7.1.5
+ gcp-metadata: 8.1.2
+ google-logging-utils: 1.1.3
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ google-auth-library@9.15.1:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 6.7.1
+ gcp-metadata: 6.1.1
+ gtoken: 7.1.0
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ optional: true
+
+ google-gax@5.0.7:
+ dependencies:
+ '@grpc/grpc-js': 1.14.4
+ '@grpc/proto-loader': 0.8.1
+ duplexify: 4.1.3
+ google-auth-library: 10.5.0
+ google-logging-utils: 1.1.3
+ node-fetch: 3.3.2
+ object-hash: 3.0.0
+ proto3-json-serializer: 3.0.4
+ protobufjs: 7.6.4
+ retry-request: 8.0.3
+ rimraf: 5.0.10
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ google-logging-utils@0.0.2:
+ optional: true
+
+ google-logging-utils@1.1.3: {}
+
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
+ gtoken@7.1.0:
+ dependencies:
+ gaxios: 6.7.1
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ optional: true
+
+ gtoken@8.0.0:
+ dependencies:
+ gaxios: 7.1.5
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -8784,6 +9625,9 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ html-entities@2.6.0:
+ optional: true
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -8794,6 +9638,23 @@ snapshots:
http-parser-js@0.5.10: {}
+ http-proxy-agent@5.0.0:
+ dependencies:
+ '@tootallnate/once': 2.0.1
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ http-proxy-agent@7.0.2:
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
@@ -8995,6 +9856,9 @@ snapshots:
is-unicode-supported@2.1.0: {}
+ is-unsafe@1.0.1:
+ optional: true
+
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -9029,6 +9893,13 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
+ jackspeak@3.4.3:
+ dependencies:
+ '@isaacs/cliui': 8.0.2
+ optionalDependencies:
+ '@pkgjs/parseargs': 0.11.0
+ optional: true
+
jest-worker@27.5.1:
dependencies:
'@types/node': 20.19.43
@@ -9073,6 +9944,10 @@ snapshots:
jsesc@3.1.0: {}
+ json-bigint@1.0.0:
+ dependencies:
+ bignumber.js: 9.3.1
+
json-buffer@3.0.1: {}
json-parse-even-better-errors@2.3.1: {}
@@ -9099,6 +9974,19 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
+ jsonwebtoken@9.0.3:
+ dependencies:
+ jws: 4.0.1
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.8.4
+
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
@@ -9106,6 +9994,28 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
+ jwa@2.0.1:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jwks-rsa@4.1.0:
+ dependencies:
+ '@types/jsonwebtoken': 9.0.10
+ debug: 4.4.3
+ jose: 6.2.3
+ limiter: 1.1.5
+ lru-cache: 11.5.1
+ lru-memoizer: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ jws@4.0.1:
+ dependencies:
+ jwa: 2.0.1
+ safe-buffer: 5.2.1
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -9174,6 +10084,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ limiter@1.1.5: {}
+
lines-and-columns@1.2.4: {}
loader-runner@4.3.2: {}
@@ -9189,8 +10101,24 @@ snapshots:
lodash.camelcase@4.3.0: {}
+ lodash.clonedeep@4.5.0: {}
+
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
lodash.merge@4.6.2: {}
+ lodash.once@4.1.1: {}
+
log-symbols@6.0.0:
dependencies:
chalk: 5.6.2
@@ -9202,12 +10130,20 @@ snapshots:
dependencies:
js-tokens: 4.0.0
+ lru-cache@10.4.3:
+ optional: true
+
lru-cache@11.5.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
+ lru-memoizer@3.0.0:
+ dependencies:
+ lodash.clonedeep: 4.5.0
+ lru-cache: 11.5.1
+
lucide-react@1.21.0(react@19.2.4):
dependencies:
react: 19.2.4
@@ -9235,12 +10171,23 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
+ mime-db@1.52.0:
+ optional: true
+
mime-db@1.54.0: {}
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+ optional: true
+
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
+ mime@3.0.0:
+ optional: true
+
mimic-fn@2.1.0: {}
mimic-fn@3.1.0: {}
@@ -9257,6 +10204,11 @@ snapshots:
dependencies:
brace-expansion: 1.1.15
+ minimatch@9.0.9:
+ dependencies:
+ brace-expansion: 2.1.1
+ optional: true
+
minimist@1.2.8: {}
minipass@7.1.3: {}
@@ -9275,6 +10227,12 @@ snapshots:
neo-async@2.6.2: {}
+ next-auth@5.0.0-beta.31(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@auth/core': 0.41.2
+ next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+
next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
react: 19.2.4
@@ -9335,8 +10293,13 @@ snapshots:
path-key: 4.0.0
unicorn-magic: 0.3.0
+ oauth4webapi@3.8.6: {}
+
object-assign@4.1.1: {}
+ object-hash@3.0.0:
+ optional: true
+
object-inspect@1.13.4: {}
object-keys@1.1.1: {}
@@ -9457,6 +10420,9 @@ snapshots:
p-try@2.2.0: {}
+ package-json-from-dist@1.0.1:
+ optional: true
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -9482,12 +10448,21 @@ snapshots:
path-exists@4.0.0: {}
+ path-expression-matcher@1.6.1:
+ optional: true
+
path-key@3.1.1: {}
path-key@4.0.0: {}
path-parse@1.0.7: {}
+ path-scurry@1.11.1:
+ dependencies:
+ lru-cache: 10.4.3
+ minipass: 7.1.3
+ optional: true
+
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.1
@@ -9530,6 +10505,12 @@ snapshots:
powershell-utils@0.1.0: {}
+ preact-render-to-string@6.5.11(preact@10.24.3):
+ dependencies:
+ preact: 10.24.3
+
+ preact@10.24.3: {}
+
prelude-ls@1.2.1: {}
pretty-format@27.5.1:
@@ -9555,6 +10536,11 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ proto3-json-serializer@3.0.4:
+ dependencies:
+ protobufjs: 7.6.4
+ optional: true
+
protobufjs@7.6.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
@@ -9661,6 +10647,10 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
+ react-hook-form@7.80.0(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+
react-is@16.13.1: {}
react-is@17.0.2: {}
@@ -9694,6 +10684,13 @@ snapshots:
react@19.2.4: {}
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+ optional: true
+
recast@0.23.11:
dependencies:
ast-types: 0.16.1
@@ -9756,8 +10753,34 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
+ retry-request@7.0.2:
+ dependencies:
+ '@types/request': 2.48.13
+ extend: 3.0.2
+ teeny-request: 9.0.0
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ optional: true
+
+ retry-request@8.0.3:
+ dependencies:
+ extend: 3.0.2
+ teeny-request: 10.1.3
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ retry@0.13.1:
+ optional: true
+
reusify@1.1.0: {}
+ rimraf@5.0.10:
+ dependencies:
+ glob: 10.5.0
+ optional: true
+
rolldown@1.0.3:
dependencies:
'@oxc-project/types': 0.133.0
@@ -10063,12 +11086,27 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
+ stream-events@1.0.5:
+ dependencies:
+ stubs: 3.0.0
+ optional: true
+
+ stream-shift@1.0.3:
+ optional: true
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
+ string-width@5.1.2:
+ dependencies:
+ eastasianwidth: 0.2.0
+ emoji-regex: 9.2.2
+ strip-ansi: 7.2.0
+ optional: true
+
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
@@ -10126,6 +11164,11 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+ optional: true
+
stringify-object@5.0.0:
dependencies:
get-own-enumerable-keys: 1.0.0
@@ -10152,6 +11195,14 @@ snapshots:
strip-json-comments@3.1.1: {}
+ strnum@2.4.1:
+ dependencies:
+ anynum: 1.0.1
+ optional: true
+
+ stubs@3.0.0:
+ optional: true
+
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4):
dependencies:
client-only: 0.0.1
@@ -10179,6 +11230,28 @@ snapshots:
tapable@2.3.3: {}
+ teeny-request@10.1.3:
+ dependencies:
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ node-fetch: 3.3.2
+ stream-events: 1.0.5
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
+ teeny-request@9.0.0:
+ dependencies:
+ http-proxy-agent: 5.0.0
+ https-proxy-agent: 5.0.1
+ node-fetch: 2.7.0
+ stream-events: 1.0.5
+ uuid: 9.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ optional: true
+
terser-webpack-plugin@5.6.1(webpack@5.107.2):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -10384,6 +11457,9 @@ snapshots:
util-deprecate@1.0.2: {}
+ uuid@9.0.1:
+ optional: true
+
validate-npm-package-name@7.0.2: {}
vary@1.1.2: {}
@@ -10572,6 +11648,13 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
+ wrap-ansi@8.1.0:
+ dependencies:
+ ansi-styles: 6.2.3
+ string-width: 5.1.2
+ strip-ansi: 7.2.0
+ optional: true
+
wrappy@1.0.2: {}
wsl-utils@0.3.1:
@@ -10581,6 +11664,9 @@ snapshots:
xml-name-validator@5.0.0: {}
+ xml-naming@0.1.0:
+ optional: true
+
xmlchars@2.2.0: {}
y18n@5.0.8: {}
diff --git a/web/proxy.ts b/web/proxy.ts
new file mode 100644
index 0000000..f187e84
--- /dev/null
+++ b/web/proxy.ts
@@ -0,0 +1,15 @@
+import { auth } from "@/auth"
+import { NextResponse } from "next/server"
+import type { NextAuthRequest } from "next-auth"
+
+export default auth((req: NextAuthRequest) => {
+ if (!req.auth) {
+ const loginUrl = new URL("/login", req.url)
+ loginUrl.searchParams.set("callbackUrl", req.nextUrl.pathname + req.nextUrl.search)
+ return NextResponse.redirect(loginUrl)
+ }
+})
+
+export const config = {
+ matcher: ["/dashboard/:path*", "/settings/:path*"],
+}
diff --git a/web/server/routers/__tests__/auth.test.ts b/web/server/routers/__tests__/auth.test.ts
index 8ee6702..6137ea3 100644
--- a/web/server/routers/__tests__/auth.test.ts
+++ b/web/server/routers/__tests__/auth.test.ts
@@ -1,50 +1,55 @@
-import { describe, it, expect } from 'vitest'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
import { appRouter } from '../_app'
import { createTRPCContext } from '../../trpc'
+import type { Session } from 'next-auth'
-function makeContext(reqHeaders: Record = {}) {
- const req = new Request('http://localhost/api/trpc', { headers: reqHeaders }) as import('next/server').NextRequest
+vi.mock('@/auth', () => ({
+ auth: vi.fn(),
+}))
+
+import { auth } from '@/auth'
+const mockAuth = vi.mocked(auth)
+
+function makeContext(session: Session | null = null) {
+ const req = new Request('http://localhost/api/trpc') as import('next/server').NextRequest
+ mockAuth.mockResolvedValue(session)
return createTRPCContext({ req })
}
+const validSession: Session = {
+ user: { id: '123', email: 'test@example.com', name: 'Test User' },
+ expires: '2099-01-01T00:00:00.000Z',
+}
+
describe('protectedProcedure', () => {
- it('throws UNAUTHORIZED when no session present', async () => {
- const ctx = await makeContext()
- const caller = appRouter.createCaller(ctx)
- await expect(caller.auth.session()).rejects.toMatchObject({
- code: 'UNAUTHORIZED',
- })
+ beforeEach(() => {
+ vi.resetAllMocks()
})
- it('allows access with Bearer token', async () => {
- const ctx = await makeContext({ authorization: 'Bearer test-token-123' })
+ it('throws UNAUTHORIZED when no session present', async () => {
+ const ctx = await makeContext(null)
const caller = appRouter.createCaller(ctx)
- const result = await caller.auth.session()
- expect(result).toEqual({ authenticated: true })
+ await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
})
- it('allows access with __session cookie', async () => {
- const ctx = await makeContext({ cookie: '__session=abc123' })
+ it('allows access with valid session', async () => {
+ const ctx = await makeContext(validSession)
const caller = appRouter.createCaller(ctx)
const result = await caller.auth.session()
- expect(result).toEqual({ authenticated: true })
- })
-
- it('throws UNAUTHORIZED for empty Bearer token', async () => {
- const ctx = await makeContext({ authorization: 'Bearer ' })
- const caller = appRouter.createCaller(ctx)
- await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
+ expect(result).toMatchObject({ authenticated: true, user: { email: 'test@example.com' } })
})
- it('throws UNAUTHORIZED for __session cookie with empty value', async () => {
- const ctx = await makeContext({ cookie: '__session=' })
+ it('returns user data in session query', async () => {
+ const ctx = await makeContext(validSession)
const caller = appRouter.createCaller(ctx)
- await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
+ const result = await caller.auth.session()
+ expect(result.user?.name).toBe('Test User')
})
- it('throws UNAUTHORIZED when __session= appears only in another cookie value', async () => {
- const ctx = await makeContext({ cookie: 'other=__session=abc' })
+ it('signOut returns success for authenticated user', async () => {
+ const ctx = await makeContext(validSession)
const caller = appRouter.createCaller(ctx)
- await expect(caller.auth.session()).rejects.toMatchObject({ code: 'UNAUTHORIZED' })
+ const result = await caller.auth.signOut()
+ expect(result).toEqual({ success: true })
})
})
diff --git a/web/server/routers/__tests__/health.test.ts b/web/server/routers/__tests__/health.test.ts
index 34b41a9..714c16f 100644
--- a/web/server/routers/__tests__/health.test.ts
+++ b/web/server/routers/__tests__/health.test.ts
@@ -1,4 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'
+
+vi.mock('@/auth', () => ({
+ auth: vi.fn().mockResolvedValue(null),
+}))
+
import { appRouter } from '../_app'
import { createTRPCContext } from '../../trpc'
diff --git a/web/server/routers/auth.ts b/web/server/routers/auth.ts
index 5a0a517..ac2f74f 100644
--- a/web/server/routers/auth.ts
+++ b/web/server/routers/auth.ts
@@ -1,12 +1,13 @@
import { protectedProcedure, router } from '../trpc'
export const authRouter = router({
- session: protectedProcedure.query(async () => {
- // Stub — will be replaced when auth is implemented
- return { authenticated: true }
+ session: protectedProcedure.query(async ({ ctx }) => {
+ return {
+ authenticated: true,
+ user: ctx.session?.user ?? null,
+ }
}),
signOut: protectedProcedure.mutation(async () => {
- // Stub — will be replaced when auth is implemented
return { success: true }
}),
})
diff --git a/web/server/trpc.ts b/web/server/trpc.ts
index 1d224d7..38242d1 100644
--- a/web/server/trpc.ts
+++ b/web/server/trpc.ts
@@ -1,13 +1,16 @@
import { initTRPC, TRPCError } from '@trpc/server'
import { type NextRequest } from 'next/server'
+import { auth } from '@/auth'
+import type { Session } from 'next-auth'
export interface TRPCContext {
req: NextRequest
- // session will be added when auth is implemented
+ session: Session | null
}
export async function createTRPCContext({ req }: { req: NextRequest }): Promise {
- return { req }
+ const session = await auth()
+ return { req, session }
}
const t = initTRPC.context().create()
@@ -16,23 +19,8 @@ export const router = t.router
export const createCallerFactory = t.createCallerFactory
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
- const authHeader = ctx.req.headers.get('authorization')
- const cookieHeader = ctx.req.headers.get('cookie')
-
- const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() : null
- const hasValidBearer = typeof bearerToken === 'string' && bearerToken.length > 0
-
- const sessionValue = cookieHeader
- ?.split(';')
- .map(c => c.trim())
- .find(c => c.startsWith('__session='))
- ?.slice('__session='.length)
- .trim()
- const hasValidSessionCookie = typeof sessionValue === 'string' && sessionValue.length > 0
-
- if (!hasValidBearer && !hasValidSessionCookie) {
+ if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
-
return next({ ctx })
})