Merge branch 'feature-superadmin-crud'

# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
This commit is contained in:
2026-04-29 02:48:43 +02:00
18 changed files with 547 additions and 87 deletions

View File

@@ -1,7 +1,7 @@
-- Demo Seed Data
-- Tutor/Admin Account (Password: admin)
INSERT OR IGNORE INTO tutors (id, name, email, password_hash)
VALUES (1, 'Demo Admin', 'admin@tutortool.com', '$2b$12$ted9u9ZsxbjhnWvTYsijMul138qhIKQG1RVsY8wGA3RFKZl8EaAsm');
INSERT OR IGNORE INTO tutors (id, name, email, password_hash, is_superadmin)
VALUES (1, 'Demo Admin', 'admin@tutortool.com', '$2b$12$ted9u9ZsxbjhnWvTYsijMul138qhIKQG1RVsY8wGA3RFKZl8EaAsm', 1);
-- Courses
INSERT OR IGNORE INTO courses (id, name, semester)

View File

@@ -0,0 +1,2 @@
-- Add is_superadmin column to tutors table
ALTER TABLE tutors ADD COLUMN is_superadmin BOOLEAN NOT NULL DEFAULT 0;

View File

@@ -4,7 +4,12 @@ use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TutorClaims { pub sub: i64, pub email: String, pub exp: u64 }
pub struct TutorClaims {
pub sub: i64,
pub email: String,
pub is_superadmin: bool,
pub exp: u64,
}
fn secret() -> Result<String, AppError> {
std::env::var("JWT_SECRET").map_err(|_| {
@@ -13,11 +18,20 @@ fn secret() -> Result<String, AppError> {
})
}
pub fn encode_jwt(id: i64, email: &str) -> Result<String, AppError> {
pub fn encode_jwt(id: i64, email: &str, is_superadmin: bool) -> Result<String, AppError> {
let exp = (chrono::Utc::now() + chrono::Duration::days(7)).timestamp() as u64;
let claims = TutorClaims { sub: id, email: email.into(), exp };
encode(&Header::default(), &claims, &EncodingKey::from_secret(secret()?.as_bytes()))
.map_err(|_| AppError::Unauthorized)
let claims = TutorClaims {
sub: id,
email: email.into(),
is_superadmin,
exp,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret()?.as_bytes()),
)
.map_err(|_| AppError::Unauthorized)
}
pub fn decode_jwt(token: &str) -> Result<TutorClaims, AppError> {
@@ -52,9 +66,10 @@ mod tests {
unsafe { std::env::set_var("JWT_SECRET", "testsecret_auth"); }
// roundtrip
let token = encode_jwt(1, "test@example.com").unwrap();
let token = encode_jwt(1, "test@example.com", true).unwrap();
let claims = decode_jwt(&token).unwrap();
assert_eq!(claims.sub, 1);
assert!(claims.is_superadmin);
// rejection
assert!(decode_jwt("not.a.token").is_err());

View File

@@ -5,7 +5,12 @@ use serde::{Deserialize, Serialize};
pub struct Course { pub id: i64, pub name: String, pub semester: String }
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Tutor { pub id: i64, pub name: String, pub email: String }
pub struct Tutor {
pub id: i64,
pub name: String,
pub email: String,
pub is_superadmin: bool,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Student { pub id: i64, pub course_id: i64, pub name: String }
@@ -60,6 +65,13 @@ pub struct LayoutElement {
}
#[derive(Deserialize)] pub struct UpsertNote { pub content: String }
#[derive(Deserialize)] pub struct ManualAttendance { pub student_id: i64 }
#[derive(Deserialize)] pub struct CreateTutor {
pub name: String,
pub email: String,
pub password: String,
pub is_superadmin: bool,
}
#[derive(Deserialize)] pub struct AssignTutor { pub tutor_id: i64 }
#[derive(Deserialize)] pub struct CheckinRequest {
pub code: String,
pub student_id: i64,

View File

@@ -11,16 +11,16 @@ async fn login(
State(pool): State<SqlitePool>,
Json(req): Json<LoginRequest>,
) -> Result<Json<Value>, AppError> {
let tutor: Option<(i64, String, String)> = sqlx::query_as(
"SELECT id, email, password_hash FROM tutors WHERE email = ?"
let tutor: Option<(i64, String, String, bool)> = sqlx::query_as(
"SELECT id, email, password_hash, is_superadmin FROM tutors WHERE email = ?"
).bind(&req.email).fetch_optional(&pool).await?;
let (id, email, hash) = tutor.ok_or(AppError::Unauthorized)?;
let (id, email, hash, is_superadmin) = tutor.ok_or(AppError::Unauthorized)?;
if !bcrypt::verify(&req.password, &hash).unwrap_or(false) {
return Err(AppError::Unauthorized);
}
let token = auth::encode_jwt(id, &email)?;
Ok(Json(json!({"token": token})))
let token = auth::encode_jwt(id, &email, is_superadmin)?;
Ok(Json(json!({"token": token, "is_superadmin": is_superadmin})))
}
pub fn router() -> Router<SqlitePool> {
@@ -44,7 +44,9 @@ mod tests {
let (status, body) = post_json(app, "/api/auth/login", "",
json!({"email":"t@test.com","password":"secret"})).await;
assert_eq!(status, 200);
assert!(serde_json::from_slice::<Value>(&body).unwrap()["token"].is_string());
let res = serde_json::from_slice::<Value>(&body).unwrap();
assert!(res["token"].is_string());
assert_eq!(res["is_superadmin"], false);
}
#[sqlx::test(migrations = "./migrations")]

View File

@@ -289,7 +289,7 @@ pub fn router() -> Router<SqlitePool> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, get, patch_json, post_json};
use crate::test_helpers::{build_test_admin_app, build_test_app, get, patch_json, post_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
@@ -298,7 +298,7 @@ mod tests {
async fn seed_open_slot_with_room(
pool: &sqlx::SqlitePool,
) -> (axum::Router, String, String, i64, i64, i64) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
// Course
let (_, body) = post_json(
@@ -314,7 +314,7 @@ mod tests {
// Tutor enrollment
let (tutor_id,): (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(pool)
.await
.unwrap();

View File

@@ -13,34 +13,102 @@ use crate::{
models::{Course, CreateCourse, CreateStudent, Student},
};
// Fix 2: filter courses to only those the tutor is a member of
// Fix 2: filter courses to only those the tutor is a member of, or all if superadmin
async fn list_courses(
State(pool): State<SqlitePool>,
claims: TutorClaims,
) -> Result<Json<Vec<Course>>, AppError> {
let courses = sqlx::query_as::<_, Course>(
"SELECT c.id, c.name, c.semester FROM courses c
JOIN tutor_courses tc ON tc.course_id = c.id
WHERE tc.tutor_id = ?"
)
.bind(claims.sub)
.fetch_all(&pool)
.await?;
let courses = if claims.is_superadmin {
sqlx::query_as::<_, Course>("SELECT id, name, semester FROM courses ORDER BY name")
.fetch_all(&pool)
.await?
} else {
sqlx::query_as::<_, Course>(
"SELECT c.id, c.name, c.semester FROM courses c
JOIN tutor_courses tc ON tc.course_id = c.id
WHERE tc.tutor_id = ?"
)
.bind(claims.sub)
.fetch_all(&pool)
.await?
};
Ok(Json(courses))
}
async fn create_course(
_claims: TutorClaims,
claims: TutorClaims,
State(pool): State<SqlitePool>,
Json(req): Json<CreateCourse>,
) -> Result<(StatusCode, Json<Value>), AppError> {
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
let id = sqlx::query("INSERT INTO courses (name, semester) VALUES (?, ?)")
.bind(&req.name)
.bind(&req.semester)
.execute(&pool)
.await?
.last_insert_rowid();
Ok((StatusCode::CREATED, Json(json!({"id": id, "name": req.name, "semester": req.semester}))))
Ok((
StatusCode::CREATED,
Json(json!({"id": id, "name": req.name, "semester": req.semester})),
))
}
async fn assign_tutor(
claims: TutorClaims,
State(pool): State<SqlitePool>,
Path(course_id): Path<i64>,
Json(req): Json<crate::models::AssignTutor>,
) -> Result<StatusCode, AppError> {
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
sqlx::query("INSERT OR IGNORE INTO tutor_courses (tutor_id, course_id) VALUES (?, ?)")
.bind(req.tutor_id)
.bind(course_id)
.execute(&pool)
.await?;
Ok(StatusCode::OK)
}
async fn unassign_tutor(
claims: TutorClaims,
State(pool): State<SqlitePool>,
Path((course_id, tutor_id)): Path<(i64, i64)>,
) -> Result<StatusCode, AppError> {
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
sqlx::query("DELETE FROM tutor_courses WHERE tutor_id = ? AND course_id = ?")
.bind(tutor_id)
.bind(course_id)
.execute(&pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_assigned_tutors(
claims: TutorClaims,
State(pool): State<SqlitePool>,
Path(course_id): Path<i64>,
) -> Result<Json<Vec<crate::models::Tutor>>, AppError> {
// Only superadmins or assigned tutors can see who else is assigned?
// Let's allow superadmins or anyone assigned to the course.
if !claims.is_superadmin {
super::verify_tutor_course_access(&pool, claims.sub, course_id).await?;
}
let tutors = sqlx::query_as::<_, crate::models::Tutor>(
"SELECT t.id, t.name, t.email, t.is_superadmin FROM tutors t
JOIN tutor_courses tc ON tc.tutor_id = t.id
WHERE tc.course_id = ?"
)
.bind(course_id)
.fetch_all(&pool)
.await?;
Ok(Json(tutors))
}
// Fix 3: verify tutor has access to this course
@@ -179,20 +247,22 @@ pub fn router() -> Router<SqlitePool> {
get(list_students).post(add_student),
)
.route("/api/admin/courses/{id}/students/import", post(import_students))
.route("/api/admin/courses/{id}/tutors", get(list_assigned_tutors).post(assign_tutor))
.route("/api/admin/courses/{id}/tutors/{tutor_id}", delete(unassign_tutor))
.route("/api/admin/students/{id}", delete(delete_student))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, delete, get, post_json};
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, post_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
use sqlx::SqlitePool;
// Fix 6: helper to seed tutor_courses membership
async fn add_tutor_to_course(pool: &SqlitePool, course_id: i64) {
let tutor_id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
let tutor_id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(pool)
.await
.unwrap();
@@ -206,7 +276,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn create_and_list_courses(pool: SqlitePool) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
let (status, body) = post_json(
app.clone(),
"/api/admin/courses",
@@ -234,7 +304,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn create_and_list_students(pool: SqlitePool) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
// Create course
let (_, body) = post_json(
app.clone(),
@@ -300,7 +370,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn delete_student_returns_204(pool: SqlitePool) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
// Create course and seed membership
let (_, body) = post_json(

View File

@@ -11,6 +11,7 @@ mod sessions;
mod attendance;
mod notes;
mod export;
mod tutors;
pub fn build(pool: SqlitePool) -> Router {
Router::new()
@@ -22,6 +23,7 @@ pub fn build(pool: SqlitePool) -> Router {
.merge(attendance::router())
.merge(notes::router())
.merge(export::router())
.merge(tutors::router())
.with_state(pool)
}

View File

@@ -284,7 +284,7 @@ pub fn router() -> Router<SqlitePool> {
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{build_test_app, delete, get, patch_json, post_json};
use crate::test_helpers::{build_test_admin_app, build_test_app, delete, get, patch_json, post_json};
use axum::http::StatusCode;
use serde_json::{json, Value};
use std::collections::HashSet;
@@ -310,7 +310,7 @@ mod tests {
// DB tests
#[sqlx::test(migrations = "./migrations")]
async fn create_and_list_sessions(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
// Create a course + enroll the tutor first
let (status, body) = post_json(
app.clone(),
@@ -325,7 +325,7 @@ mod tests {
.unwrap();
// Enroll tutor in course (needed for verify_tutor_course_access)
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(&pool)
.await
.unwrap();
@@ -365,7 +365,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn create_slot_and_open(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
// Setup: course, enroll tutor, session, slot
let (_, body) = post_json(
app.clone(),
@@ -378,7 +378,7 @@ mod tests {
.as_i64()
.unwrap();
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(&pool)
.await
.unwrap();
@@ -428,7 +428,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn slot_start_time_must_be_before_end_time(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
let (_, body) = post_json(
app.clone(),
"/api/admin/courses",
@@ -440,7 +440,7 @@ mod tests {
.as_i64()
.unwrap();
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(&pool)
.await
.unwrap();
@@ -481,7 +481,7 @@ mod tests {
#[sqlx::test(migrations = "./migrations")]
async fn delete_closed_slot(pool: sqlx::SqlitePool) {
let (app, auth) = build_test_app(pool.clone()).await;
let (app, auth) = build_test_admin_app(pool.clone()).await;
let (_, body) = post_json(
app.clone(),
"/api/admin/courses",
@@ -493,7 +493,7 @@ mod tests {
.as_i64()
.unwrap();
let tutor_id: (i64,) =
sqlx::query_as("SELECT id FROM tutors WHERE email = 'tutor@test.com'")
sqlx::query_as("SELECT id FROM tutors WHERE email = 'admin@test.com'")
.fetch_one(&pool)
.await
.unwrap();

View File

@@ -0,0 +1,86 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post, delete},
Json, Router,
};
use sqlx::SqlitePool;
use crate::{auth::TutorClaims, error::AppError, models::{CreateTutor, Tutor}};
async fn list_tutors(
claims: TutorClaims,
State(pool): State<SqlitePool>,
) -> Result<Json<Vec<Tutor>>, AppError> {
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
let tutors = sqlx::query_as::<_, Tutor>(
"SELECT id, name, email, is_superadmin FROM tutors ORDER BY name"
)
.fetch_all(&pool)
.await?;
Ok(Json(tutors))
}
async fn create_tutor(
claims: TutorClaims,
State(pool): State<SqlitePool>,
Json(req): Json<CreateTutor>,
) -> Result<(StatusCode, Json<Tutor>), AppError> {
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
let hash = bcrypt::hash(&req.password, 12).map_err(|_| AppError::Unauthorized)?;
let id = sqlx::query(
"INSERT INTO tutors (name, email, password_hash, is_superadmin) VALUES (?, ?, ?, ?)"
)
.bind(&req.name)
.bind(&req.email)
.bind(hash)
.bind(req.is_superadmin)
.execute(&pool)
.await?
.last_insert_rowid();
Ok((
StatusCode::CREATED,
Json(Tutor {
id,
name: req.name,
email: req.email,
is_superadmin: req.is_superadmin,
}),
))
}
async fn delete_tutor(
claims: TutorClaims,
State(pool): State<SqlitePool>,
Path(id): Path<i64>,
) -> Result<StatusCode, AppError> {
if !claims.is_superadmin {
return Err(AppError::Unauthorized);
}
// Don't allow deleting yourself
if claims.sub == id {
return Err(AppError::Conflict("cannot delete yourself".into()));
}
sqlx::query("DELETE FROM tutors WHERE id = ?")
.bind(id)
.execute(&pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<SqlitePool> {
Router::new()
.route("/api/admin/tutors", get(list_tutors).post(create_tutor))
.route("/api/admin/tutors/{id}", delete(delete_tutor))
}

View File

@@ -6,21 +6,34 @@ use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
/// Insert a test tutor (if not exists), return a valid JWT for that tutor.
pub async fn make_token(pool: &SqlitePool, email: &str) -> String {
pub async fn make_token(pool: &SqlitePool, email: &str, is_superadmin: bool) -> String {
let hash = bcrypt::hash("testpass", 4).unwrap();
sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash) VALUES (?,?,?)")
.bind("Test Tutor").bind(email).bind(&hash)
sqlx::query("INSERT OR IGNORE INTO tutors (name,email,password_hash,is_superadmin) VALUES (?,?,?,?)")
.bind("Test Tutor").bind(email).bind(&hash).bind(is_superadmin)
.execute(pool).await.unwrap();
let id: (i64,) = sqlx::query_as("SELECT id FROM tutors WHERE email = ?")
// Ensure the superadmin flag is correct even if it existed
sqlx::query("UPDATE tutors SET is_superadmin = ? WHERE email = ?")
.bind(is_superadmin).bind(email).execute(pool).await.unwrap();
let row: (i64, bool) = sqlx::query_as("SELECT id, is_superadmin FROM tutors WHERE email = ?")
.bind(email).fetch_one(pool).await.unwrap();
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
crate::auth::encode_jwt(id.0, email).unwrap()
crate::auth::encode_jwt(row.0, email, row.1).unwrap()
}
/// Build the full Axum app wired with the given pool, plus a Bearer auth header value.
pub async fn build_test_app(pool: SqlitePool) -> (Router, String) {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
let token = make_token(&pool, "tutor@test.com").await;
let token = make_token(&pool, "tutor@test.com", false).await;
let app = crate::routes::build(pool);
(app, format!("Bearer {token}"))
}
/// Build the full Axum app wired with a superadmin Bearer auth header.
pub async fn build_test_admin_app(pool: SqlitePool) -> (Router, String) {
unsafe { std::env::set_var("JWT_SECRET", "testsecret"); }
let token = make_token(&pool, "admin@test.com", true).await;
let app = crate::routes::build(pool);
(app, format!("Bearer {token}"))
}

View File

@@ -31,7 +31,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
auth: {
login: (email: string, password: string) =>
request<{token: string}>('/auth/login', {
request<{token: string, is_superadmin: boolean}>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
@@ -61,6 +61,23 @@ export const api = {
body: formData
}).then(res => res.json());
},
listTutors: (course_id: number) => request<any[]>(`/admin/courses/${course_id}/tutors`),
assignTutor: (course_id: number, tutor_id: number) =>
request<void>(`/admin/courses/${course_id}/tutors`, {
method: 'POST',
body: JSON.stringify({ tutor_id })
}),
unassignTutor: (course_id: number, tutor_id: number) =>
request<void>(`/admin/courses/${course_id}/tutors/${tutor_id}`, { method: 'DELETE' }),
},
tutors: {
list: () => request<any[]>('/admin/tutors'),
create: (tutor: any) =>
request<any>('/admin/tutors', {
method: 'POST',
body: JSON.stringify(tutor)
}),
delete: (id: number) => request<void>(`/admin/tutors/${id}`, { method: 'DELETE' }),
},
students: {
delete: (id: number) => request<void>(`/admin/students/${id}`, { method: 'DELETE' }),

View File

@@ -5,6 +5,10 @@ export const token = writable<string | null>(
browser ? localStorage.getItem('token') : null
);
export const isSuperadmin = writable<boolean>(
browser ? localStorage.getItem('is_superadmin') === 'true' : false
);
if (browser) {
token.subscribe((value) => {
if (value) {
@@ -13,8 +17,12 @@ if (browser) {
localStorage.removeItem('token');
}
});
isSuperadmin.subscribe((value) => {
localStorage.setItem('is_superadmin', value ? 'true' : 'false');
});
}
export function logout() {
token.set(null);
isSuperadmin.set(false);
}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { isSuperadmin } from '$lib/auth';
const {
activePath,
@@ -25,6 +26,8 @@
{ id: 'attendance', label: 'Anwesenheit', href: '/admin/attendance' },
{ id: 'rooms', label: 'Räume', href: '/admin/rooms' },
{ id: 'students', label: 'Studierende', href: '/admin/students' },
{ id: 'tutors', label: 'Tutor:innen', href: '/admin/tutors', superadmin: true },
{ id: 'export', label: 'Exporte', href: '/admin/export' },
];
function isActive(item: { id: string; href: string }): boolean {
@@ -64,14 +67,16 @@
<nav style="display:flex;flex-direction:column;gap:1px">
<div class="eyebrow" style="margin-bottom:6px">Navigation</div>
{#each navItems as item}
{@const active = isActive(item)}
<a
href={item.href}
style="text-align:left;text-decoration:none;background:{active ? 'rgba(31,27,22,0.08)' : 'transparent'};padding:8px 10px;border-radius:4px;font-family:var(--sans);font-size:13px;color:{active ? 'var(--ink)' : 'var(--ink-2)'};font-weight:{active ? 500 : 400};display:flex;align-items:center;gap:8px"
>
<span style="width:4px;height:4px;border-radius:50%;flex-shrink:0;background:{active ? 'var(--accent)' : 'var(--ink-4)'}"></span>
{item.label}
</a>
{#if !item.superadmin || $isSuperadmin}
{@const active = isActive(item)}
<a
href={item.href}
style="text-align:left;text-decoration:none;background:{active ? 'rgba(31,27,22,0.08)' : 'transparent'};padding:8px 10px;border-radius:4px;font-family:var(--sans);font-size:13px;color:{active ? 'var(--ink)' : 'var(--ink-2)'};font-weight:{active ? 500 : 400};display:flex;align-items:center;gap:8px"
>
<span style="width:4px;height:4px;border-radius:50%;flex-shrink:0;background:{active ? 'var(--accent)' : 'var(--ink-4)'}"></span>
{item.label}
</a>
{/if}
{/each}
</nav>

View File

@@ -8,6 +8,7 @@ export interface Tutor {
id: number;
name: string;
email: string;
is_superadmin: boolean;
}
export interface Student {

View File

@@ -1,18 +1,31 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Course } from '$lib/types';
import { isSuperadmin } from '$lib/auth';
import type { Course, Tutor } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let courses = $state<Course[]>([]);
let allTutors = $state<Tutor[]>([]);
let assignedTutors = $state<Record<number, Tutor[]>>({});
let newCourseName = $state('');
let newCourseSemester = $state('');
onMount(async () => {
courses = await api.admin.courses.list();
await loadData();
});
async function loadData() {
courses = await api.admin.courses.list();
if ($isSuperadmin) {
allTutors = await api.admin.tutors.list();
for (const course of courses) {
assignedTutors[course.id] = await api.admin.courses.listTutors(course.id);
}
}
}
async function createCourse(e: Event) {
e.preventDefault();
if (!newCourseName.trim() || !newCourseSemester.trim()) return;
@@ -20,7 +33,21 @@
await api.admin.courses.create(newCourseName.trim(), newCourseSemester.trim());
newCourseName = '';
newCourseSemester = '';
courses = await api.admin.courses.list();
await loadData();
} catch (e) { alert(e); }
}
async function assignTutor(courseId: number, tutorId: number) {
try {
await api.admin.courses.assignTutor(courseId, tutorId);
assignedTutors[courseId] = await api.admin.courses.listTutors(courseId);
} catch (e) { alert(e); }
}
async function unassignTutor(courseId: number, tutorId: number) {
try {
await api.admin.courses.unassignTutor(courseId, tutorId);
assignedTutors[courseId] = await api.admin.courses.listTutors(courseId);
} catch (e) { alert(e); }
}
</script>
@@ -36,24 +63,26 @@
</div>
</header>
<!-- Create course form -->
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:18px;font-weight:500">Neuen Kurs anlegen</div>
<UnderlineStroke width={160} />
</div>
<form onsubmit={createCourse} style="padding:16px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:4px">
<label for="course-name" class="tiny" style="color:var(--ink-3)">Kursname</label>
<input id="course-name" class="input" bind:value={newCourseName} placeholder="z.B. Funktionale Programmierung" style="width:260px" required />
<!-- Create course form (Superadmin only) -->
{#if $isSuperadmin}
<section class="card" style="overflow:hidden">
<div style="padding:14px 18px;border-bottom:1px solid var(--rule)">
<div class="serif" style="font-size:18px;font-weight:500">Neuen Kurs anlegen</div>
<UnderlineStroke width={160} />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label for="course-semester" class="tiny" style="color:var(--ink-3)">Semester</label>
<input id="course-semester" class="input" bind:value={newCourseSemester} placeholder="z.B. SS2026" style="width:120px" required />
</div>
<button class="btn" type="submit"><Icon name="plus" size={12} /> Kurs anlegen</button>
</form>
</section>
<form onsubmit={createCourse} style="padding:16px 18px;display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:4px">
<label for="course-name" class="tiny" style="color:var(--ink-3)">Kursname</label>
<input id="course-name" class="input" bind:value={newCourseName} placeholder="z.B. Funktionale Programmierung" style="width:260px" required />
</div>
<div style="display:flex;flex-direction:column;gap:4px">
<label for="course-semester" class="tiny" style="color:var(--ink-3)">Semester</label>
<input id="course-semester" class="input" bind:value={newCourseSemester} placeholder="z.B. SS2026" style="width:120px" required />
</div>
<button class="btn" type="submit"><Icon name="plus" size={12} /> Kurs anlegen</button>
</form>
</section>
{/if}
<!-- Courses table -->
<section class="card" style="overflow:hidden">
@@ -65,9 +94,10 @@
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">#</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Semester</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name / Semester</th>
{#if $isSuperadmin}
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Tutor:innen</th>
{/if}
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
@@ -75,12 +105,39 @@
{#each courses as course, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px;color:var(--ink-4)">{i + 1}</span>
</td>
<td style="padding:12px 14px;font-weight:500">{course.name}</td>
<td style="padding:12px 14px">
<span class="mono" style="font-size:12px">{course.semester}</span>
<div class="serif" style="font-weight:500;font-size:15px">{course.name}</div>
<div class="tiny" style="color:var(--ink-4);font-family:var(--mono)">{course.semester}</div>
</td>
{#if $isSuperadmin}
<td style="padding:12px 14px">
<div style="display:flex;flex-wrap:wrap;gap:4px;max-width:300px">
{#each assignedTutors[course.id] ?? [] as tutor}
<span class="pill closed" style="font-size:10px;padding:1px 6px">
{tutor.name}
<button
style="background:none;border:none;padding:0;margin-left:4px;cursor:pointer;color:var(--accent)"
onclick={() => unassignTutor(course.id, tutor.id)}
title="Entfernen"
>×</button>
</span>
{/each}
<select
class="tiny"
style="background:none;border:1px dashed var(--rule);border-radius:999px;padding:1px 6px;cursor:pointer"
onchange={(e) => assignTutor(course.id, parseInt(e.currentTarget.value))}
value=""
>
<option value="" disabled>+ Hinzufügen</option>
{#each allTutors.filter(t => !assignedTutors[course.id]?.some(at => at.id === t.id)) as t}
<option value={t.id}>{t.name}</option>
{/each}
</select>
</div>
</td>
{/if}
<td style="padding:12px 14px;text-align:right">
<div style="display:flex;gap:6px;justify-content:flex-end">
<a href="/admin/students" class="btn ghost sm">Studierende</a>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { api } from '$lib/api';
import { token } from '$lib/auth';
import { token, isSuperadmin } from '$lib/auth';
import { goto } from '$app/navigation';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
@@ -15,6 +15,7 @@
try {
const res = await api.auth.login(email, password);
token.set(res.token);
isSuperadmin.set(res.is_superadmin);
goto('/admin');
} catch (e: unknown) {
error = e instanceof Error ? e.message : 'Ungültige Anmeldedaten';

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import type { Tutor } from '$lib/types';
import Icon from '$lib/components/Icon.svelte';
import UnderlineStroke from '$lib/components/UnderlineStroke.svelte';
let tutors = $state<Tutor[]>([]);
let loading = $state(true);
let newTutor = $state({
name: '',
email: '',
password: '',
is_superadmin: false
});
onMount(async () => {
await loadTutors();
loading = false;
});
async function loadTutors() {
try {
tutors = await api.admin.tutors.list();
} catch (e) {
console.error(e);
}
}
async function addTutor(e: Event) {
e.preventDefault();
try {
await api.admin.tutors.create(newTutor);
newTutor = { name: '', email: '', password: '', is_superadmin: false };
await loadTutors();
} catch (err: any) {
alert(err.message);
}
}
async function deleteTutor(id: number) {
if (!confirm('Tutor:in wirklich löschen?')) return;
try {
await api.admin.tutors.delete(id);
await loadTutors();
} catch (err: any) {
alert(err.message);
}
}
function initials(name: string): string {
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
}
</script>
<div style="padding:28px 36px;display:flex;flex-direction:column;gap:22px">
<!-- Header -->
<header style="display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
<div class="eyebrow">Systemverwaltung</div>
<div class="serif" style="font-size:36px;font-weight:500;letter-spacing:-0.015em;margin-top:2px">
Tutor:innen
</div>
</div>
</header>
<div style="display:grid;grid-template-columns:1fr 320px;gap:28px;align-items:start">
<!-- Left: Tutor List -->
<section style="display:flex;flex-direction:column;gap:16px">
<div>
<div class="serif" style="font-size:18px;font-weight:500">Alle Benutzer:innen</div>
<UnderlineStroke width={120} />
</div>
<div class="card" style="overflow:hidden">
{#if loading}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Wird geladen…</span>
</div>
{:else if tutors.length === 0}
<div style="padding:32px;text-align:center">
<span class="small" style="color:var(--ink-4)">Keine Tutor:innen gefunden.</span>
</div>
{:else}
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="background:rgba(0,0,0,0.02);color:var(--ink-3);text-align:left">
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Name / E-Mail</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase">Rolle</th>
<th style="padding:10px 14px;font-family:var(--mono);font-size:10.5px;letter-spacing:0.1em;text-transform:uppercase;text-align:right">Aktionen</th>
</tr>
</thead>
<tbody>
{#each tutors as tutor, i}
<tr class="row-hover" style="border-top:{i === 0 ? 'none' : '1px solid var(--rule)'}">
<td style="padding:12px 14px">
<div style="display:flex;align-items:center;gap:10px">
<span style="width:32px;height:32px;border-radius:50%;background:var(--paper-2);color:var(--ink-3);display:flex;align-items:center;justify-content:center;font-family:var(--sans);font-size:11px;font-weight:600;flex-shrink:0">
{initials(tutor.name)}
</span>
<div>
<div style="font-weight:500">{tutor.name}</div>
<div class="tiny" style="color:var(--ink-4)">{tutor.email}</div>
</div>
</div>
</td>
<td style="padding:12px 14px">
{#if tutor.is_superadmin}
<span class="pill locked">Superadmin</span>
{:else}
<span class="pill closed">Tutor:in</span>
{/if}
</td>
<td style="padding:12px 14px;text-align:right">
<button
class="btn ghost sm"
style="color:var(--accent);border-color:rgba(138,44,31,0.2)"
onclick={() => deleteTutor(tutor.id)}
>Löschen</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</section>
<!-- Right: Add Form -->
<section style="display:flex;flex-direction:column;gap:16px">
<div>
<div class="serif" style="font-size:18px;font-weight:500">Hinzufügen</div>
<UnderlineStroke width={80} />
</div>
<div class="card" style="padding:20px">
<form onsubmit={addTutor} style="display:flex;flex-direction:column;gap:16px">
<div>
<label for="name" class="tiny" style="color:var(--ink-3)">Name</label>
<input id="name" class="input" style="width:100%" bind:value={newTutor.name} placeholder="z.B. Max Mustermann" required />
</div>
<div>
<label for="email" class="tiny" style="color:var(--ink-3)">E-Mail</label>
<input id="email" type="email" class="input" style="width:100%" bind:value={newTutor.email} placeholder="max@uni.de" required />
</div>
<div>
<label for="password" class="tiny" style="color:var(--ink-3)">Passwort</label>
<input id="password" type="password" class="input" style="width:100%" bind:value={newTutor.password} required />
</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" bind:checked={newTutor.is_superadmin} />
<span class="small">Superadmin-Rechte</span>
</label>
<button type="submit" class="btn" style="width:100%">
<Icon name="plus" size={12} /> Tutor:in anlegen
</button>
</form>
</div>
</section>
</div>
</div>