fix: frontend type errors and add k8s manifests
This commit is contained in:
56
conductor/superadmin-crud.md
Normal file
56
conductor/superadmin-crud.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Superadmin CRUD Implementation Plan
|
||||
|
||||
**Objective:** Implement a superadmin role to manage courses and tutors, ensuring only authorized users can perform system-wide administrative actions. This feature will be developed in an isolated git worktree.
|
||||
|
||||
## Key Context & Decisions
|
||||
- **Role Strategy:** A new `is_superadmin` boolean column will be added to the `tutors` database table.
|
||||
- **UI Structure:** A dedicated `/admin/tutors` page will handle tutor management. Course management will remain on `/admin/courses` but will be enhanced with superadmin-only actions (e.g., assigning tutors to courses).
|
||||
- **Workspace:** Development will be done in `.worktrees/feature-superadmin-crud`.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Workspace Isolation via Git Worktree
|
||||
- Create a new git worktree: `git worktree add .worktrees/feature-superadmin-crud -b feature-superadmin-crud`
|
||||
- All subsequent steps will be performed inside this isolated workspace.
|
||||
|
||||
### 2. Database & Models
|
||||
- Create migration `backend/migrations/002_add_superadmin.sql` to add `is_superadmin BOOLEAN NOT NULL DEFAULT 0` to the `tutors` table.
|
||||
- Update `backend/demo/demo_seed.sql` to set the default `admin@tutortool.com` as a superadmin (`is_superadmin = 1`).
|
||||
- Update `backend/src/models.rs` to include `is_superadmin: bool` in the `Tutor` struct.
|
||||
- Add `CreateTutor` and `TutorResponse` structs to `backend/src/models.rs`.
|
||||
|
||||
### 3. Auth & Core Backend
|
||||
- Modify `backend/src/auth.rs` to include `is_superadmin: bool` in `TutorClaims`. This allows auth guards to check permissions efficiently.
|
||||
- Update `backend/src/routes/auth_routes.rs` login handler to fetch `is_superadmin` and encode it in the JWT.
|
||||
- Add a helper function to verify superadmin access to reject unauthorized requests.
|
||||
|
||||
### 4. Tutors API
|
||||
- Create `backend/src/routes/tutors.rs` with endpoints:
|
||||
- `GET /api/admin/tutors` (list all tutors)
|
||||
- `POST /api/admin/tutors` (create a tutor, hashing their password)
|
||||
- `DELETE /api/admin/tutors/:id` (delete a tutor)
|
||||
- Merge these routes in `backend/src/routes/mod.rs`.
|
||||
|
||||
### 5. Course Assignments API
|
||||
- Modify `backend/src/routes/courses.rs`:
|
||||
- Enhance `GET /api/admin/courses` to return ALL courses if `claims.is_superadmin` is true, otherwise only return assigned courses.
|
||||
- Restrict `POST /api/admin/courses` to superadmins only.
|
||||
- Add `POST /api/admin/courses/:id/tutors` to assign a tutor to a course (superadmin only).
|
||||
- Add `DELETE /api/admin/courses/:id/tutors/:tutor_id` to remove a tutor from a course (superadmin only).
|
||||
- Add `GET /api/admin/courses/:id/tutors` to list tutors assigned to a course.
|
||||
|
||||
### 6. Frontend Auth & API Client
|
||||
- Update `frontend/src/lib/types.ts` to include `Tutor` and the new `is_superadmin` flag in token payload or state.
|
||||
- Add the new endpoints to `frontend/src/lib/api.ts` under `api.admin.tutors` and enhance `api.admin.courses`.
|
||||
|
||||
### 7. Frontend UI: Tutors Management
|
||||
- Update `frontend/src/lib/components/TutorShell.svelte` to conditionally render a "Tutor:innen" link in the sidebar if the user is a superadmin.
|
||||
- Create `frontend/src/routes/admin/tutors/+page.svelte` following the paper-bg design system. Include a list of tutors and a form to add a new tutor.
|
||||
|
||||
### 8. Frontend UI: Courses Enhancements
|
||||
- Modify `frontend/src/routes/admin/courses/+page.svelte` to show a "Tutor:innen zuweisen" (Assign Tutors) section for each course if the logged-in user is a superadmin.
|
||||
- Restrict the course creation form to superadmins only.
|
||||
|
||||
## Verification & Testing
|
||||
- Run `cargo test` in the backend to ensure existing tests pass and new route isolation works.
|
||||
- Perform a manual end-to-end test using the `make dev` script in the new worktree to verify the UI.
|
||||
@@ -21,7 +21,7 @@
|
||||
occupiedSeatIds = [],
|
||||
mySeatId = null,
|
||||
studentNames = {}
|
||||
} = $props<Props>();
|
||||
}: Props = $props();
|
||||
|
||||
let draggingId = $state<string | null>(null);
|
||||
let startX = 0;
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!draggingId || !editable) return;
|
||||
const index = elements.findIndex(el => el.id === draggingId);
|
||||
const index = elements.findIndex((el: LayoutElement) => el.id === draggingId);
|
||||
if (index === -1) return;
|
||||
|
||||
const newX = Math.round((e.clientX - startX) / 10) * 10 / 40;
|
||||
|
||||
@@ -1,85 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Course, Session } from '$lib/types';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import type { Course, Session } from '$lib/types';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
|
||||
|
||||
let courses = $state<Course[]>([]);
|
||||
let selectedCourseId = $state<number | null>(null);
|
||||
let sessions = $state<Session[]>([]);
|
||||
let courses = $state<Course[]>([]);
|
||||
let selectedCourseId = $state<number | null>(null);
|
||||
let sessions = $state<Session[]>([]);
|
||||
|
||||
onMount(async () => {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
||||
});
|
||||
onMount(async () => {
|
||||
courses = await api.admin.courses.list();
|
||||
if (courses.length > 0) selectedCourseId = courses[0].id;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (selectedCourseId) {
|
||||
api.admin.sessions.list(selectedCourseId).then(res => sessions = res);
|
||||
}
|
||||
});
|
||||
|
||||
function download(url: string, filename: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
$effect(() => {
|
||||
if (selectedCourseId) {
|
||||
api.admin.sessions.list(selectedCourseId).then((res: Session[]) => sessions = res);
|
||||
}
|
||||
});
|
||||
|
||||
function download(url: string, filename: string) {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Export Data</h1>
|
||||
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
|
||||
|
||||
<div class="export-grid">
|
||||
<div class="export-section">
|
||||
<h2>Global</h2>
|
||||
<div class="export-card">
|
||||
<h3>Full Database Backup</h3>
|
||||
<p>Download the latest SQLite database file.</p>
|
||||
<button onclick={() => window.open(api.admin.export.backup(), '_blank')}>Download .sqlite</button>
|
||||
<!-- Header -->
|
||||
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
|
||||
<div>
|
||||
<div class="eyebrow">Datenexport</div>
|
||||
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
|
||||
Exporte
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:28px;align-items:start">
|
||||
|
||||
<!-- Left: Global -->
|
||||
<section style="display:flex;flex-direction:column;gap:16px">
|
||||
<div>
|
||||
<div class="serif" style="font-size:18px;font-weight:500">Global</div>
|
||||
<UnderlineStroke width={60} />
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<div>
|
||||
<div class="serif" style="font-size:16px;font-weight:500">Datenbank-Backup</div>
|
||||
<div class="body" style="color:var(--ink-3);margin-top:4px">
|
||||
Lädt die aktuelle SQLite-Datenbank herunter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:4px">
|
||||
<button class="btn ghost" onclick={() => window.open(api.admin.export.backup(), '_blank')}>
|
||||
<Icon name="download" size={12} /> .sqlite Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="export-section">
|
||||
<h2>Per Course</h2>
|
||||
<select bind:value={selectedCourseId}>
|
||||
<!-- Right: Per Course -->
|
||||
<section style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-end">
|
||||
<div>
|
||||
<div class="serif" style="font-size:18px;font-weight:500">Pro Kurs</div>
|
||||
<UnderlineStroke width={80} />
|
||||
</div>
|
||||
|
||||
{#if courses.length > 0}
|
||||
<select class="input" style="font-size:12px;width:160px" bind:value={selectedCourseId}>
|
||||
{#each courses as course}
|
||||
<option value={course.id}>{course.name}</option>
|
||||
<option value={course.id}>{course.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if selectedCourseId}
|
||||
<div class="export-card">
|
||||
<h3>Full Attendance Matrix</h3>
|
||||
<p>All weeks, all students, includes bonus point calculation.</p>
|
||||
<div class="btn-group">
|
||||
<button onclick={() => window.open(api.admin.export.courseCsv(selectedCourseId!), '_blank')}>CSV</button>
|
||||
<button onclick={() => window.open(api.admin.export.courseMd(selectedCourseId!), '_blank')}>Markdown</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Weekly Exports</h3>
|
||||
<div class="sessions-list">
|
||||
{#each sessions as session}
|
||||
<div class="session-export-row">
|
||||
<span>Week {session.week_nr} ({session.date})</span>
|
||||
<div class="btn-group">
|
||||
<button class="small" onclick={() => window.open(api.admin.export.sessionCsv(session.id), '_blank')}>CSV</button>
|
||||
<button class="small" onclick={() => window.open(api.admin.export.sessionMd(session.id), '_blank')}>MD</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.export-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 20px; }
|
||||
.export-card { background: #f8f9fa; padding: 20px; border-radius: 8px; margin: 15px 0; border: 1px solid #dee2e6; }
|
||||
.btn-group { display: flex; gap: 10px; }
|
||||
button { padding: 8px 16px; cursor: pointer; }
|
||||
button.small { padding: 4px 8px; font-size: 0.9em; }
|
||||
.session-export-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #eee; }
|
||||
select { width: 100%; padding: 8px; margin-bottom: 10px; }
|
||||
</style>
|
||||
{#if selectedCourseId}
|
||||
<div class="card" style="padding:20px;display:flex;flex-direction:column;gap:12px">
|
||||
<div>
|
||||
<div class="serif" style="font-size:16px;font-weight:500">Komplette Kursmatrix</div>
|
||||
<div class="body" style="color:var(--ink-3);margin-top:4px">
|
||||
Alle Wochen, alle Studierenden, inkl. Bonuspunkte-Berechnung.
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;margin-top:4px">
|
||||
<button class="btn ghost" onclick={() => window.open(api.admin.export.courseCsv(selectedCourseId!), '_blank')}>CSV</button>
|
||||
<button class="btn ghost" onclick={() => window.open(api.admin.export.courseMd(selectedCourseId!), '_blank')}>Markdown</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:8px;margin-top:8px">
|
||||
<div class="eyebrow" style="margin-bottom:4px">Wochen-Exporte</div>
|
||||
|
||||
<div class="card" style="overflow:hidden">
|
||||
{#if sessions.length === 0}
|
||||
<div style="padding:16px;text-align:center">
|
||||
<span class="small" style="color:var(--ink-4)">Keine Sitzungen vorhanden.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||||
<tbody>
|
||||
{#each sessions as session, i}
|
||||
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
|
||||
<td style="padding:12px 16px">
|
||||
<div class="serif" style="font-weight:500;font-size:14px">Woche {String(session.week_nr).padStart(2, '0')}</div>
|
||||
<div class="tiny" style="color:var(--ink-4)">{session.date}</div>
|
||||
</td>
|
||||
<td style="padding:12px 16px;text-align:right">
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn ghost sm" onclick={() => window.open(api.admin.export.sessionCsv(session.id), '_blank')}>CSV</button>
|
||||
<button class="btn ghost sm" onclick={() => window.open(api.admin.export.sessionMd(session.id), '_blank')}>MD</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
25
k8s/ingress.yaml
Normal file
25
k8s/ingress.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: tutortool
|
||||
namespace: tutortool
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- tutor.puchstein.dev
|
||||
secretName: tutortool-tls
|
||||
rules:
|
||||
- host: tutor.puchstein.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: tutortool
|
||||
port:
|
||||
number: 80
|
||||
13
k8s/service.yaml
Normal file
13
k8s/service.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: tutortool
|
||||
namespace: tutortool
|
||||
spec:
|
||||
selector:
|
||||
app: tutortool
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
Reference in New Issue
Block a user