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:
parent
c6a8d1f1f2
commit
2351339241
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
services:
|
||||
rnotes:
|
||||
build: .
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: rnotes-online/Dockerfile
|
||||
container_name: rnotes-online
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 — 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue