feat: unify Telegram and Discord accounts via identity linking
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:
2026-05-25 13:51:10 +03:00
parent 7a2ed808c4
commit baa25f2e1e
11 changed files with 585 additions and 84 deletions
@@ -34,6 +34,13 @@
</svg>
Шаблоны
</NavLink>
<NavLink class="nav-item" href="profile" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Профиль
</NavLink>
</div>
<div class="nav-footer">
@@ -66,7 +73,7 @@
</button>
</form>
<div class="nav-version">v2.8.1</div>
<div class="nav-version">v3.0.0</div>
</div>
</Authorized>
<NotAuthorized>
@@ -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;
}
}
}