mirror of
https://github.com/CannedNet/CannedNet.git
synced 2026-10-25 15:09:23 -07:00
@@ -1,7 +1,4 @@
|
||||
@using CannedNet.Admin.Services
|
||||
@inherits LayoutComponentBase
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@{
|
||||
var body = Body;
|
||||
|
||||
@@ -16,18 +16,6 @@
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Setup</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="setup/nameserver">
|
||||
<span class="bi bi-server-nav-menu" aria-hidden="true"></span> Nameserver
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="setup/accounts">
|
||||
<span class="bi bi-people-fill-nav-menu" aria-hidden="true"></span> Admin Accounts
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Users</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="users/search">
|
||||
@@ -41,18 +29,8 @@
|
||||
<span class="bi bi-building-nav-menu" aria-hidden="true"></span> Search Rooms
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="rooms/create">
|
||||
<span class="bi bi-plus-circle-fill-nav-menu" aria-hidden="true"></span> Create Room
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-section-label px-3 mt-3 mb-1 text-muted small text-uppercase">Config</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="config/nameserver">
|
||||
<span class="bi bi-diagram-3-fill-nav-menu" aria-hidden="true"></span> NS Config
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="config/gameconfig">
|
||||
<span class="bi bi-sliders-nav-menu" aria-hidden="true"></span> Game Config
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (!AuthService.HasAnyUser())
|
||||
Navigation.NavigateTo("/setup", forceLoad: true);
|
||||
else
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
Navigation.NavigateTo("/login", forceLoad: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
@page "/change-password"
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@using System.Security.Claims
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject HttpClient Http
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
@@ -44,8 +42,6 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
|
||||
|
||||
private string currentPassword = "";
|
||||
private string newPassword = "";
|
||||
private string confirmNewPassword = "";
|
||||
@@ -75,17 +71,15 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var state = await AuthState!;
|
||||
var userIdClaim = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userIdClaim == null || !int.TryParse(userIdClaim, out var userId))
|
||||
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
message = "Error: Could not identify user.";
|
||||
return;
|
||||
}
|
||||
["newPassword"] = newPassword
|
||||
});
|
||||
|
||||
if (!AuthService.ChangePassword(userId, currentPassword, newPassword))
|
||||
var response = await Http.PutAsync("/accounts/me/changepassword", content);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
message = "Error: Current password is incorrect.";
|
||||
message = "Error: Failed to change password.";
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
@page "/config/nameserver"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Models
|
||||
@using System.Text.Json
|
||||
|
||||
<PageTitle>NS Config</PageTitle>
|
||||
|
||||
<h3>Nameserver (NS) Config</h3>
|
||||
<p class="text-muted">Manage the nameserver endpoints.json file that controls where the game client connects.</p>
|
||||
|
||||
<div class="alert alert-info">
|
||||
This is the same editor as the <a href="setup/nameserver">Setup > Nameserver</a> page.
|
||||
All endpoint URLs are derived from a single base URL.
|
||||
</div>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="baseUrl">Base URL:</label>
|
||||
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
|
||||
<div class="form-text">All endpoint URLs will use this as the base.</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Full URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@name</code></td>
|
||||
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private bool saveError;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
private string baseUrl = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await Http.GetStringAsync("/admin/api/endpoints");
|
||||
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
saveError = false;
|
||||
try
|
||||
{
|
||||
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await Http.PutAsync("/admin/api/endpoints", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "Saved successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
saveError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,6 @@
|
||||
<p class="lead text-muted">Manage your CannedNet Rec Room private server instance.</p>
|
||||
|
||||
<div class="row g-4 mt-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Setup</h5>
|
||||
<p class="card-text">Configure nameserver endpoints, admin accounts, and authentication.</p>
|
||||
<a href="setup/nameserver" class="btn btn-primary">Go to Setup</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -1,52 +1,33 @@
|
||||
@page "/login"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Login</PageTitle>
|
||||
|
||||
@if (AuthService.HasAnyUser() == false)
|
||||
{
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h4>No Admin Accounts</h4>
|
||||
<p class="text-muted">No admin accounts exist yet. Complete the first-time setup.</p>
|
||||
<a href="/setup" class="btn btn-primary">Go to Setup</a>
|
||||
</div>
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h4 class="mb-0">Admin Login</h4></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/login">
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header"><h4 class="mb-0">Admin Login</h4></div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/admin/login">
|
||||
@if (errorMessage != null)
|
||||
{
|
||||
<div class="alert alert-danger">@errorMessage</div>
|
||||
}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" name="username" class="form-control" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string? errorMessage;
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
@page "/rooms/create"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Create Room</PageTitle>
|
||||
|
||||
<h3>Create Room</h3>
|
||||
<p class="text-muted">Create a new room and optionally a default subroom.</p>
|
||||
|
||||
<form>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Room Details</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Room ID (leave 0 for auto)</label>
|
||||
<input type="number" class="form-control" @bind="roomId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" @bind="name" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea class="form-control" rows="3" @bind="description"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Creator Account ID</label>
|
||||
<input type="number" class="form-control" @bind="creatorAccountId" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Image Name</label>
|
||||
<input type="text" class="form-control" @bind="imageName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Accessibility</label>
|
||||
<input type="number" class="form-control" @bind="accessibility" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">SubRoom</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" @bind="createSubRoom" />
|
||||
<label class="form-check-label">Create a default subroom</label>
|
||||
</div>
|
||||
|
||||
@if (createSubRoom)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">SubRoom Name</label>
|
||||
<input type="text" class="form-control" @bind="subRoomName" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max Players</label>
|
||||
<input type="number" class="form-control" @bind="maxPlayers" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Platform Support</div>
|
||||
<div class="card-body">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsVRLow" />
|
||||
<label class="form-check-label">VR Low</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsQuest2" />
|
||||
<label class="form-check-label">Quest 2</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsMobile" />
|
||||
<label class="form-check-label">Mobile</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsScreens" />
|
||||
<label class="form-check-label">Screens</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsWalkVR" />
|
||||
<label class="form-check-label">Walk VR</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsTeleportVR" />
|
||||
<label class="form-check-label">Teleport VR</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" @bind="supportsJuniors" />
|
||||
<label class="form-check-label">Supports Juniors</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" @onclick="CreateRoom" disabled="@creating">
|
||||
@(creating ? "Creating..." : "Create Room")
|
||||
</button>
|
||||
<a href="rooms/search" class="btn btn-outline-secondary ms-2">Cancel</a>
|
||||
|
||||
@if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
|
||||
}
|
||||
|
||||
@if (createdRoom != null)
|
||||
{
|
||||
<div class="alert alert-success mt-2">
|
||||
Room created! <a href="rooms/edit/@createdRoom.RoomId">Edit Room #@createdRoom.RoomId</a>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool creating;
|
||||
private string? message;
|
||||
private Room? createdRoom;
|
||||
|
||||
private int roomId;
|
||||
private string name = "";
|
||||
private string description = "";
|
||||
private int creatorAccountId;
|
||||
private string imageName = "";
|
||||
private int accessibility;
|
||||
private bool createSubRoom = true;
|
||||
private string subRoomName = "";
|
||||
private int maxPlayers = 16;
|
||||
private bool supportsVRLow = true;
|
||||
private bool supportsQuest2 = true;
|
||||
private bool supportsMobile = true;
|
||||
private bool supportsScreens = true;
|
||||
private bool supportsWalkVR = true;
|
||||
private bool supportsTeleportVR = true;
|
||||
private bool supportsJuniors = true;
|
||||
|
||||
private async Task CreateRoom()
|
||||
{
|
||||
creating = true;
|
||||
message = null;
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
roomId = roomId > 0 ? roomId : 0,
|
||||
name,
|
||||
description,
|
||||
creatorAccountId,
|
||||
imageName,
|
||||
accessibility,
|
||||
createSubRoom,
|
||||
subRoomName = createSubRoom ? (string.IsNullOrWhiteSpace(subRoomName) ? name : subRoomName) : "",
|
||||
maxPlayers
|
||||
};
|
||||
var response = await Http.PostAsJsonAsync("/admin/api/rooms", payload);
|
||||
response.EnsureSuccessStatusCode();
|
||||
createdRoom = await response.Content.ReadFromJsonAsync<Room>();
|
||||
message = $"Room '{createdRoom?.Name}' created successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
@page "/setup/accounts"
|
||||
@attribute [Authorize(Policy = "OwnerOnly")]
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Models
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
|
||||
<PageTitle>Admin Accounts</PageTitle>
|
||||
|
||||
<h3>Admin Accounts</h3>
|
||||
<p class="text-muted">Manage admin panel accounts. Only the owner can create or delete accounts.</p>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Create New Admin Account</div>
|
||||
<div class="card-body">
|
||||
<form>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="newUsername" required />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" @bind="newPassword" required minlength="6" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" @bind="newConfirmPassword" required />
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" @onclick="CreateAccount" disabled="@creating">
|
||||
@(creating ? "Creating..." : "Create Account")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(createMessage))
|
||||
{
|
||||
<span class="ms-2 @(createMessage.Contains("Error") ? "text-danger" : "text-success")">@createMessage</span>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">Admin Accounts</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Role</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in AuthService.GetAll())
|
||||
{
|
||||
<tr>
|
||||
<td>@user.Id</td>
|
||||
<td>@user.Username</td>
|
||||
<td>
|
||||
@if (user.IsOwner)
|
||||
{
|
||||
<td>Owner</td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td>Admin</td>
|
||||
}
|
||||
</td>
|
||||
<td>@user.CreatedAt.ToString("g")</td>
|
||||
<td>
|
||||
@if (!user.IsOwner)
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteUser(user.Id)">Delete</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string newUsername = "";
|
||||
private string newPassword = "";
|
||||
private string newConfirmPassword = "";
|
||||
private bool creating;
|
||||
private string? createMessage;
|
||||
|
||||
private async Task CreateAccount()
|
||||
{
|
||||
creating = true;
|
||||
createMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newUsername))
|
||||
{
|
||||
createMessage = "Username is required.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
|
||||
{
|
||||
createMessage = "Password must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
if (newPassword != newConfirmPassword)
|
||||
{
|
||||
createMessage = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
if (AuthService.GetByUsername(newUsername) != null)
|
||||
{
|
||||
createMessage = "Username already exists.";
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService.CreateUser(newUsername, newPassword, isOwner: false);
|
||||
createMessage = "Admin account created successfully!";
|
||||
newUsername = "";
|
||||
newPassword = "";
|
||||
newConfirmPassword = "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
createMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteUser(int id)
|
||||
{
|
||||
var confirmed = await JSRuntime.InvokeAsync<bool>("confirm", "Delete this admin account?");
|
||||
if (!confirmed) return;
|
||||
AuthService.DeleteUser(id);
|
||||
}
|
||||
|
||||
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
@page "/setup"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Services
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>First-Time Setup</PageTitle>
|
||||
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header"><h4 class="mb-0">First-Time Setup</h4></div>
|
||||
<div class="card-body">
|
||||
@if (completed)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
<h5>Owner account created!</h5>
|
||||
<p>You can now <a href="/login">log in</a> with your new admin account.</p>
|
||||
</div>
|
||||
}
|
||||
else if (AuthService.HasAnyUser())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
Admin accounts already exist. <a href="/login">Go to login</a>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">Create the initial owner account. This account will have full access and can create additional admin accounts.</p>
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="username" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" @bind="password" required minlength="6" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" @bind="confirmPassword" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100" @onclick="CreateOwner" disabled="@creating">
|
||||
@(creating ? "Creating..." : "Create Owner Account")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(message))
|
||||
{
|
||||
<div class="alert mt-3 @(message.Contains("Error") ? "alert-danger" : "alert-success")">@message</div>
|
||||
}
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string username = "";
|
||||
private string password = "";
|
||||
private string confirmPassword = "";
|
||||
private bool creating;
|
||||
private bool completed;
|
||||
private string? message;
|
||||
|
||||
private async Task CreateOwner()
|
||||
{
|
||||
creating = true;
|
||||
message = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
{
|
||||
message = "Username is required.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||
{
|
||||
message = "Password must be at least 6 characters.";
|
||||
return;
|
||||
}
|
||||
if (password != confirmPassword)
|
||||
{
|
||||
message = "Passwords do not match.";
|
||||
return;
|
||||
}
|
||||
if (AuthService.HasAnyUser())
|
||||
{
|
||||
message = "Admin accounts already exist.";
|
||||
return;
|
||||
}
|
||||
if (AuthService.GetByUsername(username) != null)
|
||||
{
|
||||
message = "Username already taken.";
|
||||
return;
|
||||
}
|
||||
|
||||
AuthService.CreateUser(username, password, isOwner: true);
|
||||
completed = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
message = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
@page "/setup/nameserver"
|
||||
@rendermode InteractiveServer
|
||||
@using CannedNet.Admin.Models
|
||||
@using System.Text.Json
|
||||
|
||||
<PageTitle>Nameserver Setup</PageTitle>
|
||||
|
||||
<h3>Nameserver Endpoints</h3>
|
||||
<p class="text-muted">Configure the base server URL the game client connects to. All endpoints are derived from this URL.</p>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="spinner-border" role="status"></div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger">@error</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form>
|
||||
<div class="mb-3">
|
||||
<button type="submit" class="btn btn-success" @onclick="Save" disabled="@saving">
|
||||
@(saving ? "Saving..." : "Save Changes")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(saveMessage))
|
||||
{
|
||||
<span class="@(saveError ? "text-danger" : "text-success") ms-2">@saveMessage</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="baseUrl">Base URL:</label>
|
||||
<input id="baseUrl" class="form-control font-monospace" @bind="baseUrl" placeholder="https://cannednet.lapis.codes" />
|
||||
<div class="form-text">All endpoint URLs will use this as the base.</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Full URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var (name, path) in EndpointDefinitions.Endpoints)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@name</code></td>
|
||||
<td><code>@(baseUrl.TrimEnd('/') + path)</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject] private HttpClient Http { get; set; } = default!;
|
||||
|
||||
private bool loading = true;
|
||||
private bool saving;
|
||||
private bool saveError;
|
||||
private string? error;
|
||||
private string? saveMessage;
|
||||
private string baseUrl = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await Http.GetStringAsync("/admin/api/endpoints");
|
||||
baseUrl = EndpointDefinitions.ExtractBaseUrl(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Failed to load: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
saving = true;
|
||||
saveMessage = null;
|
||||
saveError = false;
|
||||
try
|
||||
{
|
||||
var json = EndpointDefinitions.BuildEndpointsJson(baseUrl);
|
||||
var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await Http.PutAsync("/admin/api/endpoints", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
saveMessage = "Saved successfully!";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
saveMessage = $"Error: {ex.Message}";
|
||||
saveError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CannedNet.Admin.Models;
|
||||
|
||||
public class AdminUser
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("passwordHash")]
|
||||
public string PasswordHash { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("isOwner")]
|
||||
public bool IsOwner { get; set; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
+61
-16
@@ -1,8 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CannedNet.Admin.Components;
|
||||
using CannedNet.Admin.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -23,22 +24,17 @@ builder.Services.AddAuthentication("AdminCookie")
|
||||
options.SlidingExpiration = true;
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy("OwnerOnly", policy => policy.RequireClaim("role", "Owner"));
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddSingleton<AdminAuthService>();
|
||||
var imagesBaseUrl = builder.Configuration.GetValue<string>("ImagesBaseUrl") ?? "http://localhost:5000/img";
|
||||
builder.Services.AddSingleton(new ImageUrlService(imagesBaseUrl));
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
|
||||
var serverUrl = builder.Configuration.GetValue<string>("ServerBaseUrl") ?? "http://localhost:5000";
|
||||
builder.Services.AddTransient<AdminApiKeyHandler>();
|
||||
builder.Services.AddTransient<JwtBearerHandler>();
|
||||
builder.Services.AddScoped(sp =>
|
||||
{
|
||||
var handler = sp.GetRequiredService<AdminApiKeyHandler>();
|
||||
var handler = sp.GetRequiredService<JwtBearerHandler>();
|
||||
handler.InnerHandler = new SocketsHttpHandler();
|
||||
return new HttpClient(handler) { BaseAddress = new Uri(serverUrl) };
|
||||
});
|
||||
@@ -47,7 +43,7 @@ var app = builder.Build();
|
||||
|
||||
// --- Admin Auth Endpoints ---
|
||||
|
||||
app.MapPost("/admin/login", async (HttpContext context, AdminAuthService authService) =>
|
||||
app.MapPost("/admin/login", async (HttpContext context) =>
|
||||
{
|
||||
var form = await context.Request.ReadFormAsync();
|
||||
var username = form["username"].FirstOrDefault() ?? "";
|
||||
@@ -59,20 +55,55 @@ app.MapPost("/admin/login", async (HttpContext context, AdminAuthService authSer
|
||||
return;
|
||||
}
|
||||
|
||||
var user = authService.GetByUsername(username);
|
||||
if (user == null || !authService.VerifyPassword(password, user.PasswordHash))
|
||||
var serverUrl = app.Services.GetRequiredService<IConfiguration>().GetValue<string>("ServerBaseUrl") ?? "http://localhost:5000";
|
||||
using var httpClient = new HttpClient { BaseAddress = new Uri(serverUrl) };
|
||||
var tokenResponse = await httpClient.PostAsync("/auth/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = username,
|
||||
["password"] = password
|
||||
}));
|
||||
|
||||
if (!tokenResponse.IsSuccessStatusCode)
|
||||
{
|
||||
context.Response.Redirect("/login?error=invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
var claims = new[]
|
||||
var json = await tokenResponse.Content.ReadAsStringAsync();
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var accessToken = doc.RootElement.GetProperty("access_token").GetString();
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("role", user.IsOwner ? "Owner" : "Admin")
|
||||
context.Response.Redirect("/login?error=invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
var payloadJson = DecodeJwtPayload(accessToken);
|
||||
using var payload = JsonDocument.Parse(payloadJson);
|
||||
var accountId = payload.RootElement.TryGetProperty("sub", out var sub) ? sub.GetString() ?? "" : "";
|
||||
var role = "";
|
||||
if (payload.RootElement.TryGetProperty("role", out var r))
|
||||
{
|
||||
if (r.ValueKind == JsonValueKind.String)
|
||||
role = r.GetString() ?? "";
|
||||
else if (r.ValueKind == JsonValueKind.Array)
|
||||
role = string.Join(",", r.EnumerateArray().Select(x => x.GetString() ?? ""));
|
||||
}
|
||||
var name = payload.RootElement.TryGetProperty("platform_id", out var pid) ? pid.GetString() ?? username : username;
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, accountId),
|
||||
new(ClaimTypes.Name, name),
|
||||
new("access_token", accessToken)
|
||||
};
|
||||
|
||||
// Map JWT roles for admin app authorization
|
||||
if (role.Split(',', StringSplitOptions.RemoveEmptyEntries).Any(rr => rr == "developer" || rr == "admin"))
|
||||
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, "AdminCookie");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
@@ -111,3 +142,17 @@ app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
|
||||
static string DecodeJwtPayload(string jwt)
|
||||
{
|
||||
var parts = jwt.Split('.');
|
||||
if (parts.Length < 2) return "{}";
|
||||
var payload = parts[1];
|
||||
switch (payload.Length % 4)
|
||||
{
|
||||
case 2: payload += "=="; break;
|
||||
case 3: payload += "="; break;
|
||||
}
|
||||
var bytes = Convert.FromBase64String(payload);
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace CannedNet.Admin.Services;
|
||||
|
||||
public class AdminApiKeyHandler : DelegatingHandler
|
||||
{
|
||||
private readonly string _apiKey;
|
||||
|
||||
public AdminApiKeyHandler(IConfiguration config)
|
||||
{
|
||||
_apiKey = config.GetValue<string>("Admin:ApiKey")
|
||||
?? config.GetValue<string>("AdminApiKey")
|
||||
?? "";
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_apiKey))
|
||||
request.Headers.Add("X-Admin-Api-Key", _apiKey);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CannedNet.Admin.Models;
|
||||
|
||||
namespace CannedNet.Admin.Services;
|
||||
|
||||
public class AdminAuthService
|
||||
{
|
||||
private readonly string _filePath;
|
||||
private List<AdminUser> _users = new();
|
||||
private readonly object _lock = new();
|
||||
private int _nextId = 1;
|
||||
|
||||
public AdminAuthService(IWebHostEnvironment env)
|
||||
{
|
||||
_filePath = Path.Combine(env.ContentRootPath, "admin_users.json");
|
||||
Load();
|
||||
}
|
||||
|
||||
public bool HasAnyUser()
|
||||
{
|
||||
lock (_lock) { return _users.Count > 0; }
|
||||
}
|
||||
|
||||
public AdminUser? GetById(int id)
|
||||
{
|
||||
lock (_lock) { return _users.FirstOrDefault(u => u.Id == id); }
|
||||
}
|
||||
|
||||
public AdminUser? GetByUsername(string username)
|
||||
{
|
||||
lock (_lock) { return _users.FirstOrDefault(u => u.Username.Equals(username, StringComparison.OrdinalIgnoreCase)); }
|
||||
}
|
||||
|
||||
public List<AdminUser> GetAll()
|
||||
{
|
||||
lock (_lock) { return _users.OrderBy(u => u.Id).ToList(); }
|
||||
}
|
||||
|
||||
public bool IsOwner(int id)
|
||||
{
|
||||
lock (_lock) { return _users.Any(u => u.Id == id && u.IsOwner); }
|
||||
}
|
||||
|
||||
public AdminUser CreateUser(string username, string password, bool isOwner)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var user = new AdminUser
|
||||
{
|
||||
Id = _nextId++,
|
||||
Username = username,
|
||||
PasswordHash = HashPassword(password),
|
||||
IsOwner = isOwner,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_users.Add(user);
|
||||
Save();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
public bool ChangePassword(int id, string oldPassword, string newPassword)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var user = _users.FirstOrDefault(u => u.Id == id);
|
||||
if (user == null) return false;
|
||||
if (user.PasswordHash != HashPassword(oldPassword)) return false;
|
||||
user.PasswordHash = HashPassword(newPassword);
|
||||
Save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool DeleteUser(int id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var user = _users.FirstOrDefault(u => u.Id == id);
|
||||
if (user == null) return false;
|
||||
_users.Remove(user);
|
||||
Save();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool VerifyPassword(string password, string hash)
|
||||
{
|
||||
return HashPassword(password) == hash;
|
||||
}
|
||||
|
||||
private string HashPassword(string password)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password + "CannedNetAdminSalt!@#$"));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_filePath))
|
||||
{
|
||||
var json = File.ReadAllText(_filePath);
|
||||
var users = JsonSerializer.Deserialize<List<AdminUser>>(json);
|
||||
if (users != null && users.Count > 0)
|
||||
{
|
||||
_users = users;
|
||||
_nextId = _users.Max(u => u.Id) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_users, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(_filePath, json);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
|
||||
namespace CannedNet.Admin.Services;
|
||||
|
||||
public class JwtBearerHandler : DelegatingHandler
|
||||
{
|
||||
private readonly AuthenticationStateProvider _authStateProvider;
|
||||
|
||||
public JwtBearerHandler(AuthenticationStateProvider authStateProvider)
|
||||
{
|
||||
_authStateProvider = authStateProvider;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await _authStateProvider.GetAuthenticationStateAsync();
|
||||
var token = state.User.FindFirst("access_token")?.Value;
|
||||
if (token != null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user