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