diff --git a/Dockerfile b/Dockerfile index 1a51686..f79e048 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,22 @@ FROM node:20-alpine AS base # Dependencies stage FROM base AS deps WORKDIR /app -COPY package.json package-lock.json* ./ -COPY prisma ./prisma/ +COPY rtrips-online/package.json rtrips-online/package-lock.json* ./ +COPY rtrips-online/prisma ./prisma/ +# Copy local SDK dependency (package.json references file:../encryptid-sdk) +COPY encryptid-sdk /encryptid-sdk/ RUN npm ci || npm install +# Ensure SDK is properly linked in node_modules +RUN rm -rf node_modules/@encryptid/sdk && \ + mkdir -p node_modules/@encryptid && \ + cp -r /encryptid-sdk node_modules/@encryptid/sdk + # Build stage FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules -COPY . . +COPY rtrips-online/ . RUN npx prisma generate RUN npm run build diff --git a/docker-compose.yml b/docker-compose.yml index 931aa39..0a95261 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: rtrips: - build: . + build: + context: .. + dockerfile: rtrips-online/Dockerfile container_name: rtrips-online restart: unless-stopped environment: diff --git a/src/app/api/trips/[id]/bookings/route.ts b/src/app/api/trips/[id]/bookings/route.ts index ab39486..457fd89 100644 --- a/src/app/api/trips/[id]/bookings/route.ts +++ b/src/app/api/trips/[id]/bookings/route.ts @@ -1,11 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'MEMBER'); + if (role instanceof NextResponse) return role; + const body = await request.json(); const booking = await prisma.booking.create({ data: { diff --git a/src/app/api/trips/[id]/canvas/route.ts b/src/app/api/trips/[id]/canvas/route.ts index 3bf0d32..7f88ee4 100644 --- a/src/app/api/trips/[id]/canvas/route.ts +++ b/src/app/api/trips/[id]/canvas/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { pushShapesToCanvas } from '@/lib/canvas-sync'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; /** * POST /api/trips/[id]/canvas @@ -10,9 +11,14 @@ import { pushShapesToCanvas } from '@/lib/canvas-sync'; * Stores the canvasSlug back on the Trip record. */ export async function POST( - _request: NextRequest, + request: NextRequest, { params }: { params: { id: string } } ) { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'MEMBER'); + if (role instanceof NextResponse) return role; try { const trip = await prisma.trip.findUnique({ where: { id: params.id }, diff --git a/src/app/api/trips/[id]/destinations/route.ts b/src/app/api/trips/[id]/destinations/route.ts index 15d86df..5029ac6 100644 --- a/src/app/api/trips/[id]/destinations/route.ts +++ b/src/app/api/trips/[id]/destinations/route.ts @@ -1,11 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'MEMBER'); + if (role instanceof NextResponse) return role; + const body = await request.json(); const destination = await prisma.destination.create({ data: { diff --git a/src/app/api/trips/[id]/expenses/route.ts b/src/app/api/trips/[id]/expenses/route.ts index 6792b53..399670f 100644 --- a/src/app/api/trips/[id]/expenses/route.ts +++ b/src/app/api/trips/[id]/expenses/route.ts @@ -1,16 +1,23 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'MEMBER'); + if (role instanceof NextResponse) return role; + const body = await request.json(); const expense = await prisma.expense.create({ data: { tripId: params.id, - paidById: body.paidById, + paidById: auth.user.id, description: body.description, amount: body.amount, currency: body.currency || 'USD', diff --git a/src/app/api/trips/[id]/itinerary/route.ts b/src/app/api/trips/[id]/itinerary/route.ts index 47559a9..d31d1b5 100644 --- a/src/app/api/trips/[id]/itinerary/route.ts +++ b/src/app/api/trips/[id]/itinerary/route.ts @@ -1,11 +1,18 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'MEMBER'); + if (role instanceof NextResponse) return role; + const body = await request.json(); const item = await prisma.itineraryItem.create({ data: { diff --git a/src/app/api/trips/[id]/packing/[itemId]/route.ts b/src/app/api/trips/[id]/packing/[itemId]/route.ts index 271a279..12adeb3 100644 --- a/src/app/api/trips/[id]/packing/[itemId]/route.ts +++ b/src/app/api/trips/[id]/packing/[itemId]/route.ts @@ -1,13 +1,20 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; export async function PATCH( - _request: NextRequest, + request: NextRequest, { params }: { params: { id: string; itemId: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { id, itemId } = await params; + const role = await requireTripRole(auth.user.id, id, 'MEMBER'); + if (role instanceof NextResponse) return role; + // Verify item belongs to trip const item = await prisma.packingItem.findFirst({ where: { id: itemId, tripId: id }, diff --git a/src/app/api/trips/[id]/packing/route.ts b/src/app/api/trips/[id]/packing/route.ts index 3a04cf5..2edbf4f 100644 --- a/src/app/api/trips/[id]/packing/route.ts +++ b/src/app/api/trips/[id]/packing/route.ts @@ -1,16 +1,23 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'MEMBER'); + if (role instanceof NextResponse) return role; + const body = await request.json(); const item = await prisma.packingItem.create({ data: { tripId: params.id, - addedById: body.addedById, + addedById: auth.user.id, name: body.name, category: body.category, quantity: body.quantity ?? 1, diff --git a/src/app/api/trips/[id]/route.ts b/src/app/api/trips/[id]/route.ts index b546204..5620486 100644 --- a/src/app/api/trips/[id]/route.ts +++ b/src/app/api/trips/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; export async function GET( _request: NextRequest, @@ -37,6 +38,12 @@ export async function PUT( { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'EDITOR'); + if (role instanceof NextResponse) return role; + const body = await request.json(); const trip = await prisma.trip.update({ where: { id: params.id }, @@ -62,10 +69,16 @@ export async function PUT( } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'OWNER'); + if (role instanceof NextResponse) return role; + await prisma.trip.delete({ where: { id: params.id } }); return NextResponse.json({ ok: true }); } catch (error) { diff --git a/src/app/api/trips/[id]/sync/route.ts b/src/app/api/trips/[id]/sync/route.ts index 4c5826d..198df3c 100644 --- a/src/app/api/trips/[id]/sync/route.ts +++ b/src/app/api/trips/[id]/sync/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, requireTripRole } from '@/lib/auth'; /** * POST /api/trips/[id]/sync @@ -13,6 +14,11 @@ export async function POST( request: NextRequest, { params }: { params: { id: string } } ) { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + + const role = await requireTripRole(auth.user.id, params.id, 'MEMBER'); + if (role instanceof NextResponse) return role; try { const body = await request.json(); const { shapeId, type, data } = body; diff --git a/src/app/api/trips/parse/route.ts b/src/app/api/trips/parse/route.ts index 2366528..02dc7a7 100644 --- a/src/app/api/trips/parse/route.ts +++ b/src/app/api/trips/parse/route.ts @@ -1,8 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; import { parseTrip } from '@/lib/gemini'; +import { requireAuth, isAuthed } from '@/lib/auth'; export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { text } = await request.json(); if (!text || typeof text !== 'string' || text.trim().length === 0) { diff --git a/src/app/api/trips/route.ts b/src/app/api/trips/route.ts index 7e2c7ae..5ba065f 100644 --- a/src/app/api/trips/route.ts +++ b/src/app/api/trips/route.ts @@ -2,9 +2,36 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { generateSlug } from '@/lib/slug'; import { ParsedTrip } from '@/lib/types'; +import { getAuthUser, requireAuth, isAuthed } from '@/lib/auth'; -export async function GET() { +export async function GET(request: NextRequest) { try { + const auth = await getAuthUser(request); + + if (auth) { + // Authenticated: return trips the user collaborates on + const trips = await prisma.trip.findMany({ + where: { + collaborators: { some: { userId: auth.user.id } }, + }, + include: { + destinations: { orderBy: { sortOrder: 'asc' } }, + collaborators: { include: { user: true } }, + _count: { + select: { + itineraryItems: true, + bookings: true, + expenses: true, + packingItems: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + return NextResponse.json(trips); + } + + // Unauthenticated: return all trips (demo access) const trips = await prisma.trip.findMany({ include: { destinations: { orderBy: { sortOrder: 'asc' } }, @@ -33,6 +60,9 @@ export async function GET() { export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const body = await request.json(); const { parsed, rawInput }: { parsed: ParsedTrip; rawInput: string } = body; @@ -66,6 +96,15 @@ export async function POST(request: NextRequest) { }, }); + // Add creator as OWNER collaborator + await tx.tripCollaborator.create({ + data: { + tripId: newTrip.id, + userId: auth.user.id, + role: 'OWNER', + }, + }); + // Create destinations if (parsed.destinations.length > 0) { await tx.destination.createMany({ @@ -118,6 +157,7 @@ export async function POST(request: NextRequest) { destinations: { orderBy: { sortOrder: 'asc' } }, itineraryItems: { orderBy: { sortOrder: 'asc' } }, bookings: true, + collaborators: { include: { user: true } }, }, }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1bc6a49..a02a957 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' import './globals.css' +import { AuthProvider } from '@/components/AuthProvider' const inter = Inter({ subsets: ['latin'], @@ -26,7 +27,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ) diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..32fd7ae --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { EncryptIDProvider } from '@encryptid/sdk/ui/react'; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + // Cast to any to bypass React 18/19 type mismatch between SDK and app + const Provider = EncryptIDProvider as any; + return ( + + {children} + + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..6ff6f40 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,83 @@ +import { getEncryptIDSession } from '@encryptid/sdk/server/nextjs'; +import { NextResponse } from 'next/server'; +import { prisma } from './prisma'; +import type { User } from '@prisma/client'; + +export interface AuthResult { + user: User; + did: string; +} + +const UNAUTHORIZED = NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + +/** + * Get authenticated user from request, or null if not authenticated. + * Upserts User in DB by DID (find-or-create). + */ +export async function getAuthUser(request: Request): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const claims: any = await getEncryptIDSession(request); + if (!claims) return null; + + const did: string | undefined = claims.did || claims.sub; + if (!did) return null; + + const username: string | null = claims.username || null; + const user = await prisma.user.upsert({ + where: { did }, + update: { username: username || undefined }, + create: { did, username }, + }); + + return { user, did }; +} + +/** + * Require authentication. Returns auth result or a 401 NextResponse. + */ +export async function requireAuth(request: Request): Promise { + const result = await getAuthUser(request); + if (!result) return UNAUTHORIZED; + return result; +} + +/** Type guard for successful auth */ +export function isAuthed(result: AuthResult | NextResponse): result is AuthResult { + return !(result instanceof NextResponse); +} + +/** + * Check if user has a role on a trip. + * Returns the role or null if no access. + */ +export async function getTripRole( + userId: string, + tripId: string +): Promise<'OWNER' | 'EDITOR' | 'VIEWER' | 'MEMBER' | null> { + const collab = await prisma.tripCollaborator.findUnique({ + where: { userId_tripId: { userId, tripId } }, + }); + return collab?.role ?? null; +} + +/** + * Require at least the specified role on a trip. + * Returns the member role or a 403 NextResponse. + */ +export async function requireTripRole( + userId: string, + tripId: string, + minRole: 'VIEWER' | 'MEMBER' | 'EDITOR' | 'OWNER' = 'VIEWER' +): Promise<'OWNER' | 'EDITOR' | 'VIEWER' | 'MEMBER' | NextResponse> { + const role = await getTripRole(userId, tripId); + if (!role) { + return NextResponse.json({ error: 'Not a collaborator on this trip' }, { status: 403 }); + } + + const hierarchy = { VIEWER: 0, MEMBER: 1, EDITOR: 2, OWNER: 3 }; + if (hierarchy[role] < hierarchy[minRole]) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }); + } + + return role; +}