Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
@@ -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

1 change: 1 addition & 0 deletions web/__mocks__/next/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<img
src={typeof src === 'string' ? src : ''}
alt={alt}
Expand Down
7 changes: 6 additions & 1 deletion web/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Home />)
Expand Down
44 changes: 44 additions & 0 deletions web/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Sign in</CardTitle>
<CardDescription>
Enter your credentials to access your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<GoogleSignInButton />
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">or</span>
<Separator className="flex-1" />
</div>
<LoginForm />
<p className="text-muted-foreground text-center text-sm">
Don&apos;t have an account?{' '}
<Link
href="/register"
className="text-primary underline-offset-4 hover:underline"
>
Sign up
</Link>
</p>
</CardContent>
</Card>
</div>
)
}
42 changes: 42 additions & 0 deletions web/app/(auth)/register/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Create account</CardTitle>
<CardDescription>Sign up to get started</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<GoogleSignInButton />
<div className="flex items-center gap-4">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">or</span>
<Separator className="flex-1" />
</div>
<RegisterForm />
<p className="text-muted-foreground text-center text-sm">
Already have an account?{' '}
<Link
href="/login"
className="text-primary underline-offset-4 hover:underline"
>
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
)
}
18 changes: 18 additions & 0 deletions web/app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-1 items-center justify-center">
<div className="text-center">
<h1 className="text-3xl font-bold">Welcome back</h1>
<p className="mt-2 text-muted-foreground">
{session.user?.name ?? session.user?.email ?? 'User'}
</p>
</div>
</div>
)
}
20 changes: 20 additions & 0 deletions web/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SidebarProvider defaultOpen={sidebarOpen} className="flex flex-1">
<AppSidebar />
<SidebarInset className="flex flex-col">
<header className="flex h-12 shrink-0 items-center border-b px-4">
<SidebarTrigger />
</header>
<div className="flex flex-1 flex-col">{children}</div>
</SidebarInset>
</SidebarProvider>
)
}
25 changes: 25 additions & 0 deletions web/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-1 flex-col gap-6 p-8">
<div>
<h1 className="text-2xl font-bold">Settings</h1>
<p className="mt-1 text-muted-foreground">Manage your account settings.</p>
</div>
<div className="rounded-lg border p-6">
<h2 className="text-sm font-medium">Account</h2>
<p className="mt-1 text-sm text-muted-foreground">
{session.user?.name && (
<span className="block">{session.user.name}</span>
)}
{session.user?.email}
</p>
</div>
</div>
)
}
2 changes: 2 additions & 0 deletions web/app/api/auth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers
12 changes: 6 additions & 6 deletions web/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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"],
});

Expand All @@ -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({
Expand All @@ -26,7 +26,7 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
className={`${manrope.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
Expand Down
4 changes: 4 additions & 0 deletions web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Header />
<Hero />
<About />
<Footer />
</div>
);
};
Expand Down
9 changes: 8 additions & 1 deletion web/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -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 <TRPCProvider>{children}</TRPCProvider>
return (
<SessionProvider>
<TRPCProvider>{children}</TRPCProvider>
<Toaster richColors position="top-right" />
</SessionProvider>
)
}
32 changes: 32 additions & 0 deletions web/auth.ts
Original file line number Diff line number Diff line change
@@ -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
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
],
pages: { signIn: "/login" },
session: { strategy: "jwt" },
})
54 changes: 54 additions & 0 deletions web/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Sidebar>
<SidebarHeader className="px-4 py-3">
<Link href="/" className="text-sm font-semibold tracking-tight hover:opacity-70 transition-opacity">
App
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Menu</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link href={item.url}>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-3">
<UserMenu />
</SidebarFooter>
</Sidebar>
)
}
19 changes: 19 additions & 0 deletions web/components/layout/NavAuth.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="h-8 w-8 rounded-full bg-muted animate-pulse" />
if (isAuthenticated) return <UserMenu />

return (
<Button asChild variant="ghost" size="sm">
<Link href="/login">Sign in</Link>
</Button>
)
}
Loading
Loading