From f62883eef6ad3be4163ae86f9c5b6b7b5d0fe6f1 Mon Sep 17 00:00:00 2001 From: GRACENOBLE Date: Wed, 24 Jun 2026 18:06:25 +0300 Subject: [PATCH 1/3] feat(web): authentication with Firebase, NextAuth v5, and sidebar dashboard (closes #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NextAuth v5 (Auth.js) with a single Credentials provider that accepts Firebase ID tokens — Google OAuth and email/password both flow through Firebase Auth SDK on the client, then exchange the ID token for a NextAuth session - proxy.ts (Next.js 16 convention) protects /dashboard and /settings, redirecting unauthenticated users to /login - tRPC protectedProcedure updated to use NextAuth auth() for session validation; TRPCContext now carries Session | null - Login, Register, and Google Sign-In pages with shadcn Card + Form + react-hook-form + Zod validation; errors surfaced via Sonner toasts - Dashboard layout with shadcn Sidebar (AppSidebar), collapsible trigger, and UserMenu (avatar + dropdown) in the sidebar footer - Settings page added so sidebar nav link resolves without 404 - Header component wired into home page; "App" logo links home - Manrope replaces Geist Sans as the global font - next.config.ts allows lh3.googleusercontent.com for Google profile pics - Shared lib/firebase.ts initialises the Firebase app and exports getFirebaseAuth() for use across client components - .env.example updated with all required vars; both backend and web examples verified complete - 79 Vitest tests pass (19 test files) --- web/.env.example | 19 + web/__tests__/page.test.tsx | 7 +- web/app/(auth)/login/page.tsx | 44 ++ web/app/(auth)/register/page.tsx | 42 ++ web/app/(dashboard)/dashboard/page.tsx | 18 + web/app/(dashboard)/layout.tsx | 16 + web/app/(dashboard)/settings/page.tsx | 25 + web/app/api/auth/[...nextauth]/route.ts | 2 + web/app/layout.tsx | 12 +- web/app/page.tsx | 4 + web/app/providers.tsx | 9 +- web/auth.ts | 37 + web/components/layout/AppSidebar.tsx | 54 ++ web/components/layout/NavAuth.tsx | 19 + .../layout/__tests__/NavAuth.test.tsx | 38 + web/components/layout/footer.tsx | 7 + web/components/layout/header.tsx | 16 + web/components/ui/form.tsx | 177 +++++ web/components/ui/sheet.tsx | 147 ++++ web/components/ui/sidebar.tsx | 702 ++++++++++++++++++ web/components/ui/tooltip.tsx | 57 ++ web/docs/_index.md | 1 + web/docs/auth.md | 247 ++++++ web/docs/data-fetching.md | 3 +- web/docs/trpc.md | 73 +- .../__tests__/GoogleSignInButton.test.tsx | 29 + .../auth/__tests__/LoginForm.test.tsx | 50 ++ .../auth/__tests__/RegisterForm.test.tsx | 58 ++ .../auth/__tests__/useSession.test.ts | 57 ++ .../auth/__tests__/validation.test.ts | 66 ++ .../auth/components/GoogleSignInButton.tsx | 53 ++ web/features/auth/components/LoginForm.tsx | 84 +++ web/features/auth/components/RegisterForm.tsx | 117 +++ web/features/auth/components/UserMenu.tsx | 55 ++ web/features/auth/hooks/useSession.ts | 20 + web/features/auth/hooks/useSignOut.ts | 15 + web/features/auth/types.ts | 11 + web/features/auth/validation.ts | 21 + web/hooks/use-mobile.ts | 19 + web/lib/firebase.ts | 22 + web/lib/trpc/__tests__/server.test.ts | 4 + web/next.config.ts | 9 +- web/package.json | 4 + web/pnpm-lock.yaml | 115 +++ web/proxy.ts | 15 + web/server/routers/__tests__/auth.test.ts | 61 +- web/server/routers/__tests__/health.test.ts | 5 + web/server/routers/auth.ts | 9 +- web/server/trpc.ts | 24 +- 49 files changed, 2613 insertions(+), 86 deletions(-) create mode 100644 web/app/(auth)/login/page.tsx create mode 100644 web/app/(auth)/register/page.tsx create mode 100644 web/app/(dashboard)/dashboard/page.tsx create mode 100644 web/app/(dashboard)/layout.tsx create mode 100644 web/app/(dashboard)/settings/page.tsx create mode 100644 web/app/api/auth/[...nextauth]/route.ts create mode 100644 web/auth.ts create mode 100644 web/components/layout/AppSidebar.tsx create mode 100644 web/components/layout/NavAuth.tsx create mode 100644 web/components/layout/__tests__/NavAuth.test.tsx create mode 100644 web/components/layout/footer.tsx create mode 100644 web/components/layout/header.tsx create mode 100644 web/components/ui/form.tsx create mode 100644 web/components/ui/sheet.tsx create mode 100644 web/components/ui/sidebar.tsx create mode 100644 web/components/ui/tooltip.tsx create mode 100644 web/docs/auth.md create mode 100644 web/features/auth/__tests__/GoogleSignInButton.test.tsx create mode 100644 web/features/auth/__tests__/LoginForm.test.tsx create mode 100644 web/features/auth/__tests__/RegisterForm.test.tsx create mode 100644 web/features/auth/__tests__/useSession.test.ts create mode 100644 web/features/auth/__tests__/validation.test.ts create mode 100644 web/features/auth/components/GoogleSignInButton.tsx create mode 100644 web/features/auth/components/LoginForm.tsx create mode 100644 web/features/auth/components/RegisterForm.tsx create mode 100644 web/features/auth/components/UserMenu.tsx create mode 100644 web/features/auth/hooks/useSession.ts create mode 100644 web/features/auth/hooks/useSignOut.ts create mode 100644 web/features/auth/types.ts create mode 100644 web/features/auth/validation.ts create mode 100644 web/hooks/use-mobile.ts create mode 100644 web/lib/firebase.ts create mode 100644 web/proxy.ts diff --git a/web/.env.example b/web/.env.example index 8aae850..3056026 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,2 +1,21 @@ # 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 — 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/__tests__/page.test.tsx b/web/__tests__/page.test.tsx index a6cef1f..8ee06bb 100644 --- a/web/__tests__/page.test.tsx +++ b/web/__tests__/page.test.tsx @@ -1,7 +1,12 @@ import { render } from '@testing-library/react' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import Home from '../app/page' +vi.mock('next-auth/react', () => ({ + 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..9c870d9 --- /dev/null +++ b/web/app/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/layout/AppSidebar' + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + 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..90680db --- /dev/null +++ b/web/auth.ts @@ -0,0 +1,37 @@ +import NextAuth from "next-auth" +import Credentials from "next-auth/providers/credentials" +import { z } from "zod" + +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 + + const parts = parsed.data.idToken.split(".") + if (parts.length !== 3) return null + + try { + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf-8"), + ) as { sub?: string; email?: string; name?: string; picture?: string } + + if (!payload.sub) return null + + return { + id: payload.sub, + email: payload.email ?? null, + name: payload.name ?? null, + image: payload.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..595822c --- /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, session: null }) + const { container } = render() + expect(container.querySelector('.animate-pulse')).toBeInTheDocument() + }) + + it('renders UserMenu when authenticated', () => { + mockUseSession.mockReturnValue({ isLoading: false, isAuthenticated: true, session: { user: { email: 'a@b.com' }, expires: '2099' } }) + render() + expect(screen.getByTestId('user-menu')).toBeInTheDocument() + }) + + it('renders a sign-in link when unauthenticated', () => { + mockUseSession.mockReturnValue({ isLoading: false, isAuthenticated: false, session: 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 ( +
+
+ + App + +
+ +
+
+ ) +} 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 ( +