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:
@@ -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)
|
||||
|
||||
2
backend/migrations/002_add_superadmin.sql
Normal file
2
backend/migrations/002_add_superadmin.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add is_superadmin column to tutors table
|
||||
ALTER TABLE tutors ADD COLUMN is_superadmin BOOLEAN NOT NULL DEFAULT 0;
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
86
backend/src/routes/tutors.rs
Normal file
86
backend/src/routes/tutors.rs
Normal 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))
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface Tutor {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_superadmin: boolean;
|
||||
}
|
||||
|
||||
export interface Student {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
169
frontend/src/routes/admin/tutors/+page.svelte
Normal file
169
frontend/src/routes/admin/tutors/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user