feat: add EncryptID auth with role-based access control
AuthProvider wraps layout, auth.ts provides requireAuth/isAuthed/requireTripRole. POST/PUT/DELETE routes require auth, GET routes remain open for demo access. Trip creator is added as OWNER collaborator. Dockerfile updated for SDK copy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
608686b22d
commit
5e9b3f828a
13
Dockerfile
13
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
services:
|
||||
rtrips:
|
||||
build: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: rtrips-online/Dockerfile
|
||||
container_name: rtrips-online
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<html lang="en">
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
{children}
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Provider serverUrl={process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL}>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<AuthResult | null> {
|
||||
// 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<AuthResult | NextResponse> {
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in New Issue