mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
feat: Initial commit for DorkNet Admin Mobile app
- Implemented main application structure using .NET MAUI. - Added App class to manage navigation between Login and Admin pages. - Created AppDesign class for consistent UI styling. - Developed pages: LoginPage, DashboardPage, PlayersPage, RoomsPage, SettingsPage, and AdminTabbedPage. - Integrated AdminApiClient for API interactions and SecureAdminSettings for secure storage of sensitive data. - Configured Android and iOS platform-specific files including manifest and app delegate. - Added README with build instructions and authentication details.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<PlayerSummary> 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<Task> 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Task> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user