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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 14:20:00 -07:00
parent c6a8d1f1f2
commit 2351339241
24 changed files with 514 additions and 16 deletions

View File

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

View File

@ -1,6 +1,8 @@
services:
rnotes:
build: .
build:
context: ..
dockerfile: rnotes-online/Dockerfile
container_name: rnotes-online
restart: unless-stopped
environment:

29
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a] flex flex-col">
<nav className="border-b border-slate-800 px-6 py-4">
<div className="max-w-6xl mx-auto flex items-center gap-3">
<Link href="/" className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-sm font-bold text-black">
rN
</div>
<span className="text-white font-semibold">rNotes</span>
</Link>
</div>
</nav>
<main className="flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl font-bold text-black mx-auto mb-4">
rN
</div>
<h1 className="text-2xl font-bold text-white">
{mode === 'signin' ? 'Sign in to rNotes' : 'Create Account'}
</h1>
<p className="text-slate-400 mt-2 text-sm">
{mode === 'signin'
? 'Use your passkey to sign in'
: 'Register with a passkey for passwordless auth'}
</p>
</div>
{/* Mode toggle */}
<div className="flex rounded-lg bg-slate-800/50 border border-slate-700 p-1 mb-6">
<button
onClick={() => { setMode('signin'); setError(''); }}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
mode === 'signin'
? 'bg-amber-500 text-black'
: 'text-slate-400 hover:text-white'
}`}
>
Sign In
</button>
<button
onClick={() => { setMode('register'); setError(''); }}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
mode === 'register'
? 'bg-amber-500 text-black'
: 'text-slate-400 hover:text-white'
}`}
>
Register
</button>
</div>
{/* Error display */}
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{mode === 'register' && (
<div className="mb-4">
<label className="block text-sm font-medium text-slate-300 mb-2">Username</label>
<input
type="text"
value={username}
onChange={(e) => 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()}
/>
</div>
)}
<button
onClick={mode === 'signin' ? handleSignIn : handleRegister}
disabled={busy || (mode === 'register' && !username.trim())}
className="w-full py-3 bg-amber-500 hover:bg-amber-400 disabled:bg-slate-700 disabled:text-slate-400 text-black font-semibold rounded-lg transition-colors flex items-center justify-center gap-2"
>
{busy ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{mode === 'signin' ? 'Signing in...' : 'Registering...'}
</>
) : (
<>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{mode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey'}
</>
)}
</button>
<p className="text-center text-xs text-slate-500 mt-6">
Powered by EncryptID &mdash; passwordless, decentralized identity
</p>
</div>
</main>
</div>
);
}
export default function SignInPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<svg className="animate-spin h-8 w-8 text-amber-400" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
}>
<SignInForm />
</Suspense>
);
}

View File

@ -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 (
<html lang="en">
<body className={`${inter.variable} font-sans antialiased`}>
{children}
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
)

View File

@ -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
</button>
<UserMenu />
</div>
</div>
</nav>

View File

@ -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() {
<Link href="/notebooks" className="text-slate-400 hover:text-white transition-colors">Notebooks</Link>
<span className="text-slate-600">/</span>
<span className="text-white">New</span>
<div className="ml-auto"><UserMenu /></div>
</div>
</nav>

View File

@ -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
</Link>
<UserMenu />
</div>
</div>
</nav>

View File

@ -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<string, string> = {
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
</button>
<UserMenu />
</div>
</div>
</nav>

View File

@ -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() {
</Link>
<span className="text-slate-600">/</span>
<span className="text-white">New Note</span>
<div className="ml-auto"><UserMenu /></div>
</div>
</nav>

View File

@ -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
</Link>
<UserMenu />
</div>
</div>
</nav>

View File

@ -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 (
<Provider serverUrl={process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL}>
{children}
</Provider>
);
}

View File

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

View File

@ -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 (
<div className="w-6 h-6 rounded-full bg-slate-700 animate-pulse" />
);
}
if (!isAuthenticated) {
return (
<Link
href="/auth/signin"
className="px-3 py-1.5 text-sm bg-amber-500 hover:bg-amber-400 text-black font-medium rounded-lg transition-colors"
>
Sign In
</Link>
);
}
const displayName = username || (did ? `${did.slice(0, 12)}...` : 'User');
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-xs font-bold text-black">
{(username || 'U')[0].toUpperCase()}
</div>
<span className="text-sm text-slate-300 hidden sm:inline">{displayName}</span>
</div>
<button
onClick={logout}
className="px-2 py-1 text-xs text-slate-500 hover:text-slate-300 border border-slate-700 hover:border-slate-600 rounded transition-colors"
>
Sign Out
</button>
</div>
);
}

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

@ -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<AuthResult | null> {
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<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);
}
/**
* 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;
}

27
src/lib/authFetch.ts Normal file
View File

@ -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<Response> {
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;
}