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");
|
||||||
|
|
@ -34,6 +34,7 @@ model Notebook {
|
||||||
canvasShapeId String?
|
canvasShapeId String?
|
||||||
isPublic Boolean @default(false)
|
isPublic Boolean @default(false)
|
||||||
sortOrder Int @default(0)
|
sortOrder Int @default(0)
|
||||||
|
workspaceSlug String @default("") // subdomain scope: "" = personal/unscoped
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ model Notebook {
|
||||||
sharedAccess SharedAccess[]
|
sharedAccess SharedAccess[]
|
||||||
|
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
|
@@index([workspaceSlug])
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CollaboratorRole {
|
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 { stripHtml } from '@/lib/strip-html';
|
||||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||||
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||||
|
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
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({
|
const notes = await prisma.note.findMany({
|
||||||
where: { notebookId: params.id, archivedAt: null },
|
where: { notebookId: params.id, archivedAt: null },
|
||||||
include: {
|
include: {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
import { requireAuth, isAuthed, getNotebookRole } from '@/lib/auth';
|
||||||
|
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const workspaceSlug = getWorkspaceSlug();
|
||||||
|
|
||||||
const notebook = await prisma.notebook.findUnique({
|
const notebook = await prisma.notebook.findUnique({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -14,6 +17,7 @@ export async function GET(
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } },
|
tags: { include: { tag: true } },
|
||||||
},
|
},
|
||||||
|
where: { archivedAt: null },
|
||||||
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
orderBy: [{ isPinned: 'desc' }, { sortOrder: 'asc' }, { updatedAt: 'desc' }],
|
||||||
},
|
},
|
||||||
collaborators: {
|
collaborators: {
|
||||||
|
|
@ -27,6 +31,11 @@ export async function GET(
|
||||||
return NextResponse.json({ error: 'Notebook not found' }, { status: 404 });
|
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);
|
return NextResponse.json(notebook);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get notebook error:', error);
|
console.error('Get notebook error:', error);
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,21 @@ import { prisma } from '@/lib/prisma';
|
||||||
import { generateSlug } from '@/lib/slug';
|
import { generateSlug } from '@/lib/slug';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||||
|
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
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({
|
const notebooks = await prisma.notebook.findMany({
|
||||||
|
where,
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { notes: true } },
|
_count: { select: { notes: true } },
|
||||||
collaborators: {
|
collaborators: {
|
||||||
|
|
@ -28,6 +39,7 @@ export async function POST(request: NextRequest) {
|
||||||
const auth = await requireAuth(request);
|
const auth = await requireAuth(request);
|
||||||
if (!isAuthed(auth)) return auth;
|
if (!isAuthed(auth)) return auth;
|
||||||
const { user } = auth;
|
const { user } = auth;
|
||||||
|
const workspaceSlug = getWorkspaceSlug();
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { title, description, coverColor } = body;
|
const { title, description, coverColor } = body;
|
||||||
|
|
||||||
|
|
@ -48,6 +60,7 @@ export async function POST(request: NextRequest) {
|
||||||
slug: finalSlug,
|
slug: finalSlug,
|
||||||
description: description?.trim() || null,
|
description: description?.trim() || null,
|
||||||
coverColor: coverColor || '#f59e0b',
|
coverColor: coverColor || '#f59e0b',
|
||||||
|
workspaceSlug: workspaceSlug || '',
|
||||||
collaborators: {
|
collaborators: {
|
||||||
create: { userId: user.id, role: 'OWNER' },
|
create: { userId: user.id, role: 'OWNER' },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { stripHtml } from '@/lib/strip-html';
|
||||||
import { NoteType } from '@prisma/client';
|
import { NoteType } from '@prisma/client';
|
||||||
import { requireAuth, isAuthed } from '@/lib/auth';
|
import { requireAuth, isAuthed } from '@/lib/auth';
|
||||||
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
import { htmlToTipTapJson, tipTapJsonToMarkdown, mapNoteTypeToCardType } from '@/lib/content-convert';
|
||||||
|
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -13,6 +14,7 @@ export async function GET(request: NextRequest) {
|
||||||
const cardType = searchParams.get('cardType');
|
const cardType = searchParams.get('cardType');
|
||||||
const tag = searchParams.get('tag');
|
const tag = searchParams.get('tag');
|
||||||
const pinned = searchParams.get('pinned');
|
const pinned = searchParams.get('pinned');
|
||||||
|
const workspaceSlug = getWorkspaceSlug();
|
||||||
|
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
archivedAt: null, // exclude soft-deleted
|
archivedAt: null, // exclude soft-deleted
|
||||||
|
|
@ -25,11 +27,19 @@ export async function GET(request: NextRequest) {
|
||||||
where.tags = { some: { tag: { name: tag.toLowerCase() } } };
|
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({
|
const notes = await prisma.note.findMany({
|
||||||
where,
|
where,
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } },
|
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 } },
|
parent: { select: { id: true, title: true } },
|
||||||
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
children: { select: { id: true, title: true, cardType: true }, where: { archivedAt: null } },
|
||||||
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
attachments: { include: { file: true }, orderBy: { position: 'asc' } },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { getWorkspaceSlug } from '@/lib/workspace';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ export async function GET(request: NextRequest) {
|
||||||
const type = searchParams.get('type');
|
const type = searchParams.get('type');
|
||||||
const cardType = searchParams.get('cardType');
|
const cardType = searchParams.get('cardType');
|
||||||
const notebookId = searchParams.get('notebookId');
|
const notebookId = searchParams.get('notebookId');
|
||||||
|
const workspaceSlug = getWorkspaceSlug();
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
return NextResponse.json({ error: 'Query parameter q is required' }, { status: 400 });
|
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}`);
|
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 ')}` : '';
|
const whereClause = filters.length > 0 ? `AND ${filters.join(' AND ')}` : '';
|
||||||
|
|
||||||
// Full-text search — prefer bodyMarkdown over contentPlain
|
// Full-text search — prefer bodyMarkdown over contentPlain
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Inter } from 'next/font/google'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
import { AuthProvider } from '@/components/AuthProvider'
|
import { AuthProvider } from '@/components/AuthProvider'
|
||||||
import { PWAInstall } from '@/components/PWAInstall'
|
import { PWAInstall } from '@/components/PWAInstall'
|
||||||
|
import { SubdomainSession } from '@/components/SubdomainSession'
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ['latin'],
|
subsets: ['latin'],
|
||||||
|
|
@ -46,6 +47,7 @@ export default function RootLayout({
|
||||||
</head>
|
</head>
|
||||||
<body className={`${inter.variable} font-sans antialiased`}>
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<SubdomainSession />
|
||||||
{children}
|
{children}
|
||||||
<PWAInstall />
|
<PWAInstall />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,23 @@ const CATEGORY_ORDER = [
|
||||||
'Social & Sharing',
|
'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 {
|
interface AppSwitcherProps {
|
||||||
current?: string;
|
current?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
|
export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [username, setUsername] = useState<string | null>(null);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -83,6 +94,18 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
|
||||||
return () => document.removeEventListener('click', handleClick);
|
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);
|
const currentMod = MODULES.find((m) => m.id === current);
|
||||||
|
|
||||||
// Group modules by category
|
// Group modules by category
|
||||||
|
|
@ -140,7 +163,7 @@ export function AppSwitcher({ current = 'notes' }: AppSwitcherProps) {
|
||||||
} transition-colors`}
|
} transition-colors`}
|
||||||
>
|
>
|
||||||
<a
|
<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"
|
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)}
|
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 { getEncryptIDSession } from '@encryptid/sdk/server/nextjs';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { prisma } from './prisma';
|
import { prisma } from './prisma';
|
||||||
|
import { getWorkspaceSlug } from './workspace';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
user: User;
|
user: User;
|
||||||
did: string;
|
did: string;
|
||||||
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UNAUTHORIZED = NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
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.
|
* Get authenticated user from request, or null if not authenticated.
|
||||||
* Upserts User in DB by DID (find-or-create).
|
* Upserts User in DB by DID (find-or-create).
|
||||||
* On first user creation, auto-claims orphaned notebooks/notes.
|
* 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> {
|
export async function getAuthUser(request: Request): Promise<AuthResult | null> {
|
||||||
const claims = await getEncryptIDSession(request);
|
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';
|
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.
|
* 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.
|
* On 401, redirects to signin page.
|
||||||
*/
|
*/
|
||||||
export async function authFetch(
|
export async function authFetch(
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {}
|
||||||
): Promise<Response> {
|
): 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);
|
const headers = new Headers(options.headers);
|
||||||
if (token) {
|
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])) {
|
if (match && !RESERVED_SUBDOMAINS.has(match[1])) {
|
||||||
const space = 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, {
|
response.cookies.set('rnotes-space', space, {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
secure: true,
|
secure: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
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 = {
|
export const config = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue