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 {alt} ({ + 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 ( +
+
+ + 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 ( +