feat: add EncryptID auth with User model and route guards

User model added to Prisma. AuthProvider wraps existing Providers.
POST/PUT/DELETE routes require auth, GET routes remain open for demo access.
CalendarSource tracks createdById. 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:23 +00:00
parent 961235b7b9
commit 875b74c2fb
11 changed files with 127 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@ -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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<AuthProvider>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</AuthProvider>
)
}