diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..641e491
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,39 @@
+name: app-ci-deploy
+
+on:
+ push:
+ branches: [master]
+ workflow_dispatch:
+
+jobs:
+ test-build-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: npm
+ - run: npm ci
+ - run: npm run test:run
+ - run: npm run build
+
+ - name: Configure SSH
+ run: |
+ mkdir -p ~/.ssh
+ printf '%s\n' "${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
+ chmod 600 ~/.ssh/deploy_key
+ printf '%s\n' "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
+
+ - name: Deploy app
+ run: |
+ ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.APP_DEPLOY_HOST }}" '
+ set -euo pipefail
+ cd /home/kcar/app
+ git fetch origin master
+ git reset --hard origin/master
+ docker network inspect kevlarai-network >/dev/null 2>&1 || docker network create kevlarai-network
+ docker compose -p app-prod -f docker-compose.yml up -d --build
+ docker compose -p app-prod -f docker-compose.yml ps
+ curl -fsSI http://127.0.0.1:3000 >/dev/null
+ '
diff --git a/App.test.tsx b/App.test.tsx
deleted file mode 100644
index 414f0a3..0000000
--- a/App.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { describe, it } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { MemoryRouter } from 'react-router-dom';
-import { WrappedApp, App } from '../src/App';
-
-describe('App', () => {
- it('Renders hello world', () => {
- // ARRANGE
- render();
- // ACT
- // EXPECT
- expect(
- screen.getByRole('heading', {
- level: 1,
- }
- )).toHaveTextContent('Hello World')
- });
- it('Renders not found if invalid path', () => {
- // ARRANGE
- render(
-
-
-
- );
- // ACT
- // EXPECT
- expect(
- screen.getByRole('heading', {
- level: 1,
- }
- )).toHaveTextContent('Not Found')
- });
-});
\ No newline at end of file
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index b9b603e..74c4361 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -1,4 +1,14 @@
services:
+ frontend-test:
+ image: node:20-bookworm
+ working_dir: /app
+ volumes:
+ - .:/app
+ - app-test-node-modules:/app/node_modules
+ command: sh -lc "npm ci && npm run test:run"
+ profiles:
+ - test
+
frontend-dev:
container_name: cc-app-dev
image: cc-app-dev:latest
@@ -15,6 +25,9 @@ services:
- kevlarai-network
restart: unless-stopped
+volumes:
+ app-test-node-modules:
+
networks:
kevlarai-network:
external: true
diff --git a/package.json b/package.json
index c6fa9af..7561261 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,9 @@
"start": "vite --host",
"build": "vite build",
"storybook": "storybook dev -p 6006",
- "build-storybook": "storybook build"
+ "build-storybook": "storybook build",
+ "test": "vitest",
+ "test:run": "vitest run"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
@@ -87,4 +89,4 @@
"vite-plugin-pwa": "^0.21.1",
"vitest": "^1.6.0"
}
-}
\ No newline at end of file
+}
diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx
new file mode 100644
index 0000000..96572a0
--- /dev/null
+++ b/src/AppRoutes.admin.test.tsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import AppRoutes from './AppRoutes';
+
+const mockUseAuth = vi.fn();
+
+vi.mock('./contexts/AuthContext', () => ({
+ useAuth: () => mockUseAuth(),
+}));
+
+vi.mock('./contexts/UserContext', () => ({
+ useUser: () => ({ isInitialized: true }),
+}));
+
+vi.mock('./debugConfig', () => ({
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+vi.mock('./pages/Layout', () => ({
+ default: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}));
+vi.mock('./pages/auth/PlatformAdminPage', () => ({ default: () => Platform Admin Page
}));
+vi.mock('./pages/auth/adminPage', () => ({ default: () => Legacy Admin
}));
+vi.mock('./pages/auth/loginPage', () => ({ default: () => Login Page
}));
+vi.mock('./pages/auth/signupPage', () => ({ default: () => Signup Page
}));
+vi.mock('./pages/user/dashboardPage', () => ({ default: () => Dashboard Page
}));
+vi.mock('./pages/user/NotFound', () => ({ default: () => Private Not Found
}));
+vi.mock('./pages/NotFoundPublic', () => ({ default: () => Public Not Found
}));
+vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => Public Home
}));
+vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => Single Player
}));
+vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => Multiplayer
}));
+vi.mock('./pages/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () => Exam Marker
}));
+vi.mock('./pages/user/calendarPage', () => ({ default: () => Calendar
}));
+vi.mock('./pages/user/settingsPage', () => ({ default: () => Settings
}));
+vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => TLDraw Dev
}));
+vi.mock('./pages/tldraw/devPage', () => ({ default: () => Dev
}));
+vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => Teacher Planner
}));
+vi.mock('./pages/morphicPage', () => ({ default: () => Morphic
}));
+vi.mock('./pages/tldraw/ShareHandler', () => ({ default: () => Share
}));
+vi.mock('./pages/searxngPage', () => ({ default: () => Search
}));
+vi.mock('./pages/dev/SimpleUploadTest', () => ({ default: () => Upload Test
}));
+vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
+ CCDocumentIntelligence: () => Doc Intelligence
,
+}));
+vi.mock('./pages/timetable', () => ({
+ TimetablePage: () => Timetable
,
+ ClassesPage: () => Classes
,
+ LessonPage: () => Lesson
,
+ TaughtLessonsPage: () => Taught Lessons
,
+ MyClassesPage: () => My Classes
,
+ EnrollmentRequestsPage: () => Enrollment Requests
,
+ StaffManagerPage: () => Staff Manager
,
+ StudentManagerPage: () => Student Manager
,
+ SchoolSettingsPage: () => School Settings
,
+ ClassDetailPage: () => Class Detail
,
+ StudentLessonsPage: () => Student Lessons
,
+ LessonPlansPage: () => Lesson Plans
,
+ LessonPlanDetailPage: () => Lesson Plan Detail
,
+}));
+
+function renderAt(path: string) {
+ return render(
+
+
+
+ );
+}
+
+function authState(overrides: Record) {
+ return {
+ user: null,
+ user_role: null,
+ accessToken: null,
+ loading: false,
+ error: null,
+ signIn: vi.fn(),
+ signOut: vi.fn(),
+ clearError: vi.fn(),
+ ...overrides,
+ };
+}
+
+describe('/admin route authorization', () => {
+ beforeEach(() => {
+ mockUseAuth.mockReset();
+ });
+
+ it('redirects anonymous users away from /admin', () => {
+ mockUseAuth.mockReturnValue(authState({ user: null, user_role: null }));
+
+ renderAt('/admin');
+
+ expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
+ expect(screen.getByText('Login Page')).toBeInTheDocument();
+ });
+
+ it('redirects authenticated non-admin users away from /admin', () => {
+ mockUseAuth.mockReturnValue(authState({
+ user: { id: 'user-1', email: 'teacher@example.com', user_type: 'email_teacher' },
+ user_role: 'email_teacher',
+ accessToken: 'token',
+ }));
+
+ renderAt('/admin');
+
+ expect(screen.queryByText('Platform Admin Page')).not.toBeInTheDocument();
+ expect(screen.getByText('Dashboard Page')).toBeInTheDocument();
+ });
+
+ it('allows super_admin users to access /admin', () => {
+ mockUseAuth.mockReturnValue(authState({
+ user: { id: 'admin-1', email: 'admin@example.com', user_type: 'super_admin' },
+ user_role: 'super_admin',
+ accessToken: 'token',
+ }));
+
+ renderAt('/admin');
+
+ expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
+ });
+});
diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index 6dbf04a..85665b7 100644
--- a/src/AppRoutes.tsx
+++ b/src/AppRoutes.tsx
@@ -69,8 +69,10 @@ const FullContextRoutes: React.FC = () => {
};
const AppRoutes: React.FC = () => {
- const { user, loading: isAuthLoading } = useAuth();
+ const { user, user_role, loading: isAuthLoading } = useAuth();
const location = useLocation();
+ const platformAdminRoles = ['super_admin', 'cc_admin', 'cc_developer'];
+ const isPlatformAdmin = !!user && platformAdminRoles.includes(user_role ?? '');
// Debug log for routing
logger.debug('routing', '🔄 Rendering routes', {
@@ -123,7 +125,13 @@ const AppRoutes: React.FC = () => {
+ !user ? (
+
+ ) : isPlatformAdmin ? (
+
+ ) : (
+
+ )
}
/>
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..836bb7b
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+import path from 'path';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/setupTests.ts'],
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+});