diff --git a/prisma/migrations/20260224_add_workspace_slug/migration.sql b/prisma/migrations/20260224_add_workspace_slug/migration.sql new file mode 100644 index 0000000..f76552a --- /dev/null +++ b/prisma/migrations/20260224_add_workspace_slug/migration.sql @@ -0,0 +1,5 @@ +-- Add workspaceSlug to Notebook for subdomain-based data isolation +ALTER TABLE "Notebook" ADD COLUMN "workspaceSlug" TEXT NOT NULL DEFAULT ''; + +-- Index for efficient workspace-scoped queries +CREATE INDEX "Notebook_workspaceSlug_idx" ON "Notebook"("workspaceSlug"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 417eac4..a10bcff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,23 +25,25 @@ model User { // ─── Notebooks ────────────────────────────────────────────────────── model Notebook { - id String @id @default(cuid()) - title String - slug String @unique - description String? @db.Text - coverColor String @default("#f59e0b") - canvasSlug String? - canvasShapeId String? - isPublic Boolean @default(false) - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + title String + slug String @unique + description String? @db.Text + coverColor String @default("#f59e0b") + canvasSlug String? + canvasShapeId String? + isPublic Boolean @default(false) + sortOrder Int @default(0) + workspaceSlug String @default("") // subdomain scope: "" = personal/unscoped + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt collaborators NotebookCollaborator[] notes Note[] sharedAccess SharedAccess[] @@index([slug]) + @@index([workspaceSlug]) } enum CollaboratorRole { diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..969ab85 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthUser } from '@/lib/auth'; +import { getWorkspaceSlug } from '@/lib/workspace'; + +export async function GET(request: NextRequest) { + try { + const auth = await getAuthUser(request); + const workspaceSlug = getWorkspaceSlug(); + + if (!auth) { + return NextResponse.json({ + authenticated: false, + workspace: workspaceSlug || null, + }); + } + + return NextResponse.json({ + authenticated: true, + user: { + id: auth.user.id, + username: auth.user.username, + did: auth.did, + }, + workspace: workspaceSlug || null, + }); + } catch { + return NextResponse.json({ authenticated: false, workspace: null }); + } +} diff --git a/src/app/api/notebooks/[id]/notes/route.ts b/src/app/api/notebooks/[id]/notes/route.ts index 627c918..dd08d09 100644 --- a/src/app/api/notebooks/[id]/notes/route.ts +++ b/src/app/api/notebooks/[id]/notes/route.ts @@ -3,12 +3,26 @@ import { prisma } from '@/lib/prisma'; import { stripHtml } from '@/lib/strip-html'; import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth'; import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert'; +import { getWorkspaceSlug } from '@/lib/workspace'; export async function GET( _request: NextRequest, { params }: { params: { id: string } } ) { try { + const workspaceSlug = getWorkspaceSlug(); + + // Verify notebook belongs to current workspace + if (workspaceSlug) { + const notebook = await prisma.notebook.findUnique({ + where: { id: params.id }, + select: { workspaceSlug: true }, + }); + if (!notebook || notebook.workspaceSlug !== workspaceSlug) { + return NextResponse.json({ error: 'Notebook not found' }, { status: 404 }); + } + } + const notes = await prisma.note.findMany({ where: { notebookId: params.id, archivedAt: null }, include: { diff --git a/src/app/api/notebooks/[id]/route.ts b/src/app/api/notebooks/[id]/route.ts index d4ffb10..458c64a 100644 --- a/src/app/api/notebooks/[id]/route.ts +++ b/src/app/api/notebooks/[id]/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth'; +import { getWorkspaceSlug } from '@/lib/workspace'; export async function GET( _request: NextRequest, { params }: { params: { id: string } } ) { try { + const workspaceSlug = getWorkspaceSlug(); + const notebook = await prisma.notebook.findUnique({ where: { id: params.id }, include: { @@ -14,6 +17,7 @@ export async function GET( include: { tags: { include: { tag: true } }, }, + where: { archivedAt: null }, orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }], }, collaborators: { @@ -27,6 +31,11 @@ export async function GET( return NextResponse.json({ error: 'Notebook not found' }, { status: 404 }); } + // Workspace boundary check: if on a subdomain, only show notebooks from that workspace + if (workspaceSlug && notebook.workspaceSlug !== workspaceSlug) { + return NextResponse.json({ error: 'Notebook not found' }, { status: 404 }); + } + return NextResponse.json(notebook); } catch (error) { console.error('Get notebook error:', error); diff --git a/src/app/api/notebooks/route.ts b/src/app/api/notebooks/route.ts index a015625..4087a84 100644 --- a/src/app/api/notebooks/route.ts +++ b/src/app/api/notebooks/route.ts @@ -3,10 +3,21 @@ import { prisma } from '@/lib/prisma'; import { generateSlug } from '@/lib/slug'; import { nanoid } from 'nanoid'; import { requireAuth, isAuthed } from '@/lib/auth'; +import { getWorkspaceSlug } from '@/lib/workspace'; export async function GET() { try { + const workspaceSlug = getWorkspaceSlug(); + + const where: Record = {}; + if (workspaceSlug) { + // On a subdomain: show only that workspace's notebooks + where.workspaceSlug = workspaceSlug; + } + // On bare domain: show all notebooks (cross-workspace view) + const notebooks = await prisma.notebook.findMany({ + where, include: { _count: { select: { notes: true } }, collaborators: { @@ -28,6 +39,7 @@ export async function POST(request: NextRequest) { const auth = await requireAuth(request); if (!isAuthed(auth)) return auth; const { user } = auth; + const workspaceSlug = getWorkspaceSlug(); const body = await request.json(); const { title, description, coverColor } = body; @@ -48,6 +60,7 @@ export async function POST(request: NextRequest) { slug: finalSlug, description: description?.trim() || null, coverColor: coverColor || '#f59e0b', + workspaceSlug: workspaceSlug || '', collaborators: { create: { userId: user.id, role: 'OWNER' }, }, diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 23be271..2f61b01 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -4,6 +4,7 @@ import { stripHtml } from '@/lib/strip-html'; import { NoteType } from '@prisma/client'; import { requireAuth, isAuthed } from '@/lib/auth'; import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert'; +import { getWorkspaceSlug } from '@/lib/workspace'; export async function GET(request: NextRequest) { try { @@ -13,6 +14,7 @@ export async function GET(request: NextRequest) { const cardType = searchParams.get('cardType'); const tag = searchParams.get('tag'); const pinned = searchParams.get('pinned'); + const workspaceSlug = getWorkspaceSlug(); const where: Record = { archivedAt: null, // exclude soft-deleted @@ -25,11 +27,19 @@ export async function GET(request: NextRequest) { where.tags = { some: { tag: { name: tag.toLowerCase() } } }; } + // Workspace boundary: filter notes by their notebook's workspace + if (workspaceSlug) { + where.notebook = { + ...(where.notebook as object || {}), + workspaceSlug, + }; + } + const notes = await prisma.note.findMany({ where, include: { tags: { include: { tag: true } }, - notebook: { select: { id: true, title: true, slug: true } }, + notebook: { select: { id: true, title: true, slug: true, workspaceSlug: true } }, parent: { select: { id: true, title: true } }, children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } }, attachments: { include: { file: true }, orderBy: { position: 'asc' } }, diff --git a/src/app/api/notes/search/route.ts b/src/app/api/notes/search/route.ts index 6416d5e..70d1582 100644 --- a/src/app/api/notes/search/route.ts +++ b/src/app/api/notes/search/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; +import { getWorkspaceSlug } from '@/lib/workspace'; export const dynamic = 'force-dynamic'; @@ -10,6 +11,7 @@ export async function GET(request: NextRequest) { const type = searchParams.get('type'); const cardType = searchParams.get('cardType'); const notebookId = searchParams.get('notebookId'); + const workspaceSlug = getWorkspaceSlug(); if (!q) { return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 }); @@ -32,6 +34,12 @@ export async function GET(request: NextRequest) { filters.push(`n."notebookId" = $${params.length}`); } + // Workspace boundary: only search within the current workspace's notebooks + if (workspaceSlug) { + params.push(workspaceSlug); + filters.push(`nb."workspaceSlug" = $${params.length}`); + } + const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : ''; // Full-text search — prefer bodyMarkdown over contentPlain diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 5dc2ac2..82451eb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Inter } from 'next/font/google' import './globals.css' import { AuthProvider } from '@/components/AuthProvider' import { PWAInstall } from '@/components/PWAInstall' +import { SubdomainSession } from '@/components/SubdomainSession' const inter = Inter({ subsets: ['latin'], @@ -46,6 +47,7 @@ export default function RootLayout({ + {children} diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 3696bad..adb22ed 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -65,12 +65,23 @@ const CATEGORY_ORDER = [ 'Social & Sharing', ]; +/** Build the URL for a module, using username subdomain if logged in */ +function getModuleUrl(m: AppModule, username: string | null): string { + if (!m.domain) return '#'; + if (username) { + // Generate . URL + return `https://${username}.${m.domain}`; + } + return `https://${m.domain}`; +} + interface AppSwitcherProps { current?: string; } export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { const [open, setOpen] = useState(false); + const [username, setUsername] = useState(null); const ref = useRef(null); useEffect(() => { @@ -83,6 +94,18 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { return () => document.removeEventListener('click', handleClick); }, []); + // Fetch current user's username for subdomain links + useEffect(() => { + fetch('/api/me') + .then((r) => r.json()) + .then((data) => { + if (data.authenticated && data.user?.username) { + setUsername(data.user.username); + } + }) + .catch(() => { /* not logged in */ }); + }, []); + const currentMod = MODULES.find((m) => m.id === current); // Group modules by category @@ -140,7 +163,7 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) { } transition-colors`} > setOpen(false)} > diff --git a/src/components/SubdomainSession.tsx b/src/components/SubdomainSession.tsx new file mode 100644 index 0000000..204edb8 --- /dev/null +++ b/src/components/SubdomainSession.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect } from 'react'; + +const TOKEN_KEY = 'encryptid_token'; +const USER_KEY = 'encryptid_user'; +const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days + +/** + * Cross-subdomain session sync. + * + * The EncryptID SDK stores tokens in localStorage (per-origin) and sets + * a cookie without a domain attribute (per-hostname). This means + * jeff.rnotes.online can't access rnotes.online's session. + * + * This component bridges that gap by: + * 1. Mirroring localStorage token to a cookie scoped to .rnotes.online + * 2. On subdomain load, restoring from the domain-wide cookie to localStorage + */ +function isRnotesDomain(): boolean { + if (typeof window === 'undefined') return false; + return window.location.hostname.endsWith('.rnotes.online') || + window.location.hostname === 'rnotes.online'; +} + +function getCookieDomain(): string { + return '.rnotes.online'; +} + +function getDomainCookie(name: string): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; +} + +function setDomainCookie(name: string, value: string): void { + document.cookie = `${name}=${encodeURIComponent(value)};path=/;domain=${getCookieDomain()};max-age=${COOKIE_MAX_AGE};SameSite=Lax;Secure`; +} + +function clearDomainCookie(name: string): void { + document.cookie = `${name}=;path=/;domain=${getCookieDomain()};max-age=0;SameSite=Lax;Secure`; +} + +export function SubdomainSession() { + useEffect(() => { + if (!isRnotesDomain()) return; + + // On mount: sync localStorage <-> domain cookie + const localToken = localStorage.getItem(TOKEN_KEY); + const cookieToken = getDomainCookie(TOKEN_KEY); + + if (localToken && !cookieToken) { + // Have localStorage token but no domain cookie — set it + setDomainCookie(TOKEN_KEY, localToken); + } else if (!localToken && cookieToken) { + // On a subdomain with no localStorage but domain cookie exists — restore + localStorage.setItem(TOKEN_KEY, cookieToken); + // Also restore user info from cookie if available + const cookieUser = getDomainCookie(USER_KEY); + if (cookieUser) { + localStorage.setItem(USER_KEY, cookieUser); + } + // Reload to let EncryptIDProvider pick up the restored token + window.location.reload(); + return; + } else if (localToken && cookieToken && localToken !== cookieToken) { + // Both exist but differ — localStorage wins (it's more recent) + setDomainCookie(TOKEN_KEY, localToken); + } + + // Watch for localStorage changes (from EncryptID SDK login/logout) + function handleStorage(e: StorageEvent) { + if (e.key === TOKEN_KEY) { + if (e.newValue) { + setDomainCookie(TOKEN_KEY, e.newValue); + } else { + clearDomainCookie(TOKEN_KEY); + clearDomainCookie(USER_KEY); + } + } + if (e.key === USER_KEY && e.newValue) { + setDomainCookie(USER_KEY, e.newValue); + } + } + + window.addEventListener('storage', handleStorage); + + // Also sync user data to domain cookie + const localUser = localStorage.getItem(USER_KEY); + if (localUser) { + setDomainCookie(USER_KEY, localUser); + } + + return () => window.removeEventListener('storage', handleStorage); + }, []); + + return null; // render nothing +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d6d17f3..c44e9c4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,11 +1,13 @@ import { getEncryptIDSession } from '@encryptid/sdk/server/nextjs'; import { NextResponse } from 'next/server'; import { prisma } from './prisma'; +import { getWorkspaceSlug } from './workspace'; import type { User } from '@prisma/client'; export interface AuthResult { user: User; did: string; + username: string | null; } const UNAUTHORIZED = NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); @@ -14,6 +16,7 @@ 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. + * Auto-migrates unscoped notebooks to user's workspace. */ export async function getAuthUser(request: Request): Promise { const claims = await getEncryptIDSession(request); @@ -55,7 +58,21 @@ export async function getAuthUser(request: Request): Promise }); } - return { user, did }; + // Auto-migrate: assign unscoped notebooks owned by this user to their workspace + if (user.username) { + const workspaceSlug = user.username.toLowerCase().replace(/[^a-z0-9-]/g, '-'); + await prisma.notebook.updateMany({ + where: { + workspaceSlug: '', + collaborators: { + some: { userId: user.id, role: 'OWNER' }, + }, + }, + data: { workspaceSlug }, + }); + } + + return { user, did, username: user.username }; } /** diff --git a/src/lib/authFetch.ts b/src/lib/authFetch.ts index 6554e50..ff2853e 100644 --- a/src/lib/authFetch.ts +++ b/src/lib/authFetch.ts @@ -1,15 +1,23 @@ const TOKEN_KEY = 'encryptid_token'; +function getCookieToken(): string | null { + if (typeof document === 'undefined') return null; + const match = document.cookie.match(/(?:^|;\s*)encryptid_token=([^;]*)/); + return match ? decodeURIComponent(match[1]) : null; +} + /** * Authenticated fetch wrapper. - * Reads JWT from localStorage and adds Authorization header. + * Reads JWT from localStorage (primary) or domain cookie (fallback). * 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 token = typeof window !== 'undefined' + ? (localStorage.getItem(TOKEN_KEY) || getCookieToken()) + : null; const headers = new Headers(options.headers); if (token) { diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts new file mode 100644 index 0000000..992e6d4 --- /dev/null +++ b/src/lib/workspace.ts @@ -0,0 +1,10 @@ +import { headers } from 'next/headers'; + +/** + * Get the current workspace slug from the request. + * Returns the subdomain (e.g. "jeff" from "jeff.rnotes.online") or "" for bare domain. + */ +export function getWorkspaceSlug(): string { + const h = headers(); + return h.get('x-workspace-slug') || ''; +} diff --git a/src/middleware.ts b/src/middleware.ts index fe6ea76..9ffe629 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,17 +9,33 @@ export function middleware(request: NextRequest) { if (match && !RESERVED_SUBDOMAINS.has(match[1])) { const space = match[1]; - const response = NextResponse.next(); + + // Clone headers and add workspace context for API routes + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-workspace-slug', space); + + const response = NextResponse.next({ + request: { headers: requestHeaders }, + }); + + // Set cookie for client-side access response.cookies.set('rnotes-space', space, { path: '/', httpOnly: false, sameSite: 'lax', secure: true, }); + return response; } - return NextResponse.next(); + // Bare domain: set empty workspace header + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-workspace-slug', ''); + + return NextResponse.next({ + request: { headers: requestHeaders }, + }); } export const config = {