1use crate::prelude::*;
2use anyhow::anyhow;
3use headless_lms_utils::services::tmc::TmcClient;
4use models::{
5 course_instance_enrollments::CourseEnrollmentsInfo, courses::Course,
6 exercise_reset_logs::ExerciseResetLog, exercise_slide_submissions::UserCourseSubmissionTime,
7 generated_certificates::UserCertificate, research_forms::ResearchFormQuestionAnswer,
8 roles::Role, suspected_cheaters::UserSuspectedCheaterInfo,
9 user_research_consents::UserResearchConsent, users::User,
10};
11use secrecy::{ExposeSecret, SecretString};
12use std::collections::{HashMap, HashSet};
13use utoipa::{OpenApi, ToSchema};
14
15#[derive(OpenApi)]
16#[openapi(paths(
17 get_user,
18 get_course_enrollments_for_user,
19 get_user_suspected_cheaters,
20 get_user_roles,
21 post_user_consents,
22 get_research_consent_by_user_id,
23 get_all_research_form_answers_with_user_id,
24 get_my_courses,
25 hide_course_from_my_courses,
26 unhide_course_from_my_courses,
27 get_my_studies,
28 get_my_certificates,
29 get_user_reset_exercise_logs,
30 get_user_course_submission_times,
31 send_reset_password_email,
32 reset_password_token_status,
33 reset_user_password,
34 change_user_password
35))]
36pub(crate) struct MainFrontendUsersApiDoc;
37
38#[instrument(skip(pool))]
42#[utoipa::path(
43 get,
44 path = "/{user_id}",
45 operation_id = "getUser",
46 tag = "users",
47 params(
48 ("user_id" = Uuid, Path, description = "User id")
49 ),
50 responses(
51 (status = 200, description = "User", body = User)
52 )
53)]
54pub async fn get_user(
55 user_id: web::Path<Uuid>,
56 pool: web::Data<PgPool>,
57 auth_user: AuthUser,
58) -> ControllerResult<web::Json<User>> {
59 let mut conn = pool.acquire().await?;
60 let user = models::users::get_by_id(&mut conn, *user_id).await?;
61
62 let token = authorize(
64 &mut conn,
65 Act::ViewUserProgressOrDetails,
66 Some(auth_user.id),
67 Res::GlobalPermissions,
68 )
69 .await?;
70 token.authorized_ok(web::Json(user))
71}
72
73#[instrument(skip(pool))]
77#[utoipa::path(
78 get,
79 path = "/{user_id}/course-enrollments",
80 operation_id = "getUserCourseEnrollments",
81 tag = "users",
82 params(
83 ("user_id" = Uuid, Path, description = "User id")
84 ),
85 responses(
86 (status = 200, description = "User course enrollments", body = CourseEnrollmentsInfo)
87 )
88)]
89pub async fn get_course_enrollments_for_user(
90 user_id: web::Path<Uuid>,
91 pool: web::Data<PgPool>,
92 auth_user: AuthUser,
93) -> ControllerResult<web::Json<CourseEnrollmentsInfo>> {
94 let mut conn = pool.acquire().await?;
95 let token = authorize(
96 &mut conn,
97 Act::ViewUserProgressOrDetails,
98 Some(auth_user.id),
99 Res::GlobalPermissions,
100 )
101 .await?;
102 let res = models::course_instance_enrollments::get_course_enrollments_info_for_user(
103 &mut conn, *user_id,
104 )
105 .await?;
106 token.authorized_ok(web::Json(res))
107}
108
109#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
110
111pub struct ConsentData {
112 pub consent: bool,
113}
114
115#[instrument(skip(pool))]
119#[utoipa::path(
120 post,
121 path = "/user-research-consents",
122 operation_id = "createUserResearchConsent",
123 tag = "users",
124 request_body = ConsentData,
125 responses(
126 (status = 200, description = "User research consent", body = UserResearchConsent)
127 )
128)]
129pub async fn post_user_consents(
130 payload: web::Json<ConsentData>,
131 user: AuthUser,
132 pool: web::Data<PgPool>,
133) -> ControllerResult<web::Json<UserResearchConsent>> {
134 let mut conn = pool.acquire().await?;
135 let token = skip_authorize();
136
137 let res = models::user_research_consents::upsert(
138 &mut conn,
139 PKeyPolicy::Generate,
140 user.id,
141 payload.consent,
142 )
143 .await?;
144 token.authorized_ok(web::Json(res))
145}
146
147#[instrument(skip(pool))]
151#[utoipa::path(
152 get,
153 path = "/get-user-research-consent",
154 operation_id = "getUserResearchConsent",
155 tag = "users",
156 responses(
157 (status = 200, description = "User research consent", body = UserResearchConsent)
158 )
159)]
160pub async fn get_research_consent_by_user_id(
161 user: AuthUser,
162 pool: web::Data<PgPool>,
163) -> ControllerResult<web::Json<UserResearchConsent>> {
164 let mut conn = pool.acquire().await?;
165 let token = skip_authorize();
166
167 let res =
168 models::user_research_consents::get_research_consent_by_user_id(&mut conn, user.id).await?;
169
170 token.authorized_ok(web::Json(res))
171}
172
173#[instrument(skip(pool))]
177#[utoipa::path(
178 get,
179 path = "/user-research-form-question-answers",
180 operation_id = "getUserResearchFormQuestionAnswers",
181 tag = "users",
182 responses(
183 (status = 200, description = "Research form answers for user", body = [ResearchFormQuestionAnswer])
184 )
185)]
186async fn get_all_research_form_answers_with_user_id(
187 user: AuthUser,
188 pool: web::Data<PgPool>,
189) -> ControllerResult<web::Json<Vec<ResearchFormQuestionAnswer>>> {
190 let mut conn = pool.acquire().await?;
191 let token = skip_authorize();
192
193 let res =
194 models::research_forms::get_all_research_form_answers_with_user_id(&mut conn, user.id)
195 .await?;
196
197 token.authorized_ok(web::Json(res))
198}
199
200#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
201pub struct MyCourse {
202 #[serde(flatten)]
203 pub course: Course,
204 pub can_hide: bool,
207}
208
209#[instrument(skip(pool))]
213#[utoipa::path(
214 get,
215 path = "/my-courses",
216 operation_id = "getMyCourses",
217 tag = "users",
218 responses(
219 (status = 200, description = "Courses for authenticated user", body = [MyCourse])
220 )
221)]
222async fn get_my_courses(
223 user: AuthUser,
224 pool: web::Data<PgPool>,
225) -> ControllerResult<web::Json<Vec<MyCourse>>> {
226 let mut conn = pool.acquire().await?;
227 let token = skip_authorize();
228
229 let courses_enrolled_to =
230 models::courses::all_courses_user_enrolled_to(&mut conn, user.id).await?;
231
232 let courses_with_roles =
233 models::courses::all_courses_with_roles_for_user(&mut conn, user.id).await?;
234
235 let settings = models::user_course_settings::get_all_by_user_id(&mut conn, user.id).await?;
236 let hidden_course_ids: HashSet<Uuid> = settings
237 .iter()
238 .filter(|s| s.hidden)
239 .map(|s| s.current_course_id)
240 .collect();
241 let enrolled_course_ids: HashSet<Uuid> = settings.iter().map(|s| s.current_course_id).collect();
242 let role_course_ids: HashSet<Uuid> = courses_with_roles.iter().map(|c| c.id).collect();
243
244 let mut combined: Vec<Course> = courses_enrolled_to
245 .clone()
246 .into_iter()
247 .chain(
248 courses_with_roles
249 .into_iter()
250 .filter(|c| !courses_enrolled_to.iter().any(|c2| c.id == c2.id)),
251 )
252 .filter(|c| !hidden_course_ids.contains(&c.id) || role_course_ids.contains(&c.id))
254 .collect();
255
256 combined.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id)));
258
259 let my_courses = combined
260 .into_iter()
261 .map(|course| {
262 let can_hide =
263 enrolled_course_ids.contains(&course.id) && !role_course_ids.contains(&course.id);
264 MyCourse { course, can_hide }
265 })
266 .collect();
267
268 token.authorized_ok(web::Json(my_courses))
269}
270
271#[instrument(skip(pool))]
276#[utoipa::path(
277 post,
278 path = "/my-courses/{course_id}/hide",
279 operation_id = "hideCourseFromMyCourses",
280 tag = "users",
281 params(
282 ("course_id" = Uuid, Path, description = "Course id")
283 ),
284 responses(
285 (status = 200, description = "Course hidden from the user's my-courses list")
286 )
287)]
288async fn hide_course_from_my_courses(
289 course_id: web::Path<Uuid>,
290 user: AuthUser,
291 pool: web::Data<PgPool>,
292) -> ControllerResult<web::Json<()>> {
293 let mut conn = pool.acquire().await?;
294 let token = skip_authorize();
295
296 let has_role = models::courses::all_courses_with_roles_for_user(&mut conn, user.id)
298 .await?
299 .iter()
300 .any(|c| c.id == *course_id);
301 if !has_role {
302 models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, true).await?;
303 }
304
305 token.authorized_ok(web::Json(()))
306}
307
308#[instrument(skip(pool))]
313#[utoipa::path(
314 post,
315 path = "/my-courses/{course_id}/unhide",
316 operation_id = "unhideCourseFromMyCourses",
317 tag = "users",
318 params(
319 ("course_id" = Uuid, Path, description = "Course id")
320 ),
321 responses(
322 (status = 200, description = "Course restored to the user's my-courses list")
323 )
324)]
325async fn unhide_course_from_my_courses(
326 course_id: web::Path<Uuid>,
327 user: AuthUser,
328 pool: web::Data<PgPool>,
329) -> ControllerResult<web::Json<()>> {
330 let mut conn = pool.acquire().await?;
331 let token = skip_authorize();
332
333 models::user_course_settings::set_hidden(&mut conn, user.id, *course_id, false).await?;
334
335 token.authorized_ok(web::Json(()))
336}
337
338#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
340pub struct MyStudiesCourseModule {
341 pub course_module_id: Uuid,
342 pub name: Option<String>,
344 pub order_number: i32,
345 pub ects_credits: Option<f32>,
346 pub uh_course_code: Option<String>,
347 pub supports_credit_registration: bool,
348 pub completion: Option<MyStudiesCompletion>,
350}
351
352#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
355pub struct MyStudiesCompletion {
356 pub course_module_completion_id: Uuid,
357 pub completion_date: DateTime<Utc>,
358 pub grade: Option<i32>,
360 pub passed: bool,
361 pub prerequisite_modules_completed: bool,
362}
363
364#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
365pub struct MyStudiesCourse {
366 pub course_id: Uuid,
367 pub course_name: String,
368 pub course_slug: String,
369 pub organization_slug: String,
370 pub language_code: String,
371 pub first_enrolled_at: DateTime<Utc>,
372 pub is_current: bool,
374 pub hidden: bool,
376 pub current_course_instance_id: Option<Uuid>,
378 pub current_course_instance_name: Option<String>,
379 pub supports_credit_registration: bool,
380 pub modules: Vec<MyStudiesCourseModule>,
381}
382
383#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
385pub struct MyStudiesTotals {
386 pub courses: i32,
387 pub completions: i32,
389 pub ects: f32,
391}
392
393#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
394pub struct MyStudies {
395 pub any_module_supports_credit_registration: bool,
398 pub courses: Vec<MyStudiesCourse>,
399 pub totals: MyStudiesTotals,
400}
401
402#[instrument(skip(pool))]
410#[utoipa::path(
411 get,
412 path = "/my-studies",
413 operation_id = "getMyStudies",
414 tag = "users",
415 responses(
416 (status = 200, description = "The authenticated user's study record", body = MyStudies)
417 )
418)]
419async fn get_my_studies(
420 user: AuthUser,
421 pool: web::Data<PgPool>,
422) -> ControllerResult<web::Json<MyStudies>> {
423 let mut conn = pool.acquire().await?;
424 let token = skip_authorize();
425
426 let enrollments_info =
427 models::course_instance_enrollments::get_course_enrollments_info_for_user(
428 &mut conn, user.id,
429 )
430 .await?;
431 let organizations = models::organizations::all_organizations_include_hidden(&mut conn).await?;
432 let organization_slugs: HashMap<Uuid, String> =
433 organizations.into_iter().map(|o| (o.id, o.slug)).collect();
434
435 let mut courses = Vec::with_capacity(enrollments_info.course_enrollments.len());
436
437 for enrollment in enrollments_info.course_enrollments {
438 let mut best_completion_by_module: HashMap<Uuid, MyStudiesCompletion> = HashMap::new();
441 for course_module in &enrollment.course_modules {
442 let visible_completions: Vec<_> = enrollment
443 .course_module_completions
444 .iter()
445 .filter(|c| c.course_module_id == course_module.id && !c.needs_to_be_reviewed)
446 .cloned()
447 .collect();
448 if let Some(best) =
449 models::course_module_completions::select_best_completion(visible_completions)
450 {
451 best_completion_by_module.insert(
453 course_module.id,
454 MyStudiesCompletion {
455 course_module_completion_id: best.id,
456 completion_date: best.completion_date,
457 grade: best.grade,
458 passed: best.passed,
459 prerequisite_modules_completed: best.prerequisite_modules_completed,
460 },
461 );
462 }
463 }
464
465 let mut modules: Vec<MyStudiesCourseModule> = enrollment
466 .course_modules
467 .iter()
468 .map(|course_module| MyStudiesCourseModule {
469 course_module_id: course_module.id,
470 name: course_module.name.clone(),
471 order_number: course_module.order_number,
472 ects_credits: course_module.ects_credits,
473 uh_course_code: course_module.uh_course_code.clone(),
474 supports_credit_registration: course_module.enable_credit_registration_via_suotar,
475 completion: best_completion_by_module.remove(&course_module.id),
476 })
477 .collect();
478 modules.sort_by_key(|m| m.order_number);
479
480 let settings_instance_id = enrollment
482 .user_course_settings
483 .as_ref()
484 .map(|s| s.current_course_instance_id);
485 let current_instance = enrollment
486 .course_instances
487 .iter()
488 .find(|ci| Some(ci.id) == settings_instance_id)
489 .or_else(|| enrollment.course_instances.first());
490
491 let Some(organization_slug) = organization_slugs
494 .get(&enrollment.course.organization_id)
495 .cloned()
496 else {
497 warn!(
498 user_id = %user.id,
499 course_id = %enrollment.course_id,
500 organization_id = %enrollment.course.organization_id,
501 "Skipping course from the user's studies because its organization is deleted"
502 );
503 continue;
504 };
505
506 courses.push(MyStudiesCourse {
507 course_id: enrollment.course_id,
508 course_name: enrollment.course.name.clone(),
509 course_slug: enrollment.course.slug.clone(),
510 organization_slug,
511 language_code: enrollment.course.language_code.clone(),
512 first_enrolled_at: enrollment.first_enrolled_at,
513 is_current: enrollment.is_current,
514 hidden: enrollment
515 .user_course_settings
516 .as_ref()
517 .is_some_and(|s| s.hidden),
518 current_course_instance_id: current_instance.map(|ci| ci.id),
519 current_course_instance_name: current_instance.and_then(|ci| ci.name.clone()),
520 supports_credit_registration: modules.iter().any(|m| m.supports_credit_registration),
521 modules,
522 });
523 }
524
525 courses.sort_by(|a, b| {
527 b.is_current
528 .cmp(&a.is_current)
529 .then(b.first_enrolled_at.cmp(&a.first_enrolled_at))
530 });
531
532 let mut total_courses = 0;
533 let mut total_completions = 0;
534 let mut total_ects = 0.0;
535 for course in courses.iter().filter(|c| !c.hidden) {
536 total_courses += 1;
537 for module in &course.modules {
538 if module.completion.as_ref().is_some_and(|c| c.passed) {
539 total_completions += 1;
540 total_ects += module.ects_credits.unwrap_or(0.0);
541 }
542 }
543 }
544
545 let res = MyStudies {
546 any_module_supports_credit_registration: courses
547 .iter()
548 .any(|c| c.supports_credit_registration),
549 totals: MyStudiesTotals {
550 courses: total_courses,
551 completions: total_completions,
552 ects: total_ects,
553 },
554 courses,
555 };
556
557 token.authorized_ok(web::Json(res))
558}
559
560#[instrument(skip(pool))]
567#[utoipa::path(
568 get,
569 path = "/my-certificates",
570 operation_id = "getMyCertificates",
571 tag = "users",
572 responses(
573 (status = 200, description = "The authenticated user's certificates", body = Vec<UserCertificate>)
574 )
575)]
576async fn get_my_certificates(
577 user: AuthUser,
578 pool: web::Data<PgPool>,
579) -> ControllerResult<web::Json<Vec<UserCertificate>>> {
580 let mut conn = pool.acquire().await?;
581 let token = skip_authorize();
582
583 let res = models::generated_certificates::get_all_by_user_id(&mut conn, user.id).await?;
584
585 token.authorized_ok(web::Json(res))
586}
587
588#[instrument(skip(pool))]
592#[utoipa::path(
593 get,
594 path = "/{user_id}/user-reset-exercise-logs",
595 operation_id = "getUserResetExerciseLogs",
596 tag = "users",
597 params(
598 ("user_id" = Uuid, Path, description = "User id")
599 ),
600 responses(
601 (status = 200, description = "User reset exercise logs", body = [ExerciseResetLog])
602 )
603)]
604pub async fn get_user_reset_exercise_logs(
605 user_id: web::Path<Uuid>,
606 pool: web::Data<PgPool>,
607 auth_user: AuthUser,
608) -> ControllerResult<web::Json<Vec<ExerciseResetLog>>> {
609 let mut conn = pool.acquire().await?;
610 let token = authorize(
611 &mut conn,
612 Act::ViewUserProgressOrDetails,
613 Some(auth_user.id),
614 Res::GlobalPermissions,
615 )
616 .await?;
617 let res =
618 models::exercise_reset_logs::get_exercise_reset_logs_for_user(&mut conn, *user_id).await?;
619
620 token.authorized_ok(web::Json(res))
621}
622
623#[instrument(skip(pool))]
628#[utoipa::path(
629 get,
630 path = "/{user_id}/courses/{course_id}/submission-times",
631 operation_id = "getUserCourseSubmissionTimes",
632 tag = "users",
633 params(
634 ("user_id" = Uuid, Path, description = "User id"),
635 ("course_id" = Uuid, Path, description = "Course id")
636 ),
637 responses(
638 (status = 200, description = "User course submission times", body = [UserCourseSubmissionTime])
639 )
640)]
641pub async fn get_user_course_submission_times(
642 path: web::Path<(Uuid, Uuid)>,
643 pool: web::Data<PgPool>,
644 auth_user: AuthUser,
645) -> ControllerResult<web::Json<Vec<UserCourseSubmissionTime>>> {
646 let (user_id, course_id) = path.into_inner();
647 let mut conn = pool.acquire().await?;
648 let token = authorize(
649 &mut conn,
650 Act::ViewUserProgressOrDetails,
651 Some(auth_user.id),
652 Res::GlobalPermissions,
653 )
654 .await?;
655 let res = models::exercise_slide_submissions::get_user_course_submission_times(
656 &mut conn, user_id, course_id,
657 )
658 .await?;
659
660 token.authorized_ok(web::Json(res))
661}
662
663#[instrument(skip(pool))]
669#[utoipa::path(
670 get,
671 path = "/{user_id}/suspected-cheaters",
672 operation_id = "getUserSuspectedCheaters",
673 tag = "users",
674 params(
675 ("user_id" = Uuid, Path, description = "User id")
676 ),
677 responses(
678 (status = 200, description = "User suspected-cheater records across courses", body = [UserSuspectedCheaterInfo])
679 )
680)]
681pub async fn get_user_suspected_cheaters(
682 user_id: web::Path<Uuid>,
683 pool: web::Data<PgPool>,
684 auth_user: AuthUser,
685) -> ControllerResult<web::Json<Vec<UserSuspectedCheaterInfo>>> {
686 let mut conn = pool.acquire().await?;
687 let token = authorize(
688 &mut conn,
689 Act::ViewUserProgressOrDetails,
690 Some(auth_user.id),
691 Res::GlobalPermissions,
692 )
693 .await?;
694 let res = models::suspected_cheaters::get_suspected_cheater_info_for_user(&mut conn, *user_id)
695 .await?;
696
697 token.authorized_ok(web::Json(res))
698}
699
700#[instrument(skip(pool))]
705#[utoipa::path(
706 get,
707 path = "/{user_id}/roles",
708 operation_id = "getUserRoles",
709 tag = "users",
710 params(
711 ("user_id" = Uuid, Path, description = "User id")
712 ),
713 responses(
714 (status = 200, description = "User roles across scopes", body = [Role])
715 )
716)]
717pub async fn get_user_roles(
718 user_id: web::Path<Uuid>,
719 pool: web::Data<PgPool>,
720 auth_user: AuthUser,
721) -> ControllerResult<web::Json<Vec<Role>>> {
722 let mut conn = pool.acquire().await?;
723 let token = authorize(
724 &mut conn,
725 Act::ViewUserProgressOrDetails,
726 Some(auth_user.id),
727 Res::GlobalPermissions,
728 )
729 .await?;
730 let res = models::roles::get_roles(&mut conn, *user_id).await?;
731
732 token.authorized_ok(web::Json(res))
733}
734
735#[derive(Debug, Serialize, Deserialize, ToSchema)]
736
737pub struct EmailData {
738 pub email: String,
739 pub language: String,
740}
741
742#[instrument(skip(pool))]
743#[utoipa::path(
744 post,
745 path = "/send-reset-password-email",
746 operation_id = "sendResetPasswordEmail",
747 tag = "users",
748 request_body = EmailData,
749 responses(
750 (status = 200, description = "Reset password email accepted", body = bool)
751 )
752)]
753pub async fn send_reset_password_email(
754 pool: web::Data<PgPool>,
755 payload: web::Json<EmailData>,
756 tmc_client: web::Data<TmcClient>,
757) -> ControllerResult<web::Json<bool>> {
758 let mut conn = pool.acquire().await?;
759 let token = skip_authorize();
760
761 let email = &payload.email.trim().to_lowercase();
762 let language = &payload.language;
763
764 let reset_template = models::email_templates::get_generic_email_template_by_type_and_language(
765 &mut conn,
766 models::email_templates::EmailTemplateType::ResetPasswordEmail,
767 language,
768 )
769 .await
770 .map_err(|_e| {
771 anyhow::anyhow!(
772 "Password reset email template not configured. Missing template 'reset-password-email' for language '{}'",
773 language
774 )
775 })?;
776
777 let user = match models::users::get_by_email(&mut conn, email).await {
778 Ok(user) => Some(user),
779 Err(_) => {
780 if let Ok(tmc_user) = tmc_client.get_user_from_tmc_with_email(email.clone()).await {
783 match models::users::find_by_upstream_id(&mut conn, tmc_user.upstream_id).await? {
787 Some(existing_user) => Some(existing_user),
788 None => Some(
789 models::users::insert_with_upstream_id_and_moocfi_id(
790 &mut conn,
791 &tmc_user.email,
792 tmc_user.first_name.as_deref(),
793 tmc_user.last_name.as_deref(),
794 tmc_user.upstream_id,
795 tmc_user.id,
796 )
797 .await?,
798 ),
799 }
800 } else {
801 None
802 }
803 }
804 };
805
806 if let Some(user) = user {
807 let token = Uuid::new_v4();
808
809 let _password_token =
810 models::user_passwords::insert_password_reset_token(&mut conn, user.id, token).await?;
811
812 let _ =
813 models::email_deliveries::insert_email_delivery(&mut conn, user.id, reset_template.id)
814 .await?;
815 }
816
817 token.authorized_ok(web::Json(true))
818}
819
820#[derive(Debug, Deserialize, ToSchema)]
821pub struct ResetPasswordTokenPayload {
822 #[schema(value_type = String)]
823 pub token: SecretString,
824}
825
826#[instrument(skip(pool))]
827#[utoipa::path(
828 post,
829 path = "/reset-password-token-status",
830 operation_id = "getResetPasswordTokenStatus",
831 tag = "users",
832 request_body = ResetPasswordTokenPayload,
833 responses(
834 (status = 200, description = "Reset password token validity", body = bool)
835 )
836)]
837pub async fn reset_password_token_status(
838 pool: web::Data<PgPool>,
839 payload: web::Json<ResetPasswordTokenPayload>,
840) -> ControllerResult<web::Json<bool>> {
841 let mut conn = pool.acquire().await?;
842 let token = skip_authorize();
843
844 let password_token = match Uuid::parse_str(payload.token.expose_secret()) {
845 Ok(u) => u,
846 Err(_) => return token.authorized_ok(web::Json(false)),
847 };
848
849 let res =
850 models::user_passwords::is_reset_password_token_valid(&mut conn, &password_token).await?;
851
852 token.authorized_ok(web::Json(res))
853}
854
855#[derive(Debug, Deserialize, ToSchema)]
856pub struct ResetPasswordData {
857 #[schema(value_type = String)]
858 pub token: SecretString,
859 #[schema(value_type = String)]
860 pub new_password: SecretString,
861}
862
863#[instrument(skip(pool))]
864#[utoipa::path(
865 post,
866 path = "/reset-password",
867 operation_id = "resetUserPassword",
868 tag = "users",
869 request_body = ResetPasswordData,
870 responses(
871 (status = 200, description = "Password reset status", body = bool)
872 )
873)]
874pub async fn reset_user_password(
875 pool: web::Data<PgPool>,
876 payload: web::Json<ResetPasswordData>,
877 tmc_client: web::Data<TmcClient>,
878) -> ControllerResult<web::Json<bool>> {
879 let mut conn = pool.acquire().await?;
880 let token = skip_authorize();
881
882 let token_uuid = Uuid::parse_str(payload.token.expose_secret())?;
883 let password_hash = models::user_passwords::hash_password(&payload.new_password)
884 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
885
886 let res = models::user_passwords::change_user_password_with_password_reset_token(
887 &mut conn,
888 token_uuid,
889 &password_hash,
890 &tmc_client,
891 )
892 .await?;
893
894 token.authorized_ok(web::Json(res))
895}
896
897#[derive(Debug, Deserialize, ToSchema)]
898pub struct ChangePasswordData {
899 #[schema(value_type = String)]
900 pub old_password: SecretString,
901 #[schema(value_type = String)]
902 pub new_password: SecretString,
903}
904
905#[instrument(skip(pool))]
906#[utoipa::path(
907 post,
908 path = "/change-password",
909 operation_id = "changeUserPassword",
910 tag = "users",
911 request_body = ChangePasswordData,
912 responses(
913 (status = 200, description = "Password change status", body = bool)
914 )
915)]
916pub async fn change_user_password(
917 pool: web::Data<PgPool>,
918 payload: web::Json<ChangePasswordData>,
919 user: AuthUser,
920) -> ControllerResult<web::Json<bool>> {
921 let mut conn = pool.acquire().await?;
922 let token = skip_authorize();
923 let password_hash = models::user_passwords::hash_password(&payload.new_password)
924 .map_err(|e| anyhow!("Failed to hash password: {:?}", e))?;
925
926 let res = models::user_passwords::change_user_password_with_old_password(
927 &mut conn,
928 user.id,
929 &payload.old_password,
930 &password_hash,
931 )
932 .await?;
933
934 token.authorized_ok(web::Json(res))
935}
936
937pub fn _add_routes(cfg: &mut ServiceConfig) {
938 cfg.route(
939 "/user-research-form-question-answers",
940 web::get().to(get_all_research_form_answers_with_user_id),
941 )
942 .route("/my-courses", web::get().to(get_my_courses))
943 .route("/my-studies", web::get().to(get_my_studies))
944 .route("/my-certificates", web::get().to(get_my_certificates))
945 .route(
946 "/my-courses/{course_id}/hide",
947 web::post().to(hide_course_from_my_courses),
948 )
949 .route(
950 "/my-courses/{course_id}/unhide",
951 web::post().to(unhide_course_from_my_courses),
952 )
953 .route(
954 "/get-user-research-consent",
955 web::get().to(get_research_consent_by_user_id),
956 )
957 .route(
958 "/user-research-consents",
959 web::post().to(post_user_consents),
960 )
961 .route(
962 "/send-reset-password-email",
963 web::post().to(send_reset_password_email),
964 )
965 .route("/{user_id}", web::get().to(get_user))
966 .route(
967 "/{user_id}/course-enrollments",
968 web::get().to(get_course_enrollments_for_user),
969 )
970 .route(
971 "/{user_id}/user-reset-exercise-logs",
972 web::get().to(get_user_reset_exercise_logs),
973 )
974 .route(
975 "/{user_id}/courses/{course_id}/submission-times",
976 web::get().to(get_user_course_submission_times),
977 )
978 .route(
979 "/{user_id}/suspected-cheaters",
980 web::get().to(get_user_suspected_cheaters),
981 )
982 .route("/{user_id}/roles", web::get().to(get_user_roles))
983 .route(
984 "/reset-password-token-status",
985 web::post().to(reset_password_token_status),
986 )
987 .route("/reset-password", web::post().to(reset_user_password))
988 .route("/change-password", web::post().to(change_user_password));
989}