Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/app/editor/EditorWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';

import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { sanitizeHtml } from '@/utils/sanitize';

const RichContentEditor = dynamic(
() => import('@/components/editor/RichContentEditor').then((mod) => mod.RichContentEditor),
{
loading: () => (
<div className="h-[500px] w-full animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800" />
),
ssr: false,
},
);

export function EditorWorkspace() {
const [content, setContent] = useState('<p>Start editing...</p>');
const [isPreviewMode, setIsPreviewMode] = useState(false);

return (
<div className="container mx-auto max-w-6xl p-8">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
Advanced Content Editor Demo
</h1>
<button
onClick={() => setIsPreviewMode((prev) => !prev)}
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
isPreviewMode
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600'
}`}
>
{isPreviewMode ? 'Back to Editor' : 'Preview'}
</button>
</div>

{isPreviewMode ? (
<div className="prose prose-lg max-w-none min-h-[calc(100vh-200px)] rounded-xl border border-gray-200 bg-white p-8 shadow-sm dark:prose-invert dark:border-gray-700 dark:bg-gray-800">
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />
</div>
) : (
<div className="mb-8">
<RichContentEditor initialContent={content} onUpdate={setContent} />
</div>
)}
</div>
);
}
103 changes: 60 additions & 43 deletions src/app/editor/page.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,67 @@
'use client';
import type { Metadata } from 'next';
import Link from 'next/link';
import { cookies } from 'next/headers';
import { PrivilegedContainer } from '@/components/shared/PrivilegedContainer';
import { UserRole } from '@/types/api';
import { EDITOR_MIN_ROLE } from '@/lib/auth/editorAccess';
import { EditorWorkspace } from './EditorWorkspace';

import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { sanitizeHtml } from '@/utils/sanitize';

const RichContentEditor = dynamic(
() => import('@/components/editor/RichContentEditor').then((mod) => mod.RichContentEditor),
{
loading: () => (
<div className="h-[500px] w-full bg-gray-100 dark:bg-gray-800 animate-pulse rounded-xl" />
),
ssr: false,
},
);

export default function EditorPage() {
const [content, setContent] = useState('<p>Start editing...</p>');
const [isPreviewMode, setIsPreviewMode] = useState(false);
export const metadata: Metadata = {
title: 'Post Editor | TeachLink',
description: 'Create and edit privileged post content with a secure editor workspace.',
};

function fallback() {
return (
<div className="container mx-auto p-8 max-w-6xl">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-800 dark:text-white">
Advanced Content Editor Demo
<main className="mx-auto flex min-h-[60vh] max-w-3xl items-center justify-center px-6 py-16">
<section className="w-full rounded-2xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-blue-600 dark:text-blue-400">
Privileged container
</p>
<h1 className="mt-3 text-3xl font-bold text-gray-900 dark:text-white">
Post editor access is restricted.
</h1>
<button
onClick={() => setIsPreviewMode((prev) => !prev)}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
isPreviewMode
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
{isPreviewMode ? 'Back to Editor' : 'Preview'}
</button>
</div>

{isPreviewMode ? (
<div className="prose dark:prose-invert max-w-none bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 min-h-[calc(100vh-200px)]">
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(content) }} />
<p className="mt-4 text-base text-gray-600 dark:text-gray-300">
Only instructors and admins can open the editor workspace.
</p>
<div className="mt-8 flex items-center justify-center gap-3">
<Link
href="/login"
className="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Sign in
</Link>
<Link
href="/dashboard"
className="rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
>
Dashboard
</Link>
</div>
) : (
<div className="mb-8">
<RichContentEditor initialContent={content} onUpdate={setContent} />
</div>
)}
</div>
</section>
</main>
);
}

function RestrictedEditorFallback() {
return fallback();
}

export default async function EditorPage() {
const cookieStore = await cookies();
const roleCookie = cookieStore.get('user-role')?.value;
const userRole = Object.values(UserRole).includes(roleCookie as UserRole)
? (roleCookie as UserRole)
: null;
Comment on lines +50 to +55

return (
<PrivilegedContainer
userRole={userRole}
requiredRole={EDITOR_MIN_ROLE}
fallback={<RestrictedEditorFallback />}
className="min-h-screen"
>
<EditorWorkspace />
</PrivilegedContainer>
);
}
40 changes: 40 additions & 0 deletions src/app/unauthorized/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import Link from 'next/link';

export const metadata: Metadata = {
title: 'Unauthorized | TeachLink',
description: 'You do not have permission to access this page.',
};

export default function UnauthorizedPage() {
return (
<main className="mx-auto flex min-h-[60vh] max-w-3xl items-center justify-center px-6 py-16">
<section className="w-full rounded-2xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-blue-600 dark:text-blue-400">
Access restricted
</p>
<h1 className="mt-3 text-3xl font-bold text-gray-900 dark:text-white">
You need elevated access to open this area.
</h1>
<p className="mt-4 text-base text-gray-600 dark:text-gray-300">
The post editor is reserved for instructors and admins so publishing tools stay in the
right hands.
</p>
<div className="mt-8 flex items-center justify-center gap-3">
<Link
href="/login"
className="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Go to login
</Link>
<Link
href="/dashboard"
className="rounded-lg border border-gray-300 px-4 py-2 font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
>
Back to dashboard
</Link>
</div>
</section>
</main>
);
}
23 changes: 23 additions & 0 deletions src/components/shared/PrivilegedContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { UserRole } from '@/types/api';
import { isAtLeastRole } from '@/lib/auth/acl';

interface PrivilegedContainerProps {
userRole: UserRole | null | undefined;
requiredRole: UserRole;
children: React.ReactNode;
fallback: React.ReactNode;
className?: string;
}

export function PrivilegedContainer({
userRole,
requiredRole,
children,
fallback,
className,
}: PrivilegedContainerProps) {
const isAllowed = isAtLeastRole(userRole, requiredRole);

return <div className={className}>{isAllowed ? children : fallback}</div>;
}
17 changes: 17 additions & 0 deletions src/lib/auth/__tests__/editorAccess.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { canAccessPostEditor, EDITOR_MIN_ROLE } from '../editorAccess';
import { UserRole } from '@/types/api';

describe('editor access', () => {
it('requires instructor-level access or higher', () => {
expect(canAccessPostEditor(UserRole.ADMIN)).toBe(true);
expect(canAccessPostEditor(UserRole.INSTRUCTOR)).toBe(true);
expect(canAccessPostEditor(UserRole.STUDENT)).toBe(false);
expect(canAccessPostEditor(UserRole.GUEST)).toBe(false);
expect(canAccessPostEditor(null)).toBe(false);
});

it('documents the minimum editor role', () => {
expect(EDITOR_MIN_ROLE).toBe(UserRole.INSTRUCTOR);
});
});
28 changes: 17 additions & 11 deletions src/lib/auth/acl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { User, UserRole, Permission } from '@/types/api';
/**
* Mapping of roles to their granted permissions.
*/
export const ROLES_PERMISSIONS: Record<UserRole, Permission[]> = {
[UserRole.ADMIN]: Object.values(Permission),
[UserRole.INSTRUCTOR]: [
export const ROLES_PERMISSIONS = {
ADMIN: Object.values(Permission),
INSTRUCTOR: [
Permission.COURSE_VIEW,
Permission.COURSE_CREATE,
Permission.COURSE_EDIT,
Expand All @@ -15,13 +15,9 @@ export const ROLES_PERMISSIONS: Record<UserRole, Permission[]> = {
Permission.CONTENT_UPLOAD,
Permission.ANALYTICS_VIEW,
],
[UserRole.STUDENT]: [
Permission.COURSE_VIEW,
Permission.COURSE_DOWNLOAD,
Permission.CONTENT_ACCESS,
],
[UserRole.GUEST]: [Permission.COURSE_VIEW],
};
STUDENT: [Permission.COURSE_VIEW, Permission.COURSE_DOWNLOAD, Permission.CONTENT_ACCESS],
GUEST: [Permission.COURSE_VIEW],
} satisfies Record<UserRole, Permission[]>;

/**
* Check if a user has a specific permission based on their role.
Expand Down Expand Up @@ -64,8 +60,18 @@ export function hasAllPermissions(
export function isAtLeast(user: User | null | undefined, role: UserRole): boolean {
if (!user) return false;

return isAtLeastRole(user.role, role);
}

/**
* Check if a role has at least the minimum required role.
* Roles are hierarchical: ADMIN > INSTRUCTOR > STUDENT > GUEST
*/
export function isAtLeastRole(userRole: UserRole | null | undefined, role: UserRole): boolean {
if (!userRole) return false;

const hierarchy = [UserRole.GUEST, UserRole.STUDENT, UserRole.INSTRUCTOR, UserRole.ADMIN];
const userRoleIndex = hierarchy.indexOf(user.role);
const userRoleIndex = hierarchy.indexOf(userRole);
const requiredRoleIndex = hierarchy.indexOf(role);

return userRoleIndex >= requiredRoleIndex;
Expand Down
8 changes: 8 additions & 0 deletions src/lib/auth/editorAccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { UserRole } from '@/types/api';
import { isAtLeastRole } from './acl';

export const EDITOR_MIN_ROLE = UserRole.INSTRUCTOR;

export function canAccessPostEditor(userRole: UserRole | null | undefined): boolean {
return isAtLeastRole(userRole, EDITOR_MIN_ROLE);
}
1 change: 1 addition & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const config = {
matcher: [
'/admin/:path*',
'/instructor/:path*',
'/editor/:path*',
'/dashboard/:path*',
'/profile/:path*',
'/api/:path*',
Expand Down
67 changes: 67 additions & 0 deletions src/middleware/__tests__/rbac.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { NextResponse, type NextRequest } from 'next/server';
import { checkRoutePermission } from '../rbac';
import { UserRole } from '@/types/api';

class MockNextUrl {
pathname: string;
search: string;
href: string;

constructor(pathname: string = '/', search: string = '', href: string = '') {
this.pathname = pathname;
this.search = search;
this.href = href || `http://localhost${pathname}${search}`;
}

clone() {
return new MockNextUrl(this.pathname, this.search, this.href);
}
}

function createMockRequest(pathname: string): NextRequest {
const nextUrl = new MockNextUrl(pathname);

return {
nextUrl,
url: `http://localhost${pathname}`,
headers: new Map() as any,
} as NextRequest;
}

describe('checkRoutePermission', () => {
it('allows instructors and admins to access /editor', () => {
const request = createMockRequest('/editor');

expect(checkRoutePermission(request, UserRole.INSTRUCTOR)).toBeNull();
expect(checkRoutePermission(request, UserRole.ADMIN)).toBeNull();
});
Comment on lines +33 to +38

it('redirects students from /editor to unauthorized', () => {
const request = createMockRequest('/editor');
const response = checkRoutePermission(request, UserRole.STUDENT);

expect(response).toBeInstanceOf(NextResponse);
expect(response?.status).toBe(307);
});

it('redirects anonymous visitors to login', () => {
const request = createMockRequest('/editor');
const response = checkRoutePermission(request, null);

expect(response).toBeInstanceOf(NextResponse);
expect(response?.status).toBe(307);
});

it('does not interfere with public routes', () => {
const request = createMockRequest('/editorial');
expect(checkRoutePermission(request, UserRole.ADMIN)).toBeNull();
});

it('protects nested editor routes', () => {
const request = createMockRequest('/editor/posts/123');

expect(checkRoutePermission(request, UserRole.INSTRUCTOR)).toBeNull();
expect(checkRoutePermission(request, UserRole.STUDENT)?.status).toBe(307);
});
});
Loading
Loading