WIP: Switch to React #6
No files matched your search
@@ -170,6 +170,25 @@ public sealed class CourseAdjustmentsController(
|
|||||||
return Ok(new { Task = task, Sessions = sessions, Classrooms = classrooms, Periods = periods });
|
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 ═══════════════
|
// ═══════════════ My adjustments ═══════════════
|
||||||
|
|
||||||
[HttpGet("mine")]
|
[HttpGet("mine")]
|
||||||
@@ -611,7 +630,8 @@ public sealed class CourseAdjustmentsController(
|
|||||||
return ValidationProblem("请选择代课教师。");
|
return ValidationProblem("请选择代课教师。");
|
||||||
if (!await db.Teachers.AnyAsync(x =>
|
if (!await db.Teachers.AnyAsync(x =>
|
||||||
x.Id == request.SubstituteTeacherId &&
|
x.Id == request.SubstituteTeacherId &&
|
||||||
x.Status == TeacherStatus.Active, cancellationToken))
|
x.Status == TeacherStatus.Active &&
|
||||||
|
!x.IsArchived, cancellationToken))
|
||||||
return ValidationProblem("代课教师不存在或已离职。");
|
return ValidationProblem("代课教师不存在或已离职。");
|
||||||
break;
|
break;
|
||||||
case CourseAdjustmentType.Cancel:
|
case CourseAdjustmentType.Cancel:
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public sealed class SsoController(
|
|||||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||||
|
var accountPage = FrontendPagePath(safeReturnUrl, "account", nativeApp);
|
||||||
if (string.IsNullOrWhiteSpace(bindingIntent) &&
|
if (string.IsNullOrWhiteSpace(bindingIntent) &&
|
||||||
(string.IsNullOrWhiteSpace(captchaId) ||
|
(string.IsNullOrWhiteSpace(captchaId) ||
|
||||||
string.IsNullOrWhiteSpace(captchaCode) ||
|
string.IsNullOrWhiteSpace(captchaCode) ||
|
||||||
@@ -77,7 +78,7 @@ public sealed class SsoController(
|
|||||||
BindingIntentCacheKey(bindingIntent),
|
BindingIntentCacheKey(bindingIntent),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (targetUserId is null)
|
if (targetUserId is null)
|
||||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
return RedirectToFrontendError("binding_intent_expired", accountPage);
|
||||||
properties.Items[BindingIntentProperty] = bindingIntent;
|
properties.Items[BindingIntentProperty] = bindingIntent;
|
||||||
}
|
}
|
||||||
var completeUrl = Url.Action(
|
var completeUrl = Url.Action(
|
||||||
@@ -97,7 +98,9 @@ public sealed class SsoController(
|
|||||||
EffectiveCallbackUrl());
|
EffectiveCallbackUrl());
|
||||||
return RedirectToFrontendError(
|
return RedirectToFrontendError(
|
||||||
"configuration_error",
|
"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)
|
if (!_options.Enabled)
|
||||||
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
return SsoProblem("统一身份认证尚未启用。", StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var safeReturnUrl = NormalizeReturnUrl(returnUrl);
|
||||||
var authentication = await HttpContext.AuthenticateAsync(
|
var authentication = await HttpContext.AuthenticateAsync(
|
||||||
SsoAuthSchemes.ExternalCookie);
|
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)
|
if (!authentication.Succeeded || authentication.Principal is null)
|
||||||
return RedirectToFrontendError("authentication_failed");
|
return RedirectToFrontendError("authentication_failed", loginPage);
|
||||||
|
|
||||||
var principal = authentication.Principal;
|
var principal = authentication.Principal;
|
||||||
var subject = principal.FindFirstValue("sub") ??
|
var subject = principal.FindFirstValue("sub") ??
|
||||||
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
if (string.IsNullOrWhiteSpace(subject))
|
if (string.IsNullOrWhiteSpace(subject))
|
||||||
return RedirectToFrontendError("missing_subject");
|
return RedirectToFrontendError("missing_subject", loginPage);
|
||||||
|
|
||||||
ApplicationUser? user = null;
|
ApplicationUser? user = null;
|
||||||
var bindingIntent =
|
var bindingIntent =
|
||||||
@@ -130,10 +140,6 @@ public sealed class SsoController(
|
|||||||
out var storedBindingIntent)
|
out var storedBindingIntent)
|
||||||
? storedBindingIntent
|
? storedBindingIntent
|
||||||
: null;
|
: 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 &&
|
var nativeState = authentication.Properties is { } nativeProperties &&
|
||||||
nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState)
|
nativeProperties.Items.TryGetValue(NativeAppStateProperty, out var storedNativeState)
|
||||||
? storedNativeState
|
? storedNativeState
|
||||||
@@ -147,13 +153,13 @@ public sealed class SsoController(
|
|||||||
? null
|
? null
|
||||||
: await userManager.FindByIdAsync(targetUserId);
|
: await userManager.FindByIdAsync(targetUserId);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return RedirectToFrontendError("binding_intent_expired", "/account");
|
return RedirectToFrontendError("binding_intent_expired", accountPage);
|
||||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
return RedirectToFrontendError("account_disabled", "/account");
|
return RedirectToFrontendError("account_disabled", accountPage);
|
||||||
|
|
||||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||||
if (linkError is not null)
|
if (linkError is not null)
|
||||||
return RedirectToFrontendError(linkError, "/account");
|
return RedirectToFrontendError(linkError, accountPage);
|
||||||
await cache.RemoveAsync(
|
await cache.RemoveAsync(
|
||||||
BindingIntentCacheKey(bindingIntent),
|
BindingIntentCacheKey(bindingIntent),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
@@ -172,7 +178,7 @@ public sealed class SsoController(
|
|||||||
{
|
{
|
||||||
var linkError = await LinkSsoIdentityAsync(user, subject);
|
var linkError = await LinkSsoIdentityAsync(user, subject);
|
||||||
if (linkError is not null)
|
if (linkError is not null)
|
||||||
return RedirectToFrontendError(linkError);
|
return RedirectToFrontendError(linkError, loginPage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,18 +201,20 @@ public sealed class SsoController(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||||
|
|
||||||
var bindingPage = BuildFrontendUrl("/sso/bind", nativeApp) +
|
var bindingPage = BuildFrontendUrl(
|
||||||
|
FrontendPagePath(safeReturnUrl, "sso/bind", nativeApp),
|
||||||
|
nativeApp) +
|
||||||
$"?code={Uri.EscapeDataString(bindingCode)}" +
|
$"?code={Uri.EscapeDataString(bindingCode)}" +
|
||||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
$"&redirect={Uri.EscapeDataString(safeReturnUrl)}";
|
||||||
return Redirect(bindingPage);
|
return Redirect(bindingPage);
|
||||||
}
|
}
|
||||||
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
if (!user.IsEnabled || await userManager.IsLockedOutAsync(user))
|
||||||
return RedirectToFrontendError("account_disabled");
|
return RedirectToFrontendError("account_disabled", loginPage);
|
||||||
|
|
||||||
user.LastLoginAt = DateTime.UtcNow;
|
user.LastLoginAt = DateTime.UtcNow;
|
||||||
var updateResult = await userManager.UpdateAsync(user);
|
var updateResult = await userManager.UpdateAsync(user);
|
||||||
if (!updateResult.Succeeded)
|
if (!updateResult.Succeeded)
|
||||||
return RedirectToFrontendError("account_update_failed");
|
return RedirectToFrontendError("account_update_failed", loginPage);
|
||||||
|
|
||||||
var exchangeCode = WebEncoders.Base64UrlEncode(
|
var exchangeCode = WebEncoders.Base64UrlEncode(
|
||||||
RandomNumberGenerator.GetBytes(32));
|
RandomNumberGenerator.GetBytes(32));
|
||||||
@@ -220,9 +228,11 @@ public sealed class SsoController(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
await HttpContext.SignOutAsync(SsoAuthSchemes.ExternalCookie);
|
||||||
|
|
||||||
var callback = BuildFrontendUrl("/sso/callback", nativeApp) +
|
var callback = BuildFrontendUrl(
|
||||||
|
FrontendPagePath(safeReturnUrl, "sso/callback", nativeApp),
|
||||||
|
nativeApp) +
|
||||||
$"?code={Uri.EscapeDataString(exchangeCode)}" +
|
$"?code={Uri.EscapeDataString(exchangeCode)}" +
|
||||||
$"&redirect={Uri.EscapeDataString(NormalizeReturnUrl(returnUrl))}";
|
$"&redirect={Uri.EscapeDataString(safeReturnUrl)}";
|
||||||
return Redirect(callback);
|
return Redirect(callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +396,7 @@ public sealed class SsoController(
|
|||||||
[HttpPost("prepare-binding")]
|
[HttpPost("prepare-binding")]
|
||||||
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
public async Task<ActionResult<SsoBindingStartResponse>> PrepareBinding(
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
|
[FromQuery] string? returnUrl = null,
|
||||||
[FromQuery] bool nativeApp = false,
|
[FromQuery] bool nativeApp = false,
|
||||||
[FromQuery] string? nativeState = null)
|
[FromQuery] string? nativeState = null)
|
||||||
{
|
{
|
||||||
@@ -411,11 +422,14 @@ public sealed class SsoController(
|
|||||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
|
||||||
},
|
},
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var safeReturnUrl = string.IsNullOrWhiteSpace(returnUrl)
|
||||||
|
? "/account"
|
||||||
|
: NormalizeReturnUrl(returnUrl);
|
||||||
var loginUrl = Url.Action(
|
var loginUrl = Url.Action(
|
||||||
nameof(Login),
|
nameof(Login),
|
||||||
values: new
|
values: new
|
||||||
{
|
{
|
||||||
returnUrl = "/account",
|
returnUrl = safeReturnUrl,
|
||||||
bindingIntent = intentCode,
|
bindingIntent = intentCode,
|
||||||
nativeApp,
|
nativeApp,
|
||||||
nativeState,
|
nativeState,
|
||||||
@@ -464,6 +478,19 @@ public sealed class SsoController(
|
|||||||
? returnUrl
|
? returnUrl
|
||||||
: "/dashboard";
|
: "/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)
|
private string BuildFrontendUrl(string path, bool nativeApp = false)
|
||||||
{
|
{
|
||||||
if (nativeApp)
|
if (nativeApp)
|
||||||
|
|||||||
@@ -6,11 +6,46 @@ using Jiaowu.Api.Infrastructure.Persistence;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Tests;
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
public sealed class CourseAdjustmentsControllerTests
|
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]
|
[Fact]
|
||||||
public async Task Reschedule_MovesOnlyTheSelectedOccurrence()
|
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
Reference in new issue
Block a user