Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
201d21a678 | ||
|
|
993265318b | ||
|
|
f40f4508fd | ||
|
|
95cf0db5a4 | ||
|
|
028c732716 | ||
|
|
acbcfde617 | ||
|
|
c002f42459 | ||
|
|
6dd87972f6 | ||
|
|
1b5fd04040 | ||
|
|
4935251125 | ||
|
|
f794dacd2d | ||
|
|
99ac7a7fae | ||
|
|
0b62723226 | ||
|
|
af317058fb |
No files matched your search
@@ -33,7 +33,10 @@
|
|||||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
|
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
|
||||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
|
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
|
||||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
|
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
|
||||||
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)"
|
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)",
|
||||||
|
"Bash(grep -E \"\\\\.tsx$\")",
|
||||||
|
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Laurens Bartelds\\\\Documents\\\\Gitea\\\\brainwave\" -Force)",
|
||||||
|
"Bash(Select-Object Name)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Deploy ARM to OrangePi
|
name: Deploy ARM to OrangePi
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
@@ -26,11 +26,15 @@ jobs:
|
|||||||
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
|
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
|
||||||
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Write Inventory
|
- name: Add OrangePi to known_hosts
|
||||||
# StrictHostKeyChecking=no is intentional for a known home-network host.
|
|
||||||
# To harden, add the host fingerprint as a secret and use a known_hosts file.
|
|
||||||
run: |
|
run: |
|
||||||
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
ssh-keyscan -H "${{ secrets.ANSIBLE_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Write Inventory
|
||||||
|
run: |
|
||||||
|
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
|
||||||
"${{ secrets.ANSIBLE_HOST }}" \
|
"${{ secrets.ANSIBLE_HOST }}" \
|
||||||
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
|
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
|
||||||
|
|
||||||
@@ -55,7 +59,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ansible-playbook ansible/deploy.yml \
|
ansible-playbook ansible/deploy.yml \
|
||||||
-i /tmp/inventory.ini \
|
-i /tmp/inventory.ini \
|
||||||
-e "artifact_path=${WORKSPACE}"
|
-e "artifact_path=${WORKSPACE}" \
|
||||||
|
-e "jwt_secret=${{ secrets.JWT_SECRET }}"
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ jobs:
|
|||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install Frontend Dependencies
|
- name: Install Frontend Dependencies
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: npm ci
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
- name: Run Frontend Tests
|
- name: Run Frontend Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
@@ -63,6 +63,10 @@ jobs:
|
|||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
volumes:
|
volumes:
|
||||||
- brainwave-db:/app/backend/db
|
- brainwave-db:/app/backend/db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
brainwave-db:
|
brainwave-db:
|
||||||
@@ -77,6 +81,10 @@ jobs:
|
|||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
volumes:
|
volumes:
|
||||||
- brainwave-db:/app/backend/db
|
- brainwave-db:/app/backend/db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
brainwave-db:
|
brainwave-db:
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ backend/db/brainwave.db-wal
|
|||||||
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
|
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
|
||||||
brainwave.db-shm
|
brainwave.db-shm
|
||||||
brainwave.db-wal
|
brainwave.db-wal
|
||||||
|
node_modules/
|
||||||
+4
-4
@@ -1,13 +1,13 @@
|
|||||||
# Stage 1: Build the frontend
|
# Stage 1: Build the frontend
|
||||||
FROM node:20-alpine AS frontend-builder
|
FROM node:22-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci --legacy-peer-deps
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build the backend
|
# Stage 2: Build the backend
|
||||||
FROM node:20-alpine AS backend-builder
|
FROM node:22-alpine AS backend-builder
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -15,7 +15,7 @@ COPY backend/ ./
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Production
|
# Stage 3: Production
|
||||||
FROM node:20-alpine AS production
|
FROM node:22-alpine AS production
|
||||||
# Install build tools for native modules like better-sqlite3
|
# Install build tools for native modules like better-sqlite3
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
+9
-2
@@ -12,7 +12,7 @@
|
|||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ deploy_dir }}"
|
path: "{{ deploy_dir }}"
|
||||||
state: directory
|
state: directory
|
||||||
mode: '0755'
|
mode: "0755"
|
||||||
|
|
||||||
- name: Copy ARM tarball
|
- name: Copy ARM tarball
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
@@ -24,6 +24,12 @@
|
|||||||
src: "{{ artifact_path }}/docker-compose.arm64.yml"
|
src: "{{ artifact_path }}/docker-compose.arm64.yml"
|
||||||
dest: "{{ deploy_dir }}/docker-compose.yml"
|
dest: "{{ deploy_dir }}/docker-compose.yml"
|
||||||
|
|
||||||
|
- name: Create .env file
|
||||||
|
ansible.builtin.copy:
|
||||||
|
content: "JWT_SECRET={{ jwt_secret }}\n"
|
||||||
|
dest: "{{ deploy_dir }}/.env"
|
||||||
|
mode: "0600"
|
||||||
|
|
||||||
- name: Load Docker image
|
- name: Load Docker image
|
||||||
ansible.builtin.command: docker load -i {{ deploy_dir }}/brainwave-arm64.tar.gz
|
ansible.builtin.command: docker load -i {{ deploy_dir }}/brainwave-arm64.tar.gz
|
||||||
register: docker_load_result
|
register: docker_load_result
|
||||||
@@ -33,4 +39,5 @@
|
|||||||
ansible.builtin.command: docker compose up -d
|
ansible.builtin.command: docker compose up -d
|
||||||
args:
|
args:
|
||||||
chdir: "{{ deploy_dir }}"
|
chdir: "{{ deploy_dir }}"
|
||||||
changed_when: true
|
register: compose_up
|
||||||
|
changed_when: "'Started' in compose_up.stdout or 'Created' in compose_up.stdout or 'Recreating' in compose_up.stdout"
|
||||||
Generated
+292
-2
@@ -14,8 +14,10 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
|
"openai": "^4.95.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -954,12 +956,21 @@
|
|||||||
"version": "25.6.0",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-fetch": {
|
||||||
|
"version": "2.6.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
||||||
|
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"form-data": "^4.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/nodemailer": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.23",
|
"version": "6.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
||||||
@@ -1118,6 +1129,18 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -1131,6 +1154,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/agentkeepalive": {
|
||||||
|
"version": "4.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
|
||||||
|
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"humanize-ms": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -1141,6 +1176,12 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -1314,6 +1355,18 @@
|
|||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
@@ -1419,6 +1472,15 @@
|
|||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -1533,6 +1595,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
@@ -1600,6 +1677,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expand-template": {
|
"node_modules/expand-template": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||||
@@ -1722,6 +1808,62 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data-encoder": {
|
||||||
|
"version": "1.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
|
||||||
|
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/form-data/node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data/node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/formdata-node": {
|
||||||
|
"version": "4.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
|
||||||
|
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "1.0.0",
|
||||||
|
"web-streams-polyfill": "4.0.0-beta.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1850,6 +1992,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
@@ -1862,6 +2019,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -1882,6 +2048,15 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/humanize-ms": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@@ -2444,6 +2619,46 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp-build": {
|
"node_modules/node-gyp-build": {
|
||||||
"version": "4.8.4",
|
"version": "4.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
@@ -2517,6 +2732,51 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "4.104.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
|
||||||
|
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^18.11.18",
|
||||||
|
"@types/node-fetch": "^2.6.4",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"agentkeepalive": "^4.2.1",
|
||||||
|
"form-data-encoder": "1.7.2",
|
||||||
|
"formdata-node": "^4.3.2",
|
||||||
|
"node-fetch": "^2.6.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai/node_modules/@types/node": {
|
||||||
|
"version": "18.19.130",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||||
|
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai/node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -3115,6 +3375,12 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -3187,7 +3453,6 @@
|
|||||||
"version": "7.19.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
@@ -3382,6 +3647,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "4.0.0-beta.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
|
||||||
|
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/why-is-node-running": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
|||||||
@@ -16,8 +16,10 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
|
"openai": "^4.95.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const config = {
|
|||||||
jwtSecret: env('JWT_SECRET'),
|
jwtSecret: env('JWT_SECRET'),
|
||||||
dbPath: env('DB_PATH', './db/brainwave.db'),
|
dbPath: env('DB_PATH', './db/brainwave.db'),
|
||||||
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
|
||||||
bodyLimit: env('BODY_LIMIT', '10mb'),
|
bodyLimit: env('BODY_LIMIT', '100kb'),
|
||||||
|
|
||||||
email: {
|
email: {
|
||||||
smtpHost: env('SMTP_HOST', ''),
|
smtpHost: env('SMTP_HOST', ''),
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import path from 'path'
|
|||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { runMigrations } from './migrations.js'
|
import { runMigrations } from './migrations.js'
|
||||||
|
|
||||||
const DB_PATH = path.isAbsolute(config.dbPath)
|
const DB_PATH = config.dbPath === ':memory:'
|
||||||
|
? ':memory:'
|
||||||
|
: path.isAbsolute(config.dbPath)
|
||||||
? config.dbPath
|
? config.dbPath
|
||||||
: path.join(process.cwd(), config.dbPath)
|
: path.join(process.cwd(), config.dbPath)
|
||||||
|
|
||||||
@@ -12,9 +14,13 @@ let db: Database.Database | null = null
|
|||||||
|
|
||||||
export function getDb(): Database.Database {
|
export function getDb(): Database.Database {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
|
if (DB_PATH !== ':memory:') {
|
||||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||||
|
}
|
||||||
db = new Database(DB_PATH)
|
db = new Database(DB_PATH)
|
||||||
|
if (DB_PATH !== ':memory:') {
|
||||||
db.pragma('journal_mode = WAL')
|
db.pragma('journal_mode = WAL')
|
||||||
|
}
|
||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
runMigrations(db)
|
runMigrations(db)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
|
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Add storage_quota_bytes if missing ──
|
||||||
|
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
|
||||||
|
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add plan if missing ──
|
||||||
|
if (!cols.some((c) => c.name === "plan")) {
|
||||||
|
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Promote first user to admin if no admins exist ──
|
// ── Promote first user to admin if no admins exist ──
|
||||||
const adminCount = db
|
const adminCount = db
|
||||||
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
|
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
|
||||||
|
|||||||
+20
-1
@@ -1,12 +1,15 @@
|
|||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
import { getDb, closeDb } from './db/database.js'
|
import { getDb, closeDb } from './db/database.js'
|
||||||
import authRoutes from './routes/auth.js'
|
import authRoutes from './routes/auth.js'
|
||||||
import examRoutes from './routes/exams.js'
|
import examRoutes from './routes/exams.js'
|
||||||
import resultRoutes from './routes/results.js'
|
import resultRoutes from './routes/results.js'
|
||||||
import adminRoutes from './routes/admin.js'
|
import adminRoutes from './routes/admin.js'
|
||||||
import setupRoutes from './routes/setup.js'
|
import setupRoutes from './routes/setup.js'
|
||||||
|
import gradeRoutes from './routes/grade.js'
|
||||||
import { errorHandler } from './middleware/error.js'
|
import { errorHandler } from './middleware/error.js'
|
||||||
import { config } from './config.js'
|
import { config } from './config.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
@@ -20,9 +23,25 @@ const PORT = config.port
|
|||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
|
// Security headers
|
||||||
|
app.use(helmet())
|
||||||
|
|
||||||
|
// CORS
|
||||||
app.use(cors({ origin: config.corsOrigin }))
|
app.use(cors({ origin: config.corsOrigin }))
|
||||||
|
|
||||||
|
// Body parsing with reasonable limit
|
||||||
app.use(express.json({ limit: config.bodyLimit }))
|
app.use(express.json({ limit: config.bodyLimit }))
|
||||||
|
|
||||||
|
// Global API rate limiting
|
||||||
|
const globalLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 200,
|
||||||
|
message: { error: 'Too many requests, please try again later' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
})
|
||||||
|
app.use('/api/', globalLimiter)
|
||||||
|
|
||||||
// Request logging
|
// Request logging
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
@@ -42,6 +61,7 @@ app.use('/api/setup', setupRoutes)
|
|||||||
app.use('/api/auth', authRoutes)
|
app.use('/api/auth', authRoutes)
|
||||||
app.use('/api/exams', examRoutes)
|
app.use('/api/exams', examRoutes)
|
||||||
app.use('/api/results', resultRoutes)
|
app.use('/api/results', resultRoutes)
|
||||||
|
app.use('/api/grade', gradeRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
|
|
||||||
// Health check with subsystem status
|
// Health check with subsystem status
|
||||||
@@ -63,7 +83,6 @@ app.get('/api/health', (_req, res) => {
|
|||||||
res.status(overall === 'healthy' ? 200 : 503).json({
|
res.status(overall === 'healthy' ? 200 : 503).json({
|
||||||
status: overall,
|
status: overall,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
version: process.env['npm_package_version'] ?? '0.0.1',
|
|
||||||
checks,
|
checks,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express'
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
|
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
|
||||||
|
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
@@ -22,6 +23,13 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
|||||||
try {
|
try {
|
||||||
const token = header.slice(7)
|
const token = header.slice(7)
|
||||||
req.user = verifyToken(token)
|
req.user = verifyToken(token)
|
||||||
|
|
||||||
|
// Check if the token has been blacklisted (e.g., after logout)
|
||||||
|
if (isTokenBlacklisted(hashToken(token))) {
|
||||||
|
res.status(401).json({ error: 'Token has been revoked' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
} catch {
|
} catch {
|
||||||
res.status(401).json({ error: 'Invalid or expired token' })
|
res.status(401).json({ error: 'Invalid or expired token' })
|
||||||
|
|||||||
@@ -17,15 +17,21 @@ export function errorHandler(
|
|||||||
_next: any
|
_next: any
|
||||||
) {
|
) {
|
||||||
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
const statusCode = err instanceof AppError ? err.statusCode : 500;
|
||||||
const message = err.message || 'Internal Server Error';
|
const isOperational = err instanceof AppError && err.isOperational;
|
||||||
|
|
||||||
console.error(`[Error] ${statusCode} - ${message}`);
|
// Log full details server-side
|
||||||
if (!(err instanceof AppError) || !err.isOperational) {
|
console.error(`[Error] ${statusCode} - ${err.message}`);
|
||||||
|
if (!isOperational) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Never leak internal error details to the client for unexpected 500s
|
||||||
|
const clientMessage = statusCode === 500 && !isOperational
|
||||||
|
? 'Internal Server Error'
|
||||||
|
: (err.message || 'Internal Server Error');
|
||||||
|
|
||||||
res.status(statusCode).json({
|
res.status(statusCode).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message,
|
message: clientMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
// Set env vars before any module that reads them at import time
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests-only'
|
||||||
|
process.env.DB_PATH = ':memory:'
|
||||||
|
|
||||||
|
// Dynamic imports ensure env vars are set before module initialisation
|
||||||
|
const { getDb, closeDb } = await import('../db/database.js')
|
||||||
|
const userRepo = await import('./userRepo.js')
|
||||||
|
const storageRepo = await import('./storageRepo.js')
|
||||||
|
|
||||||
|
describe('storageRepo', () => {
|
||||||
|
it('returns zero usage for a new user', () => {
|
||||||
|
const { id: userId } = userRepo.createUser({
|
||||||
|
email: 'storage-test@example.com',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
displayName: 'Storage Test',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const usage = storageRepo.getUserStorageUsage(userId)
|
||||||
|
expect(usage.usedBytes).toBe(0)
|
||||||
|
expect(usage.examBytes).toBe(0)
|
||||||
|
expect(usage.resultBytes).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates exam JSON length correctly', () => {
|
||||||
|
const { id: userId } = userRepo.createUser({
|
||||||
|
email: 'storage-exam@example.com',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
displayName: 'Exam Storage',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const examJson = JSON.stringify({ title: 'Test Exam', questions: [] })
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO saved_exams (user_id, title, exam_json)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(userId, 'Test Exam', examJson)
|
||||||
|
|
||||||
|
const usage = storageRepo.getUserStorageUsage(userId)
|
||||||
|
expect(usage.examBytes).toBe(examJson.length)
|
||||||
|
expect(usage.usedBytes).toBe(examJson.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates result JSON length correctly', () => {
|
||||||
|
const { id: userId } = userRepo.createUser({
|
||||||
|
email: 'storage-result@example.com',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
displayName: 'Result Storage',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const stateJson = JSON.stringify({ answers: [1, 2, 3] })
|
||||||
|
const examJson = JSON.stringify({ title: 'Result Exam' })
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO exam_results
|
||||||
|
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, 'Result Exam', 10, 20, 5, 10, 60000, stateJson, examJson)
|
||||||
|
|
||||||
|
const usage = storageRepo.getUserStorageUsage(userId)
|
||||||
|
expect(usage.resultBytes).toBe(stateJson.length + examJson.length)
|
||||||
|
expect(usage.usedBytes).toBe(stateJson.length + examJson.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('aggregates multiple exams and results', () => {
|
||||||
|
const { id: userId } = userRepo.createUser({
|
||||||
|
email: 'storage-multi@example.com',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
displayName: 'Multi Storage',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const examJson1 = JSON.stringify({ title: 'Exam 1' })
|
||||||
|
const examJson2 = JSON.stringify({ title: 'Exam 2', extra: 'data' })
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||||
|
.run(userId, 'Exam 1', examJson1)
|
||||||
|
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||||
|
.run(userId, 'Exam 2', examJson2)
|
||||||
|
|
||||||
|
const stateJson = JSON.stringify({ answers: [] })
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO exam_results
|
||||||
|
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, examJson1)
|
||||||
|
|
||||||
|
const usage = storageRepo.getUserStorageUsage(userId)
|
||||||
|
expect(usage.examBytes).toBe(examJson1.length + examJson2.length)
|
||||||
|
expect(usage.resultBytes).toBe(stateJson.length + examJson1.length)
|
||||||
|
expect(usage.usedBytes).toBe(examJson1.length + examJson2.length + stateJson.length + examJson1.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists storage items sorted by size desc', () => {
|
||||||
|
const { id: userId } = userRepo.createUser({
|
||||||
|
email: 'storage-items@example.com',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
displayName: 'Items Storage',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const smallExam = JSON.stringify({ title: 'Small' })
|
||||||
|
const largeExam = JSON.stringify({ title: 'Large Exam', content: 'x'.repeat(100) })
|
||||||
|
const stateJson = JSON.stringify({ a: 'y'.repeat(50) })
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||||
|
.run(userId, 'Small Exam', smallExam)
|
||||||
|
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||||
|
.run(userId, 'Large Exam', largeExam)
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO exam_results
|
||||||
|
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, smallExam)
|
||||||
|
|
||||||
|
const items = storageRepo.listStorageItems(userId)
|
||||||
|
expect(items.length).toBe(3)
|
||||||
|
expect(items[0].title).toBe('Large Exam')
|
||||||
|
expect(items[0].type).toBe('exam')
|
||||||
|
expect(items[1].title).toBe('Result')
|
||||||
|
expect(items[1].type).toBe('result')
|
||||||
|
expect(items[2].title).toBe('Small Exam')
|
||||||
|
expect(items[2].type).toBe('exam')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include other users data', () => {
|
||||||
|
const { id: userA } = userRepo.createUser({
|
||||||
|
email: 'user-a@example.com',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
displayName: 'User A',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
const { id: userB } = userRepo.createUser({
|
||||||
|
email: 'user-b@example.com',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
displayName: 'User B',
|
||||||
|
emailVerified: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const examJson = JSON.stringify({ title: 'A Exam' })
|
||||||
|
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
|
||||||
|
.run(userA, 'A Exam', examJson)
|
||||||
|
|
||||||
|
const usageA = storageRepo.getUserStorageUsage(userA)
|
||||||
|
const usageB = storageRepo.getUserStorageUsage(userB)
|
||||||
|
|
||||||
|
expect(usageA.usedBytes).toBe(examJson.length)
|
||||||
|
expect(usageB.usedBytes).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
|
||||||
|
export interface StorageUsage {
|
||||||
|
usedBytes: number
|
||||||
|
examBytes: number
|
||||||
|
resultBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageItem {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
type: 'exam' | 'result'
|
||||||
|
sizeBytes: number
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStorageUsage(userId: number): StorageUsage {
|
||||||
|
const db = getDb()
|
||||||
|
const examRow = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(LENGTH(exam_json)), 0) AS total
|
||||||
|
FROM saved_exams
|
||||||
|
WHERE user_id = ?
|
||||||
|
`).get(userId) as { total: number }
|
||||||
|
|
||||||
|
const resultRow = db.prepare(`
|
||||||
|
SELECT COALESCE(SUM(LENGTH(state_json) + LENGTH(exam_json)), 0) AS total
|
||||||
|
FROM exam_results
|
||||||
|
WHERE user_id = ?
|
||||||
|
`).get(userId) as { total: number }
|
||||||
|
|
||||||
|
return {
|
||||||
|
usedBytes: examRow.total + resultRow.total,
|
||||||
|
examBytes: examRow.total,
|
||||||
|
resultBytes: resultRow.total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStorageItems(userId: number): StorageItem[] {
|
||||||
|
const db = getDb()
|
||||||
|
const exams = db.prepare(`
|
||||||
|
SELECT id, title, LENGTH(exam_json) AS size_bytes, created_at
|
||||||
|
FROM saved_exams
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY size_bytes DESC
|
||||||
|
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
|
||||||
|
|
||||||
|
const results = db.prepare(`
|
||||||
|
SELECT id, title, LENGTH(state_json) + LENGTH(exam_json) AS size_bytes, completed_at AS created_at
|
||||||
|
FROM exam_results
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY size_bytes DESC
|
||||||
|
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
|
||||||
|
|
||||||
|
const items: StorageItem[] = [
|
||||||
|
...exams.map((e) => ({ id: e.id, title: e.title, type: 'exam' as const, sizeBytes: e.size_bytes, createdAt: e.created_at })),
|
||||||
|
...results.map((r) => ({ id: r.id, title: r.title, type: 'result' as const, sizeBytes: r.size_bytes, createdAt: r.created_at })),
|
||||||
|
]
|
||||||
|
|
||||||
|
items.sort((a, b) => b.sizeBytes - a.sizeBytes)
|
||||||
|
return items
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ export interface DbUser {
|
|||||||
verify_expires: number | null
|
verify_expires: number | null
|
||||||
reset_token: string | null
|
reset_token: string | null
|
||||||
reset_expires: number | null
|
reset_expires: number | null
|
||||||
|
storage_quota_bytes: number
|
||||||
|
plan: string
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,8 @@ export interface CreateUserData {
|
|||||||
emailVerified?: number
|
emailVerified?: number
|
||||||
verifyToken?: string | null
|
verifyToken?: string | null
|
||||||
verifyExpires?: number | null
|
verifyExpires?: number | null
|
||||||
|
storageQuotaBytes?: number
|
||||||
|
plan?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserData {
|
export interface UpdateUserData {
|
||||||
@@ -34,13 +38,15 @@ export interface UpdateUserData {
|
|||||||
verifyExpires?: number | null
|
verifyExpires?: number | null
|
||||||
resetToken?: string | null
|
resetToken?: string | null
|
||||||
resetExpires?: number | null
|
resetExpires?: number | null
|
||||||
|
storageQuotaBytes?: number
|
||||||
|
plan?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUser(data: CreateUserData): { id: number } {
|
export function createUser(data: CreateUserData): { id: number } {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
const result = db.prepare(`
|
const result = db.prepare(`
|
||||||
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires)
|
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires, storage_quota_bytes, plan)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
data.email.toLowerCase().trim(),
|
data.email.toLowerCase().trim(),
|
||||||
data.passwordHash,
|
data.passwordHash,
|
||||||
@@ -48,7 +54,9 @@ export function createUser(data: CreateUserData): { id: number } {
|
|||||||
data.role ?? 'user',
|
data.role ?? 'user',
|
||||||
data.emailVerified ?? 1,
|
data.emailVerified ?? 1,
|
||||||
data.verifyToken ?? null,
|
data.verifyToken ?? null,
|
||||||
data.verifyExpires ?? null
|
data.verifyExpires ?? null,
|
||||||
|
data.storageQuotaBytes ?? 1073741824,
|
||||||
|
data.plan ?? 'free'
|
||||||
)
|
)
|
||||||
return { id: result.lastInsertRowid as number }
|
return { id: result.lastInsertRowid as number }
|
||||||
}
|
}
|
||||||
@@ -63,14 +71,14 @@ export function getUserById(id: number): DbUser | undefined {
|
|||||||
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
|
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserByVerifyToken(token: string): DbUser | undefined {
|
export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as DbUser | undefined
|
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserByResetToken(token: string): DbUser | undefined {
|
export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
|
||||||
const db = getDb()
|
const db = getDb()
|
||||||
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as DbUser | undefined
|
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateUser(id: number, data: UpdateUserData): boolean {
|
export function updateUser(id: number, data: UpdateUserData): boolean {
|
||||||
@@ -110,6 +118,14 @@ export function updateUser(id: number, data: UpdateUserData): boolean {
|
|||||||
fields.push('reset_expires = ?')
|
fields.push('reset_expires = ?')
|
||||||
values.push(data.resetExpires)
|
values.push(data.resetExpires)
|
||||||
}
|
}
|
||||||
|
if (data.storageQuotaBytes !== undefined) {
|
||||||
|
fields.push('storage_quota_bytes = ?')
|
||||||
|
values.push(data.storageQuotaBytes)
|
||||||
|
}
|
||||||
|
if (data.plan !== undefined) {
|
||||||
|
fields.push('plan = ?')
|
||||||
|
values.push(data.plan)
|
||||||
|
}
|
||||||
|
|
||||||
if (fields.length === 0) return false
|
if (fields.length === 0) return false
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'
|
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import { requireAuth, requireAdmin } from '../middleware/auth.js'
|
import { requireAuth, requireAdmin } from '../middleware/auth.js'
|
||||||
@@ -6,7 +7,8 @@ import { AppError } from '../middleware/error.js'
|
|||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
import * as resultRepo from '../repositories/resultRepo.js'
|
import * as resultRepo from '../repositories/resultRepo.js'
|
||||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||||
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
|
||||||
|
import { gradeQuestionWithAI } from '../services/aiGrading.js'
|
||||||
import type { Request } from 'express'
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -124,19 +126,44 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/settings',
|
'/settings',
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (_req, res) => {
|
||||||
res.json(settingsRepo.getAllSettings())
|
res.json({ settings: settingsRepo.getAllSettings() })
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// PUT /api/admin/settings
|
// PUT /api/admin/settings
|
||||||
router.put(
|
router.put(
|
||||||
'/settings',
|
'/settings',
|
||||||
validateBody(updateSettingSchema),
|
validateBody(updateSettingsBatchSchema),
|
||||||
asyncHandler(async (req: Request, res) => {
|
asyncHandler(async (req: Request, res) => {
|
||||||
const { key, value } = req.body
|
const { settings } = req.body
|
||||||
settingsRepo.setSetting(key, value)
|
const entries = Object.entries(settings) as Array<[string, string]>
|
||||||
|
settingsRepo.setSettingsBatch(entries)
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// POST /api/admin/test-ai
|
||||||
|
router.post(
|
||||||
|
'/test-ai',
|
||||||
|
validateBody(z.object({
|
||||||
|
provider: z.string().min(1),
|
||||||
|
apiKey: z.string().min(1),
|
||||||
|
model: z.string().min(1),
|
||||||
|
baseURL: z.string().optional(),
|
||||||
|
})),
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const { provider, apiKey, model, baseURL } = req.body
|
||||||
|
const result = await gradeQuestionWithAI(
|
||||||
|
{
|
||||||
|
type: 'short-answer',
|
||||||
|
question: { prompt: 'What is 2+2?', answer: '4' },
|
||||||
|
answer: '4',
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
{ provider, apiKey, model, baseURL },
|
||||||
|
)
|
||||||
|
res.json(result)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -5,6 +5,8 @@ import { requireAuth } from '../middleware/auth.js'
|
|||||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import * as authService from '../services/authService.js'
|
import * as authService from '../services/authService.js'
|
||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
|
import { hashToken } from '../utils/tokens.js'
|
||||||
|
import { blacklistToken } from '../utils/tokenBlacklist.js'
|
||||||
import {
|
import {
|
||||||
registerSchema,
|
registerSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
@@ -113,6 +115,23 @@ router.post(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// POST /api/auth/logout
|
||||||
|
router.post(
|
||||||
|
'/logout',
|
||||||
|
requireAuth,
|
||||||
|
asyncHandler(async (req: Request, res) => {
|
||||||
|
const header = req.headers.authorization
|
||||||
|
if (header?.startsWith('Bearer ')) {
|
||||||
|
const token = header.slice(7)
|
||||||
|
const tokenHash = hashToken(token)
|
||||||
|
// Blacklist the token so it cannot be reused after logout
|
||||||
|
// Expiry is 24h from now (matching JWT expiry)
|
||||||
|
blacklistToken(tokenHash, Date.now() + 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
res.json({ success: true })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
// DELETE /api/auth/me
|
// DELETE /api/auth/me
|
||||||
router.delete(
|
router.delete(
|
||||||
'/me',
|
'/me',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireAuth } from '../middleware/auth.js'
|
|||||||
import { AppError } from '../middleware/error.js'
|
import { AppError } from '../middleware/error.js'
|
||||||
import * as examRepo from '../repositories/examRepo.js'
|
import * as examRepo from '../repositories/examRepo.js'
|
||||||
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
import { saveExamSchema, updateExamSchema, examIdParamSchema } from '../validators/examSchemas.js'
|
||||||
|
import { sanitizeExam } from '../utils/sanitizeExam.js'
|
||||||
import type { Request } from 'express'
|
import type { Request } from 'express'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -41,7 +42,7 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
validateBody(saveExamSchema),
|
validateBody(saveExamSchema),
|
||||||
asyncHandler(async (req: Request, res) => {
|
asyncHandler(async (req: Request, res) => {
|
||||||
const exam = req.body.exam
|
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||||
const { id } = examRepo.createExam(
|
const { id } = examRepo.createExam(
|
||||||
req.user!.userId,
|
req.user!.userId,
|
||||||
exam.title,
|
exam.title,
|
||||||
@@ -59,7 +60,7 @@ router.put(
|
|||||||
validateBody(updateExamSchema),
|
validateBody(updateExamSchema),
|
||||||
asyncHandler(async (req: Request, res) => {
|
asyncHandler(async (req: Request, res) => {
|
||||||
const id = Number(req.params.id)
|
const id = Number(req.params.id)
|
||||||
const exam = req.body.exam
|
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
|
||||||
const updated = examRepo.updateExam(
|
const updated = examRepo.updateExam(
|
||||||
id,
|
id,
|
||||||
req.user!.userId,
|
req.user!.userId,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { validateBody } from '../middleware/validate.js'
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
|
import { requireAuth } from '../middleware/auth.js'
|
||||||
|
import { gradeQuestionWithAI } from '../services/aiGrading.js'
|
||||||
|
|
||||||
|
const gradeRequestSchema = z.object({
|
||||||
|
type: z.enum(['essay', 'short-answer', 'translation', 'math', 'ordering', 'match']),
|
||||||
|
question: z.record(z.unknown()),
|
||||||
|
answer: z.unknown(),
|
||||||
|
language: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.use(requireAuth)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
validateBody(gradeRequestSchema),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { type, question, answer, language } = req.body as z.infer<typeof gradeRequestSchema>
|
||||||
|
const result = await gradeQuestionWithAI({ type, question, answer, language })
|
||||||
|
res.json(result)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcrypt'
|
|||||||
import { validateBody } from '../middleware/validate.js'
|
import { validateBody } from '../middleware/validate.js'
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js'
|
import { asyncHandler } from '../utils/asyncHandler.js'
|
||||||
import { AppError } from '../middleware/error.js'
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import { getDb } from '../db/database.js'
|
||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
import * as settingsRepo from '../repositories/settingsRepo.js'
|
import * as settingsRepo from '../repositories/settingsRepo.js'
|
||||||
import { setupSchema } from '../validators/setupSchemas.js'
|
import { setupSchema } from '../validators/setupSchemas.js'
|
||||||
@@ -13,7 +14,7 @@ const router = Router()
|
|||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
|
|
||||||
function getSiteTitle(): string | null {
|
function getSiteTitle(): string | null {
|
||||||
return settingsRepo.getSetting('site_title') ?? null
|
return settingsRepo.getSetting('site_name') ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/setup/status
|
// GET /api/setup/status
|
||||||
@@ -33,11 +34,17 @@ router.post(
|
|||||||
'/',
|
'/',
|
||||||
validateBody(setupSchema),
|
validateBody(setupSchema),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
if (getSiteTitle()) {
|
const db = getDb()
|
||||||
|
|
||||||
|
// Atomic transaction prevents race conditions between concurrent setup requests
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
const title = settingsRepo.getSetting('site_name')
|
||||||
|
if (title) {
|
||||||
throw new AppError(400, 'Setup already completed')
|
throw new AppError(400, 'Setup already completed')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userRepo.countUsers() > 0) {
|
const userCount = userRepo.countUsers()
|
||||||
|
if (userCount > 0) {
|
||||||
throw new AppError(400, 'Users already exist')
|
throw new AppError(400, 'Users already exist')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ router.post(
|
|||||||
appUrl,
|
appUrl,
|
||||||
} = req.body
|
} = req.body
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS)
|
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||||
|
|
||||||
const { id } = userRepo.createUser({
|
const { id } = userRepo.createUser({
|
||||||
email,
|
email,
|
||||||
@@ -67,7 +74,7 @@ router.post(
|
|||||||
})
|
})
|
||||||
|
|
||||||
const settings: Array<[string, string]> = [
|
const settings: Array<[string, string]> = [
|
||||||
['site_title', siteTitle?.trim() || 'BrainWave'],
|
['site_name', siteTitle?.trim() || 'BrainWave'],
|
||||||
['site_language', language || 'en'],
|
['site_language', language || 'en'],
|
||||||
['maintenance_mode', 'false'],
|
['maintenance_mode', 'false'],
|
||||||
]
|
]
|
||||||
@@ -82,6 +89,11 @@ router.post(
|
|||||||
|
|
||||||
settingsRepo.setSettingsBatch(settings)
|
settingsRepo.setSettingsBatch(settings)
|
||||||
|
|
||||||
|
return { id, email, displayName }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { id, email, displayName } = tx()
|
||||||
|
|
||||||
const token = signToken({
|
const token = signToken({
|
||||||
userId: id,
|
userId: id,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import OpenAI from 'openai'
|
||||||
|
import { getSetting } from '../repositories/settingsRepo.js'
|
||||||
|
|
||||||
|
export interface GradingResult {
|
||||||
|
suggestedGrade: number
|
||||||
|
feedback: string
|
||||||
|
detail?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GradeRequest {
|
||||||
|
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
|
||||||
|
question: Record<string, unknown>
|
||||||
|
answer: unknown
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClient(override?: { provider?: string; apiKey?: string; baseURL?: string }): OpenAI | null {
|
||||||
|
const provider = override?.provider ?? getSetting('ai_provider')
|
||||||
|
const apiKey = override?.apiKey ?? getSetting('ai_api_key')
|
||||||
|
if (!provider || !apiKey) return null
|
||||||
|
|
||||||
|
const customBase = override?.baseURL ?? getSetting('ai_base_url')
|
||||||
|
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
|
||||||
|
return new OpenAI({ apiKey, baseURL })
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(req: GradeRequest): string {
|
||||||
|
const { type, question, answer, language } = req
|
||||||
|
|
||||||
|
const base = `You are an exam grading assistant. Grade the student's answer and respond ONLY with a JSON object containing:
|
||||||
|
- "suggestedGrade": number between 0.0 and 1.0
|
||||||
|
- "feedback": string with 1-3 sentences explaining the grade in ${language}
|
||||||
|
- "detail": type-specific breakdown object (see below)
|
||||||
|
|
||||||
|
Do not include any text outside the JSON object.`
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'essay': {
|
||||||
|
const rubric = (question.rubric as string[] | undefined) ?? []
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
Question: ${question.prompt}
|
||||||
|
Rubric criteria: ${rubric.join('; ') || 'None provided'}
|
||||||
|
Student answer: ${String(answer ?? '')}
|
||||||
|
|
||||||
|
Evaluate how well the essay covers the rubric criteria, structure, and quality. Provide "detail" as:
|
||||||
|
{ "rubricBreakdown": [{ "item": string, "met": boolean }] }
|
||||||
|
Each item should correspond to a rubric criterion.`
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'short-answer': {
|
||||||
|
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
Question: ${question.prompt}
|
||||||
|
Accepted answers: ${accepted.join(' | ')}
|
||||||
|
Student answer: ${String(answer ?? '')}
|
||||||
|
|
||||||
|
Determine if the student's answer is semantically equivalent to any accepted form. Provide "detail" as:
|
||||||
|
{ "matchedForm": string | null, "reason": string }`
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'translation': {
|
||||||
|
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||||
|
const source = question.sourceLanguage as string
|
||||||
|
const target = question.targetLanguage as string
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
Translate from ${source} to ${target}.
|
||||||
|
Question: ${question.prompt}
|
||||||
|
Accepted translations: ${accepted.join(' | ')}
|
||||||
|
Student answer: ${String(answer ?? '')}
|
||||||
|
|
||||||
|
Evaluate meaning accuracy and fluency, not literal word-for-word matching. Provide "detail" as:
|
||||||
|
{ "accuracy": number (0-1), "fluency": number (0-1), "notes": string }`
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'math': {
|
||||||
|
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
Question: ${question.prompt}
|
||||||
|
Accepted answers (LaTeX): ${accepted.join(' | ')}
|
||||||
|
Student answer (LaTeX): ${String(answer ?? '')}
|
||||||
|
|
||||||
|
Determine if the student's LaTeX expression is mathematically equivalent to the accepted answer. Provide "detail" as:
|
||||||
|
{ "equivalent": boolean, "explanation": string }`
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ordering': {
|
||||||
|
const correctOrder = question.answer as string[]
|
||||||
|
const items = question.items as Array<{ id: string; text: string }> | undefined
|
||||||
|
const itemMap = new Map(items?.map(i => [i.id, i.text]) ?? [])
|
||||||
|
const given = answer as string[]
|
||||||
|
const correctTexts = correctOrder.map(id => itemMap.get(id) ?? id)
|
||||||
|
const givenTexts = given?.map(id => itemMap.get(id) ?? id) ?? []
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
Question: ${question.prompt}
|
||||||
|
Correct order: ${correctTexts.join(' → ')}
|
||||||
|
Student order: ${givenTexts.join(' → ')}
|
||||||
|
|
||||||
|
Determine how many items are in the correct position. Provide "detail" as:
|
||||||
|
{ "correctPositions": number[], "totalItems": number }
|
||||||
|
correctPositions should be 1-based indices of items in the correct position.`
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'match': {
|
||||||
|
const correctPairs = question.answer as Record<string, string>
|
||||||
|
const left = question.left as Array<{ id: string; text: string }> | undefined
|
||||||
|
const right = question.right as Array<{ id: string; text: string }> | undefined
|
||||||
|
const leftMap = new Map(left?.map(i => [i.id, i.text]) ?? [])
|
||||||
|
const rightMap = new Map(right?.map(i => [i.id, i.text]) ?? [])
|
||||||
|
const given = (answer as Record<string, string> | undefined) ?? {}
|
||||||
|
const correctPairTexts: string[] = []
|
||||||
|
const incorrectPairTexts: string[] = []
|
||||||
|
for (const [lId, rId] of Object.entries(correctPairs)) {
|
||||||
|
const pairText = `${leftMap.get(lId) ?? lId} → ${rightMap.get(rId) ?? rId}`
|
||||||
|
if (given[lId] === rId) {
|
||||||
|
correctPairTexts.push(pairText)
|
||||||
|
} else {
|
||||||
|
const actualR = given[lId] ? (rightMap.get(given[lId]) ?? given[lId]) : '(unanswered)'
|
||||||
|
incorrectPairTexts.push(`${leftMap.get(lId) ?? lId} → ${actualR} (should be ${rightMap.get(rId) ?? rId})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${base}
|
||||||
|
|
||||||
|
Question: ${question.prompt}
|
||||||
|
Correct pairings:
|
||||||
|
${Object.entries(correctPairs).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
|
||||||
|
Student pairings:
|
||||||
|
${Object.entries(given).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
|
||||||
|
|
||||||
|
Determine partial credit based on correct pairings. Provide "detail" as:
|
||||||
|
{ "correctPairs": string[], "incorrectPairs": string[] }
|
||||||
|
Use the human-readable text labels in the arrays.`
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gradeQuestionWithAI(
|
||||||
|
req: GradeRequest,
|
||||||
|
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
|
||||||
|
): Promise<GradingResult> {
|
||||||
|
const client = getClient(override)
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = (override?.model ?? getSetting('ai_model')) || 'gpt-4o-mini'
|
||||||
|
const prompt = buildPrompt(req)
|
||||||
|
|
||||||
|
const completion = await client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
temperature: 0.2,
|
||||||
|
max_completion_tokens: 800,
|
||||||
|
})
|
||||||
|
|
||||||
|
const raw = completion.choices[0]?.message?.content
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error('Empty response from AI provider')
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON response from AI provider')
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = parsed as Record<string, unknown>
|
||||||
|
const suggestedGrade = typeof obj.suggestedGrade === 'number'
|
||||||
|
? Math.max(0, Math.min(1, obj.suggestedGrade))
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestedGrade,
|
||||||
|
feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
|
||||||
|
detail: obj.detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import * as userRepo from '../repositories/userRepo.js'
|
import * as userRepo from '../repositories/userRepo.js'
|
||||||
import { signToken, generateVerificationToken } from '../utils/tokens.js'
|
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
|
||||||
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
|
||||||
import { AppError } from '../middleware/error.js'
|
import { AppError } from '../middleware/error.js'
|
||||||
|
import * as storageRepo from '../repositories/storageRepo.js'
|
||||||
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
|
||||||
|
|
||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
@@ -22,7 +23,8 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
|||||||
|
|
||||||
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
|
||||||
const role = isFirstUser ? 'admin' : 'user'
|
const role = isFirstUser ? 'admin' : 'user'
|
||||||
const verifyToken = generateVerificationToken()
|
const rawVerifyToken = generateVerificationToken()
|
||||||
|
const verifyTokenHash = hashToken(rawVerifyToken)
|
||||||
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
|
||||||
|
|
||||||
const { id } = userRepo.createUser({
|
const { id } = userRepo.createUser({
|
||||||
@@ -31,14 +33,14 @@ export async function register(input: RegisterInput, isFirstUser: boolean): Prom
|
|||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
role,
|
role,
|
||||||
emailVerified: 0,
|
emailVerified: 0,
|
||||||
verifyToken,
|
verifyToken: verifyTokenHash,
|
||||||
verifyExpires,
|
verifyExpires,
|
||||||
})
|
})
|
||||||
|
|
||||||
const user = userRepo.getUserById(id)!
|
const user = userRepo.getUserById(id)!
|
||||||
|
|
||||||
// Don't await — fire and forget so registration isn't blocked by email failures
|
// Don't await — fire and forget so registration isn't blocked by email failures
|
||||||
sendVerificationEmail(user.email, verifyToken).catch(() => {})
|
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: signToken({
|
token: signToken({
|
||||||
@@ -67,6 +69,10 @@ export async function login(input: LoginInput): Promise<AuthResult> {
|
|||||||
throw new AppError(401, 'Invalid email or password')
|
throw new AppError(401, 'Invalid email or password')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.email_verified) {
|
||||||
|
throw new AppError(403, 'Please verify your email before logging in')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token: signToken({
|
token: signToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -88,12 +94,20 @@ export function getMe(userId: number) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AppError(404, 'User not found')
|
throw new AppError(404, 'User not found')
|
||||||
}
|
}
|
||||||
|
const usage = storageRepo.getUserStorageUsage(userId)
|
||||||
|
const usedPercent = user.storage_quota_bytes > 0
|
||||||
|
? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100)
|
||||||
|
: 0
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
emailVerified: !!user.email_verified,
|
emailVerified: !!user.email_verified,
|
||||||
|
storageQuotaBytes: user.storage_quota_bytes,
|
||||||
|
usedBytes: usage.usedBytes,
|
||||||
|
usedPercent,
|
||||||
|
plan: user.plan,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +132,8 @@ export async function changePassword(userId: number, input: ChangePasswordInput)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyEmail(input: VerifyEmailInput) {
|
export async function verifyEmail(input: VerifyEmailInput) {
|
||||||
const user = userRepo.getUserByVerifyToken(input.token)
|
const tokenHash = hashToken(input.token)
|
||||||
|
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
|
||||||
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
|
||||||
throw new AppError(400, 'Invalid or expired verification token')
|
throw new AppError(400, 'Invalid or expired verification token')
|
||||||
}
|
}
|
||||||
@@ -141,17 +156,19 @@ export async function forgotPassword(email?: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToken = generateVerificationToken()
|
const rawResetToken = generateVerificationToken()
|
||||||
|
const resetTokenHash = hashToken(rawResetToken)
|
||||||
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
|
||||||
|
|
||||||
userRepo.updateUser(user.id, { resetToken, resetExpires })
|
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
|
||||||
|
|
||||||
// Fire and forget
|
// Fire and forget
|
||||||
sendPasswordResetEmail(user.email, resetToken).catch(() => {})
|
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetPassword(input: ResetPasswordInput) {
|
export async function resetPassword(input: ResetPasswordInput) {
|
||||||
const user = userRepo.getUserByResetToken(input.token)
|
const tokenHash = hashToken(input.token)
|
||||||
|
const user = userRepo.getUserByResetTokenHash(tokenHash)
|
||||||
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
|
||||||
throw new AppError(400, 'Invalid or expired reset token')
|
throw new AppError(400, 'Invalid or expired reset token')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Server-side sanitization for user-provided exam content.
|
||||||
|
* Removes script tags and event handlers from string fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function sanitizeString(value: unknown): unknown {
|
||||||
|
if (typeof value !== 'string') return value
|
||||||
|
return value
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<script[^>]*\/>/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/on\w+\s*=/gi, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeObject(obj: unknown): unknown {
|
||||||
|
if (typeof obj === 'string') return sanitizeString(obj)
|
||||||
|
if (Array.isArray(obj)) return obj.map(sanitizeObject)
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
result[key] = sanitizeObject(val)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
return sanitizeObject(exam) as Record<string, unknown>
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* In-memory token blacklist with SQLite persistence.
|
||||||
|
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getDb } from '../db/database.js'
|
||||||
|
|
||||||
|
const memoryCache = new Set<string>()
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
function initCache(): void {
|
||||||
|
if (initialized) return
|
||||||
|
const db = getDb()
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS token_blacklist (
|
||||||
|
token_hash TEXT PRIMARY KEY,
|
||||||
|
expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
|
||||||
|
`)
|
||||||
|
// Load recent blacklisted tokens into memory
|
||||||
|
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
|
||||||
|
.all(Date.now()) as { token_hash: string }[]
|
||||||
|
for (const row of rows) {
|
||||||
|
memoryCache.add(row.token_hash)
|
||||||
|
}
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenBlacklisted(tokenHash: string): boolean {
|
||||||
|
initCache()
|
||||||
|
return memoryCache.has(tokenHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blacklistToken(tokenHash: string, expiresAt: number): void {
|
||||||
|
initCache()
|
||||||
|
memoryCache.add(tokenHash)
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
|
||||||
|
.run(tokenHash, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupExpiredTokens(): void {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
|
||||||
|
}
|
||||||
@@ -20,3 +20,7 @@ export function verifyToken(token: string): JwtPayload {
|
|||||||
export function generateVerificationToken(): string {
|
export function generateVerificationToken(): string {
|
||||||
return crypto.randomBytes(32).toString('hex')
|
return crypto.randomBytes(32).toString('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return crypto.createHash('sha256').update(token).digest('hex')
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { updateUserRoleSchema, updateSettingSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
|
||||||
|
|
||||||
describe('updateUserRoleSchema', () => {
|
describe('updateUserRoleSchema', () => {
|
||||||
it('accepts valid role change', () => {
|
it('accepts valid role change', () => {
|
||||||
@@ -20,7 +20,7 @@ describe('updateUserRoleSchema', () => {
|
|||||||
|
|
||||||
describe('updateSettingSchema', () => {
|
describe('updateSettingSchema', () => {
|
||||||
it('accepts valid setting', () => {
|
it('accepts valid setting', () => {
|
||||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: 'BrainWave' })
|
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 'BrainWave' })
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,7 +30,30 @@ describe('updateSettingSchema', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('rejects empty value', () => {
|
it('rejects empty value', () => {
|
||||||
const result = updateSettingSchema.safeParse({ key: 'app_name', value: '' })
|
const result = updateSettingSchema.safeParse({ key: 'site_name', value: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateSettingsBatchSchema', () => {
|
||||||
|
it('accepts valid batch', () => {
|
||||||
|
const result = updateSettingsBatchSchema.safeParse({
|
||||||
|
settings: { site_name: 'BrainWave', maintenance_mode: 'false' },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid key in batch', () => {
|
||||||
|
const result = updateSettingsBatchSchema.safeParse({
|
||||||
|
settings: { site_name: 'BrainWave', unknown_key: 'value' },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty value in batch', () => {
|
||||||
|
const result = updateSettingsBatchSchema.safeParse({
|
||||||
|
settings: { site_name: '' },
|
||||||
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,11 +5,40 @@ export const updateUserRoleSchema = z.object({
|
|||||||
role: z.enum(['user', 'admin']),
|
role: z.enum(['user', 'admin']),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const ALLOWED_SETTING_KEYS = [
|
||||||
|
'site_name',
|
||||||
|
'site_language',
|
||||||
|
'maintenance_mode',
|
||||||
|
'maintenance_message',
|
||||||
|
'allow_guest_mode',
|
||||||
|
'registration_enabled',
|
||||||
|
'default_theme',
|
||||||
|
'smtp_host',
|
||||||
|
'smtp_port',
|
||||||
|
'smtp_user',
|
||||||
|
'smtp_pass',
|
||||||
|
'smtp_from',
|
||||||
|
'smtp_secure',
|
||||||
|
'app_url',
|
||||||
|
'max_upload_mb',
|
||||||
|
'ai_provider',
|
||||||
|
'ai_model',
|
||||||
|
'ai_api_key',
|
||||||
|
'ai_base_url',
|
||||||
|
] as const
|
||||||
|
|
||||||
export const updateSettingSchema = z.object({
|
export const updateSettingSchema = z.object({
|
||||||
key: z.string().min(1),
|
key: z.enum(ALLOWED_SETTING_KEYS),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const updateSettingsBatchSchema = z.object({
|
||||||
|
settings: z.record(z.string().min(1)).refine(
|
||||||
|
(obj) => Object.keys(obj).every((k) => ALLOWED_SETTING_KEYS.includes(k as typeof ALLOWED_SETTING_KEYS[number])),
|
||||||
|
{ message: 'One or more setting keys are not allowed' }
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
export const listUsersQuerySchema = z.object({
|
export const listUsersQuerySchema = z.object({
|
||||||
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
|
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('registerSchema', () => {
|
|||||||
it('accepts valid registration', () => {
|
it('accepts valid registration', () => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
password: 'password123',
|
password: 'Password123!',
|
||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
@@ -14,7 +14,7 @@ describe('registerSchema', () => {
|
|||||||
it('rejects invalid email', () => {
|
it('rejects invalid email', () => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email: 'not-an-email',
|
email: 'not-an-email',
|
||||||
password: 'password123',
|
password: 'Password123!',
|
||||||
displayName: 'Test User',
|
displayName: 'Test User',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
@@ -32,7 +32,7 @@ describe('registerSchema', () => {
|
|||||||
it('rejects empty display name', () => {
|
it('rejects empty display name', () => {
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
password: 'password123',
|
password: 'Password123!',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
@@ -65,7 +65,7 @@ describe('verifyEmailSchema', () => {
|
|||||||
|
|
||||||
describe('resetPasswordSchema', () => {
|
describe('resetPasswordSchema', () => {
|
||||||
it('accepts valid reset', () => {
|
it('accepts valid reset', () => {
|
||||||
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'newpass123' })
|
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ describe('changePasswordSchema', () => {
|
|||||||
it('accepts valid change', () => {
|
it('accepts valid change', () => {
|
||||||
const result = changePasswordSchema.safeParse({
|
const result = changePasswordSchema.safeParse({
|
||||||
currentPassword: 'oldpass',
|
currentPassword: 'oldpass',
|
||||||
newPassword: 'newpass123',
|
newPassword: 'NewPass123!',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(true)
|
expect(result.success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||||
|
|
||||||
export const registerSchema = z.object({
|
export const registerSchema = z.object({
|
||||||
email: z.string().email().max(255),
|
email: z.string().email().max(255),
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
password: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -17,7 +21,9 @@ export const verifyEmailSchema = z.object({
|
|||||||
|
|
||||||
export const resetPasswordSchema = z.object({
|
export const resetPasswordSchema = z.object({
|
||||||
token: z.string().min(1, 'Token is required'),
|
token: z.string().min(1, 'Token is required'),
|
||||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
newPassword: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProfileSchema = z.object({
|
export const updateProfileSchema = z.object({
|
||||||
@@ -26,7 +32,9 @@ export const updateProfileSchema = z.object({
|
|||||||
|
|
||||||
export const changePasswordSchema = z.object({
|
export const changePasswordSchema = z.object({
|
||||||
currentPassword: z.string(),
|
currentPassword: z.string(),
|
||||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
newPassword: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const forgotPasswordSchema = z.object({
|
export const forgotPasswordSchema = z.object({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('setupSchema', () => {
|
|||||||
it('accepts valid setup', () => {
|
it('accepts valid setup', () => {
|
||||||
const result = setupSchema.safeParse({
|
const result = setupSchema.safeParse({
|
||||||
email: 'admin@example.com',
|
email: 'admin@example.com',
|
||||||
password: 'adminpass123',
|
password: 'AdminPass123!',
|
||||||
displayName: 'Admin',
|
displayName: 'Admin',
|
||||||
siteTitle: 'BrainWave',
|
siteTitle: 'BrainWave',
|
||||||
language: 'en',
|
language: 'en',
|
||||||
@@ -16,7 +16,7 @@ describe('setupSchema', () => {
|
|||||||
it('rejects invalid email', () => {
|
it('rejects invalid email', () => {
|
||||||
const result = setupSchema.safeParse({
|
const result = setupSchema.safeParse({
|
||||||
email: 'bad',
|
email: 'bad',
|
||||||
password: 'adminpass123',
|
password: 'AdminPass123!',
|
||||||
displayName: 'Admin',
|
displayName: 'Admin',
|
||||||
})
|
})
|
||||||
expect(result.success).toBe(false)
|
expect(result.success).toBe(false)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/
|
||||||
|
|
||||||
export const setupSchema = z.object({
|
export const setupSchema = z.object({
|
||||||
email: z.string().email().max(255),
|
email: z.string().email().max(255),
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
password: z.string()
|
||||||
|
.min(8, 'Password must be at least 8 characters')
|
||||||
|
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&)'),
|
||||||
displayName: z.string().min(1, 'Display name is required').max(100),
|
displayName: z.string().min(1, 'Display name is required').max(100),
|
||||||
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
|
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
|
||||||
language: z.enum(['en', 'nl']).optional(),
|
language: z.enum(['en', 'nl']).optional(),
|
||||||
|
|||||||
+46
-1
@@ -129,7 +129,7 @@ function AppInner() {
|
|||||||
setExamState(withNotes(state));
|
setExamState(withNotes(state));
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null) => {
|
const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null, isRedo = false) => {
|
||||||
if (saveToWorkspace && user && isAuthenticated()) {
|
if (saveToWorkspace && user && isAuthenticated()) {
|
||||||
examApi.createExam(data)
|
examApi.createExam(data)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -156,6 +156,7 @@ function AppInner() {
|
|||||||
revealed: {},
|
revealed: {},
|
||||||
startedAt: Date.now(),
|
startedAt: Date.now(),
|
||||||
feedbackMode: fm,
|
feedbackMode: fm,
|
||||||
|
isRedo,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
navigate(`/exam/${getExamUrlId(data)}/q1`);
|
navigate(`/exam/${getExamUrlId(data)}/q1`);
|
||||||
@@ -195,6 +196,8 @@ function AppInner() {
|
|||||||
state: { ...examState, completedAt },
|
state: { ...examState, completedAt },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Skip saving to history / API when this is a redo session
|
||||||
|
if (!examState.isRedo) {
|
||||||
if (user && isAuthenticated()) {
|
if (user && isAuthenticated()) {
|
||||||
void resultApi.submitResult({
|
void resultApi.submitResult({
|
||||||
exam_id: savedExamIdRef.current,
|
exam_id: savedExamIdRef.current,
|
||||||
@@ -217,11 +220,36 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setHistory((prev) => [entry, ...prev]);
|
setHistory((prev) => [entry, ...prev]);
|
||||||
|
}
|
||||||
|
|
||||||
clearState();
|
clearState();
|
||||||
setExamState((s) => (s ? { ...s, completedAt } : s));
|
setExamState((s) => (s ? { ...s, completedAt } : s));
|
||||||
navigate("/results");
|
navigate("/results");
|
||||||
}, [exam, examState, user, navigate]);
|
}, [exam, examState, user, navigate]);
|
||||||
|
|
||||||
|
const redoFailed = useCallback(() => {
|
||||||
|
if (!exam || !examState) return;
|
||||||
|
const graded = exam.questions.map((q) =>
|
||||||
|
gradeQuestion(q, examState.answers[q.id]),
|
||||||
|
);
|
||||||
|
const failedQuestions = exam.questions
|
||||||
|
.map((q, i) => ({ q, result: graded[i] }))
|
||||||
|
.filter(({ result }) => !result.correct)
|
||||||
|
.map(({ q }) => q);
|
||||||
|
|
||||||
|
if (failedQuestions.length === 0) return;
|
||||||
|
|
||||||
|
// Shuffle failed questions using Fisher-Yates
|
||||||
|
const shuffled = [...failedQuestions];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const redoExam: Exam = { ...exam, questions: shuffled };
|
||||||
|
loadExam(redoExam, false, savedExamIdRef.current, true);
|
||||||
|
}, [exam, examState]);
|
||||||
|
|
||||||
const exit = () => {
|
const exit = () => {
|
||||||
setExam(null);
|
setExam(null);
|
||||||
setExamState(null);
|
setExamState(null);
|
||||||
@@ -261,6 +289,21 @@ function AppInner() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const retakeAttempt = (entry: HistoryEntry) => {
|
||||||
|
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
|
||||||
|
if (remoteId && user && isAuthenticated()) {
|
||||||
|
void resultApi.getResult(remoteId)
|
||||||
|
.then((data) => {
|
||||||
|
loadExam(data.exam as unknown as Exam, false, null, false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loadExam(entry.exam, false, null, false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loadExam(entry.exam, false, null, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const clearAllHistory = () => {
|
const clearAllHistory = () => {
|
||||||
if (confirm(t('landing.clearHistoryConfirm'))) {
|
if (confirm(t('landing.clearHistoryConfirm'))) {
|
||||||
clearHistory();
|
clearHistory();
|
||||||
@@ -436,6 +479,7 @@ function AppInner() {
|
|||||||
onResume={resume}
|
onResume={resume}
|
||||||
onClearHistory={clearAllHistory}
|
onClearHistory={clearAllHistory}
|
||||||
onReviewAttempt={reviewAttempt}
|
onReviewAttempt={reviewAttempt}
|
||||||
|
onRetakeAttempt={retakeAttempt}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -461,6 +505,7 @@ function AppInner() {
|
|||||||
state={examState}
|
state={examState}
|
||||||
onRestart={restart}
|
onRestart={restart}
|
||||||
onExit={exit}
|
onExit={exit}
|
||||||
|
onRedoFailed={redoFailed}
|
||||||
reviewOnly={false}
|
reviewOnly={false}
|
||||||
/>
|
/>
|
||||||
) : user && isAuthenticated() ? (
|
) : user && isAuthenticated() ? (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { apiFetch } from '../utils/api.js'
|
import { apiFetch } from '../utils/api.js'
|
||||||
|
import type { GradingResult } from './gradeApi.js'
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number
|
id: number
|
||||||
@@ -42,6 +43,10 @@ export interface SiteSettings {
|
|||||||
smtp_from: string
|
smtp_from: string
|
||||||
smtp_secure: string
|
smtp_secure: string
|
||||||
app_url: string
|
app_url: string
|
||||||
|
ai_provider: string
|
||||||
|
ai_model: string
|
||||||
|
ai_api_key: string
|
||||||
|
ai_base_url: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStats(): Promise<DashboardStats> {
|
export function getStats(): Promise<DashboardStats> {
|
||||||
@@ -90,3 +95,17 @@ export function sendTestEmail(to: string | undefined, smtp: SmtpConfig): Promise
|
|||||||
body: JSON.stringify({ to, smtp }),
|
body: JSON.stringify({ to, smtp }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AiTestPayload {
|
||||||
|
provider: string
|
||||||
|
apiKey: string
|
||||||
|
model: string
|
||||||
|
baseURL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testAiConnection(payload: AiTestPayload): Promise<GradingResult> {
|
||||||
|
return apiFetch('/api/admin/test-ai', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ export interface User {
|
|||||||
displayName: string
|
displayName: string
|
||||||
role: string
|
role: string
|
||||||
emailVerified?: boolean
|
emailVerified?: boolean
|
||||||
|
storageQuotaBytes?: number
|
||||||
|
usedBytes?: number
|
||||||
|
usedPercent?: number
|
||||||
|
plan?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { apiFetch } from '../utils/api.js'
|
||||||
|
|
||||||
|
export interface GradingResult {
|
||||||
|
suggestedGrade: number
|
||||||
|
feedback: string
|
||||||
|
detail?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GradeRequest {
|
||||||
|
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
|
||||||
|
question: Record<string, unknown>
|
||||||
|
answer: unknown
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gradeQuestion(req: GradeRequest): Promise<GradingResult> {
|
||||||
|
return apiFetch('/api/grade', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
text: string;
|
text: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [testEmailSending, setTestEmailSending] = useState(false);
|
const [testEmailSending, setTestEmailSending] = useState(false);
|
||||||
|
const [testAiMsg, setTestAiMsg] = useState<{
|
||||||
|
ok: boolean;
|
||||||
|
text: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [testAiSending, setTestAiSending] = useState(false);
|
||||||
|
|
||||||
const loadStats = useCallback(async () => {
|
const loadStats = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -125,6 +130,26 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sendTestAi = async () => {
|
||||||
|
if (!settingsDraft) return;
|
||||||
|
setTestAiSending(true);
|
||||||
|
setTestAiMsg(null);
|
||||||
|
try {
|
||||||
|
await adminApi.testAiConnection({
|
||||||
|
provider: settingsDraft.ai_provider,
|
||||||
|
apiKey: settingsDraft.ai_api_key,
|
||||||
|
model: settingsDraft.ai_model || 'gpt-4o-mini',
|
||||||
|
baseURL: settingsDraft.ai_base_url || undefined,
|
||||||
|
});
|
||||||
|
setTestAiMsg({ ok: true, text: t("admin.aiGrading.testSuccess") });
|
||||||
|
} catch (e) {
|
||||||
|
setTestAiMsg({ ok: false, text: (e as Error).message });
|
||||||
|
} finally {
|
||||||
|
setTestAiSending(false);
|
||||||
|
setTimeout(() => setTestAiMsg(null), 6000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUserSearch = (e: React.FormEvent) => {
|
const handleUserSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void loadUsers(userSearch);
|
void loadUsers(userSearch);
|
||||||
@@ -591,6 +616,92 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-panel">
|
||||||
|
<div className="admin-panel-header">
|
||||||
|
<h3>{t("admin.aiGrading.title")}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="admin-panel-body admin-settings-form">
|
||||||
|
<div className="admin-setting-row">
|
||||||
|
<label className="admin-setting-label" htmlFor="setting-ai-provider">
|
||||||
|
<span className="admin-setting-title">{t("admin.aiGrading.provider")}</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="setting-ai-provider"
|
||||||
|
className="input admin-setting-input"
|
||||||
|
value={settingsDraft.ai_provider || ''}
|
||||||
|
onChange={(e) => updateDraft("ai_provider", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="openai-compatible">OpenAI-compatible</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{settingsDraft.ai_provider === 'openai-compatible' && (
|
||||||
|
<div className="admin-setting-row">
|
||||||
|
<label className="admin-setting-label" htmlFor="setting-ai-base-url">
|
||||||
|
<span className="admin-setting-title">{t("admin.aiGrading.baseUrl")}</span>
|
||||||
|
<span className="admin-setting-desc">
|
||||||
|
{t("admin.aiGrading.baseUrlDesc")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setting-ai-base-url"
|
||||||
|
className="input admin-setting-input"
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
value={settingsDraft.ai_base_url || ''}
|
||||||
|
onChange={(e) => updateDraft("ai_base_url", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="admin-setting-row">
|
||||||
|
<label className="admin-setting-label" htmlFor="setting-ai-model">
|
||||||
|
<span className="admin-setting-title">{t("admin.aiGrading.model")}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setting-ai-model"
|
||||||
|
className="input admin-setting-input"
|
||||||
|
placeholder="gpt-4o-mini"
|
||||||
|
value={settingsDraft.ai_model || ''}
|
||||||
|
onChange={(e) => updateDraft("ai_model", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-setting-row">
|
||||||
|
<label className="admin-setting-label" htmlFor="setting-ai-key">
|
||||||
|
<span className="admin-setting-title">{t("admin.aiGrading.apiKey")}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="setting-ai-key"
|
||||||
|
className="input admin-setting-input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={settingsDraft.ai_api_key || ''}
|
||||||
|
onChange={(e) => updateDraft("ai_api_key", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="admin-setting-row admin-setting-row-stacked">
|
||||||
|
<div className="admin-setting-label">
|
||||||
|
<span className="admin-setting-title">{t("admin.aiGrading.testButton")}</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-test-email-row">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={() => void sendTestAi()}
|
||||||
|
disabled={testAiSending || !settingsDraft?.ai_provider || !settingsDraft?.ai_api_key || (settingsDraft?.ai_provider === 'openai-compatible' && !settingsDraft?.ai_base_url)}
|
||||||
|
>
|
||||||
|
{testAiSending ? t("admin.sending") : t("admin.aiGrading.testButton")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{testAiMsg && (
|
||||||
|
<div
|
||||||
|
className={`admin-alert ${testAiMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
|
||||||
|
>
|
||||||
|
{testAiMsg.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="admin-panel">
|
<div className="admin-panel">
|
||||||
<div className="admin-panel-header">
|
<div className="admin-panel-header">
|
||||||
<h3>{t("admin.maintenance")}</h3>
|
<h3>{t("admin.maintenance")}</h3>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Props {
|
|||||||
onResume: () => void
|
onResume: () => void
|
||||||
onClearHistory: () => void
|
onClearHistory: () => void
|
||||||
onReviewAttempt: (entry: HistoryEntry) => void
|
onReviewAttempt: (entry: HistoryEntry) => void
|
||||||
|
onRetakeAttempt?: (entry: HistoryEntry) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SAMPLES = [
|
const SAMPLES = [
|
||||||
@@ -19,7 +20,7 @@ const SAMPLES = [
|
|||||||
{ path: '/samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
|
{ path: '/samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
|
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt, onRetakeAttempt }: Readonly<Props>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
@@ -483,6 +484,9 @@ Set a 20-minute time limit. Include explanations for each answer."
|
|||||||
</div>
|
</div>
|
||||||
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
|
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
|
||||||
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
||||||
|
{onRetakeAttempt && (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={() => onRetakeAttempt(h)}>{t('landing.retake')}</button>
|
||||||
|
)}
|
||||||
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button>
|
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import * as resultApi from '../api/resultApi'
|
import * as resultApi from '../api/resultApi'
|
||||||
|
import * as examApi from '../api/examApi'
|
||||||
import { getExamUrlId } from '../utils/slug'
|
import { getExamUrlId } from '../utils/slug'
|
||||||
|
import type { Exam } from '../types'
|
||||||
|
|
||||||
interface ResultItem {
|
interface ResultItem {
|
||||||
id: number
|
id: number
|
||||||
@@ -17,7 +19,11 @@ interface ResultItem {
|
|||||||
exam_id: number | null
|
exam_id: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MyResults() {
|
interface Props {
|
||||||
|
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MyResults({ onLoadExam }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [results, setResults] = useState<ResultItem[]>([])
|
const [results, setResults] = useState<ResultItem[]>([])
|
||||||
@@ -25,6 +31,7 @@ export default function MyResults() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||||
|
const [retakingId, setRetakingId] = useState<number | null>(null)
|
||||||
|
|
||||||
const loadResults = useCallback(async () => {
|
const loadResults = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -54,6 +61,18 @@ export default function MyResults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const retakeExam = async (item: ResultItem) => {
|
||||||
|
if (!item.exam_id) return
|
||||||
|
setRetakingId(item.id)
|
||||||
|
try {
|
||||||
|
const exam = await examApi.getExam(item.exam_id)
|
||||||
|
onLoadExam(exam, false, item.exam_id)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
setRetakingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const reviewResult = async (item: ResultItem) => {
|
const reviewResult = async (item: ResultItem) => {
|
||||||
try {
|
try {
|
||||||
const data = await resultApi.getResult(item.id)
|
const data = await resultApi.getResult(item.id)
|
||||||
@@ -129,6 +148,14 @@ export default function MyResults() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="workspace-card-actions" style={{ gap: 8 }}>
|
<div className="workspace-card-actions" style={{ gap: 8 }}>
|
||||||
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => void retakeExam(r)}
|
||||||
|
disabled={retakingId === r.id || !r.exam_id}
|
||||||
|
title={r.exam_id ? undefined : t('results.cantRetake')}
|
||||||
|
>
|
||||||
|
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
|
||||||
|
</button>
|
||||||
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
|
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,51 +1,125 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { Exam, ExamState } from '../types'
|
import type { Exam, ExamState, Question } from '../types'
|
||||||
import { gradeQuestion } from '../utils/grading'
|
import { gradeQuestion } from '../utils/grading'
|
||||||
import { formatTime } from '../utils/formatting'
|
import { formatTime } from '../utils/formatting'
|
||||||
import Tex from './Tex'
|
import Tex from './Tex'
|
||||||
import QuestionMedia from './QuestionMedia'
|
import QuestionMedia from './QuestionMedia'
|
||||||
import QuestionRenderer from './questions/QuestionRenderer'
|
import QuestionRenderer from './questions/QuestionRenderer'
|
||||||
|
import * as gradeApi from '../api/gradeApi'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exam: Exam
|
exam: Exam
|
||||||
state: ExamState
|
state: ExamState
|
||||||
onRestart: () => void
|
onRestart: () => void
|
||||||
onExit: () => void
|
onExit: () => void
|
||||||
|
onRedoFailed?: () => void
|
||||||
reviewOnly?: boolean
|
reviewOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Results({ exam, state, onRestart, onExit, reviewOnly }: Props) {
|
const AI_TYPES = new Set(['essay', 'short-answer', 'translation', 'math-input', 'match', 'order'])
|
||||||
const { t } = useTranslation()
|
|
||||||
|
function isAiApplicable(q: Question): boolean {
|
||||||
|
return AI_TYPES.has(q.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTypeForApi(q: Question): gradeApi.GradeRequest['type'] | null {
|
||||||
|
switch (q.type) {
|
||||||
|
case 'essay': return 'essay'
|
||||||
|
case 'short-answer': return 'short-answer'
|
||||||
|
case 'translation': return 'translation'
|
||||||
|
case 'math-input': return 'math'
|
||||||
|
case 'match': return 'match'
|
||||||
|
case 'order': return 'ordering'
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Results({ exam, state, onRestart, onExit, onRedoFailed, reviewOnly }: Props) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
const { answers, startedAt, completedAt } = state
|
const { answers, startedAt, completedAt } = state
|
||||||
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
const elapsedMs = (completedAt ?? Date.now()) - startedAt
|
||||||
|
|
||||||
const graded = exam.questions.map((q) => ({
|
const [aiReviews, setAiReviews] = useState<Record<string, gradeApi.GradingResult>>({})
|
||||||
q,
|
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
|
||||||
userAnswer: answers[q.id],
|
const [aiErrors, setAiErrors] = useState<Record<string, string>>({})
|
||||||
result: gradeQuestion(q, answers[q.id]),
|
const [acceptedGrades, setAcceptedGrades] = useState<Record<string, number>>({})
|
||||||
}))
|
|
||||||
|
const graded = exam.questions.map((q) => {
|
||||||
|
const accepted = acceptedGrades[q.id]
|
||||||
|
const base = gradeQuestion(q, answers[q.id])
|
||||||
|
if (accepted != null) {
|
||||||
|
const pts = q.points ?? 1
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: (accepted >= 0.5 ? 'correct' : 'wrong') as typeof base.status,
|
||||||
|
correct: accepted >= 0.5,
|
||||||
|
points: Math.round(pts * accepted),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
})
|
||||||
|
|
||||||
const totalPoints = exam.questions.reduce((a, q) => a + (q.points ?? 1), 0)
|
const totalPoints = exam.questions.reduce((a, q) => a + (q.points ?? 1), 0)
|
||||||
const scorePoints = graded.reduce((a, g) => a + g.result.points, 0)
|
const scorePoints = graded.reduce((a, g) => a + g.points, 0)
|
||||||
const gradedCount = graded.filter((g) => g.result.status !== 'ungraded').length
|
const gradedCount = graded.filter((g) => g.status !== 'ungraded').length
|
||||||
const correctCount = graded.filter((g) => g.result.correct).length
|
const correctCount = graded.filter((g) => g.correct).length
|
||||||
|
const failedCount = gradedCount - correctCount
|
||||||
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0
|
const pct = totalPoints ? Math.round((scorePoints / totalPoints) * 100) : 0
|
||||||
const passing = exam.passingScore ?? 60
|
const passing = exam.passingScore ?? 60
|
||||||
const passed = pct >= passing
|
const passed = pct >= passing
|
||||||
|
|
||||||
const byCat: Record<string, { earned: number; possible: number; correct: number; total: number }> = {}
|
const byCat: Record<string, { earned: number; possible: number; correct: number; total: number }> = {}
|
||||||
graded.forEach((g) => {
|
graded.forEach((g, i) => {
|
||||||
const cat = g.q.category ?? 'Uncategorized'
|
const cat = exam.questions[i].category ?? 'Uncategorized'
|
||||||
if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 }
|
if (!byCat[cat]) byCat[cat] = { earned: 0, possible: 0, correct: 0, total: 0 }
|
||||||
byCat[cat].possible += g.q.points ?? 1
|
byCat[cat].possible += exam.questions[i].points ?? 1
|
||||||
byCat[cat].earned += g.result.points
|
byCat[cat].earned += g.points
|
||||||
byCat[cat].total += 1
|
byCat[cat].total += 1
|
||||||
if (g.result.correct) byCat[cat].correct += 1
|
if (g.correct) byCat[cat].correct += 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const [openIdx, setOpenIdx] = useState<number | null>(null)
|
const [openIdx, setOpenIdx] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const runAiReview = async (q: Question) => {
|
||||||
|
const type = mapTypeForApi(q)
|
||||||
|
if (!type) return
|
||||||
|
setAiLoading((prev) => ({ ...prev, [q.id]: true }))
|
||||||
|
setAiErrors((prev) => { const n = { ...prev }; delete n[q.id]; return n })
|
||||||
|
try {
|
||||||
|
const result = await gradeApi.gradeQuestion({
|
||||||
|
type,
|
||||||
|
question: q as unknown as Record<string, unknown>,
|
||||||
|
answer: answers[q.id],
|
||||||
|
language: i18n.language === 'nl' ? 'nl' : 'en',
|
||||||
|
})
|
||||||
|
setAiReviews((prev) => ({ ...prev, [q.id]: result }))
|
||||||
|
} catch (e) {
|
||||||
|
setAiErrors((prev) => ({ ...prev, [q.id]: (e as Error).message }))
|
||||||
|
} finally {
|
||||||
|
setAiLoading((prev) => { const n = { ...prev }; delete n[q.id]; return n })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptGrade = (qid: string, grade: number) => {
|
||||||
|
setAcceptedGrades((prev) => ({ ...prev, [qid]: grade }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepOriginal = (qid: string) => {
|
||||||
|
setAcceptedGrades((prev) => { const n = { ...prev }; delete n[qid]; return n })
|
||||||
|
setAiReviews((prev) => { const n = { ...prev }; delete n[qid]; return n })
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'correct': return t('exam.correct')
|
||||||
|
case 'wrong': return t('exam.notQuite')
|
||||||
|
case 'ungraded': return 'Ungraded'
|
||||||
|
case 'unanswered': return 'Unanswered'
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="results">
|
<div className="results">
|
||||||
<div className="results-eyebrow">
|
<div className="results-eyebrow">
|
||||||
@@ -94,39 +168,98 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<h2 className="review-heading">{t('results.review')}</h2>
|
<h2 className="review-heading">{t('results.review')}</h2>
|
||||||
{graded.map((g, i) => {
|
{exam.questions.map((q, i) => {
|
||||||
const promptText = g.q.prompt
|
const g = graded[i]
|
||||||
|
const promptText = q.prompt
|
||||||
.replace(/\$\$([^$]+)\$\$/g, '[math]')
|
.replace(/\$\$([^$]+)\$\$/g, '[math]')
|
||||||
.replace(/\$([^$]+)\$/g, '[math]')
|
.replace(/\$([^$]+)\$/g, '[math]')
|
||||||
|
const aiResult = aiReviews[q.id]
|
||||||
|
const aiBusy = aiLoading[q.id]
|
||||||
|
const aiErr = aiErrors[q.id]
|
||||||
|
const accepted = acceptedGrades[q.id]
|
||||||
|
const applicable = isAiApplicable(q)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={g.q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
|
<div key={q.id} className={`review-item${openIdx === i ? ' open' : ''}`}>
|
||||||
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
|
<div className="review-item-header" onClick={() => setOpenIdx(openIdx === i ? null : i)}>
|
||||||
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
|
<span className="review-num">{String(i + 1).padStart(2, '0')}</span>
|
||||||
<span className="review-title">
|
<span className="review-title">
|
||||||
<Tex>{promptText.slice(0, 90)}</Tex>
|
<Tex>{promptText.slice(0, 90)}</Tex>
|
||||||
{promptText.length > 90 ? '…' : ''}
|
{promptText.length > 90 ? '…' : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className={`review-status ${g.result.status}`}>{g.result.status}</span>
|
<span className={`review-status ${g.status}`}>
|
||||||
|
{statusLabel(g.status)}
|
||||||
|
{accepted != null && ' ✦'}
|
||||||
|
</span>
|
||||||
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
|
<span style={{ color: 'var(--ink-4)' }}>{openIdx === i ? '−' : '+'}</span>
|
||||||
</div>
|
</div>
|
||||||
{openIdx === i && (
|
{openIdx === i && (
|
||||||
<div className="review-body">
|
<div className="review-body">
|
||||||
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
|
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
|
||||||
<Tex>{g.q.prompt}</Tex>
|
<Tex>{q.prompt}</Tex>
|
||||||
</div>
|
</div>
|
||||||
<QuestionMedia q={g.q} />
|
<QuestionMedia q={q} />
|
||||||
<QuestionRenderer key={g.q.id} q={g.q} value={g.userAnswer} onChange={() => {}} locked graded />
|
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
|
||||||
{g.q.explanation && (
|
|
||||||
<div className="feedback" style={{ marginTop: 24 }}>
|
{applicable && !reviewOnly && (
|
||||||
<div className="feedback-label">{t('results.explanation')}</div>
|
<div style={{ marginTop: 20 }}>
|
||||||
<div className="feedback-body"><Tex>{g.q.explanation}</Tex></div>
|
{!aiResult && !aiBusy && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void runAiReview(q) }}
|
||||||
|
>
|
||||||
|
{t('results.ai.button')} ▶
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{aiBusy && (
|
||||||
|
<span className="muted" style={{ fontSize: 13 }}>{t('common.loading')}</span>
|
||||||
|
)}
|
||||||
|
{aiErr && (
|
||||||
|
<div style={{ marginTop: 8, padding: '8px 12px', borderRadius: 6, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
|
||||||
|
{t('results.ai.error')}: {aiErr}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{g.q.rubric && (
|
{aiResult && (
|
||||||
|
<div style={{ marginTop: 12, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)' }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
|
||||||
|
{t('results.ai.suggestedGrade')}: {Math.round(aiResult.suggestedGrade * 100)}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||||
|
{aiResult.feedback}
|
||||||
|
</div>
|
||||||
|
{!!aiResult.detail && (
|
||||||
|
<AiDetail detail={aiResult.detail} type={q.type} />
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 12 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); acceptGrade(q.id, aiResult.suggestedGrade) }}
|
||||||
|
>
|
||||||
|
{t('results.ai.accept')} ✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); keepOriginal(q.id) }}
|
||||||
|
>
|
||||||
|
{t('results.ai.keepOriginal')} ✗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.explanation && (
|
||||||
|
<div className="feedback" style={{ marginTop: 24 }}>
|
||||||
|
<div className="feedback-label">{t('results.explanation')}</div>
|
||||||
|
<div className="feedback-body"><Tex>{q.explanation}</Tex></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{q.rubric && (
|
||||||
<div style={{ marginTop: 20, fontSize: 13.5 }}>
|
<div style={{ marginTop: 20, fontSize: 13.5 }}>
|
||||||
<div className="meta-label" style={{ marginBottom: 8 }}>{t('results.rubric')}</div>
|
<div className="meta-label" style={{ marginBottom: 8 }}>{t('results.rubric')}</div>
|
||||||
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
|
<ul style={{ paddingLeft: 18, margin: 0, lineHeight: 1.7, color: 'var(--ink-2)' }}>
|
||||||
{g.q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
{q.rubric.map((r, j) => <li key={j}><Tex>{r}</Tex></li>)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -137,9 +270,97 @@ export default function Results({ exam, state, onRestart, onExit, reviewOnly }:
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
|
<div style={{ marginTop: 48, display: 'flex', gap: 12 }}>
|
||||||
{!reviewOnly && <button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>}
|
{!reviewOnly && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-primary" onClick={onRestart}>{t('results.retakeExam')}</button>
|
||||||
|
{onRedoFailed && failedCount > 0 && (
|
||||||
|
<button className="btn" onClick={onRedoFailed}>
|
||||||
|
{t('results.redoFailed')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
|
<button className="btn" onClick={onExit}>{t('results.backToHome')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AiDetail({ detail, type }: { detail: unknown; type: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
if (!detail || typeof detail !== 'object') return null
|
||||||
|
|
||||||
|
const d = detail as Record<string, unknown>
|
||||||
|
|
||||||
|
if (type === 'essay' && Array.isArray(d.rubricBreakdown)) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div className="meta-label" style={{ marginBottom: 6, fontSize: 12 }}>{t('results.ai.rubricBreakdown')}</div>
|
||||||
|
<table style={{ fontSize: 12, width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<tbody>
|
||||||
|
{(d.rubricBreakdown as Array<{ item: string; met: boolean }>).map((r, i) => (
|
||||||
|
<tr key={i} style={{ borderBottom: '1px solid var(--line-1)' }}>
|
||||||
|
<td style={{ padding: '4px 0' }}>{r.item}</td>
|
||||||
|
<td style={{ textAlign: 'right', color: r.met ? 'var(--right)' : 'var(--wrong)' }}>
|
||||||
|
{r.met ? '✓' : '✗'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((type === 'ordering' || type === 'order') && Array.isArray(d.correctPositions)) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||||
|
{t('results.ai.suggestedGrade')}: {(d.correctPositions as number[]).length} / {Number(d.totalItems ?? 0)} {t('results.correct').toLowerCase()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'match' && (Array.isArray(d.correctPairs) || Array.isArray(d.incorrectPairs))) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12 }}>
|
||||||
|
{Array.isArray(d.correctPairs) && (d.correctPairs as string[]).length > 0 && (
|
||||||
|
<div style={{ color: 'var(--right)', marginBottom: 4 }}>
|
||||||
|
✓ {(d.correctPairs as string[]).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Array.isArray(d.incorrectPairs) && (d.incorrectPairs as string[]).length > 0 && (
|
||||||
|
<div style={{ color: 'var(--wrong)' }}>
|
||||||
|
✗ {(d.incorrectPairs as string[]).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'math-input' && typeof d.equivalent === 'boolean') {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||||
|
{d.explanation as string}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'translation' && (typeof d.accuracy === 'number' || typeof d.fluency === 'number')) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||||
|
Accuracy: {Math.round((d.accuracy as number) * 100)}% · Fluency: {Math.round((d.fluency as number) * 100)}%
|
||||||
|
{!!d.notes && <div style={{ marginTop: 4 }}>{d.notes as string}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'short-answer' && d.matchedForm != null) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--ink-2)' }}>
|
||||||
|
{d.reason as string}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -25,6 +25,54 @@ function Seg<K extends string>({ value, options, onChange }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
function StoragePreview() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const used = user?.usedBytes ?? 0
|
||||||
|
const quota = user?.storageQuotaBytes ?? 1073741824
|
||||||
|
const percent = user?.usedPercent ?? 0
|
||||||
|
|
||||||
|
let barColor = 'var(--accent)'
|
||||||
|
let warningText: string | null = null
|
||||||
|
if (percent >= 95) {
|
||||||
|
barColor = 'var(--wrong)'
|
||||||
|
warningText = t('workspace.storageWarningCritical')
|
||||||
|
} else if (percent >= 80) {
|
||||||
|
barColor = 'var(--warn)'
|
||||||
|
warningText = t('workspace.storageWarningHigh')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="storage-overview">
|
||||||
|
<div className="storage-bar-track">
|
||||||
|
<div
|
||||||
|
className="storage-bar-fill"
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="storage-stats">
|
||||||
|
<span className="storage-used">{formatBytes(used)}</span>
|
||||||
|
<span className="storage-of"> {t('workspace.storageOf')} </span>
|
||||||
|
<span className="storage-total">{formatBytes(quota)}</span>
|
||||||
|
<span className="storage-percent"> ({percent}%)</span>
|
||||||
|
</div>
|
||||||
|
{warningText && (
|
||||||
|
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
|
||||||
|
{warningText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { user, refreshUser } = useAuth()
|
const { user, refreshUser } = useAuth()
|
||||||
@@ -288,6 +336,15 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.storage')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-panel-body workspace-settings-form">
|
||||||
|
<StoragePreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import * as examApi from '../api/examApi'
|
||||||
|
import * as resultApi from '../api/resultApi'
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StorageManager() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { user, refreshUser } = useAuth()
|
||||||
|
const [exams, setExams] = useState<examApi.ExamListItem[]>([])
|
||||||
|
const [results, setResults] = useState<resultApi.ResultListItem[]>([])
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const examList = await examApi.listExams()
|
||||||
|
setExams(examList)
|
||||||
|
const resultList = await resultApi.listResults()
|
||||||
|
setResults(resultList.results)
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const handleDeleteExam = useCallback(async (id: number) => {
|
||||||
|
if (!window.confirm(t('workspace.deleteExamConfirm'))) return
|
||||||
|
setDeleting(id)
|
||||||
|
try {
|
||||||
|
await examApi.deleteExam(id)
|
||||||
|
await loadData()
|
||||||
|
await refreshUser()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setDeleting(null)
|
||||||
|
}
|
||||||
|
}, [loadData, refreshUser, t])
|
||||||
|
|
||||||
|
const handleDeleteResult = useCallback(async (id: number) => {
|
||||||
|
if (!window.confirm(t('workspace.deleteResultConfirm'))) return
|
||||||
|
setDeleting(id)
|
||||||
|
try {
|
||||||
|
await resultApi.deleteResult(id)
|
||||||
|
await loadData()
|
||||||
|
await refreshUser()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setDeleting(null)
|
||||||
|
}
|
||||||
|
}, [loadData, refreshUser, t])
|
||||||
|
|
||||||
|
const used = user?.usedBytes ?? 0
|
||||||
|
const quota = user?.storageQuotaBytes ?? 1073741824
|
||||||
|
const percent = user?.usedPercent ?? 0
|
||||||
|
|
||||||
|
let barColor = 'var(--accent)'
|
||||||
|
let warningText: string | null = null
|
||||||
|
if (percent >= 95) {
|
||||||
|
barColor = 'var(--wrong)'
|
||||||
|
warningText = t('workspace.storageWarningCritical')
|
||||||
|
} else if (percent >= 80) {
|
||||||
|
barColor = 'var(--warn)'
|
||||||
|
warningText = t('workspace.storageWarningHigh')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="workspace-section fade-in">
|
||||||
|
<div className="workspace-section-header">
|
||||||
|
<div>
|
||||||
|
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
|
||||||
|
<h2 className="workspace-section-title">{t('workspace.storage')}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.storageUsage')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-panel-body workspace-settings-form">
|
||||||
|
<div className="storage-overview">
|
||||||
|
<div className="storage-bar-track">
|
||||||
|
<div
|
||||||
|
className="storage-bar-fill"
|
||||||
|
style={{ width: `${Math.min(percent, 100)}%`, backgroundColor: barColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="storage-stats">
|
||||||
|
<span className="storage-used">{formatBytes(used)}</span>
|
||||||
|
<span className="storage-of"> {t('workspace.storageOf')} </span>
|
||||||
|
<span className="storage-total">{formatBytes(quota)}</span>
|
||||||
|
<span className="storage-percent"> ({percent}%)</span>
|
||||||
|
</div>
|
||||||
|
{warningText && (
|
||||||
|
<div className={`workspace-alert ${percent >= 95 ? 'workspace-alert-error' : 'workspace-alert-warning'}`}>
|
||||||
|
{warningText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="storage-breakdown">
|
||||||
|
<div className="storage-breakdown-row">
|
||||||
|
<span>{t('workspace.storageExams')}</span>
|
||||||
|
<span>{exams.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="storage-breakdown-row">
|
||||||
|
<span>{t('workspace.storageResults')}</span>
|
||||||
|
<span>{results.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="workspace-settings-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setShowDetails((v) => !v)}
|
||||||
|
>
|
||||||
|
{showDetails ? t('workspace.hideDetails') : t('workspace.showDetails')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDetails && (
|
||||||
|
<div className="workspace-panel">
|
||||||
|
<div className="workspace-panel-header">
|
||||||
|
<h3>{t('workspace.storageDetails')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="workspace-panel-body">
|
||||||
|
{exams.length === 0 && results.length === 0 ? (
|
||||||
|
<p className="text-muted">{t('workspace.noStorageItems')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="storage-detail-list">
|
||||||
|
{exams.map((exam) => (
|
||||||
|
<div key={`exam-${exam.id}`} className="storage-detail-item">
|
||||||
|
<div className="storage-detail-info">
|
||||||
|
<span className="storage-detail-type">{t('workspace.exam')}</span>
|
||||||
|
<span className="storage-detail-title">{exam.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="storage-detail-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => handleDeleteExam(exam.id)}
|
||||||
|
disabled={deleting === exam.id}
|
||||||
|
>
|
||||||
|
{deleting === exam.id ? t('common.loading') : t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{results.map((result) => (
|
||||||
|
<div key={`result-${result.id}`} className="storage-detail-item">
|
||||||
|
<div className="storage-detail-info">
|
||||||
|
<span className="storage-detail-type">{t('workspace.result')}</span>
|
||||||
|
<span className="storage-detail-title">{result.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="storage-detail-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => handleDeleteResult(result.id)}
|
||||||
|
disabled={deleting === result.id}
|
||||||
|
>
|
||||||
|
{deleting === result.id ? t('common.loading') : t('common.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ import type { Exam } from '../types'
|
|||||||
import MyExams from './MyExams'
|
import MyExams from './MyExams'
|
||||||
import MyResults from './MyResults'
|
import MyResults from './MyResults'
|
||||||
import Settings from './Settings'
|
import Settings from './Settings'
|
||||||
|
import StorageManager from './StorageManager'
|
||||||
|
|
||||||
type TabKey = 'exams' | 'results' | 'settings'
|
type TabKey = 'exams' | 'results' | 'storage' | 'settings'
|
||||||
|
|
||||||
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
|
const TAB_KEYS: { key: TabKey; labelKey: string; icon: string }[] = [
|
||||||
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
|
{ key: 'exams', labelKey: 'workspace.myExams', icon: '◇' },
|
||||||
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
|
{ key: 'results', labelKey: 'workspace.results', icon: '✦' },
|
||||||
|
{ key: 'storage', labelKey: 'workspace.storage', icon: '◉' },
|
||||||
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
|
{ key: 'settings', labelKey: 'workspace.settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -83,7 +85,8 @@ export default function Workspace({ onLoadExam }: Props) {
|
|||||||
|
|
||||||
<main className="workspace-main">
|
<main className="workspace-main">
|
||||||
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
{activeTab === 'exams' && <MyExams onLoadExam={onLoadExam} />}
|
||||||
{activeTab === 'results' && <MyResults />}
|
{activeTab === 'results' && <MyResults onLoadExam={onLoadExam} />}
|
||||||
|
{activeTab === 'storage' && <StorageManager />}
|
||||||
{activeTab === 'settings' && <Settings />}
|
{activeTab === 'settings' && <Settings />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface User {
|
|||||||
email: string
|
email: string
|
||||||
displayName: string
|
displayName: string
|
||||||
role: 'user' | 'admin'
|
role: 'user' | 'admin'
|
||||||
|
storageQuotaBytes?: number
|
||||||
|
usedBytes?: number
|
||||||
|
usedPercent?: number
|
||||||
|
plan?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
@@ -47,7 +51,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await authApi.getMe()
|
const data = await authApi.getMe()
|
||||||
setUser({ id: data.id, email: data.email, displayName: data.displayName, role: (data.role as 'user' | 'admin') ?? 'user' })
|
setUser({
|
||||||
|
id: data.id,
|
||||||
|
email: data.email,
|
||||||
|
displayName: data.displayName,
|
||||||
|
role: (data.role as 'user' | 'admin') ?? 'user',
|
||||||
|
storageQuotaBytes: data.storageQuotaBytes,
|
||||||
|
usedBytes: data.usedBytes,
|
||||||
|
usedPercent: data.usedPercent,
|
||||||
|
plan: data.plan,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
clearToken()
|
clearToken()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
@@ -63,7 +76,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const login = useCallback(async (email: string, password: string) => {
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
const data = await authApi.login({ email, password })
|
const data = await authApi.login({ email, password })
|
||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setUser({ ...data.user, role: (data.user.role as 'user' | 'admin') ?? 'user' })
|
setUser({
|
||||||
|
...data.user,
|
||||||
|
role: (data.user.role as 'user' | 'admin') ?? 'user',
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
|
const register = useCallback(async (email: string, password: string, displayName: string): Promise<string> => {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"clearHistory": "Clear history",
|
"clearHistory": "Clear history",
|
||||||
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
|
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
|
||||||
"review": "Review",
|
"review": "Review",
|
||||||
|
"retake": "Retake",
|
||||||
"loadError": "Could not load: {{message}}",
|
"loadError": "Could not load: {{message}}",
|
||||||
"loadSampleError": "Could not load sample: {{message}}",
|
"loadSampleError": "Could not load sample: {{message}}",
|
||||||
"clearHistoryConfirm": "Clear all past attempts?"
|
"clearHistoryConfirm": "Clear all past attempts?"
|
||||||
@@ -110,6 +111,15 @@
|
|||||||
"untimed": "Untimed"
|
"untimed": "Untimed"
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
|
"ai": {
|
||||||
|
"button": "AI Review",
|
||||||
|
"suggestedGrade": "AI Suggested Grade",
|
||||||
|
"feedback": "AI Feedback",
|
||||||
|
"accept": "Accept",
|
||||||
|
"keepOriginal": "Keep original",
|
||||||
|
"rubricBreakdown": "Rubric breakdown",
|
||||||
|
"error": "AI grading failed — please grade manually"
|
||||||
|
},
|
||||||
"reviewing": "Reviewing past attempt",
|
"reviewing": "Reviewing past attempt",
|
||||||
"examComplete": "Exam complete",
|
"examComplete": "Exam complete",
|
||||||
"scored": "You scored ",
|
"scored": "You scored ",
|
||||||
@@ -124,6 +134,8 @@
|
|||||||
"explanation": "Explanation",
|
"explanation": "Explanation",
|
||||||
"rubric": "Rubric (self-grade)",
|
"rubric": "Rubric (self-grade)",
|
||||||
"retakeExam": "Retake exam",
|
"retakeExam": "Retake exam",
|
||||||
|
"redoFailed": "Redo failed questions",
|
||||||
|
"cantRetake": "Exam no longer saved in workspace",
|
||||||
"backToHome": "Back to home"
|
"backToHome": "Back to home"
|
||||||
},
|
},
|
||||||
"tweaks": {
|
"tweaks": {
|
||||||
@@ -184,9 +196,35 @@
|
|||||||
"textSizeDesc": "Adjust the base font size for readability",
|
"textSizeDesc": "Adjust the base font size for readability",
|
||||||
"defaultFeedback": "Default Feedback",
|
"defaultFeedback": "Default Feedback",
|
||||||
"defaultFeedbackLabel": "Feedback mode",
|
"defaultFeedbackLabel": "Feedback mode",
|
||||||
"defaultFeedbackDesc": "Choose when to see answer feedback during exams"
|
"defaultFeedbackDesc": "Choose when to see answer feedback during exams",
|
||||||
|
"storage": "Storage",
|
||||||
|
"storageUsage": "Storage Usage",
|
||||||
|
"storageOf": "of",
|
||||||
|
"storageExams": "Saved exams",
|
||||||
|
"storageResults": "Results",
|
||||||
|
"showDetails": "Show details",
|
||||||
|
"hideDetails": "Hide details",
|
||||||
|
"storageDetails": "Storage Details",
|
||||||
|
"noStorageItems": "No items using storage.",
|
||||||
|
"exam": "Exam",
|
||||||
|
"result": "Result",
|
||||||
|
"deleteExamConfirm": "Delete this exam? This cannot be undone.",
|
||||||
|
"deleteResultConfirm": "Delete this result? This cannot be undone.",
|
||||||
|
"storageWarningHigh": "You are using over 80% of your storage.",
|
||||||
|
"storageWarningCritical": "You are using over 95% of your storage. Delete some items to free up space."
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"aiGrading": {
|
||||||
|
"title": "AI Grading",
|
||||||
|
"provider": "Provider",
|
||||||
|
"model": "Model",
|
||||||
|
"apiKey": "API Key",
|
||||||
|
"baseUrl": "Base URL",
|
||||||
|
"baseUrlDesc": "Custom API base URL for OpenAI-compatible endpoints",
|
||||||
|
"testButton": "Test connection",
|
||||||
|
"testSuccess": "Connection successful",
|
||||||
|
"testFailed": "Connection failed"
|
||||||
|
},
|
||||||
"administration": "Administration",
|
"administration": "Administration",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"backToSite": "← Back to site",
|
"backToSite": "← Back to site",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"clearHistory": "Wis geschiedenis",
|
"clearHistory": "Wis geschiedenis",
|
||||||
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
|
"dateTimeFormat": "{{date}} · {{correct}}/{{graded}} correct",
|
||||||
"review": "Nakijken",
|
"review": "Nakijken",
|
||||||
|
"retake": "Opnieuw maken",
|
||||||
"loadError": "Kon niet laden: {{message}}",
|
"loadError": "Kon niet laden: {{message}}",
|
||||||
"loadSampleError": "Kon voorbeeld niet laden: {{message}}",
|
"loadSampleError": "Kon voorbeeld niet laden: {{message}}",
|
||||||
"clearHistoryConfirm": "Alle eerdere pogingen wissen?"
|
"clearHistoryConfirm": "Alle eerdere pogingen wissen?"
|
||||||
@@ -110,6 +111,15 @@
|
|||||||
"untimed": "Ongetimed"
|
"untimed": "Ongetimed"
|
||||||
},
|
},
|
||||||
"results": {
|
"results": {
|
||||||
|
"ai": {
|
||||||
|
"button": "AI-nakijken",
|
||||||
|
"suggestedGrade": "AI-suggestie",
|
||||||
|
"feedback": "AI-feedback",
|
||||||
|
"accept": "Accepteren",
|
||||||
|
"keepOriginal": "Origineel houden",
|
||||||
|
"rubricBreakdown": "Rubric-uitsplitsing",
|
||||||
|
"error": "AI-beoordeling mislukt — beoordeel handmatig"
|
||||||
|
},
|
||||||
"reviewing": "Eerdere poging bekijken",
|
"reviewing": "Eerdere poging bekijken",
|
||||||
"examComplete": "Examen voltooid",
|
"examComplete": "Examen voltooid",
|
||||||
"scored": "Je score: ",
|
"scored": "Je score: ",
|
||||||
@@ -124,6 +134,8 @@
|
|||||||
"explanation": "Uitleg",
|
"explanation": "Uitleg",
|
||||||
"rubric": "Rubric (zelfbeoordeling)",
|
"rubric": "Rubric (zelfbeoordeling)",
|
||||||
"retakeExam": "Examen opnieuw maken",
|
"retakeExam": "Examen opnieuw maken",
|
||||||
|
"redoFailed": "Foute vragen opnieuw maken",
|
||||||
|
"cantRetake": "Examen niet meer opgeslagen in werkruimte",
|
||||||
"backToHome": "Terug naar home"
|
"backToHome": "Terug naar home"
|
||||||
},
|
},
|
||||||
"tweaks": {
|
"tweaks": {
|
||||||
@@ -184,9 +196,35 @@
|
|||||||
"textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid",
|
"textSizeDesc": "Pas de basislettergrootte aan voor leesbaarheid",
|
||||||
"defaultFeedback": "Standaard Feedback",
|
"defaultFeedback": "Standaard Feedback",
|
||||||
"defaultFeedbackLabel": "Feedbackmodus",
|
"defaultFeedbackLabel": "Feedbackmodus",
|
||||||
"defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens"
|
"defaultFeedbackDesc": "Kies wanneer je antwoordfeedback ziet tijdens examens",
|
||||||
|
"storage": "Opslag",
|
||||||
|
"storageUsage": "Opslaggebruik",
|
||||||
|
"storageOf": "van",
|
||||||
|
"storageExams": "Opgeslagen examens",
|
||||||
|
"storageResults": "Resultaten",
|
||||||
|
"showDetails": "Toon details",
|
||||||
|
"hideDetails": "Verberg details",
|
||||||
|
"storageDetails": "Opslagdetails",
|
||||||
|
"noStorageItems": "Geen items die opslag gebruiken.",
|
||||||
|
"exam": "Examen",
|
||||||
|
"result": "Resultaat",
|
||||||
|
"deleteExamConfirm": "Dit examen verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"deleteResultConfirm": "Dit resultaat verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||||
|
"storageWarningHigh": "Je gebruikt meer dan 80% van je opslag.",
|
||||||
|
"storageWarningCritical": "Je gebruikt meer dan 95% van je opslag. Verwijder enkele items om ruimte vrij te maken."
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"aiGrading": {
|
||||||
|
"title": "AI-beoordeling",
|
||||||
|
"provider": "Provider",
|
||||||
|
"model": "Model",
|
||||||
|
"apiKey": "API-sleutel",
|
||||||
|
"baseUrl": "Basis-URL",
|
||||||
|
"baseUrlDesc": "Aangepaste API basis-URL voor OpenAI-compatible endpoints",
|
||||||
|
"testButton": "Verbinding testen",
|
||||||
|
"testSuccess": "Verbinding geslaagd",
|
||||||
|
"testFailed": "Verbinding mislukt"
|
||||||
|
},
|
||||||
"administration": "Beheer",
|
"administration": "Beheer",
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"backToSite": "← Terug naar site",
|
"backToSite": "← Terug naar site",
|
||||||
|
|||||||
+125
-1
@@ -424,7 +424,7 @@ select {
|
|||||||
}
|
}
|
||||||
.history-row {
|
.history-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto auto auto;
|
grid-template-columns: 1fr auto auto auto auto;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
@@ -2664,6 +2664,12 @@ select {
|
|||||||
border: 1px solid var(--correct);
|
border: 1px solid var(--correct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-alert-warning {
|
||||||
|
background: var(--warn-weak);
|
||||||
|
color: var(--warn);
|
||||||
|
border: 1px solid var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-alert-close {
|
.workspace-alert-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -2921,6 +2927,113 @@ select {
|
|||||||
Workspace / Editor Responsive
|
Workspace / Editor Responsive
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
/* ---------- Storage ---------- */
|
||||||
|
.storage-overview {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.storage-bar-track {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--paper-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.storage-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 300ms ease, background-color 300ms ease;
|
||||||
|
}
|
||||||
|
.storage-stats {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.storage-used {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.storage-of {
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.storage-total {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.storage-percent {
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.storage-breakdown {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.storage-breakdown-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13.5px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
color: var(--ink-2);
|
||||||
|
}
|
||||||
|
.storage-detail-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.storage-detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.storage-detail-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.storage-detail-type {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--ink-4);
|
||||||
|
background: var(--paper-2);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.storage-detail-title {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.storage-detail-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--wrong-weak);
|
||||||
|
color: var(--wrong);
|
||||||
|
border-color: var(--wrong);
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--wrong);
|
||||||
|
color: var(--paper);
|
||||||
|
}
|
||||||
|
.text-muted {
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.workspace-shell {
|
.workspace-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -2992,4 +3105,15 @@ select {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.storage-detail-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.storage-detail-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.storage-detail-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,7 @@ export interface ExamState {
|
|||||||
startedAt: number;
|
startedAt: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
feedbackMode: FeedbackMode;
|
feedbackMode: FeedbackMode;
|
||||||
|
isRedo?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
|
|||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "brainwave",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.161699999999996,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":9.518499999999989,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":7.022400000000005,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.217900000000014,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":4.687600000000003,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.788000000000011,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.534500000000008,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":2.5043000000000006,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.5700999999999965,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.011300000000006,"failed":false}],[":backend/src/config.test.ts",{"duration":4.423100000000005,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":5.812899999999985,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":73.72379999999998,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.070899999999995,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":4.5079000000000065,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":3.4222000000000037,"failed":false}]]}
|
{"version":"4.1.5","results":[[":frontend/src/utils/grading.test.ts",{"duration":7.442099999999982,"failed":false}],[":backend/dist/validators/resultSchemas.test.js",{"duration":5.80680000000001,"failed":false}],[":backend/src/validators/authSchemas.test.ts",{"duration":12.700600000000009,"failed":false}],[":backend/src/validators/resultSchemas.test.ts",{"duration":6.035500000000013,"failed":false}],[":backend/dist/validators/authSchemas.test.js",{"duration":5.9454000000000065,"failed":false}],[":frontend/src/utils/storage.test.ts",{"duration":6.9056999999999675,"failed":false}],[":backend/dist/validators/examSchemas.test.js",{"duration":7.014700000000005,"failed":false}],[":backend/dist/config.test.js",{"duration":4.3396999999999935,"failed":false}],[":backend/src/validators/adminSchemas.test.ts",{"duration":7.116100000000017,"failed":false}],[":frontend/src/utils/latex.test.ts",{"duration":11.073699999999974,"failed":false}],[":backend/dist/validators/adminSchemas.test.js",{"duration":6.721499999999992,"failed":false}],[":backend/src/config.test.ts",{"duration":4.0815000000000055,"failed":false}],[":backend/src/validators/examSchemas.test.ts",{"duration":6.222700000000032,"failed":false}],[":backend/dist/utils/tokens.test.js",{"duration":166.78979999999999,"failed":false}],[":frontend/src/utils/validation.test.ts",{"duration":4.239000000000004,"failed":false}],[":backend/src/utils/tokens.test.ts",{"duration":118.4222,"failed":false}],[":frontend/src/utils/slug.test.ts",{"duration":4.551799999999986,"failed":false}],[":backend/src/utils/asyncHandler.test.ts",{"duration":5.303799999999995,"failed":false}],[":backend/src/validators/setupSchemas.test.ts",{"duration":5.920199999999994,"failed":false}],[":backend/dist/validators/setupSchemas.test.js",{"duration":6.263800000000003,"failed":false}],[":backend/dist/middleware/error.test.js",{"duration":2.775499999999994,"failed":false}],[":backend/dist/utils/asyncHandler.test.js",{"duration":5.524900000000002,"failed":false}],[":frontend/src/utils/formatting.test.ts",{"duration":4.508899999999983,"failed":false}],[":backend/src/middleware/error.test.ts",{"duration":3.431699999999978,"failed":false}],[":frontend/src/utils/api.test.ts",{"duration":2.2516999999999996,"failed":false}],[":backend/src/repositories/storageRepo.test.ts",{"duration":10.521900000000016,"failed":false}]]}
|
||||||
Generated
+24
-1
@@ -1,6 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "brainwave",
|
"name": "brainwave",
|
||||||
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {}
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "brainwave",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "brainwave",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user