migrate admin panel to actual accs, configs, etc (closes #16 and #13)

This commit is contained in:
Lapis
2026-06-18 11:17:50 -04:00
parent 004f1921a6
commit 3794e6e0ab
33 changed files with 1552 additions and 1255 deletions
@@ -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 &gt; 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">
+21 -40
View File
@@ -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;
}
}
}