Merge pull request #91: feat: Discord OAuth и платформонезависимый Web Dashboard (issue #34)
Deploy Telegram Bot / build-and-push (push) Successful in 4m36s
Deploy Telegram Bot / scan-images (push) Successful in 1m22s
Deploy Telegram Bot / deploy (push) Successful in 26s

- Discord OAuth 2.0 login flow (identify + guilds scopes)
- Platform-agnostic auth via (platform, external_user_id)
- Cookie Authentication with ClaimsPrincipal
- Razor Pages updated to *ForCurrentUserAsync APIs
- CSRF protection for Discord OAuth
- V019 migration for audit log platform identity
- Version bumped to 2.8.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 12:14:27 +03:00
27 changed files with 1098 additions and 288 deletions
+7
View File
@@ -14,6 +14,13 @@ TELEGRAM_MINI_APP_URL=
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications) # Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
# Discord OAuth (для Web Dashboard)
# Client ID и Secret из OAuth2 раздела Discord Developer Portal
# Redirect URI должен указывать на /auth/discord/callback вашего домена
DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID_HERE
DISCORD_CLIENT_SECRET=YOUR_DISCORD_CLIENT_SECRET_HERE
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
# Пароль для базы данных PostgreSQL # Пароль для базы данных PostgreSQL
POSTGRES_PASSWORD=StrongPasswordForDatabase POSTGRES_PASSWORD=StrongPasswordForDatabase
+4 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 2.7.2 VERSION: 2.8.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -113,6 +113,9 @@ jobs:
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
echo "DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}" >> .env
echo "DISCORD_CLIENT_SECRET=${{ secrets.DISCORD_CLIENT_SECRET }}" >> .env
echo "DISCORD_REDIRECT_URI=${{ secrets.DISCORD_REDIRECT_URI }}" >> .env
- name: Deploy Containers - name: Deploy Containers
run: | run: |
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>2.7.2</Version> <Version>2.8.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+1 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.7.2`. **Текущая версия:** `v2.8.0`.
--- ---
+6 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f crond -f
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2 image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -67,7 +67,7 @@ services:
retries: 3 retries: 3
discord: discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2 image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -84,7 +84,7 @@ services:
retries: 3 retries: 3
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2 image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -94,6 +94,9 @@ services:
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}" - "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}" - "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
ports: ports:
- "${GMRELAY_WEB_PORT:-8080}:8080" - "${GMRELAY_WEB_PORT:-8080}:8080"
volumes: volumes:
@@ -0,0 +1,18 @@
-- =============================================================
-- V019: Rename session_audit_log.actor_telegram_id to actor_external_user_id
-- =============================================================
-- Scope: Support platform-agnostic audit log identity.
-- =============================================================
ALTER TABLE session_audit_log
ADD COLUMN actor_external_user_id VARCHAR(255);
UPDATE session_audit_log
SET actor_external_user_id = actor_telegram_id::TEXT
WHERE actor_external_user_id IS NULL;
ALTER TABLE session_audit_log
ALTER COLUMN actor_external_user_id SET NOT NULL;
ALTER TABLE session_audit_log
DROP COLUMN actor_telegram_id;
@@ -39,9 +39,19 @@
<div class="nav-footer"> <div class="nav-footer">
<div class="nav-user"> <div class="nav-user">
<div class="nav-user-avatar"> <div class="nav-user-avatar">
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?") @if (!string.IsNullOrWhiteSpace(context.User.GetAvatarUrl()))
{
<img src="@context.User.GetAvatarUrl()" alt="" />
}
else
{
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
}
</div>
<div class="nav-user-info">
<span class="nav-user-name">@context.User.Identity?.Name</span>
<span class="nav-user-platform">@GetPlatformLabel(context.User)</span>
</div> </div>
<span class="nav-user-name">@context.User.Identity?.Name</span>
</div> </div>
<form action="/auth/logout" method="post"> <form action="/auth/logout" method="post">
@@ -56,7 +66,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v2.7.2</div> <div class="nav-version">v2.8.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@@ -79,4 +89,7 @@
private void ToggleMenu() => isOpen = !isOpen; private void ToggleMenu() => isOpen = !isOpen;
private void CloseMenu() => isOpen = false; private void CloseMenu() => isOpen = false;
private static string GetPlatformLabel(System.Security.Claims.ClaimsPrincipal user) =>
user.TryGetDiscordId(out _) ? "Discord" : "Telegram";
} }
@@ -185,7 +185,7 @@
private Guid selectedGroupId; private Guid selectedGroupId;
private Guid? deletingTemplateId; private Guid? deletingTemplateId;
private bool isCreatingTemplate; private bool isCreatingTemplate;
private long telegramId; private string? externalUserId;
private string? errorMessage; private string? errorMessage;
private string? successMessage; private string? successMessage;
private CampaignTemplateEditModel templateModel = new(); private CampaignTemplateEditModel templateModel = new();
@@ -195,13 +195,13 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId)) if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
} }
groups = await SessionService.GetGroupsForGmAsync(telegramId); groups = await SessionService.GetGroupsForCurrentUserAsync();
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty; selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
if (selectedGroupId != Guid.Empty) if (selectedGroupId != Guid.Empty)
@@ -228,7 +228,7 @@
campaignTemplates = null; campaignTemplates = null;
campaignTemplateModels = []; campaignTemplateModels = [];
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId); var templates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(selectedGroupId);
if (templates is null) if (templates is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
@@ -260,9 +260,8 @@
try try
{ {
await SessionService.CreateCampaignTemplateForGmAsync( await SessionService.CreateCampaignTemplateForCurrentUserAsync(
selectedGroupId, selectedGroupId,
telegramId,
new CreateCampaignTemplateRequest( new CreateCampaignTemplateRequest(
templateModel.Name, templateModel.Name,
templateModel.Title, templateModel.Title,
@@ -298,7 +297,7 @@
try try
{ {
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId); await SessionService.DeleteCampaignTemplateForCurrentUserAsync(template.Id);
successMessage = "Шаблон кампании удалён."; successMessage = "Шаблон кампании удалён.";
await LoadTemplates(); await LoadTemplates();
} }
@@ -87,13 +87,13 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId)) if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
} }
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId); session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null) if (session is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
@@ -114,7 +114,7 @@
try try
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId)) if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
@@ -122,7 +122,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers); await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
Navigation.NavigateTo($"/group/{session!.GroupId}"); Navigation.NavigateTo($"/group/{session!.GroupId}");
} }
catch (SessionAccessDeniedException) catch (SessionAccessDeniedException)
@@ -40,8 +40,8 @@
</span> </span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue) @if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{ {
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)"> <button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
@(removingCoGmId == manager.TelegramId ? "⏳ Удаляем..." : "Убрать") @(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
</button> </button>
} }
} }
@@ -403,9 +403,9 @@
private Guid? promotingSessionId; private Guid? promotingSessionId;
private Guid? processingBatchId; private Guid? processingBatchId;
private Guid? processingTemplateId; private Guid? processingTemplateId;
private long? removingCoGmId; private string? removingCoGmId;
private bool isAddingCoGm; private bool isAddingCoGm;
private long telegramId; private string? externalUserId;
private string? errorMessage; private string? errorMessage;
private string? successMessage; private string? successMessage;
private CoGmEditModel coGmModel = new(); private CoGmEditModel coGmModel = new();
@@ -417,7 +417,7 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out telegramId)) if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
@@ -428,21 +428,21 @@
private async Task LoadSessions() private async Task LoadSessions()
{ {
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId); groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null) if (groupManagement is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
} }
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId); sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId);
if (sessions is null) if (sessions is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
} }
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId); campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
if (campaignTemplates is null) if (campaignTemplates is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
@@ -470,8 +470,8 @@
{ {
await SessionService.AddCoGmForOwnerAsync( await SessionService.AddCoGmForOwnerAsync(
GroupId, GroupId,
telegramId, "Telegram",
coGmModel.TelegramId.Value, coGmModel.TelegramId.Value.ToString(),
coGmModel.DisplayName, coGmModel.DisplayName,
coGmModel.TelegramUsername); coGmModel.TelegramUsername);
@@ -493,15 +493,15 @@
} }
} }
private async Task RemoveCoGm(long coGmTelegramId) private async Task RemoveCoGm(string coGmExternalUserId)
{ {
errorMessage = null; errorMessage = null;
successMessage = null; successMessage = null;
removingCoGmId = coGmTelegramId; removingCoGmId = coGmExternalUserId;
try try
{ {
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId); await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
successMessage = "Co-GM удалён."; successMessage = "Co-GM удалён.";
await LoadSessions(); await LoadSessions();
} }
@@ -527,7 +527,7 @@
try try
{ {
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId); await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
await LoadSessions(); await LoadSessions();
} }
catch (SessionAccessDeniedException) catch (SessionAccessDeniedException)
@@ -559,7 +559,7 @@
loadingParticipantsSessionId = sessionId; loadingParticipantsSessionId = sessionId;
try try
{ {
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId); var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId);
participantsCache[sessionId] = participants ?? []; participantsCache[sessionId] = participants ?? [];
} }
catch (Exception ex) catch (Exception ex)
@@ -582,7 +582,7 @@
try try
{ {
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId); await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId);
participantsCache.Remove(sessionId); participantsCache.Remove(sessionId);
successMessage = "Игрок исключён."; successMessage = "Игрок исключён.";
await LoadSessions(); await LoadSessions();
@@ -658,10 +658,9 @@
try try
{ {
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink); await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink);
await SessionService.UpdateBatchNotificationModeForGmAsync( await SessionService.UpdateBatchNotificationModeForCurrentUserAsync(
batch.BatchId, batch.BatchId,
telegramId,
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode)); SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
successMessage = "Настройки batch обновлены."; successMessage = "Настройки batch обновлены.";
await LoadSessions(); await LoadSessions();
@@ -696,7 +695,7 @@
try try
{ {
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime; var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays); await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays);
successMessage = "Расписание пачки обновлено."; successMessage = "Расписание пачки обновлено.";
await LoadSessions(); await LoadSessions();
} }
@@ -726,7 +725,7 @@
? BatchCloneInterval.NextMonth ? BatchCloneInterval.NextMonth
: BatchCloneInterval.NextWeek; : BatchCloneInterval.NextWeek;
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval); var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval);
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр."; successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
await LoadSessions(); await LoadSessions();
} }
@@ -759,7 +758,7 @@
return; return;
} }
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime); var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр."; successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions(); await LoadSessions();
} }
@@ -836,7 +835,7 @@
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id; private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string CurrentUserRole => private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
?? GroupManagerRoleExtensions.CoGmValue; ?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) => private static string FormatRole(string role) =>
@@ -5,7 +5,7 @@
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims @using System.Security.Claims
@attribute [Authorize] @attribute [Authorize]
@inject ISessionStore SessionStore @inject AuthorizedSessionService SessionService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -85,9 +85,9 @@
<td> <td>
<div class="player-info"> <div class="player-info">
<span class="player-name">@s.DisplayName</span> <span class="player-name">@s.DisplayName</span>
@if (!string.IsNullOrEmpty(s.TelegramUsername)) @if (!string.IsNullOrEmpty(s.ExternalUsername))
{ {
<span class="player-username">@@@s.TelegramUsername</span> <span class="player-username">@@@s.ExternalUsername</span>
} }
</div> </div>
</td> </td>
@@ -171,21 +171,20 @@
Navigation.NavigateTo("/login"); Navigation.NavigateTo("/login");
return; return;
} }
var telegramIdClaim = user.FindFirst("telegram_id")?.Value if (!user.TryGetPlatformIdentity(out var platform, out var externalUserId))
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!long.TryParse(telegramIdClaim, out var telegramId))
{ {
Navigation.NavigateTo("/login"); Navigation.NavigateTo("/login");
return; return;
} }
try try
{ {
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId)) var groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
if (groupManagement is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
} }
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new(); stats = await SessionService.GetGroupAttendanceStatsForCurrentUserAsync(GroupId) ?? new();
UpdateSortedStats(); UpdateSortedStats();
} }
catch (Exception ex) catch (Exception ex)
+3 -3
View File
@@ -43,7 +43,7 @@
<div class="glass-card group-card"> <div class="glass-card group-card">
<div class="group-card-icon">🎮</div> <div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3> <h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @group.TelegramChatId</p> <p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;"> <span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole) @FormatRole(group.ManagerRole)
</span> </span>
@@ -93,13 +93,13 @@
var user = authState.User; var user = authState.User;
userName = user.Identity?.Name ?? "Мастер Игры"; userName = user.Identity?.Name ?? "Мастер Игры";
if (!user.TryGetTelegramId(out var telegramId)) if (!user.TryGetPlatformIdentity(out _, out _))
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
} }
groups = await SessionService.GetGroupsForGmAsync(telegramId); groups = await SessionService.GetGroupsForCurrentUserAsync();
} }
private static string FormatRole(string role) => private static string FormatRole(string role) =>
+12 -1
View File
@@ -10,7 +10,7 @@
<div class="login-card"> <div class="login-card">
<img src="logo.png" alt="GM-Relay" class="login-logo" /> <img src="logo.png" alt="GM-Relay" class="login-logo" />
<h1 class="login-title">GM-Relay</h1> <h1 class="login-title">GM-Relay</h1>
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p> <p class="login-subtitle">Войдите для управления игровыми сессиями</p>
@if (Navigation.Uri.Contains("error=auth_failed")) @if (Navigation.Uri.Contains("error=auth_failed"))
{ {
@@ -20,6 +20,17 @@
} }
<div id="telegram-login-container"></div> <div id="telegram-login-container"></div>
<div class="login-divider">
<span>или</span>
</div>
<a href="/auth/discord" class="login-btn login-btn-discord">
<svg class="login-btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.086 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.086 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Войти через Discord
</a>
</div> </div>
</div> </div>
@@ -56,7 +56,7 @@
{ {
<tr> <tr>
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td> <td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
<td>@entry.ActorName (@entry.ActorTelegramId)</td> <td>@entry.ActorName (@entry.ActorExternalUserId)</td>
<td> <td>
<span class="status-badge @(GetBadgeClass(entry.ChangeType))"> <span class="status-badge @(GetBadgeClass(entry.ChangeType))">
@GetChangeTypeLabel(entry.ChangeType) @GetChangeTypeLabel(entry.ChangeType)
@@ -82,13 +82,13 @@
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var authState = await AuthStateProvider.GetAuthenticationStateAsync(); var authState = await AuthStateProvider.GetAuthenticationStateAsync();
if (!authState.User.TryGetTelegramId(out var telegramId)) if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
return; return;
} }
var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId); var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
if (session is null) if (session is null)
{ {
Navigation.NavigateTo("/access-denied"); Navigation.NavigateTo("/access-denied");
@@ -97,7 +97,7 @@
sessionTitle = session.Title; sessionTitle = session.Title;
groupId = session.GroupId; groupId = session.GroupId;
entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId); entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
} }
private string GetChangeTypeLabel(string changeType) => changeType switch private string GetChangeTypeLabel(string changeType) => changeType switch
+19
View File
@@ -0,0 +1,19 @@
namespace GmRelay.Web;
public sealed class DiscordOAuthOptions
{
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string RedirectUri { get; set; } = string.Empty;
public string[] Scopes { get; set; } = ["identify", "guilds"];
public void Validate()
{
if (string.IsNullOrWhiteSpace(ClientId))
throw new InvalidOperationException("Discord:ClientId is required.");
if (string.IsNullOrWhiteSpace(ClientSecret))
throw new InvalidOperationException("Discord:ClientSecret is required.");
if (string.IsNullOrWhiteSpace(RedirectUri))
throw new InvalidOperationException("Discord:RedirectUri is required.");
}
}
+79 -1
View File
@@ -1,3 +1,4 @@
using GmRelay.Web;
using GmRelay.Web.Components; using GmRelay.Web.Components;
using GmRelay.Web.Health; using GmRelay.Web.Health;
using GmRelay.Web.Services; using GmRelay.Web.Services;
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using Telegram.Bot; using Telegram.Bot;
using Npgsql; using Npgsql;
@@ -16,6 +18,12 @@ var builder = WebApplication.CreateBuilder(args);
// Add Aspire service defaults // Add Aspire service defaults
builder.AddServiceDefaults(); builder.AddServiceDefaults();
// Add HttpClient
builder.Services.AddHttpClient();
// Add HttpContextAccessor for platform-agnostic identity resolution
builder.Services.AddHttpContextAccessor();
// Add health checks // Add health checks
builder.Services.AddHealthChecks() builder.Services.AddHealthChecks()
.AddCheck<NpgsqlHealthCheck>("npgsql"); .AddCheck<NpgsqlHealthCheck>("npgsql");
@@ -29,6 +37,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services // Add Services
builder.Services.AddSingleton<TelegramAuthService>(); builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<ISessionStore, SessionService>(); builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>(); builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<CalendarSubscriptionService>(); builder.Services.AddScoped<CalendarSubscriptionService>();
@@ -174,6 +184,56 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
return Results.Redirect("/"); return Results.Redirect("/");
}); });
// Discord OAuth endpoints
app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) =>
{
var state = Guid.NewGuid().ToString("N");
context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
MaxAge = TimeSpan.FromMinutes(5)
});
var url = discordAuth.BuildAuthorizeUrl(state);
return Results.Redirect(url);
});
app.MapGet("/auth/discord/callback", async (
HttpContext context,
DiscordAuthService discordAuth,
ISessionStore sessionStore) =>
{
var code = context.Request.Query["code"].ToString();
var state = context.Request.Query["state"].ToString();
var storedState = context.Request.Cookies["__DiscordOAuthState"];
context.Response.Cookies.Delete("__DiscordOAuthState");
if (string.IsNullOrWhiteSpace(code) ||
string.IsNullOrWhiteSpace(state) ||
!CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(state),
System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty)))
{
return Results.Redirect("/login?error=auth_failed");
}
var user = await discordAuth.ExchangeCodeAsync(code);
if (user is null)
return Results.Redirect("/login?error=auth_failed");
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
var authProperties = new AuthenticationProperties { IsPersistent = true };
await context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
CreateDiscordPrincipal(user.Id, user.DisplayName, user.AvatarUrl),
authProperties);
return Results.Redirect("/");
});
// Public calendar subscription endpoint (no auth required) // Public calendar subscription endpoint (no auth required)
app.MapGet("/calendar/{token}.ics", async ( app.MapGet("/calendar/{token}.ics", async (
string token, string token,
@@ -200,11 +260,29 @@ static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
{ {
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)), new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new(ClaimTypes.Name, name), new(ClaimTypes.Name, name),
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)) new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
new("Platform", "Telegram")
}; };
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(claimsIdentity); return new ClaimsPrincipal(claimsIdentity);
} }
static ClaimsPrincipal CreateDiscordPrincipal(string discordId, string name, string? avatarUrl)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, discordId),
new(ClaimTypes.Name, name),
new("DiscordId", discordId),
new("Platform", "Discord")
};
if (!string.IsNullOrWhiteSpace(avatarUrl))
claims.Add(new Claim("AvatarUrl", avatarUrl));
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(claimsIdentity);
}
public sealed record TelegramWebAppAuthRequest(string InitData); public sealed record TelegramWebAppAuthRequest(string InitData);
@@ -1,182 +1,237 @@
using System.Security.Claims;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
public sealed class AuthorizedSessionService(ISessionStore sessionStore) public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor)
{ {
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) => private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
sessionStore.GetGroupsForGmAsync(gmId);
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
{ {
if (!await GroupBelongsToGmAsync(groupId, gmId)) var user = httpContextAccessor.HttpContext?.User;
{ if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
return null;
var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId;
return (platform, externalUserId, name);
}
public Task<List<WebGameGroup>> GetGroupsForCurrentUserAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return Task.FromResult(new List<WebGameGroup>());
return sessionStore.GetGroupsForUserAsync(identity.Value.Platform, identity.Value.ExternalUserId);
}
public async Task<WebGroupManagement?> GetGroupManagementForCurrentUserAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null; return null;
}
var group = await sessionStore.GetGroupAsync(groupId); var group = await sessionStore.GetGroupAsync(groupId);
if (group is null) if (group is null)
{
return null; return null;
}
var managers = await sessionStore.GetGroupManagersAsync(groupId); var managers = await sessionStore.GetGroupManagersAsync(groupId);
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId); var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId);
return new WebGroupManagement(group, managers, isOwner); return new WebGroupManagement(group, managers, isOwner);
} }
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId) public async Task<List<WebSession>?> GetUpcomingSessionsForCurrentUserAsync(Guid groupId)
{ {
if (!await GroupBelongsToGmAsync(groupId, gmId)) var identity = GetCurrentIdentity();
{ if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null; return null;
}
return await sessionStore.GetUpcomingSessionsAsync(groupId); return await sessionStore.GetUpcomingSessionsAsync(groupId);
} }
public async Task<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId) public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
{ {
var identity = GetCurrentIdentity();
if (identity is null)
return null;
var session = await sessionStore.GetSessionAsync(sessionId); var session = await sessionStore.GetSessionAsync(sessionId);
if (session is null) if (session is null)
{
return null; return null;
}
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null; return await sessionStore.IsGroupManagerAsync(session.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? session : null;
} }
public async Task<WebSessionBatch?> GetBatchForGmAsync(Guid batchId, long gmId) public async Task<WebSessionBatch?> GetBatchForCurrentUserAsync(Guid batchId)
{ {
var identity = GetCurrentIdentity();
if (identity is null)
return null;
var batch = await sessionStore.GetBatchAsync(batchId); var batch = await sessionStore.GetBatchAsync(batchId);
if (batch is null) if (batch is null)
{
return null; return null;
}
return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null; return await sessionStore.IsGroupManagerAsync(batch.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? batch : null;
} }
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) public async Task UpdateSessionForCurrentUserAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
{ {
var session = await GetSessionForGmAsync(sessionId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, gmId); throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
} }
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers); await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
if (session.Title != title) if (session.Title != title)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", session.Title, title); await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Title", session.Title, title);
if (session.ScheduledAt != scheduledAt) if (session.ScheduledAt != scheduledAt)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O")); await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
if (session.JoinLink != joinLink) if (session.JoinLink != joinLink)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", session.JoinLink, joinLink); await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Link", session.JoinLink, joinLink);
if (session.MaxPlayers != maxPlayers) if (session.MaxPlayers != maxPlayers)
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString()); await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
} }
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId) public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId)
{ {
var session = await GetSessionForGmAsync(sessionId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, gmId); throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
} }
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId); await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "WaitlistPromote", null, null); await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "WaitlistPromote", null, null);
} }
public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink) public async Task UpdateBatchDetailsForCurrentUserAsync(Guid batchId, string title, string joinLink)
{ {
var batch = await GetBatchForGmAsync(batchId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, gmId); throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
} }
await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim()); await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim());
} }
public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode) public async Task UpdateBatchNotificationModeForCurrentUserAsync(Guid batchId, SessionNotificationMode notificationMode)
{ {
var batch = await GetBatchForGmAsync(batchId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, gmId); throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
} }
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode); await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
} }
public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays) public async Task RescheduleBatchForCurrentUserAsync(Guid batchId, DateTime firstScheduledAt, int intervalDays)
{ {
if (intervalDays <= 0) if (intervalDays <= 0)
{ {
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero."); throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
} }
var batch = await GetBatchForGmAsync(batchId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, gmId); throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
} }
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays); await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
await sessionStore.LogSessionChangeAsync(batchId, gmId, "ГМ", "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O")); await sessionStore.LogSessionChangeAsync(batchId, identity.Value.ExternalUserId, identity.Value.Name, "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O"));
} }
public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval) public async Task<WebSessionBatch> CloneBatchForCurrentUserAsync(Guid batchId, BatchCloneInterval interval)
{ {
var batch = await GetBatchForGmAsync(batchId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var batch = await GetBatchForCurrentUserAsync(batchId);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, gmId); throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
} }
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval); return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
} }
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId) public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForCurrentUserAsync(Guid groupId)
{ {
if (!await GroupBelongsToGmAsync(groupId, gmId)) var identity = GetCurrentIdentity();
{ if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null; return null;
}
return await sessionStore.GetCampaignTemplatesAsync(groupId); return await sessionStore.GetCampaignTemplatesAsync(groupId);
} }
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync( public async Task<WebCampaignTemplate> CreateCampaignTemplateForCurrentUserAsync(
Guid groupId, Guid groupId,
long gmId,
CreateCampaignTemplateRequest request) CreateCampaignTemplateRequest request)
{ {
if (!await GroupBelongsToGmAsync(groupId, gmId)) var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{ {
throw new SessionAccessDeniedException(groupId, gmId); throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
} }
var normalizedRequest = NormalizeCampaignTemplateRequest(request); var normalizedRequest = NormalizeCampaignTemplateRequest(request);
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest); return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
} }
public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId) public async Task DeleteCampaignTemplateForCurrentUserAsync(Guid templateId)
{ {
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var template = await sessionStore.GetCampaignTemplateAsync(templateId); var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId))
{ {
throw new SessionAccessDeniedException(templateId, gmId); throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId);
} }
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId); await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
} }
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync( public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForCurrentUserAsync(
Guid templateId, Guid templateId,
long gmId,
DateTime firstScheduledAt) DateTime firstScheduledAt)
{ {
if (firstScheduledAt <= DateTime.UtcNow) if (firstScheduledAt <= DateTime.UtcNow)
@@ -184,89 +239,112 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future."); throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
} }
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var template = await sessionStore.GetCampaignTemplateAsync(templateId); var template = await sessionStore.GetCampaignTemplateAsync(templateId);
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId)) if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId))
{ {
throw new SessionAccessDeniedException(templateId, gmId); throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId);
} }
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt); return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
} }
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) public async Task AddCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername)
{ {
if (coGmTelegramId <= 0) if (string.IsNullOrWhiteSpace(coGmExternalUserId))
{ {
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero."); throw new ArgumentException("External user id must not be empty.", nameof(coGmExternalUserId));
} }
if (ownerTelegramId == coGmTelegramId) var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (identity.Value.ExternalUserId == coGmExternalUserId && identity.Value.Platform == coGmPlatform)
{ {
throw new InvalidOperationException("Owner is already a group manager."); throw new InvalidOperationException("Owner is already a group manager.");
} }
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{ {
throw new SessionAccessDeniedException(groupId, ownerTelegramId); throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
} }
var normalizedName = string.IsNullOrWhiteSpace(displayName) var normalizedName = string.IsNullOrWhiteSpace(displayName)
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}" ? $"{coGmPlatform} {coGmExternalUserId}"
: displayName.Trim(); : displayName.Trim();
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername) var normalizedUsername = string.IsNullOrWhiteSpace(externalUsername)
? null ? null
: telegramUsername.Trim().TrimStart('@'); : externalUsername.Trim().TrimStart('@');
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername); await sessionStore.AddGroupCoGmAsync(
groupId,
identity.Value.Platform, identity.Value.ExternalUserId,
coGmPlatform, coGmExternalUserId,
normalizedName, normalizedUsername);
} }
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId) public async Task RemoveCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{ {
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId)) var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
{ {
throw new SessionAccessDeniedException(groupId, ownerTelegramId); throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
} }
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId); await sessionStore.RemoveGroupCoGmAsync(groupId, coGmPlatform, coGmExternalUserId);
} }
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId) public async Task<List<WebParticipant>?> GetSessionParticipantsForCurrentUserAsync(Guid sessionId)
{ {
var session = await GetSessionForGmAsync(sessionId, gmId); var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null) if (session is null)
{
return null; return null;
}
return await sessionStore.GetSessionParticipantsAsync(sessionId); return await sessionStore.GetSessionParticipantsAsync(sessionId);
} }
public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId) public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForCurrentUserAsync(Guid sessionId)
{ {
var session = await GetSessionForGmAsync(sessionId, gmId); var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null) if (session is null)
{
return null; return null;
}
return await sessionStore.GetSessionHistoryAsync(sessionId); return await sessionStore.GetSessionHistoryAsync(sessionId);
} }
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId) public async Task RemovePlayerFromSessionForCurrentUserAsync(Guid sessionId, Guid participantId)
{ {
var session = await GetSessionForGmAsync(sessionId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, gmId); throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
} }
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId); await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "PlayerRemoved", participantId.ToString(), null); await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "PlayerRemoved", participantId.ToString(), null);
} }
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId) public async Task<List<PlayerAttendanceStats>?> GetGroupAttendanceStatsForCurrentUserAsync(Guid groupId)
{ {
return await sessionStore.IsGroupManagerAsync(groupId, gmId); var identity = GetCurrentIdentity();
if (identity is null)
return null;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return null;
return await sessionStore.GetGroupAttendanceStatsAsync(groupId);
} }
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request) private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
@@ -6,4 +6,37 @@ public static class ClaimsPrincipalExtensions
{ {
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) => public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId); long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
public static bool TryGetDiscordId(this ClaimsPrincipal user, out string? discordId)
{
discordId = user.FindFirst("DiscordId")?.Value;
return !string.IsNullOrWhiteSpace(discordId);
}
public static bool TryGetPlatformIdentity(this ClaimsPrincipal user, out string platform, out string externalUserId)
{
platform = string.Empty;
externalUserId = string.Empty;
var platformClaim = user.FindFirst("Platform")?.Value;
if (!string.IsNullOrWhiteSpace(platformClaim))
{
platform = platformClaim;
externalUserId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
return !string.IsNullOrWhiteSpace(externalUserId);
}
// Fallback for legacy Telegram users before Platform claim was added
if (TryGetTelegramId(user, out var telegramId))
{
platform = "Telegram";
externalUserId = telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture);
return true;
}
return false;
}
public static string? GetAvatarUrl(this ClaimsPrincipal user) =>
user.FindFirst("AvatarUrl")?.Value;
} }
@@ -0,0 +1,82 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace GmRelay.Web.Services;
public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get<DiscordOAuthOptions>() ?? new DiscordOAuthOptions();
public string BuildAuthorizeUrl(string state)
{
_options.Validate();
var scopes = string.Join(" ", _options.Scopes);
return $"https://discord.com/oauth2/authorize?client_id={_options.ClientId}&redirect_uri={Uri.EscapeDataString(_options.RedirectUri)}&response_type=code&scope={Uri.EscapeDataString(scopes)}&state={Uri.EscapeDataString(state)}";
}
public async Task<DiscordUser?> ExchangeCodeAsync(string code)
{
_options.Validate();
var client = httpClientFactory.CreateClient();
var tokenResponse = await ExchangeCodeForTokenAsync(client, code);
if (tokenResponse is null)
return null;
return await FetchUserProfileAsync(client, tokenResponse.AccessToken);
}
private async Task<DiscordTokenResponse?> ExchangeCodeForTokenAsync(HttpClient client, string code)
{
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = _options.RedirectUri,
["client_id"] = _options.ClientId,
["client_secret"] = _options.ClientSecret
});
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<DiscordTokenResponse>(json);
}
private static async Task<DiscordUser?> FetchUserProfileAsync(HttpClient client, string accessToken)
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await client.GetAsync("https://discord.com/api/users/@me");
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<DiscordUser>(json);
}
}
public sealed record DiscordTokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("token_type")] string TokenType,
[property: JsonPropertyName("expires_in")] int ExpiresIn,
[property: JsonPropertyName("scope")] string Scope);
public sealed record DiscordUser(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("username")] string Username,
[property: JsonPropertyName("discriminator")] string Discriminator,
[property: JsonPropertyName("avatar")] string? Avatar,
[property: JsonPropertyName("email")] string? Email)
{
public string DisplayName =>
Discriminator == "0" ? Username : $"{Username}#{Discriminator}";
public string? AvatarUrl =>
!string.IsNullOrWhiteSpace(Avatar)
? $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.png"
: null;
}
+11 -12
View File
@@ -5,33 +5,31 @@ namespace GmRelay.Web.Services;
public sealed record PlayerAttendanceStats( public sealed record PlayerAttendanceStats(
Guid PlayerId, Guid PlayerId,
string DisplayName, string DisplayName,
string? TelegramUsername, string? ExternalUsername,
long TotalSessions, long TotalSessions,
long ConfirmedCount, long ConfirmedCount,
long DeclinedCount, long DeclinedCount,
long NoResponseCount, long NoResponseCount,
long WaitlistedCount, long WaitlistedCount,
long CancellationAffectedCount, long CancellationAffectedCount,
decimal AttendanceRate decimal AttendanceRate);
);
public sealed record SessionAuditLogEntry( public sealed record SessionAuditLogEntry(
Guid Id, Guid Id,
Guid SessionId, Guid SessionId,
long ActorTelegramId, string ActorExternalUserId,
string ActorName, string ActorName,
string ChangeType, string ChangeType,
string? OldValue, string? OldValue,
string? NewValue, string? NewValue,
DateTime ChangedAt DateTime ChangedAt);
);
public interface ISessionStore public interface ISessionStore
{ {
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId); Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
Task<WebGameGroup?> GetGroupAsync(Guid groupId); Task<WebGameGroup?> GetGroupAsync(Guid groupId);
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId); Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId); Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId); Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId); Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId); Task<WebSession?> GetSessionAsync(Guid sessionId);
@@ -47,11 +45,12 @@ public interface ISessionStore
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request); Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId); Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt); Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername); Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername);
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId); Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId);
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId); Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId); Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId); Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue); Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId); Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
} }
@@ -1,4 +1,4 @@
namespace GmRelay.Web.Services; namespace GmRelay.Web.Services;
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId) public sealed class SessionAccessDeniedException(Guid sessionId, string externalUserId)
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'."); : InvalidOperationException($"Session '{sessionId}' is not accessible for user '{externalUserId}'.");
+101 -55
View File
@@ -10,16 +10,32 @@ namespace GmRelay.Web.Services;
public sealed record WebGameGroup( public sealed record WebGameGroup(
Guid Id, Guid Id,
long TelegramChatId, long TelegramChatId,
string? ExternalGroupId,
string Name, string Name,
long GmTelegramId, string? Platform,
string ManagerRole = GroupManagerRoleExtensions.OwnerValue); string ManagerRole = GroupManagerRoleExtensions.OwnerValue)
{
public long GmTelegramId { get; init; }
public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId)
: this(id, telegramChatId, null, name, null)
{
GmTelegramId = gmTelegramId;
}
}
public sealed record WebGroupManager( public sealed record WebGroupManager(
long TelegramId, long TelegramId,
string? ExternalUserId,
string DisplayName, string DisplayName,
string? TelegramUsername, string? TelegramUsername,
string? ExternalUsername,
string Role, string Role,
DateTime AddedAt); DateTime AddedAt)
{
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
: this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { }
}
public sealed record WebGroupManagement( public sealed record WebGroupManagement(
WebGameGroup Group, WebGameGroup Group,
@@ -44,8 +60,10 @@ public sealed record WebSession(
public sealed record WebParticipant( public sealed record WebParticipant(
Guid Id, Guid Id,
long TelegramId, long TelegramId,
string? ExternalUserId,
string DisplayName, string DisplayName,
string? TelegramUsername, string? TelegramUsername,
string? ExternalUsername,
string RsvpStatus, string RsvpStatus,
string RegistrationStatus, string RegistrationStatus,
bool IsGm, bool IsGm,
@@ -83,23 +101,25 @@ public sealed class SessionService(
ITelegramBotClient bot, ITelegramBotClient bot,
ILogger<SessionService> logger) : ISessionStore ILogger<SessionService> logger) : ISessionStore
{ {
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGameGroup>( return (await conn.QueryAsync<WebGameGroup>(
""" """
SELECT g.id, SELECT g.id,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name, g.name,
g.gm_telegram_id AS GmTelegramId, g.platform AS Platform,
gm.role AS ManagerRole gm.role AS ManagerRole
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id JOIN game_groups g ON g.id = gm.group_id
WHERE p.telegram_id = @GmId WHERE p.platform = @Platform
AND p.external_user_id = @ExternalUserId
ORDER BY g.name ORDER BY g.name
""", """,
new { GmId = gmId })).ToList(); new { Platform = platform, ExternalUserId = externalUserId })).ToList();
} }
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId) public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
@@ -109,8 +129,9 @@ public sealed class SessionService(
""" """
SELECT g.id, SELECT g.id,
g.telegram_chat_id AS TelegramChatId, g.telegram_chat_id AS TelegramChatId,
g.external_group_id AS ExternalGroupId,
g.name, g.name,
g.gm_telegram_id AS GmTelegramId, g.platform AS Platform,
@OwnerRole AS ManagerRole @OwnerRole AS ManagerRole
FROM game_groups g FROM game_groups g
WHERE g.id = @GroupId WHERE g.id = @GroupId
@@ -118,7 +139,7 @@ public sealed class SessionService(
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
} }
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -128,13 +149,14 @@ public sealed class SessionService(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
) )
""", """,
new { GroupId = groupId, TelegramId = telegramId }); new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId });
} }
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<bool>( return await conn.ExecuteScalarAsync<bool>(
@@ -144,11 +166,12 @@ public sealed class SessionService(
FROM group_managers gm FROM group_managers gm
JOIN players p ON p.id = gm.player_id JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId WHERE gm.group_id = @GroupId
AND p.telegram_id = @TelegramId AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @OwnerRole AND gm.role = @OwnerRole
) )
""", """,
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
} }
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
@@ -156,9 +179,11 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebGroupManager>( return (await conn.QueryAsync<WebGroupManager>(
""" """
SELECT p.telegram_id AS TelegramId, SELECT COALESCE(p.telegram_id, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
gm.role AS Role, gm.role AS Role,
gm.created_at AS AddedAt gm.created_at AS AddedAt
FROM group_managers gm FROM group_managers gm
@@ -179,7 +204,7 @@ public sealed class SessionService(
SELECT SELECT
p.id AS PlayerId, p.id AS PlayerId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
COUNT(DISTINCT s.id) AS TotalSessions, COUNT(DISTINCT s.id) AS TotalSessions,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount, COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
@@ -198,21 +223,21 @@ public sealed class SessionService(
WHERE s.group_id = @GroupId WHERE s.group_id = @GroupId
AND s.scheduled_at <= now() AND s.scheduled_at <= now()
AND sp.is_gm = false AND sp.is_gm = false
GROUP BY p.id, p.display_name, p.telegram_username GROUP BY p.id, p.display_name, p.external_username, p.telegram_username
ORDER BY AttendanceRate DESC, ConfirmedCount DESC ORDER BY AttendanceRate DESC, ConfirmedCount DESC
""", """,
new { GroupId = groupId })).ToList(); new { GroupId = groupId })).ToList();
} }
public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue) public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync( await conn.ExecuteAsync(
""" """
INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value) INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value)
VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue) VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue)
""", """,
new { SessionId = sessionId, ActorTelegramId = actorTelegramId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue }); new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
} }
public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
@@ -220,7 +245,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var entries = await conn.QueryAsync<SessionAuditLogEntry>( var entries = await conn.QueryAsync<SessionAuditLogEntry>(
""" """
SELECT id, session_id AS SessionId, actor_telegram_id AS ActorTelegramId, actor_name AS ActorName, SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName,
change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
FROM session_audit_log FROM session_audit_log
WHERE session_id = @SessionId WHERE session_id = @SessionId
@@ -230,32 +255,47 @@ public sealed class SessionService(
return entries.ToList(); return entries.ToList();
} }
public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl)
{
await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@DisplayName, 'Discord', @DiscordId, @DisplayName)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username
""",
new { DisplayName = displayName, DiscordId = discordId });
}
public async Task AddGroupCoGmAsync( public async Task AddGroupCoGmAsync(
Guid groupId, Guid groupId,
long ownerTelegramId, string ownerPlatform, string ownerExternalUserId,
long coGmTelegramId, string coGmPlatform, string coGmExternalUserId,
string displayName, string displayName, string? externalUsername)
string? telegramUsername)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync(); await using var transaction = await conn.BeginTransactionAsync();
await conn.ExecuteAsync( await conn.ExecuteAsync(
""" """
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username) INSERT INTO players (display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TelegramId, @DisplayName, @TelegramUsername, 'Telegram', @TelegramId::TEXT, @TelegramUsername) VALUES (@DisplayName, @ExternalUsername, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (telegram_id) DO UPDATE ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name, SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username, external_username = EXCLUDED.external_username
platform = COALESCE(players.platform, 'Telegram'),
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username)
""", """,
new new
{ {
TelegramId = coGmTelegramId,
DisplayName = displayName, DisplayName = displayName,
TelegramUsername = telegramUsername ExternalUsername = externalUsername,
Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId
}, },
transaction); transaction);
@@ -267,8 +307,8 @@ public sealed class SessionService(
@CoGmRole, @CoGmRole,
owner_player.id owner_player.id
FROM players co_gm FROM players co_gm
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId LEFT JOIN players owner_player ON owner_player.platform = @OwnerPlatform AND owner_player.external_user_id = @OwnerExternalUserId
WHERE co_gm.telegram_id = @CoGmTelegramId WHERE co_gm.platform = @CoGmPlatform AND co_gm.external_user_id = @CoGmExternalUserId
ON CONFLICT (group_id, player_id) DO UPDATE ON CONFLICT (group_id, player_id) DO UPDATE
SET role = CASE SET role = CASE
WHEN group_managers.role = @OwnerRole THEN group_managers.role WHEN group_managers.role = @OwnerRole THEN group_managers.role
@@ -279,8 +319,10 @@ public sealed class SessionService(
new new
{ {
GroupId = groupId, GroupId = groupId,
OwnerTelegramId = ownerTelegramId, OwnerPlatform = ownerPlatform,
CoGmTelegramId = coGmTelegramId, OwnerExternalUserId = ownerExternalUserId,
CoGmPlatform = coGmPlatform,
CoGmExternalUserId = coGmExternalUserId,
OwnerRole = GroupManagerRoleExtensions.OwnerValue, OwnerRole = GroupManagerRoleExtensions.OwnerValue,
CoGmRole = GroupManagerRoleExtensions.CoGmValue CoGmRole = GroupManagerRoleExtensions.CoGmValue
}, },
@@ -289,7 +331,7 @@ public sealed class SessionService(
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId) public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{ {
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
await conn.ExecuteAsync( await conn.ExecuteAsync(
@@ -298,13 +340,15 @@ public sealed class SessionService(
USING players p USING players p
WHERE gm.player_id = p.id WHERE gm.player_id = p.id
AND gm.group_id = @GroupId AND gm.group_id = @GroupId
AND p.telegram_id = @CoGmTelegramId AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND gm.role = @CoGmRole AND gm.role = @CoGmRole
""", """,
new new
{ {
GroupId = groupId, GroupId = groupId,
CoGmTelegramId = coGmTelegramId, Platform = coGmPlatform,
ExternalUserId = coGmExternalUserId,
CoGmRole = GroupManagerRoleExtensions.CoGmValue CoGmRole = GroupManagerRoleExtensions.CoGmValue
}); });
} }
@@ -426,7 +470,7 @@ public sealed class SessionService(
if (oldSession is null) if (oldSession is null)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
@@ -454,7 +498,7 @@ public sealed class SessionService(
if (updatedRows == 0) if (updatedRows == 0)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
await conn.ExecuteAsync( await conn.ExecuteAsync(
@@ -513,7 +557,7 @@ public sealed class SessionService(
if (session is null) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
var activeParticipants = await conn.ExecuteScalarAsync<int>( var activeParticipants = await conn.ExecuteScalarAsync<int>(
@@ -597,9 +641,11 @@ public sealed class SessionService(
return (await conn.QueryAsync<WebParticipant>( return (await conn.QueryAsync<WebParticipant>(
""" """
SELECT sp.id AS Id, SELECT sp.id AS Id,
p.telegram_id AS TelegramId, COALESCE(p.telegram_id, 0) AS TelegramId,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName, p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername, p.telegram_username AS TelegramUsername,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
sp.rsvp_status AS RsvpStatus, sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus, sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm, sp.is_gm AS IsGm,
@@ -637,7 +683,7 @@ public sealed class SessionService(
if (session is null) if (session is null)
{ {
throw new SessionAccessDeniedException(sessionId, 0); throw new SessionAccessDeniedException(sessionId, "0");
} }
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>( var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
@@ -744,7 +790,7 @@ public sealed class SessionService(
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
@@ -767,7 +813,7 @@ public sealed class SessionService(
if (updatedRows == 0) if (updatedRows == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
await transaction.CommitAsync(); await transaction.CommitAsync();
@@ -786,7 +832,7 @@ public sealed class SessionService(
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
if (batch is null) if (batch is null)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var updatedRows = await conn.ExecuteAsync( var updatedRows = await conn.ExecuteAsync(
@@ -807,7 +853,7 @@ public sealed class SessionService(
if (updatedRows == 0) if (updatedRows == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
await transaction.CommitAsync(); await transaction.CommitAsync();
@@ -844,7 +890,7 @@ public sealed class SessionService(
if (batchSessions.Count == 0) if (batchSessions.Count == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule( var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
@@ -928,7 +974,7 @@ public sealed class SessionService(
if (sourceSessions.Count == 0) if (sourceSessions.Count == 0)
{ {
throw new SessionAccessDeniedException(batchId, 0); throw new SessionAccessDeniedException(batchId, "0");
} }
var newBatchId = Guid.NewGuid(); var newBatchId = Guid.NewGuid();
@@ -1130,7 +1176,7 @@ public sealed class SessionService(
if (template is null) if (template is null)
{ {
throw new SessionAccessDeniedException(templateId, 0); throw new SessionAccessDeniedException(templateId, "0");
} }
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>( var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
@@ -1140,7 +1186,7 @@ public sealed class SessionService(
if (group is null) if (group is null)
{ {
throw new SessionAccessDeniedException(groupId, 0); throw new SessionAccessDeniedException(groupId, "0");
} }
var schedule = BatchSchedulePlanner.BuildRecurringSchedule( var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
+75
View File
@@ -1619,3 +1619,78 @@ body.telegram-mini-app .session-card-mobile {
display: flex; display: flex;
} }
} }
/* === Discord Login Button === */
.login-btn-discord {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1.25rem;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.9375rem;
text-decoration: none;
transition: all 0.2s ease;
background-color: #5865F2;
color: white;
border: none;
cursor: pointer;
}
.login-btn-discord:hover {
background-color: #4752C4;
transform: translateY(-1px);
}
.login-btn-discord:active {
transform: translateY(0);
}
.login-btn-discord .login-btn-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.login-divider {
display: flex;
align-items: center;
margin: 1.25rem 0;
color: var(--text-muted);
font-size: 0.875rem;
}
.login-divider::before,
.login-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--border-color);
}
.login-divider span {
padding: 0 1rem;
}
/* === Platform Indicator in Nav === */
.nav-user-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.nav-user-platform {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.025em;
}
.nav-user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
Assert.Contains("gmrelay-discord-bot:2.7.2", compose); Assert.Contains("gmrelay-discord-bot:2.8.0", compose);
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
Assert.Contains("DISCORD_BOT_TOKEN", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy);
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
{ {
var repoRoot = GetRepoRoot(); var repoRoot = GetRepoRoot();
Assert.Contains("<Version>2.7.2</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); Assert.Contains("<Version>2.8.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
Assert.Contains("VERSION: 2.7.2", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
Assert.Contains("gmrelay-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-web:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains("gmrelay-discord-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
Assert.Contains( Assert.Contains(
"v2.7.2", "v2.8.0",
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
} }
@@ -1,10 +1,29 @@
using GmRelay.Web.Services; using GmRelay.Web.Services;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using GmRelay.Shared.Domain; using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Web; namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedSessionServiceTests public sealed class AuthorizedSessionServiceTests
{ {
private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, externalUserId),
new Claim("TelegramId", externalUserId),
new Claim("Platform", "Telegram")
};
if (name is not null)
claims.Add(new Claim(ClaimTypes.Name, name));
var identity = new ClaimsIdentity(claims, "Test");
var principal = new ClaimsPrincipal(identity);
var httpContext = new DefaultHttpContext { User = principal };
return new HttpContextAccessor { HttpContext = httpContext };
}
[Fact] [Fact]
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm() public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
{ {
@@ -19,9 +38,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId); var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.NotNull(sessions); Assert.NotNull(sessions);
Assert.Single(sessions); Assert.Single(sessions);
@@ -47,9 +67,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, coGmId, GroupManagerRole.CoGm) new(groupId, coGmId, GroupManagerRole.CoGm)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId); var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.NotNull(sessions); Assert.NotNull(sessions);
Assert.Single(sessions); Assert.Single(sessions);
@@ -65,9 +86,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, 42, "Alpha", 2002L) new(groupId, 42, "Alpha", 2002L)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L); var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
Assert.Null(sessions); Assert.Null(sessions);
} }
@@ -87,9 +109,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var session = await service.GetSessionForGmAsync(sessionId, gmId); var session = await service.GetSessionForCurrentUserAsync(sessionId);
Assert.NotNull(session); Assert.NotNull(session);
Assert.Equal(sessionId, session.Id); Assert.Equal(sessionId, session.Id);
@@ -109,9 +132,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var session = await service.GetSessionForGmAsync(sessionId, 1001L); var session = await service.GetSessionForCurrentUserAsync(sessionId);
Assert.Null(session); Assert.Null(session);
} }
@@ -130,9 +154,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5); var action = () => service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action); await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateCalled); Assert.False(store.UpdateCalled);
@@ -154,9 +179,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5); await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", scheduledAt, "https://example.test/b", 5);
Assert.True(store.UpdateCalled); Assert.True(store.UpdateCalled);
Assert.Equal(groupId, store.LastUpdatedGroupId); Assert.Equal(groupId, store.LastUpdatedGroupId);
@@ -183,9 +209,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4); await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", originalTime, "https://example.test/a", 4);
Assert.Single(store.LogEntries); Assert.Single(store.LogEntries);
Assert.Equal("Title", store.LogEntries[0].ChangeType); Assert.Equal("Title", store.LogEntries[0].ChangeType);
@@ -209,10 +236,11 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var newTime = originalTime.AddDays(1); var newTime = originalTime.AddDays(1);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5); await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", newTime, "https://example.test/b", 5);
Assert.Equal(4, store.LogEntries.Count); Assert.Equal(4, store.LogEntries.Count);
Assert.Contains(store.LogEntries, e => e.ChangeType == "Title"); Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
@@ -237,9 +265,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4); await service.UpdateSessionForCurrentUserAsync(sessionId, "Session A", originalTime, "https://example.test/a", 4);
Assert.Empty(store.LogEntries); Assert.Empty(store.LogEntries);
} }
@@ -259,9 +288,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId); var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
Assert.NotNull(history); Assert.NotNull(history);
Assert.Empty(history); Assert.Empty(history);
@@ -281,9 +311,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L); var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
Assert.Null(history); Assert.Null(history);
} }
@@ -303,9 +334,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1) new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId); await service.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
Assert.True(store.PromoteCalled); Assert.True(store.PromoteCalled);
Assert.Equal(groupId, store.LastPromotedGroupId); Assert.Equal(groupId, store.LastPromotedGroupId);
@@ -326,9 +358,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b"); var action = () => service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
await Assert.ThrowsAsync<SessionAccessDeniedException>(action); await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchDetailsCalled); Assert.False(store.UpdateBatchDetailsCalled);
@@ -349,9 +382,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b"); await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled); Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId); Assert.Equal(batchId, store.LastUpdatedBatchId);
@@ -380,9 +414,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, coGmId, GroupManagerRole.CoGm) new(groupId, coGmId, GroupManagerRole.CoGm)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b"); await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
Assert.True(store.UpdateBatchDetailsCalled); Assert.True(store.UpdateBatchDetailsCalled);
Assert.Equal(batchId, store.LastUpdatedBatchId); Assert.Equal(batchId, store.LastUpdatedBatchId);
@@ -400,9 +435,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, 42, "Alpha", ownerId) new(groupId, 42, "Alpha", ownerId)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(ownerId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant"); await service.AddCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString(), "Assistant GM", "assistant");
Assert.True(store.AddCoGmCalled); Assert.True(store.AddCoGmCalled);
Assert.Equal(groupId, store.LastAddedCoGmGroupId); Assert.Equal(groupId, store.LastAddedCoGmGroupId);
@@ -427,9 +463,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, coGmId, GroupManagerRole.CoGm) new(groupId, coGmId, GroupManagerRole.CoGm)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(coGmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null); var action = () => service.AddCoGmForOwnerAsync(groupId, "Telegram", newCoGmId.ToString(), "Second Assistant", null);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action); await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.AddCoGmCalled); Assert.False(store.AddCoGmCalled);
@@ -450,9 +487,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, coGmId, GroupManagerRole.CoGm) new(groupId, coGmId, GroupManagerRole.CoGm)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(ownerId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId); await service.RemoveCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString());
Assert.True(store.RemoveCoGmCalled); Assert.True(store.RemoveCoGmCalled);
Assert.Equal(groupId, store.LastRemovedCoGmGroupId); Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
@@ -473,9 +511,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly); var action = () => service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
await Assert.ThrowsAsync<SessionAccessDeniedException>(action); await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.UpdateBatchNotificationModeCalled); Assert.False(store.UpdateBatchNotificationModeCalled);
@@ -496,9 +535,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly); await service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
Assert.True(store.UpdateBatchNotificationModeCalled); Assert.True(store.UpdateBatchNotificationModeCalled);
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId); Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
@@ -521,9 +561,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0); var action = () => service.RescheduleBatchForCurrentUserAsync(batchId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action); await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
Assert.False(store.RescheduleBatchCalled); Assert.False(store.RescheduleBatchCalled);
@@ -545,9 +586,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14); await service.RescheduleBatchForCurrentUserAsync(batchId, firstScheduledAt, intervalDays: 14);
Assert.True(store.RescheduleBatchCalled); Assert.True(store.RescheduleBatchCalled);
Assert.Equal(batchId, store.LastRescheduledBatchId); Assert.Equal(batchId, store.LastRescheduledBatchId);
@@ -571,9 +613,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0) new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek); await service.CloneBatchForCurrentUserAsync(batchId, BatchCloneInterval.NextWeek);
Assert.True(store.CloneBatchCalled); Assert.True(store.CloneBatchCalled);
Assert.Equal(batchId, store.LastClonedBatchId); Assert.Equal(batchId, store.LastClonedBatchId);
@@ -591,11 +634,11 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, 42, "Alpha", gmId) new(groupId, 42, "Alpha", gmId)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateCampaignTemplateForGmAsync( await service.CreateCampaignTemplateForCurrentUserAsync(
groupId, groupId,
gmId,
new CreateCampaignTemplateRequest( new CreateCampaignTemplateRequest(
" Weekly arc ", " Weekly arc ",
" Kingmaker ", " Kingmaker ",
@@ -626,11 +669,11 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, 42, "Alpha", gmId) new(groupId, 42, "Alpha", gmId)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateCampaignTemplateForGmAsync( await service.CreateCampaignTemplateForCurrentUserAsync(
groupId, groupId,
gmId,
new CreateCampaignTemplateRequest( new CreateCampaignTemplateRequest(
"Open table", "Open table",
"West Marches", "West Marches",
@@ -653,11 +696,11 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(groupId, 42, "Alpha", 2002L) new(groupId, 42, "Alpha", 2002L)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.CreateCampaignTemplateForGmAsync( var action = () => service.CreateCampaignTemplateForCurrentUserAsync(
groupId, groupId,
1001L,
new CreateCampaignTemplateRequest( new CreateCampaignTemplateRequest(
"Weekly arc", "Weekly arc",
"Kingmaker", "Kingmaker",
@@ -687,9 +730,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor(gmId.ToString());
var service = new AuthorizedSessionService(store, accessor);
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt); await service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, firstScheduledAt);
Assert.True(store.CreateBatchFromTemplateCalled); Assert.True(store.CreateBatchFromTemplateCalled);
Assert.Equal(templateId, store.LastCreatedBatchTemplateId); Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
@@ -711,9 +755,10 @@ public sealed class AuthorizedSessionServiceTests
[ [
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow) new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
]); ]);
var service = new AuthorizedSessionService(store); var accessor = CreateAccessor("1001");
var service = new AuthorizedSessionService(store, accessor);
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3)); var action = () => service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, DateTime.UtcNow.AddDays(3));
await Assert.ThrowsAsync<SessionAccessDeniedException>(action); await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
Assert.False(store.CreateBatchFromTemplateCalled); Assert.False(store.CreateBatchFromTemplateCalled);
@@ -1019,7 +1064,7 @@ public sealed class AuthorizedSessionServiceTests
public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue) public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
{ {
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow); var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId.ToString(), actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry); LogEntries.Add(entry);
LastLogSessionId = sessionId; LastLogSessionId = sessionId;
LastLogActorTelegramId = actorTelegramId; LastLogActorTelegramId = actorTelegramId;
@@ -1033,12 +1078,62 @@ public sealed class AuthorizedSessionServiceTests
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) => public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList()); Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) =>
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, externalUserId)).ToList());
public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) =>
Task.FromResult(IsManager(groupId, externalUserId));
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) =>
Task.FromResult(IsOwner(groupId, externalUserId));
public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername)
{
AddCoGmCalled = true;
LastAddedCoGmGroupId = groupId;
LastAddedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
LastAddedCoGmDisplayName = displayName;
LastAddedCoGmUsername = externalUsername;
return Task.CompletedTask;
}
public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
{
RemoveCoGmCalled = true;
LastRemovedCoGmGroupId = groupId;
LastRemovedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
return Task.CompletedTask;
}
public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
{
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorExternalUserId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
LogEntries.Add(entry);
LastLogSessionId = sessionId;
LastLogActorTelegramId = long.TryParse(actorExternalUserId, out var id) ? (long?)id : null;
LastLogActorName = actorName;
LastLogChangeType = changeType;
LastLogOldValue = oldValue;
LastLogNewValue = newValue;
return Task.CompletedTask;
}
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
Task.CompletedTask;
private bool IsManager(Guid groupId, long telegramId) => private bool IsManager(Guid groupId, long telegramId) =>
IsOwner(groupId, telegramId) || IsOwner(groupId, telegramId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId); managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
private bool IsOwner(Guid groupId, long telegramId) => private bool IsOwner(Guid groupId, long telegramId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId; groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
private bool IsManager(Guid groupId, string externalUserId) =>
IsOwner(groupId, externalUserId) ||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId.ToString() == externalUserId);
private bool IsOwner(Guid groupId, string externalUserId) =>
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId.ToString() == externalUserId;
} }
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role); private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
@@ -0,0 +1,139 @@
using System.Net;
using System.Text.Json;
using GmRelay.Web;
using GmRelay.Web.Services;
using Microsoft.Extensions.Configuration;
namespace GmRelay.Bot.Tests.Web;
public class DiscordAuthServiceTests
{
[Fact]
public void BuildAuthorizeUrl_GeneratesCorrectUrl()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Discord:ClientId"] = "12345",
["Discord:ClientSecret"] = "secret",
["Discord:RedirectUri"] = "https://example.com/callback"
})
.Build();
var service = new DiscordAuthService(new TestHttpClientFactory(), config);
var url = service.BuildAuthorizeUrl("state123");
Assert.Contains("client_id=12345", url);
Assert.Contains("response_type=code", url);
Assert.Contains("state=state123", url);
Assert.Contains("https%3A%2F%2Fexample.com%2Fcallback", url);
}
[Fact]
public void BuildAuthorizeUrl_WithMissingConfig_ThrowsInvalidOperationException()
{
var config = new ConfigurationBuilder().Build();
var service = new DiscordAuthService(new TestHttpClientFactory(), config);
Assert.Throws<InvalidOperationException>(() => service.BuildAuthorizeUrl("state"));
}
[Fact]
public async Task ExchangeCodeAsync_WithValidCode_ReturnsUser()
{
var handler = new TestHttpMessageHandler((request) =>
{
if (request.RequestUri?.AbsolutePath == "/api/oauth2/token")
{
var response = new DiscordTokenResponse("access_123", "Bearer", 604800, "identify");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(response))
};
}
if (request.RequestUri?.AbsolutePath == "/api/users/@me")
{
var user = new DiscordUser("98765", "TestUser", "0", "avatar123", null);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(user))
};
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Discord:ClientId"] = "client",
["Discord:ClientSecret"] = "secret",
["Discord:RedirectUri"] = "https://example.com/callback"
})
.Build();
var factory = new TestHttpClientFactory(handler);
var service = new DiscordAuthService(factory, config);
var result = await service.ExchangeCodeAsync("valid_code");
Assert.NotNull(result);
Assert.Equal("98765", result.Id);
Assert.Equal("TestUser", result.Username);
Assert.Equal("https://cdn.discordapp.com/avatars/98765/avatar123.png", result.AvatarUrl);
}
[Fact]
public async Task ExchangeCodeAsync_WithInvalidCode_ReturnsNull()
{
var handler = new TestHttpMessageHandler((request) =>
{
return new HttpResponseMessage(HttpStatusCode.BadRequest);
});
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Discord:ClientId"] = "client",
["Discord:ClientSecret"] = "secret",
["Discord:RedirectUri"] = "https://example.com/callback"
})
.Build();
var factory = new TestHttpClientFactory(handler);
var service = new DiscordAuthService(factory, config);
var result = await service.ExchangeCodeAsync("invalid_code");
Assert.Null(result);
}
private class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler? _handler;
public TestHttpClientFactory(HttpMessageHandler? handler = null)
{
_handler = handler;
}
public HttpClient CreateClient(string name) =>
_handler is not null ? new HttpClient(_handler) : new HttpClient();
}
private class TestHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
public TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(_handler(request));
}
}
}
@@ -0,0 +1,114 @@
using System.Security.Claims;
using GmRelay.Web.Services;
namespace GmRelay.Bot.Tests.Web;
public class PlatformIdentityTests
{
[Fact]
public void TryGetPlatformIdentity_TelegramUser_ReturnsTelegramPlatform()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "12345"),
new Claim("TelegramId", "12345")
}));
var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId);
Assert.True(result);
Assert.Equal("Telegram", platform);
Assert.Equal("12345", externalUserId);
}
[Fact]
public void TryGetPlatformIdentity_TelegramUserWithPlatformClaim_ReturnsTelegramPlatform()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "12345"),
new Claim("Platform", "Telegram")
}));
var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId);
Assert.True(result);
Assert.Equal("Telegram", platform);
Assert.Equal("12345", externalUserId);
}
[Fact]
public void TryGetPlatformIdentity_DiscordUser_ReturnsDiscordPlatform()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, "98765"),
new Claim("DiscordId", "98765"),
new Claim("Platform", "Discord")
}));
var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId);
Assert.True(result);
Assert.Equal("Discord", platform);
Assert.Equal("98765", externalUserId);
}
[Fact]
public void TryGetPlatformIdentity_UnknownUser_ReturnsFalse()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, "Anonymous")
}));
var result = user.TryGetPlatformIdentity(out _, out _);
Assert.False(result);
}
[Fact]
public void TryGetDiscordId_DiscordUser_ReturnsTrue()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("DiscordId", "123")
}));
var result = user.TryGetDiscordId(out var discordId);
Assert.True(result);
Assert.Equal("123", discordId);
}
[Fact]
public void TryGetDiscordId_TelegramUser_ReturnsFalse()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("TelegramId", "123")
}));
var result = user.TryGetDiscordId(out _);
Assert.False(result);
}
[Fact]
public void GetAvatarUrl_DiscordUserWithAvatar_ReturnsUrl()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim("AvatarUrl", "https://cdn.discordapp.com/avatars/1/abc.png")
}));
var result = user.GetAvatarUrl();
Assert.Equal("https://cdn.discordapp.com/avatars/1/abc.png", result);
}
[Fact]
public void GetAvatarUrl_UserWithoutAvatar_ReturnsNull()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(Array.Empty<Claim>()));
var result = user.GetAvatarUrl();
Assert.Null(result);
}
}