mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -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,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<AdminTabbedPage>())
|
||||
{
|
||||
BarBackgroundColor = AppDesign.Canvas,
|
||||
BarTextColor = AppDesign.Text,
|
||||
};
|
||||
}
|
||||
|
||||
public static void ShowLogin()
|
||||
{
|
||||
Current!.MainPage = new NavigationPage(
|
||||
AppServices.Services.GetRequiredService<LoginPage>())
|
||||
{
|
||||
BarBackgroundColor = AppDesign.Canvas,
|
||||
BarTextColor = AppDesign.Text,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net9.0-android36.0;net9.0-ios26.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>DorkNet.AdminMobile</RootNamespace>
|
||||
<UseMaui>true</UseMaui>
|
||||
<SingleProject>true</SingleProject>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationTitle>DorkNet Admin</ApplicationTitle>
|
||||
<ApplicationId>net.dorknet.adminmobile</ApplicationId>
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<AndroidTargetSdkVersion>36</AndroidTargetSdkVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">23.0</SupportedOSPlatformVersion>
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">14.2</SupportedOSPlatformVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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<App>();
|
||||
|
||||
builder.Services.AddSingleton<SecureAdminSettings>();
|
||||
builder.Services.AddSingleton<AdminApiClient>();
|
||||
builder.Services.AddTransient<LoginPage>();
|
||||
builder.Services.AddTransient<AdminTabbedPage>();
|
||||
builder.Services.AddTransient<DashboardPage>();
|
||||
builder.Services.AddTransient<PlayersPage>();
|
||||
builder.Services.AddTransient<RoomsPage>();
|
||||
builder.Services.AddTransient<SettingsPage>();
|
||||
|
||||
var app = builder.Build();
|
||||
AppServices.Services = app.Services;
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
public static class AppServices
|
||||
{
|
||||
public static IServiceProvider Services { get; set; } = default!;
|
||||
}
|
||||
@@ -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<InstanceParticipant> 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; }
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
</manifest>
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DorkNet Admin</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>net.dorknet.adminmobile</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>14.2</string>
|
||||
<key>UIDeviceFamily</key>
|
||||
<array>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 <dorknet-admin-jwt>
|
||||
CF-Access-Client-Id: <service-token-client-id>
|
||||
CF-Access-Client-Secret: <service-token-client-secret>
|
||||
CF-Access-Jwt-Assertion: <optional-jwt>
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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<AdminLoginResponse> LoginAsync(string username, string password)
|
||||
{
|
||||
var result = await SendAsync<AdminLoginResponse>(
|
||||
HttpMethod.Post,
|
||||
"login",
|
||||
new { Username = username, Password = password },
|
||||
includeJwt: false);
|
||||
|
||||
await settings.SaveSessionAsync(result.AccessToken, result.RefreshToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<AdminStats> GetStatsAsync() =>
|
||||
SendAsync<AdminStats>(HttpMethod.Get, "stats");
|
||||
|
||||
public Task<List<PlayerSummary>> GetPlayersAsync(string? query = null)
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(query)
|
||||
? "players?take=100"
|
||||
: $"players?take=100&query={Uri.EscapeDataString(query)}";
|
||||
return SendAsync<List<PlayerSummary>>(HttpMethod.Get, suffix);
|
||||
}
|
||||
|
||||
public Task<List<RoomSummary>> GetRoomsAsync() =>
|
||||
SendAsync<List<RoomSummary>>(HttpMethod.Get, "rooms?take=100");
|
||||
|
||||
public Task<List<InstanceSummary>> GetInstancesAsync() =>
|
||||
SendAsync<List<InstanceSummary>>(HttpMethod.Get, "instances");
|
||||
|
||||
public Task<ServerSettingsDto> GetSettingsAsync() =>
|
||||
SendAsync<ServerSettingsDto>(HttpMethod.Get, "settings");
|
||||
|
||||
public Task<ServerSettingsDto> SetSignupsDisabledAsync(bool disabled) =>
|
||||
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/signups", new { Disabled = disabled });
|
||||
|
||||
public Task<ServerSettingsDto> SetGlobalFriendsAsync(bool enabled) =>
|
||||
SendAsync<ServerSettingsDto>(HttpMethod.Post, "settings/global-friends", new { Enabled = enabled });
|
||||
|
||||
public Task KickPlayerAsync(long playerId, string reason) =>
|
||||
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/kick", new { Reason = reason });
|
||||
|
||||
public Task ResetAvatarAsync(long playerId) =>
|
||||
SendAsync<object>(HttpMethod.Post, $"players/{playerId}/avatar/reset");
|
||||
|
||||
private async Task<T> SendAsync<T>(
|
||||
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<T>(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);
|
||||
}
|
||||
}
|
||||
@@ -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<AdminConnectionSettings> 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<string?> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user