feat: unify Telegram and Discord accounts via identity linking
PR Checks / test-and-build (pull_request) Successful in 7m6s
PR Checks / test-and-build (pull_request) Successful in 7m6s
- Add V020 migration: player_links + identity_audit_log tables - Add ISessionStore methods: ResolveEffectivePlayerId, LinkIdentity, UnlinkIdentity, GetLinkedIdentities - Update SessionService to resolve effective player id for all permission checks - Add /auth/discord/callback linking flow when already authenticated - Add /api/me/identities GET/DELETE endpoints - Add Profile.razor page for managing linked accounts - Update NavMenu with profile link and v3.0.0 badge - Bump version to 3.0.0 across all files Bump version → 3.0.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
@page "/profile"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using System.Net.Http.Json
|
||||
@attribute [Authorize]
|
||||
@inject IHttpClientFactory HttpClientFactory
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Профиль — GM-Relay</PageTitle>
|
||||
|
||||
<div class="profile-container">
|
||||
<h1 class="page-title">Профиль</h1>
|
||||
|
||||
@if (identities is null)
|
||||
{
|
||||
<p class="loading-text">Загрузка...</p>
|
||||
}
|
||||
else if (identities.Count == 0)
|
||||
{
|
||||
<div class="profile-card">
|
||||
<p>Связанные аккаунты не найдены.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="profile-card">
|
||||
<h2 class="section-title">Связанные аккаунты</h2>
|
||||
<ul class="identity-list">
|
||||
@foreach (var id in identities)
|
||||
{
|
||||
<li class="identity-item">
|
||||
<div class="identity-info">
|
||||
<span class="identity-platform">@id.Platform</span>
|
||||
<span class="identity-name">@id.DisplayName</span>
|
||||
</div>
|
||||
@if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId)
|
||||
{
|
||||
<button class="btn btn-secondary btn-small"
|
||||
@onclick="() => Unlink(id.Platform, id.ExternalUserId)"
|
||||
disabled="@isUnlinking">
|
||||
Отвязать
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="identity-badge">Текущий</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="profile-card">
|
||||
<h2 class="section-title">Добавить аккаунт</h2>
|
||||
@if (!HasLinkedPlatform("Discord"))
|
||||
{
|
||||
<a class="btn btn-primary" href="/auth/discord">
|
||||
Привязать Discord
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="muted-text">Discord уже привязан.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
<div class="alert alert-error">@errorMessage</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(successMessage))
|
||||
{
|
||||
<div class="alert alert-success">@successMessage</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private List<LinkedIdentity>? identities;
|
||||
private string? currentPlatform;
|
||||
private string? currentExternalUserId;
|
||||
private bool isUnlinking;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (AuthenticationStateTask is not null)
|
||||
{
|
||||
var authState = await AuthenticationStateTask;
|
||||
var user = authState.User;
|
||||
if (user.TryGetPlatformIdentity(out var plat, out var extId))
|
||||
{
|
||||
currentPlatform = plat;
|
||||
currentExternalUserId = extId;
|
||||
}
|
||||
}
|
||||
|
||||
await LoadIdentities();
|
||||
}
|
||||
|
||||
private async Task LoadIdentities()
|
||||
{
|
||||
try
|
||||
{
|
||||
var http = HttpClientFactory.CreateClient();
|
||||
http.BaseAddress = new Uri(Navigation.BaseUri);
|
||||
identities = await http.GetFromJsonAsync<List<LinkedIdentity>>("api/me/identities");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Не удалось загрузить аккаунты: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasLinkedPlatform(string platform)
|
||||
{
|
||||
return identities?.Any(i => i.Platform == platform) ?? false;
|
||||
}
|
||||
|
||||
private async Task Unlink(string platform, string externalUserId)
|
||||
{
|
||||
isUnlinking = true;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
var http = HttpClientFactory.CreateClient();
|
||||
http.BaseAddress = new Uri(Navigation.BaseUri);
|
||||
var response = await http.DeleteAsync($"api/me/identities/{Uri.EscapeDataString(platform)}/{Uri.EscapeDataString(externalUserId)}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
successMessage = $"{platform} аккаунт отвязан.";
|
||||
await LoadIdentities();
|
||||
}
|
||||
else
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
errorMessage = $"Ошибка отвязки: {body}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Ошибка отвязки: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
isUnlinking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user