✨ Implementation of email code and in app code
This commit is contained in:
parent
3c123be6a7
commit
af39694be6
@ -1,9 +1,14 @@
|
||||
using System.Globalization;
|
||||
using DysonNetwork.Sphere.Auth;
|
||||
using DysonNetwork.Sphere.Email;
|
||||
using DysonNetwork.Sphere.Localization;
|
||||
using DysonNetwork.Sphere.Pages.Emails;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using EFCore.BulkExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using NodaTime;
|
||||
using Org.BouncyCastle.Utilities;
|
||||
using OtpNet;
|
||||
|
||||
namespace DysonNetwork.Sphere.Account;
|
||||
@ -12,7 +17,10 @@ public class AccountService(
|
||||
AppDatabase db,
|
||||
MagicSpellService spells,
|
||||
NotificationService nty,
|
||||
ICacheService cache
|
||||
EmailService email,
|
||||
IStringLocalizer<NotificationResource> localizer,
|
||||
ICacheService cache,
|
||||
ILogger<AccountService> logger
|
||||
)
|
||||
{
|
||||
public static void SetCultureInfo(Account account)
|
||||
@ -26,7 +34,7 @@ public class AccountService(
|
||||
CultureInfo.CurrentCulture = info;
|
||||
CultureInfo.CurrentUICulture = info;
|
||||
}
|
||||
|
||||
|
||||
public const string AccountCachePrefix = "account:";
|
||||
|
||||
public async Task PurgeAccountCache(Account account)
|
||||
@ -192,6 +200,119 @@ public class AccountService(
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send the auth factor verification code to users, for factors like in-app code and email.
|
||||
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
|
||||
/// </summary>
|
||||
/// <param name="account">The owner of the auth factor</param>
|
||||
/// <param name="factor">The auth factor needed to send code</param>
|
||||
/// <param name="hint">The part of the contact method for verification</param>
|
||||
public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null)
|
||||
{
|
||||
var code = new Random().Next(100000, 999999).ToString("000000");
|
||||
|
||||
switch (factor.Type)
|
||||
{
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
if (await _GetFactorCode(factor) is not null)
|
||||
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||
|
||||
await nty.SendNotification(
|
||||
account,
|
||||
"auth.verification",
|
||||
localizer["AuthCodeTitle"],
|
||||
null,
|
||||
localizer["AuthCodeBody", code],
|
||||
save: true
|
||||
);
|
||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
|
||||
break;
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
if (await _GetFactorCode(factor) is not null)
|
||||
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||
|
||||
ArgumentNullException.ThrowIfNull(hint);
|
||||
hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", "");
|
||||
if (string.IsNullOrWhiteSpace(hint))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...",
|
||||
factor.Id,
|
||||
hint
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var contact = await db.AccountContacts
|
||||
.Where(c => c.Type == AccountContactType.Email)
|
||||
.Where(c => c.VerifiedAt != null)
|
||||
.Where(c => EF.Functions.ILike(c.Content, $"%{hint}%"))
|
||||
.Include(c => c.Account)
|
||||
.FirstOrDefaultAsync();
|
||||
if (contact is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...",
|
||||
factor.Id,
|
||||
hint
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await email.SendTemplatedEmailAsync<VerificationEmail, VerificationEmailModel>(
|
||||
contact.Content,
|
||||
localizer["EmailVerificationTitle"],
|
||||
localizer["VerificationEmail"],
|
||||
new VerificationEmailModel
|
||||
{
|
||||
Name = account.Name,
|
||||
Code = code
|
||||
}
|
||||
);
|
||||
|
||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
|
||||
break;
|
||||
case AccountAuthFactorType.Password:
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
default:
|
||||
// No need to send, such as password etc...
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code)
|
||||
{
|
||||
switch (factor.Type)
|
||||
{
|
||||
case AccountAuthFactorType.EmailCode:
|
||||
case AccountAuthFactorType.InAppCode:
|
||||
var correctCode = await _GetFactorCode(factor);
|
||||
return correctCode is not null && string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
|
||||
case AccountAuthFactorType.Password:
|
||||
case AccountAuthFactorType.TimedCode:
|
||||
default:
|
||||
return factor.VerifyPassword(code);
|
||||
}
|
||||
}
|
||||
|
||||
private const string AuthFactorCachePrefix = "authfactor:";
|
||||
|
||||
private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires)
|
||||
{
|
||||
await cache.SetAsync(
|
||||
$"{AuthFactorCachePrefix}{factor.Id}:code",
|
||||
code,
|
||||
expires
|
||||
);
|
||||
}
|
||||
|
||||
private async Task<string?> _GetFactorCode(AccountAuthFactor factor)
|
||||
{
|
||||
return await cache.GetAsync<string?>(
|
||||
$"{AuthFactorCachePrefix}{factor.Id}:code"
|
||||
);
|
||||
}
|
||||
|
||||
public async Task DeleteSession(Account account, Guid sessionId)
|
||||
{
|
||||
var session = await db.AuthSessions
|
||||
@ -204,10 +325,10 @@ public class AccountService(
|
||||
.Include(s => s.Challenge)
|
||||
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
|
||||
.ToListAsync();
|
||||
|
||||
if(session.Challenge.DeviceId is not null)
|
||||
|
||||
if (session.Challenge.DeviceId is not null)
|
||||
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
|
||||
|
||||
|
||||
// The current session should be included in the sessions' list
|
||||
db.AuthSessions.RemoveRange(sessions);
|
||||
await db.SaveChangesAsync();
|
||||
|
@ -67,13 +67,12 @@ public class NotificationService(
|
||||
string? subtitle = null,
|
||||
string? content = null,
|
||||
Dictionary<string, object>? meta = null,
|
||||
bool isSilent = false
|
||||
bool isSilent = false,
|
||||
bool save = true
|
||||
)
|
||||
{
|
||||
if (title is null && subtitle is null && content is null)
|
||||
{
|
||||
throw new ArgumentException("Unable to send notification that completely empty.");
|
||||
}
|
||||
|
||||
var notification = new Notification
|
||||
{
|
||||
@ -85,8 +84,11 @@ public class NotificationService(
|
||||
AccountId = account.Id,
|
||||
};
|
||||
|
||||
db.Add(notification);
|
||||
await db.SaveChangesAsync();
|
||||
if (save)
|
||||
{
|
||||
db.Add(notification);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (!isSilent) _ = DeliveryNotification(notification);
|
||||
|
||||
|
@ -87,7 +87,11 @@ public class AuthController(
|
||||
}
|
||||
|
||||
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
|
||||
public async Task<ActionResult> RequestFactorCode([FromRoute] Guid id, [FromRoute] Guid factorId)
|
||||
public async Task<ActionResult> RequestFactorCode(
|
||||
[FromRoute] Guid id,
|
||||
[FromRoute] Guid factorId,
|
||||
[FromBody] string? hint
|
||||
)
|
||||
{
|
||||
var challenge = await db.AuthChallenges
|
||||
.Include(e => e.Account)
|
||||
@ -98,7 +102,14 @@ public class AuthController(
|
||||
.Where(e => e.Account == challenge.Account).FirstOrDefaultAsync();
|
||||
if (factor is null) return NotFound("Auth factor was not found.");
|
||||
|
||||
// TODO do the logic here
|
||||
try
|
||||
{
|
||||
await accounts.SendFactorCode(challenge.Account, factor, hint);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@ -127,7 +138,7 @@ public class AuthController(
|
||||
|
||||
try
|
||||
{
|
||||
if (factor.VerifyPassword(request.Password))
|
||||
if (await accounts.VerifyFactorCode(factor, request.Password))
|
||||
{
|
||||
challenge.StepRemain--;
|
||||
challenge.BlacklistFactors.Add(factor.Id);
|
||||
@ -226,8 +237,8 @@ public class AuthController(
|
||||
var tk = auth.CreateToken(session);
|
||||
return Ok(new TokenExchangeResponse { Token = tk });
|
||||
case "refresh_token":
|
||||
// Since we no longer need the refresh token
|
||||
// This case is blank for now, thinking to mock it if the OIDC standard requires it
|
||||
// Since we no longer need the refresh token
|
||||
// This case is blank for now, thinking to mock it if the OIDC standard requires it
|
||||
default:
|
||||
return BadRequest("Unsupported grant type.");
|
||||
}
|
||||
|
@ -16,4 +16,10 @@ public class PasswordResetEmailModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Link { get; set; }
|
||||
}
|
||||
|
||||
public class VerificationEmailModel
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Code { get; set; }
|
||||
}
|
62
DysonNetwork.Sphere/Pages/Emails/VerificationEmail.razor
Normal file
62
DysonNetwork.Sphere/Pages/Emails/VerificationEmail.razor
Normal file
@ -0,0 +1,62 @@
|
||||
@using DysonNetwork.Sphere.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<table class="container">
|
||||
<tr>
|
||||
<td class="columns">
|
||||
<h1 style="font-size: 1.875rem; font-weight: 700; color: #111827; margin: 0; text-align: center;">
|
||||
@(Localizer["VerificationHeader1"])
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="columns">
|
||||
<p style="color: #374151; margin: 0;">
|
||||
@(Localizer["VerificationPara1"]) @Name,
|
||||
</p>
|
||||
<p style="color: #374151; margin: 0;">
|
||||
@(Localizer["VerificationPara2"])
|
||||
</p>
|
||||
<p style="color: #374151; margin: 0;">
|
||||
@(Localizer["VerificationPara3"])
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="columns">
|
||||
<div style="text-align: center;">
|
||||
<div style="background-color: #f3f4f6; padding: 1rem; border-radius: 0.5rem; display: inline-block; margin: 1rem 0;">
|
||||
<span style="font-size: 1.5rem; font-weight: 600; color: #111827; letter-spacing: 0.1em;">@Code</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="columns">
|
||||
<p style="color: #374151; margin: 0;">
|
||||
@(Localizer["VerificationPara4"])
|
||||
</p>
|
||||
<p style="color: #374151; margin: 0;">
|
||||
@(Localizer["VerificationPara5"])
|
||||
</p>
|
||||
<p style="color: #374151; margin: 2rem 0 0 0;">
|
||||
@(LocalizerShared["EmailFooter1"]) <br />
|
||||
@(LocalizerShared["EmailFooter2"])
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Code { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
@ -81,4 +81,25 @@
|
||||
<data name="PasswordResetPara4" xml:space="preserve">
|
||||
<value>If you didn't request this, you can ignore this email safety.</value>
|
||||
</data>
|
||||
<data name="VerificationHeader1" xml:space="preserve">
|
||||
<value>Verify Your Email Address</value>
|
||||
</data>
|
||||
<data name="VerificationPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="VerificationPara2" xml:space="preserve">
|
||||
<value>Thank you for creating an account on the Solar Network. We're excited to have you join our community!</value>
|
||||
</data>
|
||||
<data name="VerificationPara3" xml:space="preserve">
|
||||
<value>To verify your email address and access all features of your account, please use the verification code below:</value>
|
||||
</data>
|
||||
<data name="VerificationPara4" xml:space="preserve">
|
||||
<value>This code will expire in 30 minutes. Please enter it on the verification page to complete your registration.</value>
|
||||
</data>
|
||||
<data name="VerificationPara5" xml:space="preserve">
|
||||
<value>If you didn't create this account, please ignore this email.</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>Verify your email address</value>
|
||||
</data>
|
||||
</root>
|
@ -74,4 +74,25 @@
|
||||
<data name="EmailPasswordResetTitle" xml:space="preserve">
|
||||
<value>重置您的密码</value>
|
||||
</data>
|
||||
<data name="VerificationHeader1" xml:space="preserve">
|
||||
<value>验证您的电子邮箱</value>
|
||||
</data>
|
||||
<data name="VerificationPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="VerificationPara2" xml:space="preserve">
|
||||
<value>感谢您在 Solar Network 上注册账号,我们很高兴您即将加入我们的社区!</value>
|
||||
</data>
|
||||
<data name="VerificationPara3" xml:space="preserve">
|
||||
<value>请使用以下验证码来验证您的电子邮箱并获取账号的所有功能:</value>
|
||||
</data>
|
||||
<data name="VerificationPara4" xml:space="preserve">
|
||||
<value>此验证码将在30分钟后失效。请在验证页面输入此验证码以完成注册。</value>
|
||||
</data>
|
||||
<data name="VerificationPara5" xml:space="preserve">
|
||||
<value>如果您并未创建此账号,请忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的电子邮箱</value>
|
||||
</data>
|
||||
</root>
|
@ -98,5 +98,17 @@ namespace DysonNetwork.Sphere.Resources.Localization {
|
||||
return ResourceManager.GetString("PostReactContentBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string AuthCodeTitle {
|
||||
get {
|
||||
return ResourceManager.GetString("AuthCodeTitle", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string AuthCodeBody {
|
||||
get {
|
||||
return ResourceManager.GetString("AuthCodeBody", resourceCulture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -45,4 +45,10 @@
|
||||
<data name="PostReactContentBody" xml:space="preserve">
|
||||
<value>{0} added a reaction {1} to your post {2}</value>
|
||||
</data>
|
||||
<data name="AuthCodeTitle" xml:space="preserve">
|
||||
<value>Disposable Verification Code</value>
|
||||
</data>
|
||||
<data name="AuthCodeBody" xml:space="preserve">
|
||||
<value>{0} is your disposable code, it will expires in 5 minutes</value>
|
||||
</data>
|
||||
</root>
|
@ -38,4 +38,10 @@
|
||||
<data name="PostReactContentBody" xml:space="preserve">
|
||||
<value>{0} 给你的帖子添加了一个 {1} 的反应 {2}</value>
|
||||
</data>
|
||||
<data name="AuthCodeTitle" xml:space="preserve">
|
||||
<value>一次性验证码</value>
|
||||
</data>
|
||||
<data name="AuthCodeBody" xml:space="preserve">
|
||||
<value>{0} 是你的一次性验证码,它将会在五分钟内过期</value>
|
||||
</data>
|
||||
</root>
|
@ -15,6 +15,7 @@
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalScheduleBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F2b_003Ff86eadcb_003FDailyTimeIntervalScheduleBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalTriggerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F5c_003F297b8312_003FDailyTimeIntervalTriggerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa0b45f29f34f594814a7b1fbc25fe5ef3c18257956ed4f4fbfa68717db58_003FDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADbFunctionsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc1c46ed28c61e1caa79185e4375a8ae7cd11cd5ba8853dcb37577f93f2ca8d5_003FDbFunctionsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADiagnosticServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F47e01f36dea14a23aaea6e0391c1347ace00_003F3c_003F140e6d8b_003FDiagnosticServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADirectory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fde_003F94973e27_003FDirectory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEndpointConventionBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F8a_003F101938e3_003FEndpointConventionBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||
@ -106,8 +107,8 @@
|
||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexedValue">False</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmails_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">False</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FNotificationResource/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FNotificationResource/@EntryIndexedValue">False</s:Boolean>
|
||||
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user