feat: add username-based personal subdomains (<username>.rnotes.online)
Implements workspace-scoped data isolation via subdomain routing: - Schema: add workspaceSlug to Notebook model + migration - Middleware: extract subdomain → x-workspace-slug header - API: filter notebooks/notes/search by workspace on subdomains - AppSwitcher: generate <username>.r*.online links when logged in - Sessions: SubdomainSession component syncs auth across subdomains via .rnotes.online domain-wide cookie - Auth: auto-migrate unscoped notebooks to user's workspace - New /api/me endpoint for client-side auth + workspace state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4eb24038b6
commit
c27290ee80
|
|
@ -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");
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {};
|
||||
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' },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {
|
||||
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' } },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<AuthProvider>
|
||||
<SubdomainSession />
|
||||
{children}
|
||||
<PWAInstall />
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -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 <username>.<domain> 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<string | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(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`}
|
||||
>
|
||||
<a
|
||||
href={m.domain ? `https://${m.domain}` : '#'}
|
||||
href={getModuleUrl(m, username)}
|
||||
className="flex items-center gap-2.5 flex-1 px-3.5 py-2 text-slate-200 no-underline min-w-0"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<AuthResult | null> {
|
||||
const claims = await getEncryptIDSession(request);
|
||||
|
|
@ -55,7 +58,21 @@ export async function getAuthUser(request: Request): Promise<AuthResult | null>
|
|||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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') || '';
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue