9 changed files with 728 additions and 3 deletions
@ -1,6 +1,19 @@ |
|||||
{ |
{ |
||||
"culture": "en", |
"culture": "en", |
||||
"texts": { |
"texts": { |
||||
"Required": "Required" |
"Required": "Required", |
||||
|
"InvalidSelectAccountRequest": "Invalid request for a selected account!", |
||||
|
"NoAvailableAccounts": "No Available Accounts!", |
||||
|
"InvalidSelectAccount": "Invalid Select Account!", |
||||
|
"SelectAccount": "Select Account", |
||||
|
"CurrentAccount": "Current", |
||||
|
"SignedInAs": "Select to login to the account of {0}", |
||||
|
"LastLoginTime": "Last Login", |
||||
|
"Continue": "Continue", |
||||
|
"SwitchToAnotherAccount": "Switch to another account", |
||||
|
"CreateNewAccount": "Create a new account", |
||||
|
"SelectedAccountId": "Selected a cccount", |
||||
|
"RememberSelection": "Remember selection", |
||||
|
"RememberMe": "Remember me" |
||||
} |
} |
||||
} |
} |
||||
@ -1,6 +1,19 @@ |
|||||
{ |
{ |
||||
"culture": "zh-Hans", |
"culture": "zh-Hans", |
||||
"texts": { |
"texts": { |
||||
"Required": "必须" |
"Required": "必须", |
||||
|
"InvalidSelectAccountRequest": "无效的选择账户请求!", |
||||
|
"NoAvailableAccounts": "未找到可用账户!", |
||||
|
"InvalidSelectAccount": "选择的账户无效!", |
||||
|
"SelectAccount": "选择账户", |
||||
|
"CurrentAccount": "当前账户", |
||||
|
"SignedInAs": "选择登录到 {0} 的账户", |
||||
|
"LastLoginTime": "上次登录", |
||||
|
"Continue": "继续", |
||||
|
"SwitchToAnotherAccount": "使用其他账户", |
||||
|
"CreateNewAccount": "创建新账户", |
||||
|
"SelectedAccountId": "请选择一个账户", |
||||
|
"RememberSelection": "记住我的选择", |
||||
|
"RememberMe": "保持登录状态" |
||||
} |
} |
||||
} |
} |
||||
@ -0,0 +1,133 @@ |
|||||
|
@page |
||||
|
@using Microsoft.AspNetCore.Mvc.Localization |
||||
|
@using Volo.Abp.AspNetCore.Mvc.UI.Layout |
||||
|
@using Volo.Abp.AspNetCore.Mvc.UI.Theming; |
||||
|
@using Volo.Abp.OpenIddict.Localization |
||||
|
@using LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account |
||||
|
@inject IThemeManager ThemeManager |
||||
|
@inject IPageLayout PageLayout |
||||
|
@inject IHtmlLocalizer<AbpOpenIddictResource> L |
||||
|
@model SelectAccountModel |
||||
|
|
||||
|
@{ |
||||
|
Layout = ThemeManager.CurrentTheme.GetAccountLayout(); |
||||
|
PageLayout.Content.Title = L["SelectAccount"].Value; |
||||
|
} |
||||
|
|
||||
|
@section styles |
||||
|
{ |
||||
|
<abp-style-bundle name="@typeof(SelectAccountModel).FullName" /> |
||||
|
} |
||||
|
|
||||
|
@section scripts |
||||
|
{ |
||||
|
<abp-script-bundle name="@typeof(SelectAccountModel).FullName" /> |
||||
|
} |
||||
|
|
||||
|
<abp-card> |
||||
|
<abp-card-body> |
||||
|
@if (Model.AvailableAccounts.Any()) |
||||
|
{ |
||||
|
<form method="post" id="selectAccountForm"> |
||||
|
@Html.AntiForgeryToken() |
||||
|
|
||||
|
<input type="hidden" asp-for="Input.ClientId" value="@Model.ClientName" /> |
||||
|
<input type="hidden" asp-for="Input.RedirectUri" value="@Model.RedirectUri" /> |
||||
|
<input type="hidden" asp-for="Input.RememberSelection" value="true" /> |
||||
|
<input type="hidden" asp-for="Input.RememberMe" value="true" /> |
||||
|
|
||||
|
<div class="alert alert-info d-flex align-items-center mb-4"> |
||||
|
<i class="fas fa-user-check me-2"></i> |
||||
|
<span>@string.Format(@L["SignedInAs"].Value, @Model.UserName)</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="list-group list-group-flush rounded-3"> |
||||
|
@for (int i = 0; i < Model.AvailableAccounts.Count; i++) |
||||
|
{ |
||||
|
var account = Model.AvailableAccounts[i]; |
||||
|
var accountId = $"{account.UserId}@{account.TenantId}"; |
||||
|
var accountName = $"{account.UserName}"; |
||||
|
if (!string.IsNullOrWhiteSpace(account.TenantName)) |
||||
|
{ |
||||
|
accountName += "/" + account.TenantName; |
||||
|
} |
||||
|
// TODO: Date format |
||||
|
var lastLoginTime = account.LastLoginTime.HasValue ? account.LastLoginTime.Value.ToString("yyyy-MM:dd HH:mm:ss") : ""; |
||||
|
var isCurrent = account.IsCurrentAccount; |
||||
|
// TODO: 实现用户头像 |
||||
|
var avatar = !string.IsNullOrWhiteSpace(account.UserName) ? account.UserName[0].ToString().ToUpper() : "U"; |
||||
|
|
||||
|
<label class="list-group-item list-group-item-action border-0 cursor-pointer @(isCurrent ? "active" : "") account-item" |
||||
|
for="account-@i"> |
||||
|
<div class="d-flex align-items-center"> |
||||
|
<!-- 头像 --> |
||||
|
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3" |
||||
|
style="width: 40px; height: 40px; font-size: 16px; font-weight: 500;"> |
||||
|
@avatar |
||||
|
</div> |
||||
|
|
||||
|
<!-- 账户信息 --> |
||||
|
<div class="flex-grow-1 me-3"> |
||||
|
<div class="d-flex align-items-center"> |
||||
|
<span class="fw-semibold">@accountName</span> |
||||
|
@if (isCurrent) |
||||
|
{ |
||||
|
<span class="badge bg-success ms-2">@L["CurrentAccount"].Value</span> |
||||
|
} |
||||
|
</div> |
||||
|
<div class="text-muted small"> |
||||
|
@account.Email |
||||
|
</div> |
||||
|
@if (!string.IsNullOrWhiteSpace(lastLoginTime)) |
||||
|
{ |
||||
|
<div class="text-muted small"> |
||||
|
@L["LastLoginTime"]: @lastLoginTime |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
<!-- 选择账户 --> |
||||
|
<input type="radio" |
||||
|
asp-for="Input.SelectedAccountId" |
||||
|
value="@accountId" |
||||
|
id="account-@i" |
||||
|
class="form-check-input" |
||||
|
style="width: 1.2em; height: 1.2em;" |
||||
|
checked="@isCurrent" /> |
||||
|
</div> |
||||
|
</label> |
||||
|
} |
||||
|
</div> |
||||
|
|
||||
|
<!-- 操作按钮 --> |
||||
|
<div class="d-grid gap-2 mt-2"> |
||||
|
<button type="submit" class="btn btn-primary" id="continueButton"> |
||||
|
@L["Continue"].Value |
||||
|
<i class="fas fa-arrow-right"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
<div class="alert alert-warning mb-4"> |
||||
|
<i class="fas fa-info-circle me-2"></i> |
||||
|
@L["NoAccountsAvailable"].Value |
||||
|
</div> |
||||
|
} |
||||
|
|
||||
|
<!-- 其他选项 --> |
||||
|
<div class="d-grid gap-2 mt-2"> |
||||
|
<a href="@Url.Page("/Account/Login", new { ReturnUrl = Model.RedirectUri, prompt = "login" })" |
||||
|
class="btn btn-outline-primary"> |
||||
|
<i class="fas fa-exchange-alt me-2"></i> |
||||
|
@L["SwitchToAnotherAccount"].Value |
||||
|
</a> |
||||
|
<a href="@Url.Page("/Account/Register", new { ReturnUrl = Model.RedirectUri })" |
||||
|
class="btn btn-outline-primary"> |
||||
|
<i class="fas fa-user-plus me-2"></i> |
||||
|
@L["CreateNewAccount"].Value |
||||
|
</a> |
||||
|
</div> |
||||
|
</abp-card-body> |
||||
|
</abp-card> |
||||
@ -0,0 +1,468 @@ |
|||||
|
using Microsoft.AspNetCore.Authorization; |
||||
|
using Microsoft.AspNetCore.Mvc; |
||||
|
using Microsoft.AspNetCore.WebUtilities; |
||||
|
using Microsoft.Extensions.Logging; |
||||
|
using Microsoft.Extensions.Options; |
||||
|
using Microsoft.Extensions.Primitives; |
||||
|
using OpenIddict.Abstractions; |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.Account.Web.Pages.Account; |
||||
|
using Volo.Abp.Data; |
||||
|
using Volo.Abp.Identity; |
||||
|
using Volo.Abp.MultiTenancy; |
||||
|
using Volo.Abp.OpenIddict.Localization; |
||||
|
using Volo.Abp.Users; |
||||
|
using Volo.Abp.Validation; |
||||
|
using static LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account.SelectAccountModel; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account; |
||||
|
|
||||
|
[Authorize] |
||||
|
public class SelectAccountModel : AccountPageModel |
||||
|
{ |
||||
|
private const string LastLoginTimeFieldName = "LastLoginTime"; |
||||
|
|
||||
|
[BindProperty(SupportsGet = true)] |
||||
|
public string RedirectUri { get; set; } |
||||
|
|
||||
|
public string ClientName { get; set; } |
||||
|
|
||||
|
public string UserName { get; set; } |
||||
|
|
||||
|
[BindProperty] |
||||
|
public SelectAccountInput Input { get; set; } |
||||
|
|
||||
|
public List<UserAccountInfo> AvailableAccounts { get; set; } = new(); |
||||
|
|
||||
|
protected IOpenIddictApplicationManager ApplicationManager => LazyServiceProvider.LazyGetRequiredService<IOpenIddictApplicationManager>(); |
||||
|
|
||||
|
protected ITenantStore TenantStore => LazyServiceProvider.LazyGetRequiredService<ITenantStore>(); |
||||
|
|
||||
|
public SelectAccountModel() |
||||
|
{ |
||||
|
LocalizationResourceType = typeof(AbpOpenIddictResource); |
||||
|
} |
||||
|
|
||||
|
public async virtual Task<IActionResult> OnGetAsync() |
||||
|
{ |
||||
|
// 检查用户是否已登录
|
||||
|
if (!User.Identity.IsAuthenticated) |
||||
|
{ |
||||
|
// 未登录,重定向到登录页面
|
||||
|
return RedirectToPage("/Account/Login", new |
||||
|
{ |
||||
|
ReturnUrl = Url.Page("/Account/SelectAccount", new { RedirectUri }), |
||||
|
Prompt = "select_account" |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
var requestInfo = await ParseOriginalRequestFromRedirectUriAsync(); |
||||
|
if (requestInfo == null) |
||||
|
{ |
||||
|
Alerts.Warning(L["InvalidSelectAccountRequest"]); |
||||
|
return Page(); |
||||
|
} |
||||
|
|
||||
|
var application = await ApplicationManager.FindByClientIdAsync(requestInfo.ClientId); |
||||
|
ClientName = await ApplicationManager.GetLocalizedDisplayNameAsync(application) ?? requestInfo.ClientId; |
||||
|
|
||||
|
var currentUser = await UserManager.GetUserAsync(User); |
||||
|
if (currentUser == null) |
||||
|
{ |
||||
|
await SignInManager.SignOutAsync(); |
||||
|
return RedirectToPage("/Account/Login", new |
||||
|
{ |
||||
|
ReturnUrl = requestInfo.RedirectUri |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
UserName = currentUser.UserName; |
||||
|
|
||||
|
AvailableAccounts = await DiscoverUserAccountsAsync(currentUser, requestInfo.ClientId); |
||||
|
|
||||
|
if (AvailableAccounts.Count == 0) |
||||
|
{ |
||||
|
Alerts.Warning(L["NoAvailableAccounts"]); |
||||
|
} |
||||
|
|
||||
|
// 仅关联一个账户时直接登录
|
||||
|
if (AvailableAccounts.Count == 1) |
||||
|
{ |
||||
|
return await HandleLoginAsync(currentUser, Input.RememberMe, Input.RememberSelection); |
||||
|
} |
||||
|
|
||||
|
return Page(); |
||||
|
} |
||||
|
|
||||
|
public async virtual Task<IActionResult> OnPostAsync() |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
ValidateModel(); |
||||
|
|
||||
|
await IdentityOptions.SetAsync(); |
||||
|
|
||||
|
var tenantUser = ParseSelectedAccountId(Input.SelectedAccountId); |
||||
|
if (tenantUser == null) |
||||
|
{ |
||||
|
Alerts.Warning(L["InvalidSelectAccount"]); |
||||
|
return Page(); |
||||
|
} |
||||
|
|
||||
|
var user = await ValidateSelectedAccountAsync(tenantUser.UserId, tenantUser.TenantId); |
||||
|
if (user == null) |
||||
|
{ |
||||
|
Alerts.Warning(L["InvalidSelectAccount"]); |
||||
|
return Page(); |
||||
|
} |
||||
|
|
||||
|
using (CurrentTenant.Change(tenantUser.TenantId)) |
||||
|
{ |
||||
|
return await HandleLoginAsync(user, Input.RememberMe, Input.RememberSelection); |
||||
|
} |
||||
|
} |
||||
|
catch (AbpIdentityResultException e) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(e.Message)) |
||||
|
{ |
||||
|
Alerts.Warning(GetLocalizeExceptionMessage(e)); |
||||
|
return Page(); |
||||
|
} |
||||
|
|
||||
|
throw; |
||||
|
} |
||||
|
catch (AbpValidationException) |
||||
|
{ |
||||
|
return Page(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected async virtual Task<IActionResult> HandleLoginAsync(IdentityUser user, bool rememberMe = false, bool rememberSelection = false) |
||||
|
{ |
||||
|
// 检查是否已经是当前用户
|
||||
|
var currentUser = await UserManager.GetUserAsync(User); |
||||
|
if (currentUser == null || currentUser.Id == user.Id) |
||||
|
{ |
||||
|
// 使用选择的账户登录
|
||||
|
// TODO: 实现 RememberMe
|
||||
|
await SignInManager.SignInAsync(user, rememberMe); |
||||
|
|
||||
|
// TODO: date format
|
||||
|
user.SetProperty(LastLoginTimeFieldName, Clock.Now.ToString("yyyy-MM-dd HH:mm:ss")); |
||||
|
|
||||
|
// TODO: 实现 RememberSelection
|
||||
|
if (rememberSelection) |
||||
|
{ |
||||
|
await SaveAccountSelectionAsync( |
||||
|
Input.ClientId, |
||||
|
user.Id, |
||||
|
user.TenantId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 重定向回原始授权请求
|
||||
|
return await RedirectSafelyAsync(Input.RedirectUri); |
||||
|
} |
||||
|
|
||||
|
protected virtual Task<OriginalRequestInfo> ParseOriginalRequestFromRedirectUriAsync() |
||||
|
{ |
||||
|
if (string.IsNullOrWhiteSpace(RedirectUri)) |
||||
|
{ |
||||
|
return Task.FromResult<OriginalRequestInfo>(null); |
||||
|
} |
||||
|
|
||||
|
try |
||||
|
{ |
||||
|
var info = new OriginalRequestInfo(); |
||||
|
string queryString = null; |
||||
|
|
||||
|
if (RedirectUri.StartsWith("/")) |
||||
|
{ |
||||
|
int queryIndex = RedirectUri.IndexOf('?'); |
||||
|
if (queryIndex >= 0) |
||||
|
{ |
||||
|
queryString = RedirectUri.Substring(queryIndex + 1); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
// 没有查询参数,尝试直接解析整个字符串
|
||||
|
queryString = RedirectUri; |
||||
|
} |
||||
|
} |
||||
|
else if (RedirectUri.Contains("://")) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
var uri = new Uri(RedirectUri); |
||||
|
queryString = uri.Query; |
||||
|
if (queryString.StartsWith("?")) |
||||
|
{ |
||||
|
queryString = queryString.Substring(1); |
||||
|
} |
||||
|
} |
||||
|
catch (UriFormatException) |
||||
|
{ |
||||
|
queryString = RedirectUri; |
||||
|
} |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
queryString = RedirectUri; |
||||
|
} |
||||
|
|
||||
|
// 解析查询参数
|
||||
|
if (!string.IsNullOrWhiteSpace(queryString)) |
||||
|
{ |
||||
|
var query = QueryHelpers.ParseQuery(queryString); |
||||
|
|
||||
|
info.ClientId = GetQueryValue(query, "client_id"); |
||||
|
info.RedirectUri = GetQueryValue(query, "redirect_uri"); |
||||
|
info.ResponseType = GetQueryValue(query, "response_type"); |
||||
|
info.Scope = GetQueryValue(query, "scope"); |
||||
|
info.State = GetQueryValue(query, "state"); |
||||
|
info.Nonce = GetQueryValue(query, "nonce"); |
||||
|
info.CodeChallenge = GetQueryValue(query, "code_challenge"); |
||||
|
info.CodeChallengeMethod = GetQueryValue(query, "code_challenge_method"); |
||||
|
info.Prompt = GetQueryValue(query, "prompt"); |
||||
|
} |
||||
|
|
||||
|
return Task.FromResult(info); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
Logger.LogWarning(ex, "解析RedirectUri参数错误: {message}", ex.Message); |
||||
|
return Task.FromResult<OriginalRequestInfo>(null); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected async virtual Task<List<UserAccountInfo>> DiscoverUserAccountsAsync( |
||||
|
IdentityUser currentUser, |
||||
|
string clientId) |
||||
|
{ |
||||
|
var accounts = new List<UserAccountInfo> |
||||
|
{ |
||||
|
new UserAccountInfo |
||||
|
{ |
||||
|
IsCurrentAccount = true, |
||||
|
UserId = currentUser.Id.ToString(), |
||||
|
TenantId = CurrentTenant.Id, |
||||
|
TenantName = CurrentTenant.Name, |
||||
|
UserName = currentUser.UserName, |
||||
|
Email = currentUser.Email, |
||||
|
LastLoginTime = currentUser.GetProperty<DateTime?>(LastLoginTimeFieldName), |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
// 获取客户端允许的租户
|
||||
|
var allowedTenants = await GetAllowedTenantsForClientAsync(clientId); |
||||
|
|
||||
|
foreach (var tenant in allowedTenants) |
||||
|
{ |
||||
|
var tenantUserAccount = await GetTenantUserAccountInfoAsync(currentUser.UserName, tenant); |
||||
|
if (tenantUserAccount != null) |
||||
|
{ |
||||
|
accounts.Add(tenantUserAccount); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 按最后登录时间排序,当前账户排第一
|
||||
|
return accounts |
||||
|
.OrderByDescending(a => a.IsCurrentAccount) |
||||
|
.ThenByDescending(a => a.LastLoginTime) |
||||
|
.ToList(); |
||||
|
} |
||||
|
|
||||
|
protected async virtual Task<UserAccountInfo> GetTenantUserAccountInfoAsync(string userName, TenantInfo tenant) |
||||
|
{ |
||||
|
using (CurrentTenant.Change(tenant.Id, tenant.Name)) |
||||
|
{ |
||||
|
var user = await UserManager.FindByNameAsync(userName); |
||||
|
if (user != null) |
||||
|
{ |
||||
|
// 检查用户是否有效
|
||||
|
if (user.IsActive && !await UserManager.IsLockedOutAsync(user)) |
||||
|
{ |
||||
|
var lastLoginTime = user.GetProperty<DateTime?>(LastLoginTimeFieldName); |
||||
|
return new UserAccountInfo |
||||
|
{ |
||||
|
IsCurrentAccount = false, |
||||
|
UserId = user.Id.ToString(), |
||||
|
TenantId = tenant.Id, |
||||
|
TenantName = tenant.Name, |
||||
|
UserName = user.UserName, |
||||
|
Email = user.Email, |
||||
|
LastLoginTime = lastLoginTime, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected async virtual Task<List<TenantInfo>> GetAllowedTenantsForClientAsync(string clientId) |
||||
|
{ |
||||
|
var tenants = new List<TenantInfo>(); |
||||
|
|
||||
|
if (string.IsNullOrWhiteSpace(clientId)) |
||||
|
{ |
||||
|
return tenants; |
||||
|
} |
||||
|
|
||||
|
var application = await ApplicationManager.FindByClientIdAsync(clientId); |
||||
|
if (application == null) |
||||
|
{ |
||||
|
return tenants; |
||||
|
} |
||||
|
|
||||
|
var properties = await ApplicationManager.GetPropertiesAsync(application); |
||||
|
if (properties.TryGetValue("AllowedTenants", out var allowedTenantsValue)) |
||||
|
{ |
||||
|
var tenantIds = allowedTenantsValue.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries); |
||||
|
|
||||
|
foreach (var tenantIdString in tenantIds) |
||||
|
{ |
||||
|
if (Guid.TryParse(tenantIdString.Trim(), out var tenantId)) |
||||
|
{ |
||||
|
var tenant = await TenantStore.FindAsync(tenantId); |
||||
|
if (tenant != null && tenant.IsActive) |
||||
|
{ |
||||
|
tenants.Add(new TenantInfo |
||||
|
{ |
||||
|
Id = tenant.Id, |
||||
|
Name = tenant.Name |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return tenants; |
||||
|
} |
||||
|
|
||||
|
protected virtual TenantUser ParseSelectedAccountId(string selectedAccountId) |
||||
|
{ |
||||
|
// 解析账户格式为: UserId@TenantId
|
||||
|
if (string.IsNullOrWhiteSpace(selectedAccountId)) |
||||
|
{ |
||||
|
return new TenantUser(); |
||||
|
} |
||||
|
|
||||
|
var parts = selectedAccountId.Split('@', StringSplitOptions.RemoveEmptyEntries); |
||||
|
if (parts.Length < 1) |
||||
|
{ |
||||
|
return new TenantUser(); |
||||
|
} |
||||
|
|
||||
|
if (Guid.TryParse(parts[0], out var userId)) |
||||
|
{ |
||||
|
if (parts.Length > 1 && |
||||
|
Guid.TryParse(parts[1], out var tenantId)) |
||||
|
{ |
||||
|
return new TenantUser(tenantId, userId); |
||||
|
} |
||||
|
return new TenantUser(null, userId); |
||||
|
} |
||||
|
|
||||
|
return new TenantUser(); |
||||
|
} |
||||
|
|
||||
|
protected virtual string GetQueryValue(Dictionary<string, StringValues> query, string key) |
||||
|
{ |
||||
|
return query.TryGetValue(key, out var value) ? value.ToString() : null; |
||||
|
} |
||||
|
|
||||
|
protected async virtual Task<IdentityUser> ValidateSelectedAccountAsync(Guid userId, Guid? tenantId) |
||||
|
{ |
||||
|
using (CurrentTenant.Change(tenantId)) |
||||
|
{ |
||||
|
var user = await UserManager.FindByIdAsync(userId.ToString()); |
||||
|
if (user == null) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (!user.IsActive) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (await UserManager.IsLockedOutAsync(user)) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return user; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected virtual Task SaveAccountSelectionAsync(string clientId, Guid userId, Guid? tenantId) |
||||
|
{ |
||||
|
// TODO: 保存用户当前选择账户, 下次选择账户时默认选择此账户
|
||||
|
return Task.CompletedTask; |
||||
|
} |
||||
|
|
||||
|
public class OriginalRequestInfo |
||||
|
{ |
||||
|
public string ClientId { get; set; } |
||||
|
public string RedirectUri { get; set; } |
||||
|
public string Scope { get; set; } |
||||
|
public string State { get; set; } |
||||
|
public string Nonce { get; set; } |
||||
|
public string ResponseType { get; set; } |
||||
|
public string CodeChallenge { get; set; } |
||||
|
public string CodeChallengeMethod { get; set; } |
||||
|
public string Prompt { get; set; } |
||||
|
} |
||||
|
|
||||
|
public class SelectAccountInput |
||||
|
{ |
||||
|
[Required] |
||||
|
public string SelectedAccountId { get; set; } |
||||
|
|
||||
|
[Required] |
||||
|
public string ClientId { get; set; } |
||||
|
|
||||
|
[Required] |
||||
|
public string RedirectUri { get; set; } |
||||
|
|
||||
|
public bool RememberSelection { get; set; } = true; |
||||
|
|
||||
|
public bool RememberMe { get; set; } = true; |
||||
|
} |
||||
|
|
||||
|
public class UserAccountInfo |
||||
|
{ |
||||
|
public string UserId { get; set; } |
||||
|
public Guid? TenantId { get; set; } |
||||
|
public string TenantName { get; set; } |
||||
|
public string UserName { get; set; } |
||||
|
public string Email { get; set; } |
||||
|
public DateTime? LastLoginTime { get; set; } |
||||
|
public bool IsCurrentAccount { get; set; } |
||||
|
} |
||||
|
|
||||
|
public class TenantInfo |
||||
|
{ |
||||
|
public Guid Id { get; set; } |
||||
|
public string Name { get; set; } |
||||
|
} |
||||
|
|
||||
|
public class TenantUser |
||||
|
{ |
||||
|
public Guid? TenantId { get; set; } |
||||
|
public Guid UserId { get; set; } |
||||
|
public TenantUser() |
||||
|
{ |
||||
|
|
||||
|
} |
||||
|
public TenantUser(Guid? tenantId, Guid userId) |
||||
|
{ |
||||
|
TenantId = tenantId; |
||||
|
UserId = userId; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
$(function () { |
||||
|
var l = abp.localization.getResource('AbpOpenIddict'); |
||||
|
|
||||
|
// 账户选择交互 - 使用 Bootstrap 的 list-group-item-action
|
||||
|
$('.account-item').on('click', function (e) { |
||||
|
// 如果点击的是链接或按钮,不处理
|
||||
|
if ($(e.target).is('a, button, .btn') || |
||||
|
$(e.target).closest('a, button, .btn').length) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 移除所有 active 状态
|
||||
|
$('.account-item').removeClass('active'); |
||||
|
|
||||
|
// 添加当前选中状态
|
||||
|
$(this).addClass('active'); |
||||
|
|
||||
|
// 选中对应的 radio button
|
||||
|
var radio = $(this).find('input[type="radio"]'); |
||||
|
radio.prop('checked', true); |
||||
|
}); |
||||
|
|
||||
|
// 表单提交验证
|
||||
|
$('#selectAccountForm').on('submit', function (e) { |
||||
|
var selectedAccount = $('input[name="Input.SelectedAccountId"]:checked').val(); |
||||
|
|
||||
|
if (!selectedAccount) { |
||||
|
e.preventDefault(); |
||||
|
abp.message.warn(l('SelectedAccountId')); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
}); |
||||
|
|
||||
|
// 初始选中第一个账户(如果不是当前账户)
|
||||
|
if (!$('.account-item.active').length) { |
||||
|
$('.account-item:first').trigger('click'); |
||||
|
} |
||||
|
|
||||
|
// 自动聚焦到选中的账户
|
||||
|
$('.account-item.active').find('input[type="radio"]').focus(); |
||||
|
}); |
||||
@ -0,0 +1,29 @@ |
|||||
|
/* 鼠标手型 */ |
||||
|
.cursor-pointer { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
/* 账户列表项悬停效果增强 */ |
||||
|
.account-item:hover { |
||||
|
background-color: var(--bs-list-group-action-hover-bg); |
||||
|
transform: translateY(-1px); |
||||
|
transition: all 0.2s ease; |
||||
|
} |
||||
|
|
||||
|
/* 当前账户特殊样式 */ |
||||
|
.account-item.active { |
||||
|
background-color: var(--bs-primary-bg-subtle); |
||||
|
border-left: 3px solid var(--bs-primary); |
||||
|
} |
||||
|
|
||||
|
/* 响应式调整 */ |
||||
|
@media (max-width: 576px) { |
||||
|
.min-vh-100 { |
||||
|
min-height: auto; |
||||
|
padding: 1rem 0; |
||||
|
} |
||||
|
|
||||
|
.card-body { |
||||
|
padding: 1rem !important; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue