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:
Jeff Emmett 2026-02-18 10:20:17 +00:00
parent 608686b22d
commit 5e9b3f828a
16 changed files with 230 additions and 11 deletions

View File

@ -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

View File

@ -1,6 +1,8 @@
services:
rtrips:
build: .
build:
context: ..
dockerfile: rtrips-online/Dockerfile
container_name: rtrips-online
restart: unless-stopped
environment:

View File

@ -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: {

View File

@ -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 },

View File

@ -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: {

View File

@ -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',

View File

@ -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: {

View File

@ -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 },

View File

@ -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,

View File

@ -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) {

View File

@ -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;

View File

@ -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) {

View File

@ -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 } },
},
});

View File

@ -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>
)

View File

@ -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>
);
}

83
src/lib/auth.ts Normal file
View File

@ -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;
}