diff --git a/DorkNet.AdminMobile/App.cs b/DorkNet.AdminMobile/App.cs new file mode 100644 index 0000000..23b92bc --- /dev/null +++ b/DorkNet.AdminMobile/App.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Controls; +using DorkNet.AdminMobile.Design; +using DorkNet.AdminMobile.Pages; + +namespace DorkNet.AdminMobile; + +public class App : Application +{ + public App(LoginPage loginPage) + { + UserAppTheme = AppTheme.Dark; + MainPage = new NavigationPage(loginPage) + { + BarBackgroundColor = AppDesign.Canvas, + BarTextColor = AppDesign.Text, + }; + } + + public static void ShowAdmin() + { + Current!.MainPage = new NavigationPage( + AppServices.Services.GetRequiredService()) + { + BarBackgroundColor = AppDesign.Canvas, + BarTextColor = AppDesign.Text, + }; + } + + public static void ShowLogin() + { + Current!.MainPage = new NavigationPage( + AppServices.Services.GetRequiredService()) + { + BarBackgroundColor = AppDesign.Canvas, + BarTextColor = AppDesign.Text, + }; + } +} diff --git a/DorkNet.AdminMobile/Design/AppDesign.cs b/DorkNet.AdminMobile/Design/AppDesign.cs new file mode 100644 index 0000000..21ae895 --- /dev/null +++ b/DorkNet.AdminMobile/Design/AppDesign.cs @@ -0,0 +1,130 @@ +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Shapes; + +namespace DorkNet.AdminMobile.Design; + +public static class AppDesign +{ + public static readonly Color Canvas = Color.FromArgb("#0b0f14"); + public static readonly Color Surface = Color.FromArgb("#1d222c"); + public static readonly Color SurfaceGlass = Color.FromArgb("#bb222833"); + public static readonly Color SurfaceLifted = Color.FromArgb("#293141"); + public static readonly Color Text = Color.FromArgb("#f6f8fb"); + public static readonly Color Muted = Color.FromArgb("#aab4c4"); + public static readonly Color Subtle = Color.FromArgb("#748094"); + public static readonly Color Accent = Color.FromArgb("#78c7ff"); + public static readonly Color AndroidAccent = Color.FromArgb("#9ee37d"); + public static readonly Color Danger = Color.FromArgb("#f26d7d"); + + public static Brush PageBackground => new LinearGradientBrush + { + StartPoint = new Point(0, 0), + EndPoint = new Point(1, 1), + GradientStops = + { + new GradientStop(Color.FromArgb("#0b0f14"), 0), + new GradientStop(Color.FromArgb("#121927"), 0.52f), + new GradientStop(Color.FromArgb("#10151c"), 1), + }, + }; + + public static Color PlatformAccent => +#if ANDROID + AndroidAccent; +#else + Accent; +#endif + + public static Label Title(string text) => new() + { + Text = text, + TextColor = Text, + FontSize = 28, + FontAttributes = FontAttributes.Bold, + LineBreakMode = LineBreakMode.TailTruncation, + }; + + public static Label Section(string text) => new() + { + Text = text.ToUpperInvariant(), + TextColor = Subtle, + FontSize = 12, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(0, 12, 0, 0), + }; + + public static Border GlassCard(View content, Thickness? padding = null) => new() + { + Padding = padding ?? new Thickness(14), + Stroke = Color.FromArgb("#33ffffff"), + StrokeThickness = 1, + Background = new LinearGradientBrush + { + StartPoint = new Point(0, 0), + EndPoint = new Point(1, 1), + GradientStops = + { + new GradientStop(Color.FromArgb("#cc27303d"), 0), + new GradientStop(Color.FromArgb("#99202733"), 1), + }, + }, + StrokeShape = new RoundRectangle { CornerRadius = 24 }, + Shadow = new Shadow + { + Brush = Brush.Black, + Offset = new Point(0, 12), + Radius = 22, + Opacity = 0.25f, + }, + Content = content, + }; + + public static Button PrimaryButton(string text) => new() + { + Text = text, + BackgroundColor = PlatformAccent, + TextColor = Colors.Black, + CornerRadius = 22, + HeightRequest = 48, + FontAttributes = FontAttributes.Bold, + Padding = new Thickness(18, 0), + }; + + public static Button SecondaryButton(string text) => new() + { + Text = text, + BackgroundColor = SurfaceLifted, + TextColor = Text, + CornerRadius = 22, + HeightRequest = 46, + Padding = new Thickness(16, 0), + }; + + public static Button DangerButton(string text) => new() + { + Text = text, + BackgroundColor = Color.FromArgb("#4c1d28"), + TextColor = Color.FromArgb("#ffd5dc"), + CornerRadius = 22, + HeightRequest = 44, + Padding = new Thickness(14, 0), + }; + + public static Entry Entry(string placeholder, Keyboard? keyboard = null, bool secret = false) => new() + { + Placeholder = placeholder, + IsPassword = secret, + TextColor = Text, + PlaceholderColor = Subtle, + BackgroundColor = Color.FromArgb("#19202b"), + ClearButtonVisibility = ClearButtonVisibility.WhileEditing, + Keyboard = keyboard ?? Keyboard.Text, + HeightRequest = 48, + }; + + public static Label StatusLabel() => new() + { + TextColor = Muted, + FontSize = 13, + }; +} diff --git a/DorkNet.AdminMobile/DorkNet.AdminMobile.csproj b/DorkNet.AdminMobile/DorkNet.AdminMobile.csproj new file mode 100644 index 0000000..8839015 --- /dev/null +++ b/DorkNet.AdminMobile/DorkNet.AdminMobile.csproj @@ -0,0 +1,18 @@ + + + net9.0-android36.0;net9.0-ios26.0 + Exe + DorkNet.AdminMobile + true + true + enable + enable + DorkNet Admin + net.dorknet.adminmobile + 1.0 + 1 + 36 + 23.0 + 14.2 + + diff --git a/DorkNet.AdminMobile/MauiProgram.cs b/DorkNet.AdminMobile/MauiProgram.cs new file mode 100644 index 0000000..f2d9711 --- /dev/null +++ b/DorkNet.AdminMobile/MauiProgram.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Controls.Hosting; +using Microsoft.Maui.Hosting; +using DorkNet.AdminMobile.Pages; +using DorkNet.AdminMobile.Services; + +namespace DorkNet.AdminMobile; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder.UseMauiApp(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + + var app = builder.Build(); + AppServices.Services = app.Services; + return app; + } +} + +public static class AppServices +{ + public static IServiceProvider Services { get; set; } = default!; +} diff --git a/DorkNet.AdminMobile/Models/AdminModels.cs b/DorkNet.AdminMobile/Models/AdminModels.cs new file mode 100644 index 0000000..9652daf --- /dev/null +++ b/DorkNet.AdminMobile/Models/AdminModels.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Serialization; + +namespace DorkNet.AdminMobile.Models; + +public sealed class AdminLoginResponse +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } = string.Empty; + + [JsonPropertyName("account_id")] + public long AccountId { get; set; } + + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("display_name")] + public string DisplayName { get; set; } = string.Empty; +} + +public sealed class PlayerSummary +{ + public long Id { get; set; } + public string Username { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string? Email { get; set; } + public bool IsAdmin { get; set; } + public bool IsDeveloper { get; set; } + public bool IsVerified { get; set; } + public bool IsJunior { get; set; } + public bool Online { get; set; } + public int Level { get; set; } + public int XP { get; set; } + public DateTime? LastSeenAt { get; set; } + public DateTime? BannedUntil { get; set; } +} + +public sealed class RoomSummary +{ + public long Id { get; set; } + public string Name { get; set; } = string.Empty; + public long CreatorPlayerId { get; set; } + public int BlobCount { get; set; } + public bool IsAGRoom { get; set; } + public bool IsDormRoom { get; set; } +} + +public sealed class InstanceSummary +{ + public long RoomInstanceId { get; set; } + public long RoomId { get; set; } + public string RoomName { get; set; } = string.Empty; + public string PhotonRoomId { get; set; } = string.Empty; + public string PhotonRegionId { get; set; } = string.Empty; + public int MaxCapacity { get; set; } + public bool IsPrivate { get; set; } + public List Participants { get; set; } = []; +} + +public sealed class InstanceParticipant +{ + public long Id { get; set; } + public string Username { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public bool IsMaster { get; set; } +} + +public sealed class ServerSettingsDto +{ + public bool SignupsDisabled { get; set; } + public bool GlobalFriendsEnabled { get; set; } + public bool WeeklyChallengesCompletedRequired { get; set; } + public DateTime UpdatedAt { get; set; } +} + +public sealed class AdminStats +{ + public PlayerStats Players { get; set; } = new(); + public RoomStats Rooms { get; set; } = new(); + public int Inventions { get; set; } + public PhotoStats Photos { get; set; } = new(); + public ModerationStats Moderation { get; set; } = new(); + public DateTime ServerTime { get; set; } +} + +public sealed class PlayerStats +{ + public int Total { get; set; } + public int OnlineNow { get; set; } + public int BannedNow { get; set; } + public int NewToday { get; set; } +} + +public sealed class RoomStats +{ + public int Total { get; set; } + public long TotalVisits { get; set; } + public long TotalCheers { get; set; } + public int ActiveSessionCount { get; set; } + public int InGamePlayerCount { get; set; } +} + +public sealed class PhotoStats +{ + public int Today { get; set; } +} + +public sealed class ModerationStats +{ + public int OpenReports { get; set; } + public int ActiveIpBans { get; set; } +} diff --git a/DorkNet.AdminMobile/Pages/AdminTabbedPage.cs b/DorkNet.AdminMobile/Pages/AdminTabbedPage.cs new file mode 100644 index 0000000..f8e2045 --- /dev/null +++ b/DorkNet.AdminMobile/Pages/AdminTabbedPage.cs @@ -0,0 +1,23 @@ +using Microsoft.Maui.Controls; +using DorkNet.AdminMobile.Design; + +namespace DorkNet.AdminMobile.Pages; + +public sealed class AdminTabbedPage : TabbedPage +{ + public AdminTabbedPage( + DashboardPage dashboard, + PlayersPage players, + RoomsPage rooms, + SettingsPage settings) + { + BarBackgroundColor = AppDesign.Canvas; + BarTextColor = AppDesign.Text; + SelectedTabColor = AppDesign.PlatformAccent; + UnselectedTabColor = AppDesign.Subtle; + Children.Add(dashboard); + Children.Add(players); + Children.Add(rooms); + Children.Add(settings); + } +} diff --git a/DorkNet.AdminMobile/Pages/DashboardPage.cs b/DorkNet.AdminMobile/Pages/DashboardPage.cs new file mode 100644 index 0000000..c214747 --- /dev/null +++ b/DorkNet.AdminMobile/Pages/DashboardPage.cs @@ -0,0 +1,91 @@ +using Microsoft.Maui.Controls; +using DorkNet.AdminMobile.Design; +using DorkNet.AdminMobile.Models; +using DorkNet.AdminMobile.Services; + +namespace DorkNet.AdminMobile.Pages; + +public sealed class DashboardPage : ContentPage +{ + private readonly AdminApiClient api; + private readonly Label status = AppDesign.StatusLabel(); + private readonly Grid metrics = new() { ColumnSpacing = 10, RowSpacing = 10 }; + + public DashboardPage(AdminApiClient api) + { + this.api = api; + Title = "Dashboard"; + Background = AppDesign.PageBackground; + + var refresh = AppDesign.SecondaryButton("Refresh"); + refresh.Clicked += async (_, _) => await LoadAsync(); + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 16, + Spacing = 14, + Children = + { + AppDesign.Title("Operations"), + refresh, + metrics, + status, + }, + }, + }; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + if (metrics.Children.Count == 0) await LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + status.Text = "Loading..."; + var s = await api.GetStatsAsync(); + DrawMetrics(s); + status.Text = $"Server time {s.ServerTime:g}"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private void DrawMetrics(AdminStats s) + { + metrics.Children.Clear(); + metrics.RowDefinitions.Clear(); + metrics.ColumnDefinitions.Clear(); + metrics.ColumnDefinitions.Add(new ColumnDefinition()); + metrics.ColumnDefinitions.Add(new ColumnDefinition()); + AddMetric(0, 0, "Players", $"{s.Players.Total}", $"{s.Players.OnlineNow} online"); + AddMetric(0, 1, "Rooms", $"{s.Rooms.Total}", $"{s.Rooms.ActiveSessionCount} active sessions"); + AddMetric(1, 0, "Moderation", $"{s.Moderation.OpenReports}", "open reports"); + AddMetric(1, 1, "Traffic", $"{s.Rooms.InGamePlayerCount}", $"{s.Rooms.TotalVisits:n0} visits"); + AddMetric(2, 0, "New today", $"{s.Players.NewToday}", "players"); + AddMetric(2, 1, "Photos", $"{s.Photos.Today}", "last 24h"); + } + + private void AddMetric(int row, int col, string label, string value, string caption) + { + while (metrics.RowDefinitions.Count <= row) metrics.RowDefinitions.Add(new RowDefinition()); + var tile = AppDesign.GlassCard(new VerticalStackLayout + { + Spacing = 4, + Children = + { + new Label { Text = label, TextColor = AppDesign.Subtle, FontSize = 12 }, + new Label { Text = value, TextColor = AppDesign.Text, FontSize = 28, FontAttributes = FontAttributes.Bold }, + new Label { Text = caption, TextColor = AppDesign.Muted, FontSize = 12 }, + }, + }, new Thickness(14)); + metrics.Add(tile, col, row); + } +} diff --git a/DorkNet.AdminMobile/Pages/LoginPage.cs b/DorkNet.AdminMobile/Pages/LoginPage.cs new file mode 100644 index 0000000..7c2a1ab --- /dev/null +++ b/DorkNet.AdminMobile/Pages/LoginPage.cs @@ -0,0 +1,122 @@ +using Microsoft.Maui.Controls; +using DorkNet.AdminMobile.Design; +using DorkNet.AdminMobile.Services; + +namespace DorkNet.AdminMobile.Pages; + +public sealed class LoginPage : ContentPage +{ + private readonly AdminApiClient api; + private readonly SecureAdminSettings settings; + private readonly Entry baseUrl = AppDesign.Entry("https://admin.example.com", Keyboard.Url); + private readonly Entry cfClientId = AppDesign.Entry("Cloudflare Access Client ID"); + private readonly Entry cfClientSecret = AppDesign.Entry("Cloudflare Access Client Secret", secret: true); + private readonly Entry cfJwt = AppDesign.Entry("Cloudflare Access JWT assertion", secret: true); + private readonly Entry username = AppDesign.Entry("Admin username"); + private readonly Entry password = AppDesign.Entry("Password", secret: true); + private readonly Label status = AppDesign.StatusLabel(); + private readonly ActivityIndicator busy = new() { IsVisible = false }; + private bool loaded; + + public LoginPage(AdminApiClient api, SecureAdminSettings settings) + { + this.api = api; + this.settings = settings; + Title = "DorkNet Admin"; + Background = AppDesign.PageBackground; + + var login = AppDesign.PrimaryButton("Sign in"); + login.Clicked += OnLogin; + + var continueSaved = AppDesign.SecondaryButton("Continue saved session"); + continueSaved.Clicked += async (_, _) => + { + if (await settings.GetJwtAsync() is { Length: > 0 }) App.ShowAdmin(); + else status.Text = "No saved DorkNet admin session is stored."; + }; + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = new Thickness(20, 28), + Spacing = 14, + Children = + { + AppDesign.Title("Admin mobile"), + new Label + { + Text = "Connect to a DorkNet admin host. Cloudflare Access service tokens are sent with every request when configured.", + TextColor = AppDesign.Muted, + FontSize = 14, + }, + AppDesign.GlassCard(new VerticalStackLayout + { + Spacing = 10, + Children = + { + AppDesign.Section("Connection"), + baseUrl, + cfClientId, + cfClientSecret, + cfJwt, + }, + }), + AppDesign.GlassCard(new VerticalStackLayout + { + Spacing = 10, + Children = + { + AppDesign.Section("DorkNet admin login"), + username, + password, + login, + continueSaved, + }, + }), + busy, + status, + }, + }, + }; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + if (loaded) return; + loaded = true; + var saved = await settings.LoadConnectionAsync(); + baseUrl.Text = saved.BaseUrl; + cfClientId.Text = saved.CloudflareAccessClientId; + cfClientSecret.Text = saved.CloudflareAccessClientSecret; + cfJwt.Text = saved.CloudflareAccessJwt; + } + + private async void OnLogin(object? sender, EventArgs e) + { + busy.IsVisible = true; + busy.IsRunning = true; + status.Text = string.Empty; + try + { + await settings.SaveConnectionAsync(new AdminConnectionSettings( + baseUrl.Text ?? string.Empty, + cfClientId.Text ?? string.Empty, + cfClientSecret.Text ?? string.Empty, + cfJwt.Text ?? string.Empty)); + await api.LoginAsync(username.Text ?? string.Empty, password.Text ?? string.Empty); + App.ShowAdmin(); + } + catch (Exception ex) + { + status.Text = ex.Message; + } + finally + { + busy.IsRunning = false; + busy.IsVisible = false; + } + } + +} diff --git a/DorkNet.AdminMobile/Pages/PlayersPage.cs b/DorkNet.AdminMobile/Pages/PlayersPage.cs new file mode 100644 index 0000000..343094f --- /dev/null +++ b/DorkNet.AdminMobile/Pages/PlayersPage.cs @@ -0,0 +1,117 @@ +using Microsoft.Maui.Controls; +using DorkNet.AdminMobile.Design; +using DorkNet.AdminMobile.Models; +using DorkNet.AdminMobile.Services; + +namespace DorkNet.AdminMobile.Pages; + +public sealed class PlayersPage : ContentPage +{ + private readonly AdminApiClient api; + private readonly Entry search = AppDesign.Entry("Search players"); + private readonly CollectionView list = new(); + private readonly Label status = AppDesign.StatusLabel(); + private List players = []; + + public PlayersPage(AdminApiClient api) + { + this.api = api; + Title = "Players"; + Background = AppDesign.PageBackground; + + var refresh = AppDesign.SecondaryButton("Refresh"); + refresh.Clicked += async (_, _) => await LoadAsync(); + search.Completed += async (_, _) => await LoadAsync(); + + list.ItemTemplate = new DataTemplate(() => + { + var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 16 }; + name.SetBinding(Label.TextProperty, nameof(PlayerSummary.DisplayName)); + var handle = new Label { TextColor = AppDesign.Muted, FontSize = 13 }; + handle.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Username), stringFormat: "@{0}")); + var detail = new Label { TextColor = AppDesign.Subtle, FontSize = 12 }; + detail.SetBinding(Label.TextProperty, new Binding(nameof(PlayerSummary.Level), stringFormat: "Level {0}")); + var online = new Label { TextColor = AppDesign.AndroidAccent, FontSize = 12, FontAttributes = FontAttributes.Bold }; + online.SetBinding(Label.IsVisibleProperty, nameof(PlayerSummary.Online)); + online.Text = "ONLINE"; + + var kick = MiniButton("Kick"); + kick.Clicked += async (s, _) => + { + if ((s as Button)?.BindingContext is PlayerSummary p) + await ConfirmAction($"Kick @{p.Username}?", () => api.KickPlayerAsync(p.Id, "Kicked from mobile admin.")); + }; + var resetAvatar = MiniButton("Reset avatar"); + resetAvatar.Clicked += async (s, _) => + { + if ((s as Button)?.BindingContext is PlayerSummary p) + await ConfirmAction($"Reset @{p.Username}'s avatar?", () => api.ResetAvatarAsync(p.Id)); + }; + + var actions = new HorizontalStackLayout { Spacing = 8, Children = { kick, resetAvatar } }; + return AppDesign.GlassCard(new VerticalStackLayout + { + Spacing = 6, + Children = { name, handle, detail, online, actions }, + }, new Thickness(12, 10)); + }); + + Content = new VerticalStackLayout + { + Padding = 16, + Spacing = 12, + Children = + { + AppDesign.Title("Players"), + new HorizontalStackLayout { Spacing = 8, Children = { search, refresh } }, + list, + status, + }, + }; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + if (players.Count == 0) await LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + status.Text = "Loading..."; + players = await api.GetPlayersAsync(search.Text); + list.ItemsSource = players; + status.Text = $"{players.Count} players loaded"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } + + private async Task ConfirmAction(string message, Func action) + { + if (!await DisplayAlert("Confirm", message, "Run", "Cancel")) return; + try + { + await action(); + await LoadAsync(); + } + catch (Exception ex) + { + await DisplayAlert("Request failed", ex.Message, "OK"); + } + } + + private static Button MiniButton(string text) => new() + { + Text = text, + FontSize = 12, + Padding = new Thickness(10, 6), + CornerRadius = 6, + BackgroundColor = AppDesign.SurfaceLifted, + TextColor = AppDesign.Text, + }; +} diff --git a/DorkNet.AdminMobile/Pages/RoomsPage.cs b/DorkNet.AdminMobile/Pages/RoomsPage.cs new file mode 100644 index 0000000..26588e1 --- /dev/null +++ b/DorkNet.AdminMobile/Pages/RoomsPage.cs @@ -0,0 +1,97 @@ +using Microsoft.Maui.Controls; +using DorkNet.AdminMobile.Design; +using DorkNet.AdminMobile.Models; +using DorkNet.AdminMobile.Services; + +namespace DorkNet.AdminMobile.Pages; + +public sealed class RoomsPage : ContentPage +{ + private readonly AdminApiClient api; + private readonly CollectionView rooms = new(); + private readonly CollectionView instances = new(); + private readonly Label status = AppDesign.StatusLabel(); + private bool loaded; + + public RoomsPage(AdminApiClient api) + { + this.api = api; + Title = "Rooms"; + Background = AppDesign.PageBackground; + + rooms.ItemTemplate = new DataTemplate(() => + { + var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 }; + name.SetBinding(Label.TextProperty, nameof(RoomSummary.Name)); + var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 }; + meta.SetBinding(Label.TextProperty, new Binding(nameof(RoomSummary.Id), stringFormat: "Room #{0}")); + return AppDesign.GlassCard(new VerticalStackLayout + { + Children = { name, meta }, + }, new Thickness(12, 9)); + }); + + instances.ItemTemplate = new DataTemplate(() => + { + var name = new Label { TextColor = AppDesign.Text, FontAttributes = FontAttributes.Bold, FontSize = 15 }; + name.SetBinding(Label.TextProperty, nameof(InstanceSummary.RoomName)); + var meta = new Label { TextColor = AppDesign.Muted, FontSize = 12 }; + meta.SetBinding(Label.TextProperty, new Binding(nameof(InstanceSummary.PhotonRegionId), stringFormat: "Region {0}")); + var count = new Label { TextColor = AppDesign.Subtle, FontSize = 12 }; + count.SetBinding(Label.TextProperty, new Binding("Participants.Count", stringFormat: "{0} participants")); + return AppDesign.GlassCard(new VerticalStackLayout + { + Children = { name, meta, count }, + }, new Thickness(12, 9)); + }); + + var refresh = AppDesign.SecondaryButton("Refresh"); + refresh.Clicked += async (_, _) => await LoadAsync(); + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = 16, + Spacing = 14, + Children = + { + AppDesign.Title("Rooms"), + refresh, + new Label { Text = "Live instances", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, + instances, + new Label { Text = "Recent rooms", TextColor = AppDesign.Muted, FontAttributes = FontAttributes.Bold }, + rooms, + status, + }, + }, + }; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + if (!loaded) + { + loaded = true; + await LoadAsync(); + } + } + + private async Task LoadAsync() + { + try + { + status.Text = "Loading..."; + var instanceRows = await api.GetInstancesAsync(); + var roomRows = await api.GetRoomsAsync(); + instances.ItemsSource = instanceRows; + rooms.ItemsSource = roomRows; + status.Text = $"{instanceRows.Count} active instances, {roomRows.Count} rooms loaded"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + } +} diff --git a/DorkNet.AdminMobile/Pages/SettingsPage.cs b/DorkNet.AdminMobile/Pages/SettingsPage.cs new file mode 100644 index 0000000..025010d --- /dev/null +++ b/DorkNet.AdminMobile/Pages/SettingsPage.cs @@ -0,0 +1,119 @@ +using Microsoft.Maui.Controls; +using DorkNet.AdminMobile.Design; +using DorkNet.AdminMobile.Services; + +namespace DorkNet.AdminMobile.Pages; + +public sealed class SettingsPage : ContentPage +{ + private readonly AdminApiClient api; + private readonly SecureAdminSettings settings; + private readonly Switch signupsDisabled = new(); + private readonly Switch globalFriends = new(); + private readonly Label status = AppDesign.StatusLabel(); + private bool loading; + + public SettingsPage(AdminApiClient api, SecureAdminSettings settings) + { + this.api = api; + this.settings = settings; + Title = "Settings"; + Background = AppDesign.PageBackground; + + signupsDisabled.Toggled += async (_, e) => + { + if (!loading) await SaveToggleAsync(() => api.SetSignupsDisabledAsync(e.Value)); + }; + globalFriends.Toggled += async (_, e) => + { + if (!loading) await SaveToggleAsync(() => api.SetGlobalFriendsAsync(e.Value)); + }; + + var refresh = AppDesign.SecondaryButton("Refresh"); + refresh.Clicked += async (_, _) => await LoadAsync(); + var logout = AppDesign.DangerButton("Clear saved session"); + logout.Clicked += (_, _) => + { + settings.ClearSession(); + App.ShowLogin(); + }; + + Content = new VerticalStackLayout + { + Padding = 16, + Spacing = 14, + Children = + { + AppDesign.Title("Settings"), + refresh, + Row("Disable new account creation", signupsDisabled), + Row("Global friends mode", globalFriends), + logout, + status, + }, + }; + } + + protected override async void OnAppearing() + { + base.OnAppearing(); + await LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + loading = true; + status.Text = "Loading..."; + var s = await api.GetSettingsAsync(); + signupsDisabled.IsToggled = s.SignupsDisabled; + globalFriends.IsToggled = s.GlobalFriendsEnabled; + status.Text = $"Updated {s.UpdatedAt:g}"; + } + catch (Exception ex) + { + status.Text = ex.Message; + } + finally + { + loading = false; + } + } + + private async Task SaveToggleAsync(Func save) + { + try + { + status.Text = "Saving..."; + await save(); + status.Text = "Saved"; + } + catch (Exception ex) + { + status.Text = ex.Message; + await LoadAsync(); + } + } + + private static Grid Row(string label, View control) + { + var grid = new Grid + { + ColumnDefinitions = + { + new ColumnDefinition(), + new ColumnDefinition { Width = GridLength.Auto }, + }, + Padding = new Thickness(12, 10), + }; + grid.Add(new Label + { + Text = label, + TextColor = AppDesign.Text, + VerticalTextAlignment = TextAlignment.Center, + }, 0, 0); + grid.Add(control, 1, 0); + return AppDesign.GlassCard(grid, new Thickness(0)); + } +} diff --git a/DorkNet.AdminMobile/Platforms/Android/AndroidManifest.xml b/DorkNet.AdminMobile/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000..3e846aa --- /dev/null +++ b/DorkNet.AdminMobile/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/DorkNet.AdminMobile/Platforms/Android/MainActivity.cs b/DorkNet.AdminMobile/Platforms/Android/MainActivity.cs new file mode 100644 index 0000000..041fc42 --- /dev/null +++ b/DorkNet.AdminMobile/Platforms/Android/MainActivity.cs @@ -0,0 +1,18 @@ +using Android.App; +using Android.Content.PM; +using Microsoft.Maui; + +namespace DorkNet.AdminMobile; + +[Activity( + MainLauncher = true, + ConfigurationChanges = + ConfigChanges.ScreenSize | + ConfigChanges.Orientation | + ConfigChanges.UiMode | + ConfigChanges.ScreenLayout | + ConfigChanges.SmallestScreenSize | + ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity +{ +} diff --git a/DorkNet.AdminMobile/Platforms/Android/MainApplication.cs b/DorkNet.AdminMobile/Platforms/Android/MainApplication.cs new file mode 100644 index 0000000..f2652b0 --- /dev/null +++ b/DorkNet.AdminMobile/Platforms/Android/MainApplication.cs @@ -0,0 +1,13 @@ +using Android.App; +using Android.Runtime; +using Microsoft.Maui; +using Microsoft.Maui.Hosting; + +namespace DorkNet.AdminMobile; + +[Application] +public class MainApplication(nint handle, JniHandleOwnership ownership) + : MauiApplication(handle, ownership) +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/DorkNet.AdminMobile/Platforms/iOS/AppDelegate.cs b/DorkNet.AdminMobile/Platforms/iOS/AppDelegate.cs new file mode 100644 index 0000000..23fbdd6 --- /dev/null +++ b/DorkNet.AdminMobile/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,11 @@ +using Foundation; +using Microsoft.Maui; +using Microsoft.Maui.Hosting; + +namespace DorkNet.AdminMobile; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/DorkNet.AdminMobile/Platforms/iOS/Info.plist b/DorkNet.AdminMobile/Platforms/iOS/Info.plist new file mode 100644 index 0000000..acdb44f --- /dev/null +++ b/DorkNet.AdminMobile/Platforms/iOS/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleDisplayName + DorkNet Admin + CFBundleIdentifier + net.dorknet.adminmobile + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + LSRequiresIPhoneOS + + MinimumOSVersion + 14.2 + UIDeviceFamily + + 1 + 2 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/DorkNet.AdminMobile/Platforms/iOS/Program.cs b/DorkNet.AdminMobile/Platforms/iOS/Program.cs new file mode 100644 index 0000000..aba4d57 --- /dev/null +++ b/DorkNet.AdminMobile/Platforms/iOS/Program.cs @@ -0,0 +1,11 @@ +using UIKit; + +namespace DorkNet.AdminMobile; + +public static class Program +{ + public static void Main(string[] args) + { + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/DorkNet.AdminMobile/README.md b/DorkNet.AdminMobile/README.md new file mode 100644 index 0000000..240490a --- /dev/null +++ b/DorkNet.AdminMobile/README.md @@ -0,0 +1,35 @@ +# DorkNet Admin Mobile + +Native Android/iOS admin app for DorkNet, built with .NET MAUI. + +## Authentication + +The app stores sensitive values in platform secure storage: + +- DorkNet admin JWT from `POST /api/admin/v1/login` +- Cloudflare Access service-token client id +- Cloudflare Access service-token client secret +- Optional Cloudflare Access JWT assertion + +Every API request includes: + +```http +Authorization: Bearer +CF-Access-Client-Id: +CF-Access-Client-Secret: +CF-Access-Jwt-Assertion: +``` + +The Cloudflare headers are optional, but required when the admin host is behind Cloudflare Access. + +## Build + +Install the .NET MAUI workloads, then build per platform: + +```powershell +dotnet workload install maui +dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-android36.0 +dotnet build DorkNet.AdminMobile\DorkNet.AdminMobile.csproj -f net9.0-ios26.0 +``` + +The project targets Android 16 / API 36 and iOS 26 SDK. iOS device/archive builds still require Xcode 26 on macOS. diff --git a/DorkNet.AdminMobile/Services/AdminApiClient.cs b/DorkNet.AdminMobile/Services/AdminApiClient.cs new file mode 100644 index 0000000..c299ba1 --- /dev/null +++ b/DorkNet.AdminMobile/Services/AdminApiClient.cs @@ -0,0 +1,141 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using DorkNet.AdminMobile.Models; + +namespace DorkNet.AdminMobile.Services; + +public sealed class AdminApiClient(SecureAdminSettings settings) +{ + private static readonly JsonSerializerOptions Json = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly HttpClient http = new() + { + Timeout = TimeSpan.FromSeconds(20), + }; + + public async Task LoginAsync(string username, string password) + { + var result = await SendAsync( + HttpMethod.Post, + "login", + new { Username = username, Password = password }, + includeJwt: false); + + await settings.SaveSessionAsync(result.AccessToken, result.RefreshToken); + return result; + } + + public Task GetStatsAsync() => + SendAsync(HttpMethod.Get, "stats"); + + public Task> GetPlayersAsync(string? query = null) + { + var suffix = string.IsNullOrWhiteSpace(query) + ? "players?take=100" + : $"players?take=100&query={Uri.EscapeDataString(query)}"; + return SendAsync>(HttpMethod.Get, suffix); + } + + public Task> GetRoomsAsync() => + SendAsync>(HttpMethod.Get, "rooms?take=100"); + + public Task> GetInstancesAsync() => + SendAsync>(HttpMethod.Get, "instances"); + + public Task GetSettingsAsync() => + SendAsync(HttpMethod.Get, "settings"); + + public Task SetSignupsDisabledAsync(bool disabled) => + SendAsync(HttpMethod.Post, "settings/signups", new { Disabled = disabled }); + + public Task SetGlobalFriendsAsync(bool enabled) => + SendAsync(HttpMethod.Post, "settings/global-friends", new { Enabled = enabled }); + + public Task KickPlayerAsync(long playerId, string reason) => + SendAsync(HttpMethod.Post, $"players/{playerId}/kick", new { Reason = reason }); + + public Task ResetAvatarAsync(long playerId) => + SendAsync(HttpMethod.Post, $"players/{playerId}/avatar/reset"); + + private async Task SendAsync( + HttpMethod method, + string path, + object? body = null, + bool includeJwt = true) + { + var connection = await settings.LoadConnectionAsync(); + var url = $"{connection.BaseUrl.TrimEnd('/')}/api/admin/v1/{path.TrimStart('/')}"; + using var request = new HttpRequestMessage(method, url); + AddCloudflareAccessHeaders(request, connection); + + if (includeJwt) + { + var jwt = await settings.GetJwtAsync(); + if (!string.IsNullOrWhiteSpace(jwt)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); + } + + if (body is not null) + { + var json = JsonSerializer.Serialize(body, Json); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + using var response = await http.SendAsync(request); + var text = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + throw new AdminApiException((int)response.StatusCode, ExtractError(text, response.ReasonPhrase)); + + if (typeof(T) == typeof(object) || string.IsNullOrWhiteSpace(text)) + return default!; + + return JsonSerializer.Deserialize(text, Json) + ?? throw new AdminApiException(0, "Server returned an empty response."); + } + + private static void AddCloudflareAccessHeaders(HttpRequestMessage request, AdminConnectionSettings connection) + { + if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessClientId) && + !string.IsNullOrWhiteSpace(connection.CloudflareAccessClientSecret)) + { + request.Headers.SetOrReplace("CF-Access-Client-Id", connection.CloudflareAccessClientId); + request.Headers.SetOrReplace("CF-Access-Client-Secret", connection.CloudflareAccessClientSecret); + } + + if (!string.IsNullOrWhiteSpace(connection.CloudflareAccessJwt)) + request.Headers.SetOrReplace("CF-Access-Jwt-Assertion", connection.CloudflareAccessJwt); + } + + private static string ExtractError(string text, string? fallback) + { + if (string.IsNullOrWhiteSpace(text)) return fallback ?? "Request failed."; + try + { + using var doc = JsonDocument.Parse(text); + if (doc.RootElement.TryGetProperty("error", out var error)) + return error.GetString() ?? fallback ?? "Request failed."; + if (doc.RootElement.TryGetProperty("message", out var message)) + return message.GetString() ?? fallback ?? "Request failed."; + } + catch { } + return text.Length > 300 ? text[..300] : text; + } +} + +public sealed class AdminApiException(int statusCode, string message) : Exception(message) +{ + public int StatusCode { get; } = statusCode; +} + +internal static class HeaderExtensions +{ + public static void SetOrReplace(this HttpRequestHeaders headers, string name, string value) + { + headers.Remove(name); + headers.Add(name, value); + } +} diff --git a/DorkNet.AdminMobile/Services/SecureAdminSettings.cs b/DorkNet.AdminMobile/Services/SecureAdminSettings.cs new file mode 100644 index 0000000..bb09ce6 --- /dev/null +++ b/DorkNet.AdminMobile/Services/SecureAdminSettings.cs @@ -0,0 +1,50 @@ +using Microsoft.Maui.Storage; + +namespace DorkNet.AdminMobile.Services; + +public sealed record AdminConnectionSettings( + string BaseUrl, + string CloudflareAccessClientId, + string CloudflareAccessClientSecret, + string CloudflareAccessJwt); + +public sealed class SecureAdminSettings +{ + private const string BaseUrlKey = "admin.baseUrl"; + private const string JwtKey = "admin.jwt"; + private const string RefreshKey = "admin.refresh"; + private const string CfClientIdKey = "cf.access.clientId"; + private const string CfClientSecretKey = "cf.access.clientSecret"; + private const string CfJwtKey = "cf.access.jwt"; + + public async Task LoadConnectionAsync() + { + var baseUrl = await SecureStorage.GetAsync(BaseUrlKey) ?? "https://admin.rec.net"; + var cfId = await SecureStorage.GetAsync(CfClientIdKey) ?? string.Empty; + var cfSecret = await SecureStorage.GetAsync(CfClientSecretKey) ?? string.Empty; + var cfJwt = await SecureStorage.GetAsync(CfJwtKey) ?? string.Empty; + return new AdminConnectionSettings(baseUrl, cfId, cfSecret, cfJwt); + } + + public async Task SaveConnectionAsync(AdminConnectionSettings settings) + { + await SecureStorage.SetAsync(BaseUrlKey, settings.BaseUrl.TrimEnd('/')); + await SecureStorage.SetAsync(CfClientIdKey, settings.CloudflareAccessClientId.Trim()); + await SecureStorage.SetAsync(CfClientSecretKey, settings.CloudflareAccessClientSecret.Trim()); + await SecureStorage.SetAsync(CfJwtKey, settings.CloudflareAccessJwt.Trim()); + } + + public Task GetJwtAsync() => SecureStorage.GetAsync(JwtKey); + + public async Task SaveSessionAsync(string accessToken, string refreshToken) + { + await SecureStorage.SetAsync(JwtKey, accessToken); + await SecureStorage.SetAsync(RefreshKey, refreshToken); + } + + public void ClearSession() + { + SecureStorage.Remove(JwtKey); + SecureStorage.Remove(RefreshKey); + } +} diff --git a/DorkNet.sln b/DorkNet.sln index e94908d..01601e1 100644 --- a/DorkNet.sln +++ b/DorkNet.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{07C2787E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrateSqliteToPostgres", "Tools\MigrateSqliteToPostgres\MigrateSqliteToPostgres.csproj", "{70C60098-1B11-4C13-943B-A8A056429EAE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DorkNet.AdminMobile", "DorkNet.AdminMobile\DorkNet.AdminMobile.csproj", "{C2265F00-DFC5-4352-A94C-B887E3C0E5CA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -57,6 +59,18 @@ Global {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x64.Build.0 = Release|Any CPU {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.ActiveCfg = Release|Any CPU {70C60098-1B11-4C13-943B-A8A056429EAE}.Release|x86.Build.0 = Release|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|x64.Build.0 = Debug|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Debug|x86.Build.0 = Debug|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|Any CPU.Build.0 = Release|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x64.ActiveCfg = Release|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x64.Build.0 = Release|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.ActiveCfg = Release|Any CPU + {C2265F00-DFC5-4352-A94C-B887E3C0E5CA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE