Skip to content

Commit b5ac2a0

Browse files
feat: Add Dashboard functionality and integrate Forum Notification Widget
- Implemented Dashboard access policies and authorization handlers. - Created DashboardController to manage dashboard data retrieval and rendering. - Added DashboardViewModel to aggregate data from multiple sources for the dashboard. - Developed Index view for the dashboard with summary cards, server health, and moderation trends. - Introduced a Forum Notification Widget for Invision Community integration, including HMAC token generation and API interaction. - Enhanced navigation to include access to the Dashboard. - Updated map rotation views to improve user experience and error handling. - Added CSS and JavaScript for the notification widget to ensure proper styling and functionality.
1 parent 8b625f6 commit b5ac2a0

13 files changed

Lines changed: 974 additions & 2 deletions

File tree

docs/forum-notification-widget.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Forum Notification Widget — Invision Community Integration
2+
3+
## Overview
4+
5+
This document describes how to embed the XtremeIdiots Portal notification widget on the xtremeidiots.com Invision Community forum. The widget shows personalised notifications for logged-in forum users and a public activity feed for guests.
6+
7+
## Prerequisites
8+
9+
1. The HMAC shared secret must be configured in both:
10+
- **Portal**: via Azure App Configuration key `XtremeIdiots:ExternalWidget:HmacSecret` (auto-provisioned by portal-environments Terraform)
11+
- **Forum**: as a custom Invision Community setting (see step 2 below)
12+
13+
2. The portal must be deployed with the external notifications API (`/api/external/notifications`)
14+
15+
## Step 1: Add the HMAC Secret to Invision Community
16+
17+
1. Go to **ACP → System → Settings** (or use a custom plugin settings page)
18+
2. Add a custom setting:
19+
- **Key**: `portal_hmac_secret`
20+
- **Value**: Copy the HMAC secret from Azure Key Vault (`external-widget-hmac-secret` in the shared Key Vault)
21+
22+
> **Important**: The secret value must match exactly between the portal and forum. After the initial Terraform apply, retrieve the value from Azure Key Vault and paste it into the forum setting.
23+
24+
## Step 2: Add the Widget to the Forum Template
25+
26+
Edit the Invision Community theme template where you want the widget to appear (e.g., sidebar, footer, or a custom page block).
27+
28+
### Option A: Global Template (sidebar on all pages)
29+
30+
Edit the **globalTemplate** or **sidebar** template in your theme:
31+
32+
```html
33+
{{if member.member_id}}
34+
<?php
35+
$secret = \IPS\Settings::i()->portal_hmac_secret;
36+
$memberId = \IPS\Member::loggedIn()->member_id;
37+
$timestamp = time();
38+
$hmac = hash_hmac('sha256', "{$memberId}:{$timestamp}", $secret);
39+
$token = base64_encode("{$memberId}:{$timestamp}:{$hmac}");
40+
?>
41+
<div id="portal-notifications-widget"
42+
data-token="<?php echo $token; ?>"
43+
data-portal-url="https://portal.xtremeidiots.com">
44+
</div>
45+
{{else}}
46+
<div id="portal-notifications-widget"
47+
data-token=""
48+
data-portal-url="https://portal.xtremeidiots.com">
49+
</div>
50+
{{endif}}
51+
<script src="https://portal.xtremeidiots.com/js/forum-widget.js" defer></script>
52+
```
53+
54+
### Option B: Using an Invision Community Plugin Hook
55+
56+
If you prefer not to mix PHP into templates, create a simple plugin:
57+
58+
**File: `hooks/portalWidget.php`**
59+
```php
60+
class portalWidget
61+
{
62+
public function globalTemplate($html)
63+
{
64+
$member = \IPS\Member::loggedIn();
65+
$token = '';
66+
67+
if ($member->member_id) {
68+
$secret = \IPS\Settings::i()->portal_hmac_secret;
69+
$memberId = $member->member_id;
70+
$timestamp = time();
71+
$hmac = hash_hmac('sha256', "{$memberId}:{$timestamp}", $secret);
72+
$token = base64_encode("{$memberId}:{$timestamp}:{$hmac}");
73+
}
74+
75+
$widget = <<<HTML
76+
<div id="portal-notifications-widget"
77+
data-token="{$token}"
78+
data-portal-url="https://portal.xtremeidiots.com">
79+
</div>
80+
<script src="https://portal.xtremeidiots.com/js/forum-widget.js" defer></script>
81+
HTML;
82+
83+
// Insert before </body>
84+
return str_replace('</body>', $widget . '</body>', $html);
85+
}
86+
}
87+
```
88+
89+
### Option C: Custom HTML Block (simplest)
90+
91+
If you just want a quick test, use the **Pages** app or a **Custom Block**:
92+
93+
1. Go to **ACP → Pages → Blocks → Create New Block**
94+
2. Choose **Custom HTML**
95+
3. Paste:
96+
```html
97+
<?php
98+
$token = '';
99+
$member = \IPS\Member::loggedIn();
100+
if ($member->member_id) {
101+
$secret = \IPS\Settings::i()->portal_hmac_secret;
102+
$memberId = $member->member_id;
103+
$timestamp = time();
104+
$hmac = hash_hmac('sha256', "{$memberId}:{$timestamp}", $secret);
105+
$token = base64_encode("{$memberId}:{$timestamp}:{$hmac}");
106+
}
107+
?>
108+
<div id="portal-notifications-widget"
109+
data-token="<?php echo $token; ?>"
110+
data-portal-url="https://portal.xtremeidiots.com">
111+
</div>
112+
<script src="https://portal.xtremeidiots.com/js/forum-widget.js" defer></script>
113+
```
114+
4. Place the block in your desired sidebar/widget area
115+
116+
## How It Works
117+
118+
1. **Token generation** (forum-side): When a logged-in forum user loads a page, the PHP template generates an HMAC-SHA256 signed token containing their forum member ID and a Unix timestamp, signed with the shared secret.
119+
120+
2. **Token format**: `Base64({forumMemberId}:{timestampUnix}:{hmacHex})`
121+
122+
3. **Widget load**: The JavaScript widget (`forum-widget.js`) reads the token from the `data-token` attribute and calls the portal API.
123+
124+
4. **API response**:
125+
- **No token / invalid token**: Returns a public feed (recent admin actions)
126+
- **Valid token**: Portal maps the forum member ID to a UserProfile, returns personalised notifications scoped to the user's permissions
127+
128+
5. **Polling**: For authenticated users, the widget polls every 60 seconds for new notifications.
129+
130+
6. **Security**: Tokens expire after 5 minutes, preventing replay attacks. The HMAC signature prevents token forgery.
131+
132+
## Widget Features
133+
134+
- **Unread count badge** with notification bell
135+
- **Notification list** with title, message, and relative timestamps
136+
- **Mark as read** on click (individual) and "Read all" button
137+
- **Unclaimed actions banner** for admins with pending actions
138+
- **"View all in Portal"** link to the full notifications page
139+
- **Graceful degradation** — shows public feed if not authenticated
140+
- **Self-contained** — no jQuery or other dependencies required
141+
- **Responsive** — max-width 400px, fits sidebar widgets
142+
143+
## Styling
144+
145+
The widget injects its own CSS with `xi-pw-` prefixed class names to avoid conflicts with the forum theme. The default styling uses a neutral colour scheme that should work with most themes.
146+
147+
To customise, override the styles in your forum theme CSS:
148+
```css
149+
/* Example: match dark theme */
150+
.xi-portal-widget { background: #1a1a2e; color: #eee; border-color: #333; }
151+
.xi-pw-header { background: #16213e; }
152+
.xi-pw-item { color: #ddd; border-bottom-color: #333; }
153+
.xi-pw-item:hover { background: #1a1a2e; color: #fff; }
154+
```
155+
156+
## Troubleshooting
157+
158+
| Issue | Cause | Fix |
159+
|-------|-------|-----|
160+
| Widget shows "Portal URL not configured" | Missing `data-portal-url` attribute | Ensure the attribute is set in the template |
161+
| Widget shows "Unable to load notifications" | CORS or network error | Check browser console; verify portal CORS allows the forum domain |
162+
| All users see public feed only | HMAC secret mismatch | Ensure the forum `portal_hmac_secret` matches the Azure Key Vault value |
163+
| Token always expired | Server clock drift | Ensure both servers have NTP synced clocks (±1 minute tolerance) |

src/XtremeIdiots.Portal.Web/Auth/Constants/AuthPolicies.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ public static class AuthPolicies
4747
// Home policies
4848
public const string AccessHome = nameof(AccessHome);
4949

50+
// Dashboard policies
51+
public const string AccessDashboard = nameof(AccessDashboard);
52+
5053
// Profile policies
5154
public const string AccessProfile = nameof(AccessProfile);
5255

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using XtremeIdiots.Portal.Web.Auth.Requirements;
3+
4+
namespace XtremeIdiots.Portal.Web.Auth.Handlers;
5+
6+
/// <summary>
7+
/// Handles authorization requirements for dashboard access.
8+
/// The dashboard is read-only overview data, accessible to any admin role.
9+
/// </summary>
10+
public class DashboardAuthHandler : IAuthorizationHandler
11+
{
12+
public Task HandleAsync(AuthorizationHandlerContext context)
13+
{
14+
foreach (var requirement in context.PendingRequirements)
15+
{
16+
if (requirement is AccessDashboard)
17+
{
18+
BaseAuthorizationHelper.CheckClaimTypes(context, requirement,
19+
BaseAuthorizationHelper.ClaimGroups.ServerAdminAccessLevels);
20+
}
21+
}
22+
23+
return Task.CompletedTask;
24+
}
25+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
3+
namespace XtremeIdiots.Portal.Web.Auth.Requirements;
4+
5+
/// <summary>
6+
/// Authorization requirement for accessing the admin dashboard
7+
/// </summary>
8+
public class AccessDashboard : IAuthorizationRequirement
9+
{
10+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using Microsoft.ApplicationInsights;
2+
using Microsoft.AspNetCore.Authorization;
3+
using Microsoft.AspNetCore.Mvc;
4+
5+
using XtremeIdiots.Portal.Repository.Api.Client.V1;
6+
using XtremeIdiots.Portal.Web.Auth.Constants;
7+
using XtremeIdiots.Portal.Web.Extensions;
8+
using XtremeIdiots.Portal.Web.Services;
9+
using XtremeIdiots.Portal.Web.ViewModels;
10+
11+
namespace XtremeIdiots.Portal.Web.Controllers;
12+
13+
/// <summary>
14+
/// Controller for the admin dashboard, providing an at-a-glance operational overview.
15+
/// </summary>
16+
[Authorize(Policy = AuthPolicies.AccessDashboard)]
17+
public class DashboardController(
18+
IRepositoryApiClient repositoryApiClient,
19+
IAgentTelemetryService agentTelemetryService,
20+
TelemetryClient telemetryClient,
21+
ILogger<DashboardController> logger,
22+
IConfiguration configuration) : BaseController(telemetryClient, logger, configuration)
23+
{
24+
/// <summary>
25+
/// Displays the admin dashboard with summary cards, server health, moderation trends, and admin leaderboard.
26+
/// </summary>
27+
[HttpGet]
28+
public async Task<IActionResult> Index(CancellationToken cancellationToken = default)
29+
{
30+
return await ExecuteWithErrorHandlingAsync(async () =>
31+
{
32+
var viewModel = new DashboardViewModel();
33+
34+
// Fetch all data sources in parallel
35+
var summaryTask = repositoryApiClient.Dashboard.V1.GetDashboardSummary(cancellationToken);
36+
var leaderboardTask = repositoryApiClient.Dashboard.V1.GetAdminLeaderboard(30, cancellationToken);
37+
var trendTask = repositoryApiClient.Dashboard.V1.GetModerationTrend(30, cancellationToken);
38+
var utilizationTask = repositoryApiClient.Dashboard.V1.GetServerUtilization(cancellationToken);
39+
40+
await Task.WhenAll(summaryTask, leaderboardTask, trendTask, utilizationTask).ConfigureAwait(false);
41+
42+
// Summary
43+
var summaryResponse = await summaryTask.ConfigureAwait(false);
44+
if (summaryResponse.IsSuccess && summaryResponse.Result?.Data is not null)
45+
{
46+
viewModel.Summary = summaryResponse.Result.Data;
47+
}
48+
49+
// Admin leaderboard
50+
var leaderboardResponse = await leaderboardTask.ConfigureAwait(false);
51+
if (leaderboardResponse.IsSuccess && leaderboardResponse.Result?.Data?.Items is not null)
52+
{
53+
viewModel.AdminLeaderboard = [.. leaderboardResponse.Result.Data.Items];
54+
}
55+
56+
// Moderation trend
57+
var trendResponse = await trendTask.ConfigureAwait(false);
58+
if (trendResponse.IsSuccess && trendResponse.Result?.Data?.Items is not null)
59+
{
60+
viewModel.ModerationTrend = [.. trendResponse.Result.Data.Items];
61+
}
62+
63+
// Server utilization
64+
var utilizationResponse = await utilizationTask.ConfigureAwait(false);
65+
if (utilizationResponse.IsSuccess && utilizationResponse.Result?.Data is not null)
66+
{
67+
viewModel.ServerUtilization = utilizationResponse.Result.Data;
68+
}
69+
70+
// Agent telemetry (non-critical — dashboard renders without it)
71+
try
72+
{
73+
viewModel.AgentStatuses = await agentTelemetryService.GetAllServersStatusAsync(cancellationToken).ConfigureAwait(false);
74+
}
75+
catch (Exception ex)
76+
{
77+
Logger.LogWarning(ex, "Failed to retrieve agent telemetry for dashboard");
78+
viewModel.AgentTelemetryUnavailable = true;
79+
}
80+
81+
TrackSuccessTelemetry("DashboardLoaded", nameof(Index));
82+
83+
Logger.LogInformation("User {UserId} loaded admin dashboard", User.XtremeIdiotsId());
84+
85+
return View(viewModel);
86+
}, nameof(Index)).ConfigureAwait(false);
87+
}
88+
}

src/XtremeIdiots.Portal.Web/Extensions/PolicyExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ public static void AddXtremeIdiotsPolicies(this AuthorizationOptions options)
4444

4545
options.AddPolicy(AuthPolicies.AccessHome, policy => policy.Requirements.Add(new AccessHome()));
4646

47+
options.AddPolicy(AuthPolicies.AccessDashboard, policy => policy.Requirements.Add(new AccessDashboard()));
48+
4749
options.AddPolicy(AuthPolicies.AccessProfile, policy => policy.Requirements.Add(new AccessProfile()));
4850

4951
options.AddPolicy(AuthPolicies.AccessMaps, policy => policy.Requirements.Add(new AccessMaps()));

src/XtremeIdiots.Portal.Web/Extensions/ServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public static void AddXtremeIdiotsAuth(this IServiceCollection services)
1616
services.AddSingleton<IAuthorizationHandler, CredentialsAuthHandler>();
1717
services.AddSingleton<IAuthorizationHandler, DemosAuthHandler>();
1818
services.AddSingleton<IAuthorizationHandler, GameServersAuthHandler>();
19+
services.AddSingleton<IAuthorizationHandler, DashboardAuthHandler>();
1920
services.AddSingleton<IAuthorizationHandler, HomeAuthHandler>();
2021
services.AddSingleton<IAuthorizationHandler, ProfileAuthHandler>();
2122
services.AddSingleton<IAuthorizationHandler, MapsAuthHandler>();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Dashboard;
2+
using XtremeIdiots.Portal.Web.Services;
3+
4+
namespace XtremeIdiots.Portal.Web.ViewModels;
5+
6+
/// <summary>
7+
/// View model for the admin dashboard, aggregating data from multiple API sources.
8+
/// </summary>
9+
public class DashboardViewModel
10+
{
11+
/// <summary>
12+
/// Aggregated summary counts from the repository API (servers, players, bans, reports, actions).
13+
/// </summary>
14+
public DashboardSummaryDto? Summary { get; set; }
15+
16+
/// <summary>
17+
/// Admin activity leaderboard entries.
18+
/// </summary>
19+
public List<AdminLeaderboardEntryDto> AdminLeaderboard { get; set; } = [];
20+
21+
/// <summary>
22+
/// Daily moderation action counts for trend visualisation.
23+
/// </summary>
24+
public List<ModerationTrendDataPointDto> ModerationTrend { get; set; } = [];
25+
26+
/// <summary>
27+
/// Per-server utilization data (avg/peak players).
28+
/// </summary>
29+
public ServerUtilizationCollectionDto? ServerUtilization { get; set; }
30+
31+
/// <summary>
32+
/// Agent telemetry summaries for all servers (from Application Insights, not the repository API).
33+
/// </summary>
34+
public IReadOnlyList<AgentServerSummary> AgentStatuses { get; set; } = [];
35+
36+
/// <summary>
37+
/// True if agent telemetry data failed to load.
38+
/// </summary>
39+
public bool AgentTelemetryUnavailable { get; set; }
40+
}

0 commit comments

Comments
 (0)