Account contacts APIs

💄 Redesign emails
This commit is contained in:
2025-06-08 17:18:23 +08:00
parent b1faabb07b
commit 144b7fcfc2
18 changed files with 727 additions and 268 deletions

View File

@ -90,6 +90,7 @@ public class AccountContact : ModelBase
public Guid Id { get; set; }
public AccountContactType Type { get; set; }
public Instant? VerifiedAt { get; set; }
public bool IsPrimary { get; set; } = false;
[MaxLength(1024)] public string Content { get; set; } = string.Empty;
public Guid AccountId { get; set; }

View File

@ -97,7 +97,8 @@ public class AccountController(
new()
{
Type = AccountContactType.Email,
Content = request.Email
Content = request.Email,
IsPrimary = true
}
},
AuthFactors = new List<AccountAuthFactor>

View File

@ -437,7 +437,7 @@ public class AccountCurrentController(
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
// Group sessions by the related DeviceId, then create an AuthorizedDevice for each group.
@ -558,4 +558,106 @@ public class AccountCurrentController(
return BadRequest(ex.Message);
}
}
[HttpGet("contacts")]
[Authorize]
public async Task<ActionResult<List<AccountContact>>> GetContacts()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contacts = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id)
.ToListAsync();
return Ok(contacts);
}
public class AccountContactRequest
{
[Required] public AccountContactType Type { get; set; }
[Required] public string Content { get; set; } = null!;
}
[HttpPost("contacts")]
[Authorize]
public async Task<ActionResult<AccountContact>> CreateContact([FromBody] AccountContactRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
var contact = await accounts.CreateContactMethod(currentUser, request.Type, request.Content);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("contacts/{id:guid}/verify")]
[Authorize]
public async Task<ActionResult<AccountContact>> VerifyContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
await accounts.VerifyContactMethod(currentUser, contact);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("contacts/{id:guid}/primary")]
[Authorize]
public async Task<ActionResult<AccountContact>> SetPrimaryContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
contact = await accounts.SetContactMethodPrimary(currentUser, contact);
return Ok(contact);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("contacts/{id:guid}")]
[Authorize]
public async Task<ActionResult<AccountContact>> DeleteContact(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var contact = await db.AccountContacts
.Where(c => c.AccountId == currentUser.Id && c.Id == id)
.FirstOrDefaultAsync();
if (contact is null) return NotFound();
try
{
await accounts.DeleteContactMethod(currentUser, contact);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@ -293,7 +293,8 @@ public class AccountService(
case AccountAuthFactorType.EmailCode:
case AccountAuthFactorType.InAppCode:
var correctCode = await _GetFactorCode(factor);
var isCorrect = correctCode is not null && string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
var isCorrect = correctCode is not null &&
string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
return isCorrect;
case AccountAuthFactorType.Password:
@ -320,27 +321,28 @@ public class AccountService(
$"{AuthFactorCachePrefix}{factor.Id}:code"
);
}
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
{
var session = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
var session = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Id == sessionId && s.AccountId == account.Id)
.FirstOrDefaultAsync();
if (session is null) throw new InvalidOperationException("Session was not found.");
await db.AuthChallenges
.Where(s => s.DeviceId == session.Challenge.DeviceId)
.ExecuteUpdateAsync(p => p.SetProperty(s => s.DeviceId, label));
await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.Challenge.DeviceId == session.Challenge.DeviceId)
.ExecuteUpdateAsync(p => p.SetProperty(s => s.Label, label));
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
foreach(var item in sessions)
var sessions = await db.AuthSessions
.Include(s => s.Challenge)
.Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId)
.ToListAsync();
foreach (var item in sessions)
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
return session;
return session;
}
public async Task DeleteSession(Account account, Guid sessionId)
@ -369,6 +371,72 @@ public class AccountService(
await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}");
}
public async Task<AccountContact> CreateContactMethod(Account account, AccountContactType type, string content)
{
var contact = new AccountContact
{
Type = type,
Content = content
};
db.AccountContacts.Add(contact);
await db.SaveChangesAsync();
return contact;
}
public async Task VerifyContactMethod(Account account, AccountContact contact)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.ContactVerification,
new Dictionary<string, object> { { "contact_method", contact.Content } },
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
public async Task<AccountContact> SetContactMethodPrimary(Account account, AccountContact contact)
{
if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account.");
if (contact.VerifiedAt is null)
throw new InvalidOperationException("Cannot set unverified contact method as primary.");
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
await db.AccountContacts
.Where(c => c.AccountId == account.Id && c.Type == contact.Type)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false));
contact.IsPrimary = true;
db.AccountContacts.Update(contact);
await db.SaveChangesAsync();
await transaction.CommitAsync();
return contact;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task DeleteContactMethod(Account account, AccountContact contact)
{
if (contact.AccountId != account.Id)
throw new InvalidOperationException("Contact method does not belong to this account.");
if (contact.IsPrimary)
throw new InvalidOperationException("Cannot delete primary contact method.");
db.AccountContacts.Remove(contact);
await db.SaveChangesAsync();
}
/// Maintenance methods for server administrator
public async Task EnsureAccountProfileCreated()
{

View File

@ -86,7 +86,7 @@ public class MagicSpellService(
{
case MagicSpellType.AccountActivation:
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Name,
contact.Account.Nick,
contact.Content,
localizer["EmailLandingTitle"],
new LandingEmailModel
@ -98,7 +98,7 @@ public class MagicSpellService(
break;
case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Name,
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel
@ -110,7 +110,7 @@ public class MagicSpellService(
break;
case MagicSpellType.AuthPasswordReset:
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Name,
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new PasswordResetEmailModel
@ -120,6 +120,20 @@ public class MagicSpellService(
}
);
break;
case MagicSpellType.ContactVerification:
if (spell.Meta["contact_method"] is not string contactMethod)
throw new InvalidOperationException("Contact method is not found.");
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick,
contactMethod!,
localizer["EmailContactVerificationTitle"],
new ContactVerificationEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
default:
throw new ArgumentOutOfRangeException();
}
@ -142,7 +156,6 @@ public class MagicSpellService(
var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
if (account is null) break;
db.Accounts.Remove(account);
await db.SaveChangesAsync();
break;
case MagicSpellType.AccountActivation:
var contactMethod = spell.Meta["contact_method"] as string;
@ -173,12 +186,24 @@ public class MagicSpellService(
});
}
db.Remove(spell);
await db.SaveChangesAsync();
break;
case MagicSpellType.ContactVerification:
var verifyContactMethod = spell.Meta["contact_method"] as string;
var verifyContact = await db.AccountContacts
.FirstOrDefaultAsync(c => c.Content == verifyContactMethod);
if (verifyContact is not null)
{
verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(verifyContact);
}
break;
default:
throw new ArgumentOutOfRangeException();
}
db.Remove(spell);
await db.SaveChangesAsync();
}
public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)