diff --git a/Backend/Contracts/AuthContracts.cs b/Backend/Contracts/AuthContracts.cs index 6efee9d..4b49510 100644 --- a/Backend/Contracts/AuthContracts.cs +++ b/Backend/Contracts/AuthContracts.cs @@ -36,3 +36,8 @@ public sealed record AuthSessionDto( string? TeamLogin = null, string? BoundTwitchUserId = null, string? BoundTwitchDisplayName = null); + +public sealed record TwitchBindingDisconnectResponse( + bool Disconnected, + bool LoggedOut, + AuthSessionDto? Session); diff --git a/Backend/Data/TeamAccountBootstrapper.cs b/Backend/Data/TeamAccountBootstrapper.cs index c5061e5..9601ff7 100644 --- a/Backend/Data/TeamAccountBootstrapper.cs +++ b/Backend/Data/TeamAccountBootstrapper.cs @@ -85,11 +85,8 @@ public static class TeamAccountBootstrapper changed = true; } - if (member.CreatedByTwitchId == SeedActor - && (string.IsNullOrWhiteSpace(member.UpdatedByTwitchId) - || string.Equals(member.UpdatedByTwitchId, SeedActor, StringComparison.Ordinal)) - && (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal) - || !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal))) + if (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal) + || !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal)) { member.PasswordHash = seed.Credentials.Value.Hash; member.PasswordSalt = seed.Credentials.Value.Salt; diff --git a/Backend/Endpoints/AuthDataDeletionEndpoints.cs b/Backend/Endpoints/AuthDataDeletionEndpoints.cs index 47943ee..1f325a3 100644 --- a/Backend/Endpoints/AuthDataDeletionEndpoints.cs +++ b/Backend/Endpoints/AuthDataDeletionEndpoints.cs @@ -17,6 +17,14 @@ public static partial class AuthEndpoints return Results.Unauthorized(); } + var teamMember = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted); + if (teamMember is not null) + { + return Results.Json( + new { message = "Team-Accounts werden nicht ueber die automatische Teilnahme-Datenloeschung entfernt." }, + statusCode: StatusCodes.Status403Forbidden); + } + var twitchUserId = session.TwitchUserId; await using var transaction = await db.Database.BeginTransactionAsync(context.RequestAborted); diff --git a/Backend/Endpoints/AuthEndpoints.cs b/Backend/Endpoints/AuthEndpoints.cs index d142b1e..adc0b7d 100644 --- a/Backend/Endpoints/AuthEndpoints.cs +++ b/Backend/Endpoints/AuthEndpoints.cs @@ -33,6 +33,11 @@ public static partial class AuthEndpoints .WithName("StartTwitchAuthorization") .WithOpenApi(); + group.MapDelete("/twitch/binding", DisconnectTwitchBinding) + .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) + .WithName("DisconnectTwitchBinding") + .WithOpenApi(); + group.MapGet("/twitch/callback", CompleteTwitchAuthorization) .RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy) .WithName("CompleteTwitchAuthorization") diff --git a/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs b/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs index e20ccc1..fd168fc 100644 --- a/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs +++ b/Backend/Endpoints/AuthTwitchOAuthEndpoints.cs @@ -212,6 +212,62 @@ public static partial class AuthEndpoints return RedirectToTwitchCallback(oauthState, "connected"); } + private static async Task DisconnectTwitchBinding( + HttpContext context, + AwardsDbContext db, + IUserSessionService userSessionService) + { + var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted); + if (session is null) + { + return Results.Unauthorized(); + } + + var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted); + if (member is null || !member.IsActive) + { + return Results.BadRequest(new { message = "Dieser Account nutzt keinen aktiven Team-Login." }); + } + + var boundTwitchUserId = NormalizeTwitchUserId(member.BoundTwitchUserId); + if (string.IsNullOrWhiteSpace(boundTwitchUserId)) + { + return Results.Ok(new TwitchBindingDisconnectResponse( + false, + false, + await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); + } + + var currentTeamLogin = ReadTeamLoginFromSession(session.TwitchUserId); + var currentSessionUsesBoundTwitch = string.IsNullOrWhiteSpace(currentTeamLogin) + && string.Equals(NormalizeTwitchUserId(session.TwitchUserId), boundTwitchUserId, StringComparison.OrdinalIgnoreCase); + + foreach (var linkedSession in db.UserSessions.Where(item => item.TwitchUserId == boundTwitchUserId)) + { + linkedSession.IsActive = false; + } + + member.BoundTwitchUserId = null; + member.BoundTwitchDisplayName = null; + member.TwitchBoundAt = null; + member.UpdatedAt = DateTimeOffset.UtcNow; + member.UpdatedByTwitchId = session.TwitchUserId; + + if (currentSessionUsesBoundTwitch) + { + session.IsActive = false; + } + + await db.SaveChangesAsync(context.RequestAborted); + + return Results.Ok(new TwitchBindingDisconnectResponse( + true, + currentSessionUsesBoundTwitch, + currentSessionUsesBoundTwitch + ? null + : await ToAuthSessionDtoAsync(db, session, cancellationToken: context.RequestAborted))); + } + private static async Task CompleteTwitchTeamLoginAsync( HttpContext context, TwitchOAuthState oauthState, diff --git a/Backend/README.md b/Backend/README.md index 0ec5161..c4274ed 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -112,6 +112,10 @@ VTSA_TEAM_OWNER_PASSWORD= VTSA_TEAM_CREATOR_PASSWORD= ``` +These fixed owner/creator credentials are authoritative on API startup. If a +personal team account gets locked out after an admin reset, redeploying with the +configured environment password restores the login. + Frontend app-wide demo gate: ```text diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 8827424..5f2ba65 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -68,6 +68,27 @@ async function bindTeamTwitch() { } } +async function disconnectTeamTwitch() { + accountActionError.value = '' + accountActionSuccess.value = '' + try { + const response = await authStore.disconnectTwitchBinding() + if (response.loggedOut) { + closeAccountModal() + await router.replace({ name: 'login' }) + return + } + + accountActionSuccess.value = response.disconnected + ? 'Twitch-Verknüpfung wurde entfernt.' + : 'Dieser Team-Account war nicht mit Twitch verknüpft.' + } catch (error) { + accountActionError.value = error instanceof Error + ? error.message + : 'Twitch konnte gerade nicht entknüpft werden.' + } +} + async function deleteMyData() { accountActionError.value = '' try { @@ -202,6 +223,7 @@ const linkInactive = linkBase + 'background:transparent;color:#6f6685;font-weigh @cancel-delete="cancelAccountDeletion" @logout="doLogout" @bind-team-twitch="bindTeamTwitch" + @disconnect-team-twitch="disconnectTeamTwitch" @confirm-delete="deleteMyData" /> diff --git a/frontend/src/components/AppShellAccountModals.vue b/frontend/src/components/AppShellAccountModals.vue index 12bfa37..54ee15e 100644 --- a/frontend/src/components/AppShellAccountModals.vue +++ b/frontend/src/components/AppShellAccountModals.vue @@ -23,13 +23,32 @@ defineEmits<{ 'cancel-delete': [] logout: [] 'bind-team-twitch': [] + 'disconnect-team-twitch': [] 'confirm-delete': [] }>() -const twitchUserId = computed(() => props.session?.twitchUserId ?? '') +const isTeamPasswordSession = computed(() => Boolean(props.session?.teamLogin && props.session.twitchUserId.startsWith('team:'))) +const isTwitchAuthenticated = computed(() => Boolean(props.session && !isTeamPasswordSession.value)) +const profileHandle = computed(() => props.session?.teamLogin ?? props.session?.twitchUserId ?? '') const role = computed(() => props.session?.role ?? 'viewer') const isTeamSession = computed(() => Boolean(props.session?.teamLogin)) +const isParticipantSession = computed(() => !isTeamSession.value) const canBindTwitch = computed(() => isTeamSession.value && !props.session?.mustChangePassword) +const hasBoundTwitch = computed(() => Boolean(props.session?.boundTwitchUserId)) +const showTeamLoginDetail = computed(() => Boolean(props.session?.teamLogin && !isTeamSession.value)) +const authStatusLabel = computed(() => { + if (isTeamPasswordSession.value) return 'Angemeldet mit Team-Login' + if (isTeamSession.value) return 'Admin-Login über Twitch' + return 'Angemeldet über Twitch' +}) +const authStatusText = computed(() => { + if (isTeamPasswordSession.value && hasBoundTwitch.value) return 'Twitch ist verknüpft, diese Session läuft aber über den Team-Login.' + if (isTeamPasswordSession.value) return 'Du bist mit Login und Passwort angemeldet.' + if (isTeamSession.value) return 'Diese Admin-Session nutzt deinen verknüpften Twitch-Account.' + return 'Diese Session nutzt deinen Twitch-Account.' +}) +const primaryIdentityLabel = computed(() => isTeamSession.value ? 'Team-Login' : 'Twitch-ID') +const logoutLabel = computed(() => !isTeamSession.value && isTwitchAuthenticated.value ? 'Von Twitch abmelden' : 'Abmelden')