WIP: Switch to React #6

Draft
biss wants to merge 21 commits from codex/react-migration into master
pull from: codex/react-migration
111 changed files with 9155 additions and 20 deletions

No files matched your search

@@ -170,6 +170,25 @@ public sealed class CourseAdjustmentsController(
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
}
[HttpGet("teacher-options")]
[Authorize(Roles = Applicants)]
public async Task<ActionResult> GetTeacherOptions(CancellationToken cancellationToken)
{
return Ok(await db.Teachers.AsNoTracking()
.Where(x => x.Status == TeacherStatus.Active && !x.IsArchived)
.OrderBy(x => x.College!.Name)
.ThenBy(x => x.TeacherNumber)
.Select(x => new
{
x.Id,
x.TeacherNumber,
x.Name,
x.Title,
CollegeName = x.College!.Name
})
.ToListAsync(cancellationToken));
}
// ═══════════════ My adjustments ═══════════════
[HttpGet("mine")]
@@ -611,7 +630,8 @@ public sealed class CourseAdjustmentsController(
return ValidationProblem("请选择代课教师。");
if (!await db.Teachers.AnyAsync(x =>
x.Id == request.SubstituteTeacherId &&
x.Status == TeacherStatus.Active, cancellationToken))
x.Status == TeacherStatus.Active &&
!x.IsArchived, cancellationToken))
return ValidationProblem("代课教师不存在或已离职。");
break;
case CourseAdjustmentType.Cancel:
+46 -19
View File
@@ -56,6 +56,7 @@ public sealed class SsoController(
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var accountPage = FrontendPagePath(safeReturnUrl, "account", nativeApp);
if (string.IsNullOrWhiteSpace(bindingIntent) &&
(string.IsNullOrWhiteSpace(captchaId) ||
string.IsNullOrWhiteSpace(captchaCode) ||
@@ -77,7 +78,7 @@ public sealed class SsoController(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
if (targetUserId is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
return RedirectToFrontendError("binding_intent_expired", accountPage);
properties.Items[BindingIntentProperty] = bindingIntent;
}
var completeUrl = Url.Action(
@@ -97,7 +98,9 @@ public sealed class SsoController(
EffectiveCallbackUrl());
return RedirectToFrontendError(
"configuration_error",
string.IsNullOrWhiteSpace(bindingIntent) ? "/login" : "/account");
string.IsNullOrWhiteSpace(bindingIntent)
? FrontendPagePath(safeReturnUrl, "login", nativeApp)
: accountPage);
}
}
@@ -111,16 +114,23 @@ public sealed class SsoController(
if (!_options.Enabled)
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
var authentication = await HttpContext.AuthenticateAsync(
SsoAuthSchemes.ExternalCookie);
var nativeApp = authentication.Properties is { } externalProperties &&
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
bool.TryParse(nativeAppValue, out var isNativeApp) &&
isNativeApp;
var loginPage = FrontendPagePath(safeReturnUrl, "login", nativeApp);
var accountPage = FrontendPagePath(safeReturnUrl, "account", nativeApp);
if (!authentication.Succeeded || authentication.Principal is null)
return RedirectToFrontendError("authentication_failed");
return RedirectToFrontendError("authentication_failed", loginPage);
var principal = authentication.Principal;
var subject = principal.FindFirstValue("sub") ??
principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(subject))
return RedirectToFrontendError("missing_subject");
return RedirectToFrontendError("missing_subject", loginPage);
ApplicationUser? user = null;
var bindingIntent =
@@ -130,10 +140,6 @@ public sealed class SsoController(
out var storedBindingIntent)
? storedBindingIntent
: null;
var nativeApp = authentication.Properties is { } externalProperties &&
externalProperties.Items.TryGetValue(NativeAppProperty, out var nativeAppValue) &&
bool.TryParse(nativeAppValue, out var isNativeApp) &&
isNativeApp;
var nativeState = authentication.Properties is { } nativeProperties &&
nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState)
? storedNativeState
@@ -147,13 +153,13 @@ public sealed class SsoController(
? null
: await userManager.FindByIdAsync(targetUserId);
if (user is null)
return RedirectToFrontendError("binding_intent_expired", "/account");
return RedirectToFrontendError("binding_intent_expired", accountPage);
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled", "/account");
return RedirectToFrontendError("account_disabled", accountPage);
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError, "/account");
return RedirectToFrontendError(linkError, accountPage);
await cache.RemoveAsync(
BindingIntentCacheKey(bindingIntent),
cancellationToken);
@@ -172,7 +178,7 @@ public sealed class SsoController(
{
var linkError = await LinkSsoIdentityAsync(user, subject);
if (linkError is not null)
return RedirectToFrontendError(linkError);
return RedirectToFrontendError(linkError, loginPage);
}
}
}
@@ -195,18 +201,20 @@ public sealed class SsoController(
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var bindingPage = BuildFrontendUrl("/sso/bind", nativeApp) +
var bindingPage = BuildFrontendUrl(
FrontendPagePath(safeReturnUrl, "sso/bind", nativeApp),
nativeApp) +
$"?code={Uri.EscapeDataString(bindingCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
$"&redirect={Uri.EscapeDataString(safeReturnUrl)}";
return Redirect(bindingPage);
}
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
return RedirectToFrontendError("account_disabled");
return RedirectToFrontendError("account_disabled", loginPage);
user.LastLoginAt = DateTime.UtcNow;
var updateResult = await userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
return RedirectToFrontendError("account_update_failed");
return RedirectToFrontendError("account_update_failed", loginPage);
var exchangeCode = WebEncoders.Base64UrlEncode(
RandomNumberGenerator.GetBytes(32));
@@ -220,9 +228,11 @@ public sealed class SsoController(
cancellationToken);
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
var callback = BuildFrontendUrl("/sso/callback", nativeApp) +
var callback = BuildFrontendUrl(
FrontendPagePath(safeReturnUrl, "sso/callback", nativeApp),
nativeApp) +
$"?code={Uri.EscapeDataString(exchangeCode)}" +
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
$"&redirect={Uri.EscapeDataString(safeReturnUrl)}";
return Redirect(callback);
}
@@ -386,6 +396,7 @@ public sealed class SsoController(
[HttpPost("prepare-binding")]
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
CancellationToken cancellationToken,
[FromQuery] string? returnUrl = null,
[FromQuery] bool nativeApp = false,
[FromQuery] string? nativeState = null)
{
@@ -411,11 +422,14 @@ public sealed class SsoController(
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
},
cancellationToken);
var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl)
? "/account"
: NormalizeReturnUrl(returnUrl);
var loginUrl = Url.Action(
nameof(Login),
values: new
{
returnUrl = "/account",
returnUrl = safeReturnUrl,
bindingIntent = intentCode,
nativeApp,
nativeState,
@@ -464,6 +478,19 @@ public sealed class SsoController(
? returnUrl
: "/dashboard";
internal static string FrontendPagePath(
string? returnUrl,
string page,
bool nativeApp = false)
{
var normalizedPage = "/" + page.Trim('/');
if (nativeApp) return normalizedPage;
var normalizedReturnUrl = NormalizeReturnUrl(returnUrl);
return normalizedReturnUrl.StartsWith("/next/", StringComparison.OrdinalIgnoreCase)
? "/next" + normalizedPage
: normalizedPage;
}
private string BuildFrontendUrl(string path, bool nativeApp = false)
{
if (nativeApp)
@@ -6,11 +6,46 @@ using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
namespace Jiaowu.Api.Tests;
public sealed class CourseAdjustmentsControllerTests
{
[Fact]
public async Task TeacherOptions_ReturnOnlyActiveNonArchivedTeachers()
{
await using var fixture = await Fixture.CreateAsync();
var collegeId = await fixture.Db.Courses.Select(x => x.CollegeId).SingleAsync();
fixture.Db.Teachers.AddRange(
new Teacher
{
TeacherNumber = "T002",
Name = "离职教师",
CollegeId = collegeId,
Status = TeacherStatus.Retired
},
new Teacher
{
TeacherNumber = "T003",
Name = "归档教师",
CollegeId = collegeId,
IsArchived = true
});
await fixture.Db.SaveChangesAsync();
var controller = new CourseAdjustmentsController(
fixture.Db,
new TestScope(fixture.TeacherUserId, DataScope.Self, SystemRoles.Teacher));
var result = await controller.GetTeacherOptions(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var json = JsonSerializer.Serialize(ok.Value);
Assert.Contains("T001", json);
Assert.DoesNotContain("T002", json);
Assert.DoesNotContain("T003", json);
}
[Fact]
public async Task Reschedule_MovesOnlyTheSelectedOccurrence()
{
Loaded 3 of 111 files, more files were not shown because too many files have changed in this diff. Show more