feat: implement core radius service and database schema for guest access management
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
**/.dockerignore
|
||||
**/.env
|
||||
**/.git
|
||||
**/.gitignore
|
||||
**/.project
|
||||
**/.settings
|
||||
**/.toolstarget
|
||||
**/.vs
|
||||
**/.vscode
|
||||
**/.idea
|
||||
**/*.*proj.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/azds.yaml
|
||||
**/bin
|
||||
**/charts
|
||||
**/docker-compose*
|
||||
**/Dockerfile*
|
||||
**/node_modules
|
||||
**/npm-debug.log
|
||||
**/obj
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
@@ -0,0 +1,5 @@
|
||||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Rider ignored files
|
||||
/.idea.radiuscontroller.iml
|
||||
/modules.xml
|
||||
/projectSettingsUpdater.xml
|
||||
/contentModel.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="UserContentModel">
|
||||
<attachedFolders />
|
||||
<explicitIncludes />
|
||||
<explicitExcludes />
|
||||
</component>
|
||||
</project>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,48 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: radius_postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: radius
|
||||
POSTGRES_USER: radius
|
||||
POSTGRES_PASSWORD: radpass
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
|
||||
freeradius:
|
||||
image: freeradius/freeradius-server:latest
|
||||
container_name: radius_freeradius
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "1812:1812/udp"
|
||||
- "1813:1813/udp"
|
||||
environment:
|
||||
- DB_NAME=radius
|
||||
- DB_HOST=postgres
|
||||
- DB_USER=radius
|
||||
- DB_PASS=radpass
|
||||
|
||||
radiuscontroller:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: radiuscontroller/Dockerfile
|
||||
container_name: radius_web_controller
|
||||
restart: always
|
||||
depends_on:
|
||||
- postgres
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=radius;Username=radius;Password=radpass;
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "radiuscontroller", "radiuscontroller\radiuscontroller.csproj", "{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5CA2077-CB9E-4A1F-A7D4-00F69BCD99E3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Guest Wi-Fi RADIUS Controller</title>
|
||||
<base href="/"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="@Assets["app.css"]"/>
|
||||
<link rel="stylesheet" href="@Assets["radiuscontroller.styles.css"]"/>
|
||||
<ImportMap/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager NavManager
|
||||
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<a href="/" class="brand-logo">
|
||||
<div class="brand-icon">
|
||||
<i class="bi bi-wifi"></i>
|
||||
</div>
|
||||
<span>Guest Radius</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav-links">
|
||||
<NavLink href="" Match="NavLinkMatch.All" class="nav-item">
|
||||
<i class="bi bi-qr-code-scan"></i> Guest Registration
|
||||
</NavLink>
|
||||
|
||||
<NavLink href="admin" class="nav-item admin-btn">
|
||||
<i class="bi bi-shield-lock"></i> Admin Panel
|
||||
</NavLink>
|
||||
|
||||
@if (AuthService.IsAuthenticated)
|
||||
{
|
||||
<button class="nav-item btn-secondary" style="border:none; cursor:pointer;" @onclick="LogoutAdmin">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout (@AuthService.CurrentAdminUsername)
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
@Body
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
RADIUS Guest Portal & Database Manager • Powered by FreeRADIUS & PostgreSQL • © @DateTime.UtcNow.Year
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet style="display:none;">
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthService.OnAuthStateChanged += StateHasChanged;
|
||||
await AuthService.InitializeAsync();
|
||||
}
|
||||
|
||||
private async Task LogoutAdmin()
|
||||
{
|
||||
await AuthService.LogoutAsync();
|
||||
NavManager.NavigateTo("/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
@page "/admin"
|
||||
@rendermode InteractiveServer
|
||||
@inject IRadiusService RadiusService
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager NavManager
|
||||
|
||||
<div style="max-width: 1280px; margin: 0 auto;">
|
||||
@if (!AuthService.IsAuthenticated)
|
||||
{
|
||||
<div class="glass-card" style="text-align: center; padding: 3rem;">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p style="margin-top: 1rem; color: var(--text-muted);">Verifying Administrator Session...</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.02em;">
|
||||
<i class="bi bi-speedometer2" style="color: var(--primary);"></i> RADIUS Controller Dashboard
|
||||
</h1>
|
||||
<p style="color: var(--text-muted); font-size: 0.95rem;">
|
||||
Manage guest bandwidth caps, session durations, and monitor live PostgreSQL accounting.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-sm btn-secondary" @onclick="RefreshData" disabled="@isLoading">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (notificationMessage != null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 1rem 1.25rem; margin-bottom: 1.5rem; border-color: rgba(20, 184, 166, 0.4); background: rgba(20, 184, 166, 0.1);">
|
||||
<i class="bi bi-info-circle-fill" style="color: var(--accent-teal);"></i> @notificationMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Top Statistics Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon primary">
|
||||
<i class="bi bi-people-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-number">@users.Count</div>
|
||||
<div class="stat-label">Total Registered Guests</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon emerald">
|
||||
<i class="bi bi-wifi"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-number" style="color: var(--accent-emerald);">@activeSessions.Count</div>
|
||||
<div class="stat-label">Active Sessions Now</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon teal">
|
||||
<i class="bi bi-cloud-arrow-down-fill"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-number">@FormatTotalDataUsed()</div>
|
||||
<div class="stat-label">Total Data Consumed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon amber">
|
||||
<i class="bi bi-sliders"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="stat-number">@(config?.DefaultSessionTimeMinutes ?? 0)m / @(config?.DefaultDataLimitMb ?? 0)MB</div>
|
||||
<div class="stat-label">Default Policy (Time / Cap)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Navigation Tabs -->
|
||||
<div class="tab-nav">
|
||||
<button class="tab-btn @(activeTab == "users" ? "active" : "")" @onclick='() => activeTab = "users"'>
|
||||
<i class="bi bi-person-lines-fill"></i> Guest Users & Usage (@users.Count)
|
||||
</button>
|
||||
<button class="tab-btn @(activeTab == "config" ? "active" : "")" @onclick='() => activeTab = "config"'>
|
||||
<i class="bi bi-gear-wide-connected"></i> Global RADIUS Settings
|
||||
</button>
|
||||
<button class="tab-btn @(activeTab == "active" ? "active" : "")" @onclick='() => activeTab = "active"'>
|
||||
<i class="bi bi-activity"></i> Live Connections (@activeSessions.Count)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB 1: User Management & Accounting Data -->
|
||||
@if (activeTab == "users")
|
||||
{
|
||||
<div class="glass-card">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<div style="position: relative; max-width: 320px; width: 100%;">
|
||||
<input type="text"
|
||||
class="form-control-glass"
|
||||
placeholder="Search guest name or username..."
|
||||
@bind="searchQuery"
|
||||
@bind:event="oninput"
|
||||
style="padding-left: 2.5rem; font-size: 0.9rem;" />
|
||||
<i class="bi bi-search" style="position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: var(--text-muted);"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Guest Name</th>
|
||||
<th>RADIUS Credentials</th>
|
||||
<th>Status</th>
|
||||
<th>Session Duration Limit</th>
|
||||
<th>Data Used / Cap</th>
|
||||
<th>Total Online Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (FilteredUsers.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
|
||||
<i class="bi bi-inbox" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i>
|
||||
No guest accounts found.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var user in FilteredUsers)
|
||||
{
|
||||
double percentage = user.DataLimitMb > 0 ? Math.Min(100, Math.Round((user.UsedDataMb / user.DataLimitMb) * 100, 1)) : 0;
|
||||
string fillClass = percentage >= 85 ? "warning" : "normal";
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700;">@user.GuestName</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-dim);">
|
||||
Registered: @user.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy HH:mm")
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-family: var(--font-mono); font-weight: 600; color: var(--accent-teal);">
|
||||
@user.Username
|
||||
</div>
|
||||
<div style="font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted);">
|
||||
Pwd: <span style="color: var(--accent-purple);">@user.Password</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (user.IsCurrentlyConnected)
|
||||
{
|
||||
<span class="badge badge-emerald"><i class="bi bi-broadcast"></i> Online</span>
|
||||
}
|
||||
else if (user.Status == "Revoked")
|
||||
{
|
||||
<span class="badge badge-rose"><i class="bi bi-x-circle"></i> Revoked</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge badge-primary"><i class="bi bi-check-circle"></i> @user.Status</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<strong>@user.SessionTimeMinutes mins</strong>
|
||||
<div style="font-size: 0.75rem; color: var(--text-dim);">
|
||||
RADIUS Session-Timeout
|
||||
</div>
|
||||
</td>
|
||||
<td style="min-width: 180px;">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.85rem; font-weight: 600;">
|
||||
<span>@user.UsedDataMb MB</span>
|
||||
<span style="color: var(--text-muted);">/ @user.DataLimitMb MB</span>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill @fillClass" style="width: @percentage%;"></div>
|
||||
</div>
|
||||
<div style="font-size: 0.725rem; color: var(--text-dim); margin-top: 0.2rem;">
|
||||
Upload: @FormatMb(user.TotalInputOctets) | Down: @FormatMb(user.TotalOutputOctets)
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@FormatSeconds(user.TotalSessionTimeSeconds)
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.35rem;">
|
||||
<button class="btn-sm btn-secondary" title="Edit Session & Data Limits" @onclick="() => OpenEditModal(user)">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@if (user.Status == "Active")
|
||||
{
|
||||
<button class="btn-sm btn-danger" title="Revoke Access" @onclick="() => RevokeUser(user.ProfileId)">
|
||||
<i class="bi bi-slash-circle"></i>
|
||||
</button>
|
||||
}
|
||||
<button class="btn-sm btn-secondary" style="color: var(--accent-rose);" title="Delete User" @onclick="() => DeleteUser(user.ProfileId)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 2: Global Configuration Settings -->
|
||||
@if (activeTab == "config")
|
||||
{
|
||||
<div class="glass-card" style="max-width: 680px;">
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 1rem;">
|
||||
<i class="bi bi-sliders" style="color: var(--primary);"></i> Default RADIUS Policy Settings
|
||||
</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
|
||||
These parameters determine the initial session duration limit and total bandwidth quota assigned when a new guest registers.
|
||||
</p>
|
||||
|
||||
<EditForm Model="this" OnValidSubmit="SaveGlobalConfig">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default Session Time (Minutes)</label>
|
||||
<input type="number"
|
||||
class="form-control-glass"
|
||||
@bind="editConfigSessionTime"
|
||||
min="1"
|
||||
max="10080"
|
||||
required />
|
||||
<span style="font-size: 0.775rem; color: var(--text-dim);">
|
||||
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Session-Timeout</code> (in seconds) for FreeRADIUS reply.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Default Data Usage Cap (Megabytes)</label>
|
||||
<input type="number"
|
||||
class="form-control-glass"
|
||||
@bind="editConfigDataLimit"
|
||||
min="1"
|
||||
max="1048576"
|
||||
required />
|
||||
<span style="font-size: 0.775rem; color: var(--text-dim);">
|
||||
Translates to RADIUS attribute <code style="color: var(--accent-teal);">Max-Octets</code> (in bytes) for FreeRADIUS reply.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-gradient-primary" style="width: auto; padding: 0.8rem 2rem;">
|
||||
<i class="bi bi-save-fill"></i> Save Default Policy
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- TAB 3: Live Active Sessions -->
|
||||
@if (activeTab == "active")
|
||||
{
|
||||
<div class="glass-card">
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 1rem;">
|
||||
<i class="bi bi-broadcast" style="color: var(--accent-emerald);"></i> Live Connected Devices
|
||||
</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem; margin-bottom: 1.5rem;">
|
||||
Real-time active RADIUS sessions recorded in PostgreSQL <code style="color: var(--accent-teal);">radacct</code> table.
|
||||
</p>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>NAS IP Address</th>
|
||||
<th>Calling Station (MAC)</th>
|
||||
<th>Framed IP</th>
|
||||
<th>Start Time</th>
|
||||
<th>Online Duration</th>
|
||||
<th>Downloaded / Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (activeSessions.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 3rem;">
|
||||
<i class="bi bi-wifi-off" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i>
|
||||
No active sessions currently online.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var session in activeSessions)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<span style="font-family: var(--font-mono); font-weight: 700; color: var(--accent-teal);">
|
||||
@session.Username
|
||||
</span>
|
||||
</td>
|
||||
<td>@session.NasIpAddress</td>
|
||||
<td>
|
||||
<span style="font-family: var(--font-mono); font-size: 0.85rem;">
|
||||
@(string.IsNullOrEmpty(session.CallingStationId) ? "N/A" : session.CallingStationId)
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style="font-family: var(--font-mono); font-size: 0.85rem;">
|
||||
@(string.IsNullOrEmpty(session.FramedIpAddress) ? "DHCP Assigned" : session.FramedIpAddress)
|
||||
</span>
|
||||
</td>
|
||||
<td>@session.AcctStartTime?.ToLocalTime().ToString("HH:mm:ss MMM dd")</td>
|
||||
<td>@FormatSeconds(session.AcctSessionTime ?? 0)</td>
|
||||
<td>
|
||||
<span style="color: #34d399;">↓ @FormatMb(session.AcctOutputOctets ?? 0) MB</span> /
|
||||
<span style="color: #60a5fa;">↑ @FormatMb(session.AcctInputOctets ?? 0) MB</span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Edit User Limits Modal Overlay -->
|
||||
@if (selectedUserForEdit != null)
|
||||
{
|
||||
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(8px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem;">
|
||||
<div class="glass-card" style="max-width: 500px; width: 100%; border-color: rgba(99, 102, 241, 0.4);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||
<h3 style="font-size: 1.25rem; font-weight: 700;">
|
||||
<i class="bi bi-pencil-square" style="color: var(--primary);"></i> Edit Guest Policy
|
||||
</h3>
|
||||
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForEdit = null">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="background: rgba(255,255,255,0.03); padding: 0.85rem; border-radius: var(--radius-sm); margin-bottom: 1.5rem; font-size: 0.9rem;">
|
||||
<div>Guest: <strong>@selectedUserForEdit.GuestName</strong></div>
|
||||
<div style="font-family: var(--font-mono); color: var(--accent-teal);">Username: @selectedUserForEdit.Username</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Session Time Limit (Minutes)</label>
|
||||
<input type="number" class="form-control-glass" @bind="editModalSessionTime" min="1" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Data Usage Limit (MB)</label>
|
||||
<input type="number" class="form-control-glass" @bind="editModalDataLimit" min="1" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Reset Password (Optional)</label>
|
||||
<input type="text" class="form-control-glass" placeholder="Leave blank to keep existing password" @bind="editModalNewPassword" />
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem;">
|
||||
<button class="btn-sm btn-secondary" @onclick="() => selectedUserForEdit = null">Cancel</button>
|
||||
<button class="btn-gradient-primary" style="width: auto;" @onclick="SaveUserEdit">
|
||||
<i class="bi bi-check-lg"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool isLoading = true;
|
||||
private string activeTab = "users";
|
||||
private string searchQuery = string.Empty;
|
||||
private string? notificationMessage;
|
||||
|
||||
private SystemConfigDto? config;
|
||||
private List<UserUsageDto> users = new();
|
||||
private List<RadAcct> activeSessions = new();
|
||||
|
||||
private int editConfigSessionTime;
|
||||
private long editConfigDataLimit;
|
||||
|
||||
private UserUsageDto? selectedUserForEdit;
|
||||
private int editModalSessionTime;
|
||||
private long editModalDataLimit;
|
||||
private string editModalNewPassword = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await AuthService.InitializeAsync();
|
||||
if (!AuthService.IsAuthenticated)
|
||||
{
|
||||
NavManager.NavigateTo("/admin/login");
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadDataAsync();
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
config = await RadiusService.GetSystemConfigAsync();
|
||||
editConfigSessionTime = config.DefaultSessionTimeMinutes;
|
||||
editConfigDataLimit = config.DefaultDataLimitMb;
|
||||
|
||||
users = await RadiusService.GetAllUsersWithUsageAsync();
|
||||
activeSessions = await RadiusService.GetActiveSessionsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
notificationMessage = "Error loading data: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshData()
|
||||
{
|
||||
notificationMessage = null;
|
||||
await LoadDataAsync();
|
||||
ShowNotification("Data refreshed successfully.");
|
||||
}
|
||||
|
||||
private List<UserUsageDto> FilteredUsers
|
||||
{
|
||||
get
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchQuery)) return users;
|
||||
string q = searchQuery.Trim().ToLowerInvariant();
|
||||
return users.Where(u => u.GuestName.ToLowerInvariant().Contains(q) || u.Username.ToLowerInvariant().Contains(q)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveGlobalConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
await RadiusService.UpdateSystemConfigAsync(editConfigSessionTime, editConfigDataLimit);
|
||||
config = await RadiusService.GetSystemConfigAsync();
|
||||
ShowNotification("Global default session time & data cap policy updated.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowNotification("Failed to update policy: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenEditModal(UserUsageDto user)
|
||||
{
|
||||
selectedUserForEdit = user;
|
||||
editModalSessionTime = user.SessionTimeMinutes;
|
||||
editModalDataLimit = user.DataLimitMb;
|
||||
editModalNewPassword = string.Empty;
|
||||
}
|
||||
|
||||
private async Task SaveUserEdit()
|
||||
{
|
||||
if (selectedUserForEdit == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await RadiusService.UpdateUserLimitsAsync(selectedUserForEdit.ProfileId, editModalSessionTime, editModalDataLimit);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(editModalNewPassword))
|
||||
{
|
||||
await RadiusService.ResetUserPasswordAsync(selectedUserForEdit.ProfileId, editModalNewPassword.Trim());
|
||||
}
|
||||
|
||||
selectedUserForEdit = null;
|
||||
await LoadDataAsync();
|
||||
ShowNotification("User limits updated successfully.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowNotification("Error updating user: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RevokeUser(int profileId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RadiusService.RevokeUserAsync(profileId);
|
||||
await LoadDataAsync();
|
||||
ShowNotification("User access revoked.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowNotification("Error revoking user: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteUser(int profileId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RadiusService.DeleteUserAsync(profileId);
|
||||
await LoadDataAsync();
|
||||
ShowNotification("User deleted.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowNotification("Error deleting user: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowNotification(string message)
|
||||
{
|
||||
notificationMessage = message;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private string FormatTotalDataUsed()
|
||||
{
|
||||
long totalOctets = users.Sum(u => u.TotalOctets);
|
||||
double gb = (double)totalOctets / (1024 * 1024 * 1024);
|
||||
if (gb >= 1.0)
|
||||
return $"{Math.Round(gb, 2)} GB";
|
||||
double mb = (double)totalOctets / (1024 * 1024);
|
||||
return $"{Math.Round(mb, 1)} MB";
|
||||
}
|
||||
|
||||
private static double FormatMb(long bytes) => Math.Round((double)bytes / (1024 * 1024), 2);
|
||||
|
||||
private static string FormatSeconds(long seconds)
|
||||
{
|
||||
if (seconds <= 0) return "0s";
|
||||
TimeSpan t = TimeSpan.FromSeconds(seconds);
|
||||
if (t.TotalHours >= 1)
|
||||
return $"{Math.Floor(t.TotalHours)}h {t.Minutes}m";
|
||||
if (t.TotalMinutes >= 1)
|
||||
return $"{t.Minutes}m {t.Seconds}s";
|
||||
return $"{t.Seconds}s";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
@page "/admin/login"
|
||||
@rendermode InteractiveServer
|
||||
@inject AdminAuthService AuthService
|
||||
@inject NavigationManager NavManager
|
||||
|
||||
<div class="portal-wrapper">
|
||||
<div class="portal-header">
|
||||
<div class="brand-icon" style="margin: 0 auto 1rem auto; width: 60px; height: 60px; font-size: 1.8rem; background: linear-gradient(135deg, var(--accent-purple), var(--primary));">
|
||||
<i class="bi bi-shield-lock-fill"></i>
|
||||
</div>
|
||||
<h1 class="portal-title">Administrator Login</h1>
|
||||
<p class="portal-subtitle">Access RADIUS server policies, session limits, and data accounting</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<EditForm Model="this" OnValidSubmit="HandleLogin">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="adminUsername">Username</label>
|
||||
<input id="adminUsername"
|
||||
type="text"
|
||||
class="form-control-glass"
|
||||
placeholder="admin"
|
||||
@bind="username"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="adminPassword">Password</label>
|
||||
<input id="adminPassword"
|
||||
type="password"
|
||||
class="form-control-glass"
|
||||
placeholder="••••••••"
|
||||
@bind="password"
|
||||
required />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div style="color: var(--accent-rose); font-size: 0.875rem; margin-bottom: 1rem;">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i> @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="submit" class="btn-gradient-primary" disabled="@isSubmitting">
|
||||
@if (isSubmitting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Authenticating...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-box-arrow-in-right"></i>
|
||||
<span>Sign In to Admin Panel</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<div style="margin-top: 1.5rem; text-align: center; padding-top: 1rem; border-top: 1px solid var(--bg-card-border); font-size: 0.8rem; color: var(--text-dim);">
|
||||
Default credentials: <code style="color: var(--accent-teal);">admin</code> / <code style="color: var(--accent-teal);">admin123</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string username = string.Empty;
|
||||
private string password = string.Empty;
|
||||
private bool isSubmitting = false;
|
||||
private string errorMessage = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await AuthService.InitializeAsync();
|
||||
if (AuthService.IsAuthenticated)
|
||||
{
|
||||
NavManager.NavigateTo("/admin");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
isSubmitting = true;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
bool success = await AuthService.LoginAsync(username, password);
|
||||
if (success)
|
||||
{
|
||||
NavManager.NavigateTo("/admin");
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Invalid admin username or password.";
|
||||
}
|
||||
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
protected override void OnInitialized() =>
|
||||
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@inject IRadiusService RadiusService
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="portal-wrapper">
|
||||
<div class="portal-header">
|
||||
<div class="brand-icon" style="margin: 0 auto 1rem auto; width: 60px; height: 60px; font-size: 1.8rem;">
|
||||
<i class="bi bi-wifi"></i>
|
||||
</div>
|
||||
<h1 class="portal-title">Guest Wi-Fi Access</h1>
|
||||
<p class="portal-subtitle">Enter your name to generate your instant high-speed network credentials</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
@if (config != null)
|
||||
{
|
||||
<div style="display: flex; gap: 1rem; background: rgba(255,255,255,0.03); border: 1px solid var(--bg-card-border); padding: 0.85rem 1rem; border-radius: var(--radius-sm); margin-bottom: 1.5rem; font-size: 0.875rem;">
|
||||
<div>
|
||||
<i class="bi bi-clock-history text-primary" style="color: var(--primary);"></i>
|
||||
<span style="color: var(--text-muted); margin-left: 0.35rem;">Session Limit:</span> <strong>@config.DefaultSessionTimeMinutes mins</strong>
|
||||
</div>
|
||||
<div>
|
||||
<i class="bi bi-database text-teal" style="color: var(--accent-teal);"></i>
|
||||
<span style="color: var(--text-muted); margin-left: 0.35rem;">Data Cap:</span> <strong>@config.DefaultDataLimitMb MB</strong>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<EditForm Model="this" OnValidSubmit="HandleRegistration">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="guestNameInput">Full Name or Guest Identifier</label>
|
||||
<input id="guestNameInput"
|
||||
type="text"
|
||||
class="form-control-glass"
|
||||
placeholder="e.g. Alex Taylor"
|
||||
@bind="guestName"
|
||||
required
|
||||
disabled="@isSubmitting" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div style="color: var(--accent-rose); font-size: 0.875rem; margin-bottom: 1rem;">
|
||||
<i class="bi bi-exclamation-circle-fill"></i> @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="submit" class="btn-gradient-primary" disabled="@(isSubmitting || string.IsNullOrWhiteSpace(guestName))">
|
||||
@if (isSubmitting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
<span>Generating Access...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-key-fill"></i>
|
||||
<span>Get Wi-Fi Password</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
@if (result != null)
|
||||
{
|
||||
<div class="credentials-box">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
||||
<h3 style="font-size: 1.1rem; font-weight: 700; color: white;">
|
||||
<i class="bi bi-check-circle-fill" style="color: var(--accent-teal);"></i> Access Granted!
|
||||
</h3>
|
||||
<span class="badge badge-emerald">Ready to Connect</span>
|
||||
</div>
|
||||
|
||||
<p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem;">
|
||||
Connect to the <strong>Guest-WiFi</strong> network on your device and enter the credentials below:
|
||||
</p>
|
||||
|
||||
<div class="cred-row">
|
||||
<div>
|
||||
<div class="cred-label">Username</div>
|
||||
<div class="cred-value">@result.Username</div>
|
||||
</div>
|
||||
<button class="copy-btn" @onclick='() => CopyToClipboard(result.Username, "Username")'>
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cred-row">
|
||||
<div>
|
||||
<div class="cred-label">Password</div>
|
||||
<div class="cred-value" style="color: var(--accent-purple);">@result.Password</div>
|
||||
</div>
|
||||
<button class="copy-btn" @onclick='() => CopyToClipboard(result.Password, "Password")'>
|
||||
<i class="bi bi-clipboard"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1rem; font-size: 0.8rem; color: var(--text-muted);">
|
||||
<div style="background: rgba(11,15,25,0.4); padding: 0.6rem; border-radius: var(--radius-sm);">
|
||||
<i class="bi bi-hourglass-split" style="color: var(--accent-amber);"></i> Session: <strong>@result.SessionTimeMinutes Mins</strong>
|
||||
</div>
|
||||
<div style="background: rgba(11,15,25,0.4); padding: 0.6rem; border-radius: var(--radius-sm);">
|
||||
<i class="bi bi-wifi-off" style="color: var(--accent-rose);"></i> Data Cap: <strong>@result.DataLimitMb MB</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(copyNotification))
|
||||
{
|
||||
<div style="text-align: center; color: var(--accent-teal); font-size: 0.8rem; font-weight: 600; margin-top: 0.75rem; animation: fadeIn 0.2s;">
|
||||
<i class="bi bi-check2"></i> @copyNotification
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string guestName = string.Empty;
|
||||
private bool isSubmitting = false;
|
||||
private string errorMessage = string.Empty;
|
||||
private RegistrationResultDto? result;
|
||||
private SystemConfigDto? config;
|
||||
private string copyNotification = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
config = await RadiusService.GetSystemConfigAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Database connection offline or initializing: " + ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRegistration()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(guestName)) return;
|
||||
|
||||
isSubmitting = true;
|
||||
errorMessage = string.Empty;
|
||||
copyNotification = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
result = await RadiusService.RegisterGuestAsync(guestName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = "Failed to create credentials: " + ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyToClipboard(string text, string label)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
|
||||
copyNotification = $"{label} copied to clipboard!";
|
||||
StateHasChanged();
|
||||
await Task.Delay(2500);
|
||||
copyNotification = string.Empty;
|
||||
StateHasChanged();
|
||||
}
|
||||
catch
|
||||
{
|
||||
copyNotification = $"Failed to copy {label} automatically.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@page "/home_deprecated"
|
||||
|
||||
<p>Redirecting...</p>
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1"/>
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,13 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using radiuscontroller
|
||||
@using radiuscontroller.Components
|
||||
@using radiuscontroller.Models
|
||||
@using radiuscontroller.Services
|
||||
@using radiuscontroller.Data
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Models;
|
||||
|
||||
namespace radiuscontroller.Data;
|
||||
|
||||
public class RadiusDbContext : DbContext
|
||||
{
|
||||
public RadiusDbContext(DbContextOptions<RadiusDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<RadCheck> RadCheck { get; set; } = null!;
|
||||
public DbSet<RadReply> RadReply { get; set; } = null!;
|
||||
public DbSet<RadAcct> RadAcct { get; set; } = null!;
|
||||
public DbSet<GuestProfile> GuestProfiles { get; set; } = null!;
|
||||
public DbSet<AdminUser> AdminUsers { get; set; } = null!;
|
||||
public DbSet<SystemSetting> SystemSettings { get; set; } = null!;
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<RadCheck>().ToTable("radcheck");
|
||||
modelBuilder.Entity<RadReply>().ToTable("radreply");
|
||||
modelBuilder.Entity<RadAcct>().ToTable("radacct");
|
||||
modelBuilder.Entity<GuestProfile>().ToTable("guest_profiles");
|
||||
modelBuilder.Entity<AdminUser>().ToTable("admin_users");
|
||||
modelBuilder.Entity<SystemSetting>().ToTable("system_settings");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY ["radiuscontroller/radiuscontroller.csproj", "radiuscontroller/"]
|
||||
RUN dotnet restore "radiuscontroller/radiuscontroller.csproj"
|
||||
COPY . .
|
||||
WORKDIR "/src/radiuscontroller"
|
||||
RUN dotnet build "./radiuscontroller.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "./radiuscontroller.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["dotnet", "radiuscontroller.dll"]
|
||||
@@ -0,0 +1,235 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace radiuscontroller.Models;
|
||||
|
||||
[Table("radcheck")]
|
||||
public class RadCheck
|
||||
{
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column("username")]
|
||||
[StringLength(64)]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Column("attribute")]
|
||||
[StringLength(64)]
|
||||
public string Attribute { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Column("op")]
|
||||
[StringLength(2)]
|
||||
public string Op { get; set; } = ":=";
|
||||
|
||||
[Required]
|
||||
[Column("value")]
|
||||
[StringLength(253)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[Table("radreply")]
|
||||
public class RadReply
|
||||
{
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column("username")]
|
||||
[StringLength(64)]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Column("attribute")]
|
||||
[StringLength(64)]
|
||||
public string Attribute { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Column("op")]
|
||||
[StringLength(2)]
|
||||
public string Op { get; set; } = "=";
|
||||
|
||||
[Required]
|
||||
[Column("value")]
|
||||
[StringLength(253)]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[Table("radacct")]
|
||||
public class RadAcct
|
||||
{
|
||||
[Key]
|
||||
[Column("radacctid")]
|
||||
public long RadAcctId { get; set; }
|
||||
|
||||
[Column("acctsessionid")]
|
||||
public string AcctSessionId { get; set; } = string.Empty;
|
||||
|
||||
[Column("acctuniqueid")]
|
||||
public string AcctUniqueId { get; set; } = string.Empty;
|
||||
|
||||
[Column("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Column("realm")]
|
||||
public string? Realm { get; set; }
|
||||
|
||||
[Column("nasipaddress")]
|
||||
public string NasIpAddress { get; set; } = string.Empty;
|
||||
|
||||
[Column("nasportid")]
|
||||
public string? NasPortId { get; set; }
|
||||
|
||||
[Column("nasporttype")]
|
||||
public string? NasPortType { get; set; }
|
||||
|
||||
[Column("acctstarttime")]
|
||||
public DateTime? AcctStartTime { get; set; }
|
||||
|
||||
[Column("acctupdatetime")]
|
||||
public DateTime? AcctUpdateTime { get; set; }
|
||||
|
||||
[Column("acctstoptime")]
|
||||
public DateTime? AcctStopTime { get; set; }
|
||||
|
||||
[Column("acctinterval")]
|
||||
public int? AcctInterval { get; set; }
|
||||
|
||||
[Column("acctsessiontime")]
|
||||
public long? AcctSessionTime { get; set; }
|
||||
|
||||
[Column("acctauthentic")]
|
||||
public string? AcctAuthentic { get; set; }
|
||||
|
||||
[Column("connectinfo_in")]
|
||||
public string? ConnectInfoIn { get; set; }
|
||||
|
||||
[Column("connectinfo_out")]
|
||||
public string? ConnectInfoOut { get; set; }
|
||||
|
||||
[Column("acctinputoctets")]
|
||||
public long? AcctInputOctets { get; set; }
|
||||
|
||||
[Column("acctoutputoctets")]
|
||||
public long? AcctOutputOctets { get; set; }
|
||||
|
||||
[Column("calledstationid")]
|
||||
public string? CalledStationId { get; set; }
|
||||
|
||||
[Column("callingstationid")]
|
||||
public string? CallingStationId { get; set; }
|
||||
|
||||
[Column("acctterminatecause")]
|
||||
public string? AcctTerminateCause { get; set; }
|
||||
|
||||
[Column("servicetype")]
|
||||
public string? ServiceType { get; set; }
|
||||
|
||||
[Column("framedprotocol")]
|
||||
public string? FramedProtocol { get; set; }
|
||||
|
||||
[Column("framedipaddress")]
|
||||
public string? FramedIpAddress { get; set; }
|
||||
}
|
||||
|
||||
[Table("guest_profiles")]
|
||||
public class GuestProfile
|
||||
{
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column("guest_name")]
|
||||
public string GuestName { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Column("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Column("created_at")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Column("session_time_minutes")]
|
||||
public int SessionTimeMinutes { get; set; }
|
||||
|
||||
[Column("data_limit_mb")]
|
||||
public long DataLimitMb { get; set; }
|
||||
|
||||
[Column("status")]
|
||||
public string Status { get; set; } = "Active";
|
||||
}
|
||||
|
||||
[Table("admin_users")]
|
||||
public class AdminUser
|
||||
{
|
||||
[Key]
|
||||
[Column("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column("username")]
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Column("password_hash")]
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
[Column("created_at")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
[Table("system_settings")]
|
||||
public class SystemSetting
|
||||
{
|
||||
[Key]
|
||||
[Column("key")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[Column("value")]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
[Column("updated_at")]
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
// DTOs
|
||||
public class UserUsageDto
|
||||
{
|
||||
public int ProfileId { get; set; }
|
||||
public string GuestName { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public int SessionTimeMinutes { get; set; }
|
||||
public long DataLimitMb { get; set; }
|
||||
public string Status { get; set; } = "Active";
|
||||
|
||||
public long TotalInputOctets { get; set; } // Upload
|
||||
public long TotalOutputOctets { get; set; } // Download
|
||||
public long TotalOctets => TotalInputOctets + TotalOutputOctets;
|
||||
public double UsedDataMb => Math.Round((double)TotalOctets / (1024 * 1024), 2);
|
||||
public long TotalSessionTimeSeconds { get; set; }
|
||||
public bool IsCurrentlyConnected { get; set; }
|
||||
}
|
||||
|
||||
public class RegistrationResultDto
|
||||
{
|
||||
public string GuestName { get; set; } = string.Empty;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public int SessionTimeMinutes { get; set; }
|
||||
public long DataLimitMb { get; set; }
|
||||
public DateTime ExpirationEstimate { get; set; }
|
||||
}
|
||||
|
||||
public class SystemConfigDto
|
||||
{
|
||||
public int DefaultSessionTimeMinutes { get; set; } = 60;
|
||||
public long DefaultDataLimitMb { get; set; } = 500;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Components;
|
||||
using radiuscontroller.Data;
|
||||
using radiuscontroller.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add database context
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? "Host=localhost;Port=5432;Database=radius;Username=radius;Password=radpass";
|
||||
|
||||
builder.Services.AddDbContext<RadiusDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
|
||||
// Add services
|
||||
builder.Services.AddScoped<IRadiusService, RadiusService>();
|
||||
builder.Services.AddScoped<AdminAuthService>();
|
||||
|
||||
// Add Razor components
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Auto-seed admin user and default settings if DB available
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var radiusService = scope.ServiceProvider.GetRequiredService<IRadiusService>();
|
||||
await radiusService.EnsureDatabaseCreatedAndSeededAsync();
|
||||
}
|
||||
|
||||
// Configure HTTP request pipeline
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5126",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7044;http://localhost:5126",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Data;
|
||||
|
||||
namespace radiuscontroller.Services;
|
||||
|
||||
public class AdminAuthService
|
||||
{
|
||||
private readonly RadiusDbContext _db;
|
||||
private readonly ProtectedSessionStorage _sessionStorage;
|
||||
|
||||
private const string SessionKey = "AdminUsername";
|
||||
public string? CurrentAdminUsername { get; private set; }
|
||||
public bool IsAuthenticated => !string.IsNullOrEmpty(CurrentAdminUsername);
|
||||
|
||||
public event Action? OnAuthStateChanged;
|
||||
|
||||
public AdminAuthService(RadiusDbContext db, ProtectedSessionStorage sessionStorage)
|
||||
{
|
||||
_db = db;
|
||||
_sessionStorage = sessionStorage;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _sessionStorage.GetAsync<string>(SessionKey);
|
||||
if (result.Success && !string.IsNullOrEmpty(result.Value))
|
||||
{
|
||||
CurrentAdminUsername = result.Value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Session storage might fail during prerendering or early socket connection
|
||||
CurrentAdminUsername = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> LoginAsync(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||
return false;
|
||||
|
||||
var admin = await _db.AdminUsers.FirstOrDefaultAsync(u => u.Username == username.Trim().ToLowerInvariant());
|
||||
if (admin == null)
|
||||
return false;
|
||||
|
||||
bool isValid = BCrypt.Net.BCrypt.Verify(password, admin.PasswordHash);
|
||||
if (isValid)
|
||||
{
|
||||
CurrentAdminUsername = admin.Username;
|
||||
await _sessionStorage.SetAsync(SessionKey, admin.Username);
|
||||
NotifyStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
CurrentAdminUsername = null;
|
||||
try
|
||||
{
|
||||
await _sessionStorage.DeleteAsync(SessionKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore storage errors during logout
|
||||
}
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnAuthStateChanged?.Invoke();
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using radiuscontroller.Data;
|
||||
using radiuscontroller.Models;
|
||||
|
||||
namespace radiuscontroller.Services;
|
||||
|
||||
public interface IRadiusService
|
||||
{
|
||||
Task<SystemConfigDto> GetSystemConfigAsync();
|
||||
Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb);
|
||||
Task<RegistrationResultDto> RegisterGuestAsync(string guestName);
|
||||
Task<List<UserUsageDto>> GetAllUsersWithUsageAsync();
|
||||
Task<List<RadAcct>> GetActiveSessionsAsync();
|
||||
Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb);
|
||||
Task RevokeUserAsync(int profileId);
|
||||
Task DeleteUserAsync(int profileId);
|
||||
Task ResetUserPasswordAsync(int profileId, string newPassword);
|
||||
Task EnsureDatabaseCreatedAndSeededAsync();
|
||||
}
|
||||
|
||||
public class RadiusService : IRadiusService
|
||||
{
|
||||
private readonly RadiusDbContext _db;
|
||||
private readonly ILogger<RadiusService> _logger;
|
||||
|
||||
public RadiusService(RadiusDbContext db, ILogger<RadiusService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SystemConfigDto> GetSystemConfigAsync()
|
||||
{
|
||||
var settings = await _db.SystemSettings.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||
|
||||
int sessionTime = 60; // 60 minutes default
|
||||
if (settings.TryGetValue("default_session_time", out var sessionStr) && int.TryParse(sessionStr, out var parsedSession))
|
||||
{
|
||||
sessionTime = parsedSession;
|
||||
}
|
||||
|
||||
long dataLimit = 500; // 500 MB default
|
||||
if (settings.TryGetValue("default_data_limit", out var dataStr) && long.TryParse(dataStr, out var parsedData))
|
||||
{
|
||||
dataLimit = parsedData;
|
||||
}
|
||||
|
||||
return new SystemConfigDto
|
||||
{
|
||||
DefaultSessionTimeMinutes = sessionTime,
|
||||
DefaultDataLimitMb = dataLimit
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpdateSystemConfigAsync(int defaultSessionTimeMinutes, long defaultDataLimitMb)
|
||||
{
|
||||
await SetOrUpdateSettingAsync("default_session_time", defaultSessionTimeMinutes.ToString());
|
||||
await SetOrUpdateSettingAsync("default_data_limit", defaultDataLimitMb.ToString());
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SetOrUpdateSettingAsync(string key, string value)
|
||||
{
|
||||
var existing = await _db.SystemSettings.FirstOrDefaultAsync(s => s.Key == key);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Value = value;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.SystemSettings.Add(new SystemSetting
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<RegistrationResultDto> RegisterGuestAsync(string guestName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(guestName))
|
||||
{
|
||||
throw new ArgumentException("Guest name cannot be empty.", nameof(guestName));
|
||||
}
|
||||
|
||||
var config = await GetSystemConfigAsync();
|
||||
|
||||
// Format clean username base
|
||||
string cleanName = Regex.Replace(guestName.Trim().ToLowerInvariant(), @"[^a-z0-9]", "");
|
||||
if (string.IsNullOrEmpty(cleanName)) cleanName = "guest";
|
||||
if (cleanName.Length > 12) cleanName = cleanName[..12];
|
||||
|
||||
string username = $"{cleanName}_{RandomNumberGenerator.GetInt32(1000, 9999)}";
|
||||
string password = GenerateReadablePassword(8);
|
||||
|
||||
// Save Cleartext-Password for FreeRADIUS
|
||||
_db.RadCheck.Add(new RadCheck
|
||||
{
|
||||
Username = username,
|
||||
Attribute = "Cleartext-Password",
|
||||
Op = ":=",
|
||||
Value = password
|
||||
});
|
||||
|
||||
// Set Session-Timeout (in seconds)
|
||||
long sessionTimeoutSeconds = (long)config.DefaultSessionTimeMinutes * 60;
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = username,
|
||||
Attribute = "Session-Timeout",
|
||||
Op = "=",
|
||||
Value = sessionTimeoutSeconds.ToString()
|
||||
});
|
||||
|
||||
// Set Max-Octets (in bytes)
|
||||
long maxOctetsBytes = config.DefaultDataLimitMb * 1024 * 1024;
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = username,
|
||||
Attribute = "Max-Octets",
|
||||
Op = "=",
|
||||
Value = maxOctetsBytes.ToString()
|
||||
});
|
||||
|
||||
// Create Guest Profile record
|
||||
var profile = new GuestProfile
|
||||
{
|
||||
GuestName = guestName.Trim(),
|
||||
Username = username,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
SessionTimeMinutes = config.DefaultSessionTimeMinutes,
|
||||
DataLimitMb = config.DefaultDataLimitMb,
|
||||
Status = "Active"
|
||||
};
|
||||
_db.GuestProfiles.Add(profile);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new RegistrationResultDto
|
||||
{
|
||||
GuestName = profile.GuestName,
|
||||
Username = username,
|
||||
Password = password,
|
||||
SessionTimeMinutes = config.DefaultSessionTimeMinutes,
|
||||
DataLimitMb = config.DefaultDataLimitMb,
|
||||
ExpirationEstimate = DateTime.UtcNow.AddMinutes(config.DefaultSessionTimeMinutes)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageDto>> GetAllUsersWithUsageAsync()
|
||||
{
|
||||
var profiles = await _db.GuestProfiles.OrderByDescending(p => p.CreatedAt).ToListAsync();
|
||||
var passwords = await _db.RadCheck
|
||||
.Where(rc => rc.Attribute == "Cleartext-Password")
|
||||
.ToDictionaryAsync(rc => rc.Username, rc => rc.Value);
|
||||
|
||||
// Group accounting data by username
|
||||
var acctStats = await _db.RadAcct
|
||||
.GroupBy(ra => ra.Username)
|
||||
.Select(g => new
|
||||
{
|
||||
Username = g.Key,
|
||||
TotalInput = g.Sum(x => x.AcctInputOctets ?? 0),
|
||||
TotalOutput = g.Sum(x => x.AcctOutputOctets ?? 0),
|
||||
TotalSessionTime = g.Sum(x => x.AcctSessionTime ?? 0),
|
||||
IsConnected = g.Any(x => x.AcctStopTime == null)
|
||||
})
|
||||
.ToDictionaryAsync(g => g.Username);
|
||||
|
||||
var result = new List<UserUsageDto>();
|
||||
|
||||
foreach (var p in profiles)
|
||||
{
|
||||
passwords.TryGetValue(p.Username, out var pwd);
|
||||
acctStats.TryGetValue(p.Username, out var acct);
|
||||
|
||||
result.Add(new UserUsageDto
|
||||
{
|
||||
ProfileId = p.Id,
|
||||
GuestName = p.GuestName,
|
||||
Username = p.Username,
|
||||
Password = pwd ?? "••••••••",
|
||||
CreatedAt = p.CreatedAt,
|
||||
SessionTimeMinutes = p.SessionTimeMinutes,
|
||||
DataLimitMb = p.DataLimitMb,
|
||||
Status = p.Status,
|
||||
TotalInputOctets = acct?.TotalInput ?? 0,
|
||||
TotalOutputOctets = acct?.TotalOutput ?? 0,
|
||||
TotalSessionTimeSeconds = acct?.TotalSessionTime ?? 0,
|
||||
IsCurrentlyConnected = acct?.IsConnected ?? false
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<RadAcct>> GetActiveSessionsAsync()
|
||||
{
|
||||
return await _db.RadAcct
|
||||
.Where(ra => ra.AcctStopTime == null)
|
||||
.OrderByDescending(ra => ra.AcctStartTime)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task UpdateUserLimitsAsync(int profileId, int newSessionTimeMinutes, long newDataLimitMb)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
profile.SessionTimeMinutes = newSessionTimeMinutes;
|
||||
profile.DataLimitMb = newDataLimitMb;
|
||||
|
||||
// Update RadReply Session-Timeout
|
||||
var sessionReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Session-Timeout");
|
||||
if (sessionReply != null)
|
||||
{
|
||||
sessionReply.Value = (newSessionTimeMinutes * 60).ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Session-Timeout",
|
||||
Op = "=",
|
||||
Value = (newSessionTimeMinutes * 60).ToString()
|
||||
});
|
||||
}
|
||||
|
||||
// Update RadReply Max-Octets
|
||||
var dataReply = await _db.RadReply.FirstOrDefaultAsync(rr => rr.Username == profile.Username && rr.Attribute == "Max-Octets");
|
||||
long maxOctetsBytes = newDataLimitMb * 1024 * 1024;
|
||||
if (dataReply != null)
|
||||
{
|
||||
dataReply.Value = maxOctetsBytes.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.RadReply.Add(new RadReply
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Max-Octets",
|
||||
Op = "=",
|
||||
Value = maxOctetsBytes.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task RevokeUserAsync(int profileId)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
profile.Status = "Revoked";
|
||||
|
||||
// Remove Cleartext-Password and insert Auth-Type := Reject
|
||||
var checks = await _db.RadCheck.Where(rc => rc.Username == profile.Username).ToListAsync();
|
||||
_db.RadCheck.RemoveRange(checks);
|
||||
|
||||
_db.RadCheck.Add(new RadCheck
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Auth-Type",
|
||||
Op = ":=",
|
||||
Value = "Reject"
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteUserAsync(int profileId)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
var username = profile.Username;
|
||||
|
||||
var checks = await _db.RadCheck.Where(rc => rc.Username == username).ToListAsync();
|
||||
var replies = await _db.RadReply.Where(rr => rr.Username == username).ToListAsync();
|
||||
|
||||
_db.RadCheck.RemoveRange(checks);
|
||||
_db.RadReply.RemoveRange(replies);
|
||||
_db.GuestProfiles.Remove(profile);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task ResetUserPasswordAsync(int profileId, string newPassword)
|
||||
{
|
||||
var profile = await _db.GuestProfiles.FindAsync(profileId);
|
||||
if (profile == null) return;
|
||||
|
||||
var check = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Cleartext-Password");
|
||||
if (check != null)
|
||||
{
|
||||
check.Value = newPassword;
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.RadCheck.Add(new RadCheck
|
||||
{
|
||||
Username = profile.Username,
|
||||
Attribute = "Cleartext-Password",
|
||||
Op = ":=",
|
||||
Value = newPassword
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.Status == "Revoked")
|
||||
{
|
||||
profile.Status = "Active";
|
||||
var rejectCheck = await _db.RadCheck.FirstOrDefaultAsync(rc => rc.Username == profile.Username && rc.Attribute == "Auth-Type");
|
||||
if (rejectCheck != null)
|
||||
{
|
||||
_db.RadCheck.Remove(rejectCheck);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task EnsureDatabaseCreatedAndSeededAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.Database.EnsureCreatedAsync();
|
||||
|
||||
if (!await _db.AdminUsers.AnyAsync())
|
||||
{
|
||||
// Default admin user: admin / admin123
|
||||
string hash = BCrypt.Net.BCrypt.HashPassword("admin123");
|
||||
_db.AdminUsers.Add(new AdminUser
|
||||
{
|
||||
Username = "admin",
|
||||
PasswordHash = hash,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (!await _db.SystemSettings.AnyAsync())
|
||||
{
|
||||
_db.SystemSettings.Add(new SystemSetting { Key = "default_session_time", Value = "60", UpdatedAt = DateTime.UtcNow });
|
||||
_db.SystemSettings.Add(new SystemSetting { Key = "default_data_limit", Value = "500", UpdatedAt = DateTime.UtcNow });
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Database initialization warning (Database might be populated via Docker init.sql script).");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateReadablePassword(int length)
|
||||
{
|
||||
const string chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
var result = new char[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
result[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=radius;Username=radius;Password=radpass"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\.dockerignore">
|
||||
<Link>.dockerignore</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,528 @@
|
||||
:root {
|
||||
--bg-dark: #0b0f19;
|
||||
--bg-card: rgba(19, 26, 43, 0.7);
|
||||
--bg-card-border: rgba(255, 255, 255, 0.08);
|
||||
--bg-card-hover: rgba(30, 41, 67, 0.8);
|
||||
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--primary-glow: rgba(99, 102, 241, 0.35);
|
||||
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-rose: #f43f5e;
|
||||
--accent-emerald: #10b981;
|
||||
|
||||
--text-main: #f8fafc;
|
||||
--text-muted: #94a3b8;
|
||||
--text-dim: #64748b;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
--radius-xl: 28px;
|
||||
|
||||
--shadow-glow: 0 0 25px rgba(99, 102, 241, 0.15);
|
||||
--shadow-card: 0 10px 30px -5px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--bg-dark);
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, rgba(168, 85, 247, 0.1) 0px, transparent 50%),
|
||||
radial-gradient(at 50% 100%, rgba(20, 184, 166, 0.08) 0px, transparent 50%);
|
||||
background-attachment: fixed;
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(11, 15, 25, 0.8);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* App Container */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header Navbar */
|
||||
.app-header {
|
||||
background: rgba(11, 15, 25, 0.85);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--bg-card-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: 0.85rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-main);
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--primary), var(--accent-purple));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
box-shadow: 0 0 15px var(--primary-glow);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:hover, .nav-item.active {
|
||||
color: var(--text-main);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-item.admin-btn {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2));
|
||||
border: 1px solid rgba(99, 102, 241, 0.4);
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
.nav-item.admin-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.35), rgba(168, 85, 247, 0.35));
|
||||
color: white;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Layout Body */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2.5rem 2rem;
|
||||
max-width: 1320px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Glassmorphism Cards */
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 2rem;
|
||||
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
.glass-card.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
box-shadow: var(--shadow-card), var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Hero & Guest Portal Styling */
|
||||
.portal-wrapper {
|
||||
max-width: 580px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.portal-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.portal-title {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(135deg, #ffffff 30%, var(--text-muted));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portal-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control-glass {
|
||||
width: 100%;
|
||||
background: rgba(11, 15, 25, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.9rem 1.2rem;
|
||||
color: var(--text-main);
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control-glass:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-glow);
|
||||
background: rgba(11, 15, 25, 0.85);
|
||||
}
|
||||
|
||||
.btn-gradient-primary {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.95rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 4px 15px var(--primary-glow);
|
||||
}
|
||||
|
||||
.btn-gradient-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(99, 102, 241, 0.45);
|
||||
}
|
||||
|
||||
.btn-gradient-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Credentials Output Box */
|
||||
.credentials-box {
|
||||
background: linear-gradient(135deg, rgba(20, 184, 166, 0.08), rgba(99, 102, 241, 0.12));
|
||||
border: 1px solid rgba(20, 184, 166, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.75rem;
|
||||
margin-top: 1.5rem;
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.cred-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(11, 15, 25, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.cred-label {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cred-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-teal);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-main);
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Stats Cards Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-icon.primary { background: rgba(99, 102, 241, 0.15); color: var(--primary); }
|
||||
.stat-icon.teal { background: rgba(20, 184, 166, 0.15); color: var(--accent-teal); }
|
||||
.stat-icon.amber { background: rgba(245, 158, 11, 0.15); color: var(--accent-amber); }
|
||||
.stat-icon.emerald { background: rgba(16, 185, 129, 0.15); color: var(--accent-emerald); }
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Data Table */
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--bg-card-border);
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
|
||||
.custom-table th {
|
||||
background: rgba(15, 22, 38, 0.95);
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--bg-card-border);
|
||||
}
|
||||
|
||||
.custom-table td {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-main);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.custom-table tbody tr {
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge-emerald { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
|
||||
.badge-amber { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
|
||||
.badge-rose { background: rgba(244, 63, 94, 0.15); color: #fda4af; border: 1px solid rgba(244, 63, 94, 0.3); }
|
||||
.badge-primary { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; border: 1px solid rgba(99, 102, 241, 0.3); }
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-bar-bg {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-bar-fill.normal { background: linear-gradient(90deg, var(--accent-teal), var(--primary)); }
|
||||
.progress-bar-fill.warning { background: linear-gradient(90deg, var(--accent-amber), var(--accent-rose)); }
|
||||
|
||||
/* Tabs */
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid var(--bg-card-border);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 0.85rem 1.5rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.btn-sm {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--text-main);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: rgba(244, 63, 94, 0.15);
|
||||
color: #fda4af;
|
||||
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(244, 63, 94, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.app-footer {
|
||||
border-top: 1px solid var(--bg-card-border);
|
||||
padding: 1.5rem 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
-- Initialize PostgreSQL database for FreeRADIUS and RadiusController App
|
||||
|
||||
-- Standard FreeRADIUS PostgreSQL Schema
|
||||
CREATE TABLE IF NOT EXISTS radcheck (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
attribute VARCHAR(64) NOT NULL DEFAULT '',
|
||||
op VARCHAR(2) NOT NULL DEFAULT ':=',
|
||||
value VARCHAR(253) NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radcheck_username ON radcheck (username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radreply (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
attribute VARCHAR(64) NOT NULL DEFAULT '',
|
||||
op VARCHAR(2) NOT NULL DEFAULT '=',
|
||||
value VARCHAR(253) NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radreply_username ON radreply (username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radgroupcheck (
|
||||
id SERIAL PRIMARY KEY,
|
||||
groupname VARCHAR(64) NOT NULL DEFAULT '',
|
||||
attribute VARCHAR(64) NOT NULL DEFAULT '',
|
||||
op VARCHAR(2) NOT NULL DEFAULT ':=',
|
||||
value VARCHAR(253) NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radgroupcheck_groupname ON radgroupcheck (groupname);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radgroupreply (
|
||||
id SERIAL PRIMARY KEY,
|
||||
groupname VARCHAR(64) NOT NULL DEFAULT '',
|
||||
attribute VARCHAR(64) NOT NULL DEFAULT '',
|
||||
op VARCHAR(2) NOT NULL DEFAULT '=',
|
||||
value VARCHAR(253) NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radgroupreply_groupname ON radgroupreply (groupname);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radusergroup (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
groupname VARCHAR(64) NOT NULL DEFAULT '',
|
||||
priority INT NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radusergroup_username ON radusergroup (username);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS radacct (
|
||||
radacctid BIGSERIAL PRIMARY KEY,
|
||||
acctsessionid VARCHAR(64) NOT NULL DEFAULT '',
|
||||
acctuniqueid VARCHAR(32) NOT NULL DEFAULT '',
|
||||
username VARCHAR(64) NOT NULL DEFAULT '',
|
||||
realm VARCHAR(64) DEFAULT '',
|
||||
nasipaddress VARCHAR(15) NOT NULL DEFAULT '',
|
||||
nasportid VARCHAR(32) DEFAULT NULL,
|
||||
nasporttype VARCHAR(32) DEFAULT NULL,
|
||||
acctstarttime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||
acctupdatetime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||
acctstoptime TIMESTAMP WITH TIME ZONE DEFAULT NULL,
|
||||
acctinterval INT DEFAULT NULL,
|
||||
acctsessiontime BIGINT DEFAULT NULL,
|
||||
acctauthentic VARCHAR(32) DEFAULT NULL,
|
||||
connectinfo_in VARCHAR(50) DEFAULT NULL,
|
||||
connectinfo_out VARCHAR(50) DEFAULT NULL,
|
||||
acctinputoctets BIGINT DEFAULT NULL,
|
||||
acctoutputoctets BIGINT DEFAULT NULL,
|
||||
calledstationid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
callingstationid VARCHAR(50) NOT NULL DEFAULT '',
|
||||
acctterminatecause VARCHAR(32) NOT NULL DEFAULT '',
|
||||
servicetype VARCHAR(32) DEFAULT NULL,
|
||||
framedprotocol VARCHAR(32) DEFAULT NULL,
|
||||
framedipaddress VARCHAR(15) NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS radacct_username ON radacct (username);
|
||||
CREATE INDEX IF NOT EXISTS radacct_active ON radacct (acctstoptime) WHERE acctstoptime IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS nas (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nasname VARCHAR(128) NOT NULL,
|
||||
shortname VARCHAR(32),
|
||||
type VARCHAR(30) DEFAULT 'other',
|
||||
ports INT,
|
||||
secret VARCHAR(60) NOT NULL,
|
||||
server VARCHAR(64),
|
||||
community VARCHAR(50),
|
||||
description VARCHAR(200) DEFAULT 'RADIUS Client'
|
||||
);
|
||||
|
||||
-- Application Management Tables
|
||||
CREATE TABLE IF NOT EXISTS guest_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guest_name VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
session_time_minutes INT NOT NULL DEFAULT 60,
|
||||
data_limit_mb BIGINT NOT NULL DEFAULT 500,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'Active'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key VARCHAR(64) PRIMARY KEY,
|
||||
value VARCHAR(255) NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Seed Default Settings
|
||||
INSERT INTO system_settings (key, value) VALUES ('default_session_time', '60') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO system_settings (key, value) VALUES ('default_data_limit', '500') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- Seed Default Admin User: admin / admin123 (BCrypt hash)
|
||||
INSERT INTO admin_users (username, password_hash)
|
||||
VALUES ('admin', '$2a$11$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy')
|
||||
ON CONFLICT (username) DO NOTHING;
|
||||
Reference in New Issue
Block a user