diff --git a/Dockerfile b/Dockerfile index ac45fdf..b80d54a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,17 +4,27 @@ FROM node:20-alpine AS builder WORKDIR /app # Copy package files -COPY package.json package-lock.json* ./ +COPY rcal-online/package.json rcal-online/package-lock.json* ./ + +# Copy prisma schema +COPY rcal-online/prisma ./prisma/ + +# Copy local SDK dependency (package.json references file:../encryptid-sdk) +COPY encryptid-sdk /encryptid-sdk/ # Install dependencies -RUN npm ci +RUN npm ci || npm install -# Copy prisma schema and generate client -COPY prisma ./prisma/ +# 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 + +# Generate Prisma client RUN npx prisma generate # Copy source files -COPY . . +COPY rcal-online/ . # Build the application ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/docker-compose.yml b/docker-compose.yml index cc75954..75795c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,14 @@ services: rcal: - build: . + build: + context: .. + dockerfile: rcal-online/Dockerfile container_name: rcal-online restart: unless-stopped environment: - NODE_ENV=production - DATABASE_URL=postgresql://rcal:${POSTGRES_PASSWORD}@rcal-postgres:5432/rcal + - NEXT_PUBLIC_ENCRYPTID_SERVER_URL=${NEXT_PUBLIC_ENCRYPTID_SERVER_URL:-https://encryptid.jeffemmett.com} depends_on: rcal-postgres: condition: service_healthy diff --git a/package.json b/package.json index d4fb25d..cd48741 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@encryptid/sdk": "file:../encryptid-sdk", "@prisma/client": "^6.19.2", "@tanstack/react-query": "^5.17.0", "clsx": "^2.1.0", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5e00273..a96a0ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,6 +7,18 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id @default(cuid()) + did String @unique + username String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + sources CalendarSource[] + + @@map("users") +} + model Event { id String @id @default(cuid()) sourceId String @map("source_id") @@ -71,11 +83,15 @@ model CalendarSource { syncError String @default("") @map("sync_error") syncConfig Json? @map("sync_config") + createdById String? @map("created_by_id") + createdBy User? @relation(fields: [createdById], references: [id]) + createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") events Event[] + @@index([createdById]) @@map("calendar_sources") } diff --git a/src/app/api/events/[id]/route.ts b/src/app/api/events/[id]/route.ts index cad7deb..424091c 100644 --- a/src/app/api/events/[id]/route.ts +++ b/src/app/api/events/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { requireAuth, isAuthed } from '@/lib/auth' export async function GET( _request: NextRequest, @@ -30,6 +31,9 @@ export async function PUT( { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + const body = await request.json() const event = await prisma.event.update({ @@ -57,10 +61,13 @@ 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 + await prisma.event.delete({ where: { id: params.id } }) return new NextResponse(null, { status: 204 }) } catch (err) { diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index f81013d..53a7307 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { requireAuth, isAuthed } from '@/lib/auth' export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl @@ -99,6 +100,9 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + const body = await request.json() const event = await prisma.event.create({ diff --git a/src/app/api/sources/[id]/route.ts b/src/app/api/sources/[id]/route.ts index cf78299..3bd9bf6 100644 --- a/src/app/api/sources/[id]/route.ts +++ b/src/app/api/sources/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { requireAuth, isAuthed } from '@/lib/auth' export async function GET( _request: NextRequest, @@ -30,6 +31,9 @@ export async function PUT( { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request) + if (!isAuthed(auth)) return auth + const body = await request.json() const source = await prisma.calendarSource.update({ @@ -51,10 +55,13 @@ 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 + await prisma.calendarSource.delete({ where: { id: params.id } }) return new NextResponse(null, { status: 204 }) } catch (err) { diff --git a/src/app/api/sources/route.ts b/src/app/api/sources/route.ts index 6f280f8..b3a9cde 100644 --- a/src/app/api/sources/route.ts +++ b/src/app/api/sources/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' +import { requireAuth, isAuthed } from '@/lib/auth' export async function GET() { try { @@ -32,6 +33,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 source = await prisma.calendarSource.create({ @@ -42,6 +46,7 @@ export async function POST(request: NextRequest) { isVisible: body.is_visible ?? true, isActive: body.is_active ?? true, syncConfig: body.sync_config || null, + createdById: auth.user.id, }, }) 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..d41be18 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,47 @@ +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); +} diff --git a/src/providers/index.tsx b/src/providers/index.tsx index b6594ed..1554cc8 100644 --- a/src/providers/index.tsx +++ b/src/providers/index.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' +import { AuthProvider } from '@/components/AuthProvider' export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -17,8 +18,10 @@ export function Providers({ children }: { children: React.ReactNode }) { ) return ( - - {children} - + + + {children} + + ) }