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:
@@ -215,6 +215,25 @@ app.MapGet("/auth/discord/callback", async (
|
||||
|
||||
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
|
||||
|
||||
// If already authenticated via another platform, link instead of replacing session
|
||||
if (context.User.Identity?.IsAuthenticated == true
|
||||
&& context.User.TryGetPlatformIdentity(out var currentPlatform, out var currentExternalUserId)
|
||||
&& currentPlatform != "Discord")
|
||||
{
|
||||
try
|
||||
{
|
||||
await sessionStore.LinkIdentityAsync(
|
||||
currentPlatform, currentExternalUserId,
|
||||
"Discord", user.Id,
|
||||
user.DisplayName);
|
||||
return Results.Redirect("/profile?linked=discord");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Redirect($"/profile?link_error={Uri.EscapeDataString(ex.Message)}");
|
||||
}
|
||||
}
|
||||
|
||||
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||
await context.SignInAsync(
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
@@ -224,6 +243,38 @@ app.MapGet("/auth/discord/callback", async (
|
||||
return Results.Redirect("/");
|
||||
});
|
||||
|
||||
// Identity linking API endpoints
|
||||
app.MapGet("/api/me/identities", async (
|
||||
HttpContext context,
|
||||
ISessionStore sessionStore) =>
|
||||
{
|
||||
if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||
return Results.Unauthorized();
|
||||
|
||||
var identities = await sessionStore.GetLinkedIdentitiesAsync(platform, externalUserId);
|
||||
return Results.Ok(identities);
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapDelete("/api/me/identities/{targetPlatform}/{targetExternalUserId}", async (
|
||||
HttpContext context,
|
||||
ISessionStore sessionStore,
|
||||
string targetPlatform,
|
||||
string targetExternalUserId) =>
|
||||
{
|
||||
if (!context.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||
return Results.Unauthorized();
|
||||
|
||||
try
|
||||
{
|
||||
await sessionStore.UnlinkIdentityAsync(platform, externalUserId, targetPlatform, targetExternalUserId);
|
||||
return Results.NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Public calendar subscription endpoint (no auth required)
|
||||
app.MapGet("/calendar/{token}.ics", async (
|
||||
string token,
|
||||
|
||||
Reference in New Issue
Block a user