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
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue