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:
parent
961235b7b9
commit
875b74c2fb
20
Dockerfile
20
Dockerfile
|
|
@ -4,17 +4,27 @@ FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# 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
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN npm ci || npm install
|
||||||
|
|
||||||
# Copy prisma schema and generate client
|
# Ensure SDK is properly linked in node_modules
|
||||||
COPY prisma ./prisma/
|
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
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY rcal-online/ .
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
services:
|
services:
|
||||||
rcal:
|
rcal:
|
||||||
build: .
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: rcal-online/Dockerfile
|
||||||
container_name: rcal-online
|
container_name: rcal-online
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_URL=postgresql://rcal:${POSTGRES_PASSWORD}@rcal-postgres:5432/rcal
|
- 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:
|
depends_on:
|
||||||
rcal-postgres:
|
rcal-postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@encryptid/sdk": "file:../encryptid-sdk",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,18 @@ datasource db {
|
||||||
url = env("DATABASE_URL")
|
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 {
|
model Event {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sourceId String @map("source_id")
|
sourceId String @map("source_id")
|
||||||
|
|
@ -71,11 +83,15 @@ model CalendarSource {
|
||||||
syncError String @default("") @map("sync_error")
|
syncError String @default("") @map("sync_error")
|
||||||
syncConfig Json? @map("sync_config")
|
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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
events Event[]
|
events Event[]
|
||||||
|
|
||||||
|
@@index([createdById])
|
||||||
@@map("calendar_sources")
|
@@map("calendar_sources")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireAuth, isAuthed } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -30,6 +31,9 @@ export async function PUT(
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (!isAuthed(auth)) return auth
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
const event = await prisma.event.update({
|
const event = await prisma.event.update({
|
||||||
|
|
@ -57,10 +61,13 @@ export async function PUT(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (!isAuthed(auth)) return auth
|
||||||
|
|
||||||
await prisma.event.delete({ where: { id: params.id } })
|
await prisma.event.delete({ where: { id: params.id } })
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireAuth, isAuthed } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
|
|
@ -99,6 +100,9 @@ export async function GET(request: NextRequest) {
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (!isAuthed(auth)) return auth
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
const event = await prisma.event.create({
|
const event = await prisma.event.create({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireAuth, isAuthed } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -30,6 +31,9 @@ export async function PUT(
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (!isAuthed(auth)) return auth
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
const source = await prisma.calendarSource.update({
|
const source = await prisma.calendarSource.update({
|
||||||
|
|
@ -51,10 +55,13 @@ export async function PUT(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (!isAuthed(auth)) return auth
|
||||||
|
|
||||||
await prisma.calendarSource.delete({ where: { id: params.id } })
|
await prisma.calendarSource.delete({ where: { id: params.id } })
|
||||||
return new NextResponse(null, { status: 204 })
|
return new NextResponse(null, { status: 204 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { requireAuth, isAuthed } from '@/lib/auth'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -32,6 +33,9 @@ export async function GET() {
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const auth = await requireAuth(request)
|
||||||
|
if (!isAuthed(auth)) return auth
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
const source = await prisma.calendarSource.create({
|
const source = await prisma.calendarSource.create({
|
||||||
|
|
@ -42,6 +46,7 @@ export async function POST(request: NextRequest) {
|
||||||
isVisible: body.is_visible ?? true,
|
isVisible: body.is_visible ?? true,
|
||||||
isActive: body.is_active ?? true,
|
isActive: body.is_active ?? true,
|
||||||
syncConfig: body.sync_config || null,
|
syncConfig: body.sync_config || null,
|
||||||
|
createdById: auth.user.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,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);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { AuthProvider } from '@/components/AuthProvider'
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
|
|
@ -17,8 +18,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<AuthProvider>
|
||||||
{children}
|
<QueryClientProvider client={queryClient}>
|
||||||
</QueryClientProvider>
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue