From 2351339241d0600b2cc4dbe71dae22adb1698886 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 14:20:00 -0700 Subject: [PATCH] feat: integrate EncryptID SDK for passkey authentication Wire up EncryptID SDK for user authentication with WebAuthn passkeys. All write API routes (POST/PUT/DELETE) now require auth, while reads remain public. First user auto-claims orphaned notebooks/notes. New files: - src/lib/auth.ts: getAuthUser, requireAuth, getNotebookRole helpers - src/lib/authFetch.ts: client-side fetch wrapper with JWT token - src/components/AuthProvider.tsx: EncryptIDProvider wrapper - src/components/UserMenu.tsx: sign in/out UI for nav bar - src/app/auth/signin/page.tsx: passkey login/register page Protected routes: notebooks CRUD, notes CRUD, canvas create, uploads. Ownership checks: notebook collaborator roles, note author verification. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 9 +- docker-compose.yml | 4 +- package-lock.json | 29 ++++ package.json | 1 + src/app/api/notebooks/[id]/canvas/route.ts | 11 +- src/app/api/notebooks/[id]/notes/route.ts | 10 ++ src/app/api/notebooks/[id]/route.ts | 19 ++- src/app/api/notebooks/route.ts | 7 + src/app/api/notes/[id]/route.ts | 34 +++- src/app/api/notes/route.ts | 5 + src/app/api/uploads/route.ts | 3 + src/app/auth/signin/page.tsx | 187 +++++++++++++++++++++ src/app/layout.tsx | 5 +- src/app/notebooks/[id]/page.tsx | 7 +- src/app/notebooks/new/page.tsx | 5 +- src/app/notebooks/page.tsx | 2 + src/app/notes/[id]/page.tsx | 9 +- src/app/notes/new/page.tsx | 5 +- src/app/page.tsx | 2 + src/components/AuthProvider.tsx | 14 ++ src/components/FileUpload.tsx | 3 +- src/components/UserMenu.tsx | 44 +++++ src/lib/auth.ts | 88 ++++++++++ src/lib/authFetch.ts | 27 +++ 24 files changed, 514 insertions(+), 16 deletions(-) create mode 100644 src/app/auth/signin/page.tsx create mode 100644 src/components/AuthProvider.tsx create mode 100644 src/components/UserMenu.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/authFetch.ts diff --git a/Dockerfile b/Dockerfile index 1a51686..637b2dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,15 +3,18 @@ FROM node:20-alpine AS base # Dependencies stage FROM base AS deps WORKDIR /app -COPY package.json package-lock.json* ./ -COPY prisma ./prisma/ +COPY rnotes-online/package.json rnotes-online/package-lock.json* ./ +COPY rnotes-online/prisma ./prisma/ +# Copy local SDK dependency +COPY encryptid-sdk ./encryptid-sdk/ RUN npm ci || npm install # Build stage FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules -COPY . . +COPY --from=deps /app/encryptid-sdk ./encryptid-sdk +COPY rnotes-online/ . RUN npx prisma generate RUN npm run build diff --git a/docker-compose.yml b/docker-compose.yml index f7e6873..a0cee7f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: rnotes: - build: . + build: + context: .. + dockerfile: rnotes-online/Dockerfile container_name: rnotes-online restart: unless-stopped environment: diff --git a/package-lock.json b/package-lock.json index 9402662..3251a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "rnotes-online", "version": "0.1.0", "dependencies": { + "@encryptid/sdk": "file:../encryptid-sdk", "@prisma/client": "^6.19.2", "@tiptap/extension-code-block-lowlight": "^3.19.0", "@tiptap/extension-image": "^3.19.0", @@ -38,6 +39,30 @@ "typescript": "^5" } }, + "../encryptid-sdk": { + "name": "@encryptid/sdk", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "hono": "^4.11.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "typescript": "^5.7.0" + }, + "peerDependencies": { + "next": ">=14.0.0", + "react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -51,6 +76,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@encryptid/sdk": { + "resolved": "../encryptid-sdk", + "link": true + }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", diff --git a/package.json b/package.json index 4d69a97..d094480 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@tiptap/pm": "^3.19.0", "@tiptap/react": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", + "@encryptid/sdk": "file:../encryptid-sdk", "dompurify": "^3.2.0", "lowlight": "^3.3.0", "marked": "^15.0.0", diff --git a/src/app/api/notebooks/[id]/canvas/route.ts b/src/app/api/notebooks/[id]/canvas/route.ts index 8559d85..33bcac2 100644 --- a/src/app/api/notebooks/[id]/canvas/route.ts +++ b/src/app/api/notebooks/[id]/canvas/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { pushShapesToCanvas } from '@/lib/canvas-sync'; +import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth'; /** * POST /api/notebooks/[id]/canvas @@ -9,10 +10,18 @@ import { pushShapesToCanvas } from '@/lib/canvas-sync'; * with initial shapes from the notebook's notes. */ export async function POST( - _request: NextRequest, + request: NextRequest, { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { user } = auth; + const role = await getNotebookRole(user.id, params.id); + if (!role || role === 'VIEWER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const notebook = await prisma.notebook.findUnique({ where: { id: params.id }, include: { diff --git a/src/app/api/notebooks/[id]/notes/route.ts b/src/app/api/notebooks/[id]/notes/route.ts index 0867a5c..f3206b6 100644 --- a/src/app/api/notebooks/[id]/notes/route.ts +++ b/src/app/api/notebooks/[id]/notes/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { stripHtml } from '@/lib/strip-html'; +import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth'; export async function GET( _request: NextRequest, @@ -27,6 +28,14 @@ export async function POST( { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { user } = auth; + const role = await getNotebookRole(user.id, params.id); + if (!role || role === 'VIEWER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const body = await request.json(); const { title, content, type, url, language, tags, fileUrl, mimeType, fileSize } = body; @@ -54,6 +63,7 @@ export async function POST( const note = await prisma.note.create({ data: { notebookId: params.id, + authorId: user.id, title: title.trim(), content: content || '', contentPlain, diff --git a/src/app/api/notebooks/[id]/route.ts b/src/app/api/notebooks/[id]/route.ts index b4fbe0c..d4ffb10 100644 --- a/src/app/api/notebooks/[id]/route.ts +++ b/src/app/api/notebooks/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth'; export async function GET( _request: NextRequest, @@ -38,6 +39,14 @@ export async function PUT( { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { user } = auth; + const role = await getNotebookRole(user.id, params.id); + if (!role || role === 'VIEWER') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const body = await request.json(); const { title, description, coverColor, isPublic } = body; @@ -59,10 +68,18 @@ 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; + const { user } = auth; + const role = await getNotebookRole(user.id, params.id); + if (role !== 'OWNER') { + return NextResponse.json({ error: 'Only the owner can delete a notebook' }, { status: 403 }); + } + await prisma.notebook.delete({ where: { id: params.id } }); return NextResponse.json({ ok: true }); } catch (error) { diff --git a/src/app/api/notebooks/route.ts b/src/app/api/notebooks/route.ts index d73a7a0..a015625 100644 --- a/src/app/api/notebooks/route.ts +++ b/src/app/api/notebooks/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { generateSlug } from '@/lib/slug'; import { nanoid } from 'nanoid'; +import { requireAuth, isAuthed } from '@/lib/auth'; export async function GET() { try { @@ -24,6 +25,9 @@ export async function GET() { export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { user } = auth; const body = await request.json(); const { title, description, coverColor } = body; @@ -44,6 +48,9 @@ export async function POST(request: NextRequest) { slug: finalSlug, description: description?.trim() || null, coverColor: coverColor || '#f59e0b', + collaborators: { + create: { userId: user.id, role: 'OWNER' }, + }, }, }); diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts index 0775b90..9c9617d 100644 --- a/src/app/api/notes/[id]/route.ts +++ b/src/app/api/notes/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { stripHtml } from '@/lib/strip-html'; +import { requireAuth, isAuthed } from '@/lib/auth'; export async function GET( _request: NextRequest, @@ -32,6 +33,22 @@ export async function PUT( { params }: { params: { id: string } } ) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; + const { user } = auth; + + // Verify the user is the author + const existing = await prisma.note.findUnique({ + where: { id: params.id }, + select: { authorId: true }, + }); + if (!existing) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }); + } + if (existing.authorId && existing.authorId !== user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + const body = await request.json(); const { title, content, type, url, language, isPinned, notebookId, tags } = body; @@ -84,10 +101,25 @@ 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; + const { user } = auth; + + const existing = await prisma.note.findUnique({ + where: { id: params.id }, + select: { authorId: true }, + }); + if (!existing) { + return NextResponse.json({ error: 'Note not found' }, { status: 404 }); + } + if (existing.authorId && existing.authorId !== user.id) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + await prisma.note.delete({ where: { id: params.id } }); return NextResponse.json({ ok: true }); } catch (error) { diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index af2b5ee..27daa51 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { stripHtml } from '@/lib/strip-html'; import { NoteType } from '@prisma/client'; +import { requireAuth, isAuthed } from '@/lib/auth'; export async function GET(request: NextRequest) { try { @@ -38,6 +39,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 { user } = auth; const body = await request.json(); const { title, content, type, notebookId, url, language, tags, fileUrl, mimeType, fileSize } = body; @@ -69,6 +73,7 @@ export async function POST(request: NextRequest) { contentPlain, type: type || 'NOTE', notebookId: notebookId || null, + authorId: user.id, url: url || null, language: language || null, fileUrl: fileUrl || null, diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index 79f4059..defb67d 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -3,6 +3,7 @@ import { writeFile, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; import { nanoid } from 'nanoid'; +import { requireAuth, isAuthed } from '@/lib/auth'; const UPLOAD_DIR = process.env.UPLOAD_DIR || '/app/uploads'; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB @@ -26,6 +27,8 @@ function sanitizeFilename(name: string): string { export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (!isAuthed(auth)) return auth; const formData = await request.formData(); const file = formData.get('file') as File | null; diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx new file mode 100644 index 0000000..435c8d6 --- /dev/null +++ b/src/app/auth/signin/page.tsx @@ -0,0 +1,187 @@ +'use client'; + +import { useState, useEffect, Suspense } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { useEncryptID } from '@encryptid/sdk/ui/react'; + +function SignInForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const returnUrl = searchParams.get('returnUrl') || '/'; + const { isAuthenticated, loading: authLoading, login, register } = useEncryptID(); + + const [mode, setMode] = useState<'signin' | 'register'>('signin'); + const [username, setUsername] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated && !authLoading) { + router.push(returnUrl); + } + }, [isAuthenticated, authLoading, router, returnUrl]); + + const handleSignIn = async () => { + setError(''); + setBusy(true); + try { + await login(); + router.push(returnUrl); + } catch (err) { + setError(err instanceof Error ? err.message : 'Sign in failed. Make sure you have a registered passkey.'); + } finally { + setBusy(false); + } + }; + + const handleRegister = async () => { + if (!username.trim()) { + setError('Username is required'); + return; + } + setError(''); + setBusy(true); + try { + await register(username.trim()); + router.push(returnUrl); + } catch (err) { + setError(err instanceof Error ? err.message : 'Registration failed.'); + } finally { + setBusy(false); + } + }; + + if (authLoading) { + return ( +
+ + + + +
+ ); + } + + return ( +
+ + +
+
+
+
+ rN +
+

+ {mode === 'signin' ? 'Sign in to rNotes' : 'Create Account'} +

+

+ {mode === 'signin' + ? 'Use your passkey to sign in' + : 'Register with a passkey for passwordless auth'} +

+
+ + {/* Mode toggle */} +
+ + +
+ + {/* Error display */} + {error && ( +
+ {error} +
+ )} + + {mode === 'register' && ( +
+ + setUsername(e.target.value)} + placeholder="Choose a username" + className="w-full px-4 py-3 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/50" + autoFocus + onKeyDown={(e) => e.key === 'Enter' && handleRegister()} + /> +
+ )} + + + +

+ Powered by EncryptID — passwordless, decentralized identity +

+
+
+
+ ); +} + +export default function SignInPage() { + return ( + + + + + + + }> + + + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f0eacaa..ecb2bdb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' import './globals.css' +import { AuthProvider } from '@/components/AuthProvider' const inter = Inter({ subsets: ['latin'], @@ -26,7 +27,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ) diff --git a/src/app/notebooks/[id]/page.tsx b/src/app/notebooks/[id]/page.tsx index ad1506c..1e8198b 100644 --- a/src/app/notebooks/[id]/page.tsx +++ b/src/app/notebooks/[id]/page.tsx @@ -5,6 +5,8 @@ import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { NoteCard } from '@/components/NoteCard'; import { CanvasEmbed } from '@/components/CanvasEmbed'; +import { UserMenu } from '@/components/UserMenu'; +import { authFetch } from '@/lib/authFetch'; interface NoteData { id: string; @@ -53,7 +55,7 @@ export default function NotebookDetailPage() { if (creatingCanvas) return; setCreatingCanvas(true); try { - const res = await fetch(`/api/notebooks/${params.id}/canvas`, { method: 'POST' }); + const res = await authFetch(`/api/notebooks/${params.id}/canvas`, { method: 'POST' }); if (res.ok) { fetchNotebook(); setShowCanvas(true); @@ -67,7 +69,7 @@ export default function NotebookDetailPage() { const handleDelete = async () => { if (!confirm('Delete this notebook and all its notes?')) return; - await fetch(`/api/notebooks/${params.id}`, { method: 'DELETE' }); + await authFetch(`/api/notebooks/${params.id}`, { method: 'DELETE' }); router.push('/notebooks'); }; @@ -142,6 +144,7 @@ export default function NotebookDetailPage() { > Delete + diff --git a/src/app/notebooks/new/page.tsx b/src/app/notebooks/new/page.tsx index 88dfe95..801f468 100644 --- a/src/app/notebooks/new/page.tsx +++ b/src/app/notebooks/new/page.tsx @@ -3,6 +3,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { UserMenu } from '@/components/UserMenu'; +import { authFetch } from '@/lib/authFetch'; const COVER_COLORS = [ '#f59e0b', '#ef4444', '#8b5cf6', '#3b82f6', @@ -22,7 +24,7 @@ export default function NewNotebookPage() { setSaving(true); try { - const res = await fetch('/api/notebooks', { + const res = await authFetch('/api/notebooks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, coverColor }), @@ -51,6 +53,7 @@ export default function NewNotebookPage() { Notebooks / New +
diff --git a/src/app/notebooks/page.tsx b/src/app/notebooks/page.tsx index ca48ce7..165192b 100644 --- a/src/app/notebooks/page.tsx +++ b/src/app/notebooks/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { NotebookCard } from '@/components/NotebookCard'; import { SearchBar } from '@/components/SearchBar'; +import { UserMenu } from '@/components/UserMenu'; interface NotebookData { id: string; @@ -50,6 +51,7 @@ export default function NotebooksPage() { > New Notebook + diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 1c41a55..6016ff3 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -5,6 +5,8 @@ import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; import { NoteEditor } from '@/components/NoteEditor'; import { TagBadge } from '@/components/TagBadge'; +import { UserMenu } from '@/components/UserMenu'; +import { authFetch } from '@/lib/authFetch'; const TYPE_COLORS: Record = { NOTE: 'bg-amber-500/20 text-amber-400', @@ -60,7 +62,7 @@ export default function NoteDetailPage() { if (saving) return; setSaving(true); try { - const res = await fetch(`/api/notes/${params.id}`, { + const res = await authFetch(`/api/notes/${params.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: editTitle, content: editContent }), @@ -79,7 +81,7 @@ export default function NoteDetailPage() { const handleTogglePin = async () => { if (!note) return; - const res = await fetch(`/api/notes/${params.id}`, { + const res = await authFetch(`/api/notes/${params.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isPinned: !note.isPinned }), @@ -92,7 +94,7 @@ export default function NoteDetailPage() { const handleDelete = async () => { if (!confirm('Delete this note?')) return; - await fetch(`/api/notes/${params.id}`, { method: 'DELETE' }); + await authFetch(`/api/notes/${params.id}`, { method: 'DELETE' }); if (note?.notebook) { router.push(`/notebooks/${note.notebook.id}`); } else { @@ -185,6 +187,7 @@ export default function NoteDetailPage() { > Delete + diff --git a/src/app/notes/new/page.tsx b/src/app/notes/new/page.tsx index 960729b..9fe0ba2 100644 --- a/src/app/notes/new/page.tsx +++ b/src/app/notes/new/page.tsx @@ -5,6 +5,8 @@ import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { NoteEditor } from '@/components/NoteEditor'; import { FileUpload } from '@/components/FileUpload'; +import { UserMenu } from '@/components/UserMenu'; +import { authFetch } from '@/lib/authFetch'; const NOTE_TYPES = [ { value: 'NOTE', label: 'Note', desc: 'Rich text note' }, @@ -83,7 +85,7 @@ function NewNoteForm() { ? `/api/notebooks/${notebookId}/notes` : '/api/notes'; - const res = await fetch(endpoint, { + const res = await authFetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), @@ -115,6 +117,7 @@ function NewNoteForm() { / New Note +
diff --git a/src/app/page.tsx b/src/app/page.tsx index 7d9b607..4889007 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import Link from 'next/link'; import { NotebookCard } from '@/components/NotebookCard'; import { SearchBar } from '@/components/SearchBar'; +import { UserMenu } from '@/components/UserMenu'; interface NotebookData { id: string; @@ -53,6 +54,7 @@ export default function HomePage() { > New Note + diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..cdcf876 --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -0,0 +1,14 @@ +'use client'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +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/components/FileUpload.tsx b/src/components/FileUpload.tsx index 0c7be86..f38d160 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useRef, useCallback } from 'react'; +import { authFetch } from '@/lib/authFetch'; interface UploadResult { url: string; @@ -36,7 +37,7 @@ export function FileUpload({ onUpload, accept, maxSize = 50 * 1024 * 1024, class const formData = new FormData(); formData.append('file', file); - const res = await fetch('/api/uploads', { + const res = await authFetch('/api/uploads', { method: 'POST', body: formData, }); diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..11aa6b3 --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useEncryptID } from '@encryptid/sdk/ui/react'; +import Link from 'next/link'; + +export function UserMenu() { + const { isAuthenticated, username, did, loading, logout } = useEncryptID(); + + if (loading) { + return ( +
+ ); + } + + if (!isAuthenticated) { + return ( + + Sign In + + ); + } + + const displayName = username || (did ? `${did.slice(0, 12)}...` : 'User'); + + return ( +
+
+
+ {(username || 'U')[0].toUpperCase()} +
+ {displayName} +
+ +
+ ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d6d17f3 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,88 @@ +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). + * On first user creation, auto-claims orphaned notebooks/notes. + */ +export async function getAuthUser(request: Request): Promise { + const claims = await getEncryptIDSession(request); + if (!claims) return null; + + const did = claims.did || claims.sub; + if (!did) return null; + + // Upsert user by DID + const user = await prisma.user.upsert({ + where: { did }, + update: { username: claims.username || undefined }, + create: { did, username: claims.username || null }, + }); + + // First-user auto-claim: if this is the only user, claim orphaned resources + const userCount = await prisma.user.count(); + if (userCount === 1) { + // Claim notebooks with no collaborators + const orphanedNotebooks = await prisma.notebook.findMany({ + where: { collaborators: { none: {} } }, + select: { id: true }, + }); + if (orphanedNotebooks.length > 0) { + await prisma.notebookCollaborator.createMany({ + data: orphanedNotebooks.map((nb) => ({ + userId: user.id, + notebookId: nb.id, + role: 'OWNER' as const, + })), + skipDuplicates: true, + }); + } + + // Claim notes with no author + await prisma.note.updateMany({ + where: { authorId: null }, + data: { authorId: user.id }, + }); + } + + return { user, did }; +} + +/** + * Require authentication. Returns auth result or a 401 NextResponse. + * Callers should check: `if (auth instanceof NextResponse) return auth;` + */ +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); +} + +/** + * Check if user has a role on a notebook (OWNER, EDITOR, or VIEWER). + * Returns the role or null if no access. + */ +export async function getNotebookRole( + userId: string, + notebookId: string +): Promise<'OWNER' | 'EDITOR' | 'VIEWER' | null> { + const collab = await prisma.notebookCollaborator.findUnique({ + where: { userId_notebookId: { userId, notebookId } }, + }); + return collab?.role ?? null; +} diff --git a/src/lib/authFetch.ts b/src/lib/authFetch.ts new file mode 100644 index 0000000..6554e50 --- /dev/null +++ b/src/lib/authFetch.ts @@ -0,0 +1,27 @@ +const TOKEN_KEY = 'encryptid_token'; + +/** + * Authenticated fetch wrapper. + * Reads JWT from localStorage and adds Authorization header. + * On 401, redirects to signin page. + */ +export async function authFetch( + url: string, + options: RequestInit = {} +): Promise { + const token = typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null; + + const headers = new Headers(options.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + const res = await fetch(url, { ...options, headers }); + + if (res.status === 401 && typeof window !== 'undefined') { + const returnUrl = encodeURIComponent(window.location.pathname); + window.location.href = `/auth/signin?returnUrl=${returnUrl}`; + } + + return res; +}