feat: Add Multi-Admin and Per-Interface Peer Defaults

Multi-Admin Feature:
- New DashboardAdmins.py module for managing multiple admin users
- API endpoints for admin CRUD operations
- Frontend UI in Settings for admin management
- Auto-migration from config file to database

Per-Interface Peer Defaults:
- Override Peer Settings now apply when creating new peers
- Frontend loads per-interface defaults in Add Peer modal
- Fallback to global defaults if override not set

Both features are backward compatible with WGDashboard v4.3.1
pull/1117/head
V-Bantserov 2026-02-02 22:45:24 +02:00
parent 8f973021fc
commit a88f9335fa
6 changed files with 1072 additions and 60 deletions

View File

@ -40,6 +40,7 @@ from modules.DashboardClients import DashboardClients
from modules.DashboardPlugins import DashboardPlugins
from modules.DashboardWebHooks import DashboardWebHooks
from modules.NewConfigurationTemplates import NewConfigurationTemplates
from modules.DashboardAdmins import DashboardAdmins
class CustomJsonEncoder(DefaultJSONProvider):
def __init__(self, app):
@ -201,6 +202,31 @@ with app.app_context():
DashboardPlugins: DashboardPlugins = DashboardPlugins(app, WireguardConfigurations)
DashboardWebHooks: DashboardWebHooks = DashboardWebHooks(DashboardConfig)
NewConfigurationTemplates: NewConfigurationTemplates = NewConfigurationTemplates()
# Initialize DashboardAdmins
DashboardAdmins: DashboardAdmins = DashboardAdmins(
DashboardConfig.engine,
DashboardConfig.GetConfig('Database', 'type')[1]
)
# Migrate admin from config file to database (first-time only)
if DashboardAdmins.getAdminCount() == 0:
_, configUsername = DashboardConfig.GetConfig("Account", "username")
_, configPassword = DashboardConfig.GetConfig("Account", "password")
_, enableTotp = DashboardConfig.GetConfig("Account", "enable_totp")
_, totpKey = DashboardConfig.GetConfig("Account", "totp_key")
success, msg = DashboardAdmins.migrateFromConfig(
username=configUsername,
passwordHash=configPassword,
enableTotp=enableTotp,
totpKey=totpKey
)
if success:
print(f"[WGDashboard] Admin migrated to database: {configUsername}")
else:
print(f"[WGDashboard] Admin migration warning: {msg}")
InitWireguardConfigurationsList(startup=True)
DashboardClients: DashboardClients = DashboardClients(WireguardConfigurations)
app.register_blueprint(createClientBlueprint(WireguardConfigurations, DashboardConfig, DashboardClients))
@ -297,30 +323,31 @@ def API_AuthenticateLogin():
resp.set_cookie("authToken", authToken)
session.permanent = True
return resp
valid = bcrypt.checkpw(data['password'].encode("utf-8"),
DashboardConfig.GetConfig("Account", "password")[1].encode("utf-8"))
totpEnabled = DashboardConfig.GetConfig("Account", "enable_totp")[1]
totpValid = False
if totpEnabled:
totpValid = pyotp.TOTP(DashboardConfig.GetConfig("Account", "totp_key")[1]).now() == data['totp']
if (valid
and data['username'] == DashboardConfig.GetConfig("Account", "username")[1]
and ((totpEnabled and totpValid) or not totpEnabled)
):
authToken = hashlib.sha256(f"{data['username']}{datetime.now()}".encode()).hexdigest()
session['role'] = 'admin'
session['username'] = authToken
resp = ResponseObject(True, DashboardConfig.GetConfig("Other", "welcome_session")[1])
resp.set_cookie("authToken", authToken)
session.permanent = True
DashboardLogger.log(str(request.url), str(request.remote_addr), Message=f"Login success: {data['username']}")
return resp
DashboardLogger.log(str(request.url), str(request.remote_addr), Message=f"Login failed: {data['username']}")
if totpEnabled:
return ResponseObject(False, "Sorry, your username, password or OTP is incorrect.")
else:
# Authenticate using DashboardAdmins
success, admin, msg = DashboardAdmins.authenticate(data['username'], data['password'])
if not success:
DashboardLogger.log(str(request.url), str(request.remote_addr), Message=f"Login failed: {data['username']}")
return ResponseObject(False, "Sorry, your username or password is incorrect.")
# Check TOTP if enabled
if admin['enable_totp'] and admin['totp_verified']:
totpValid = pyotp.TOTP(admin['totp_key']).now() == data.get('totp', '')
if not totpValid:
DashboardLogger.log(str(request.url), str(request.remote_addr), Message=f"Login failed (TOTP): {data['username']}")
return ResponseObject(False, "Sorry, your username, password or OTP is incorrect.")
authToken = hashlib.sha256(f"{data['username']}{datetime.now()}".encode()).hexdigest()
session['role'] = 'admin'
session['username'] = authToken
session['admin_id'] = admin['id']
session['admin_username'] = admin['username']
resp = ResponseObject(True, DashboardConfig.GetConfig("Other", "welcome_session")[1])
resp.set_cookie("authToken", authToken)
session.permanent = True
DashboardLogger.log(str(request.url), str(request.remote_addr), Message=f"Login success: {data['username']}")
return resp
@app.get(f'{APP_PREFIX}/api/signout')
def API_SignOut():
@ -329,6 +356,129 @@ def API_SignOut():
session.clear()
return resp
# =====================================================
# Admin Management API Endpoints
# =====================================================
@app.get(f'{APP_PREFIX}/api/admins')
def API_GetAdmins():
"""Get all admin users"""
admins = DashboardAdmins.getAllAdmins()
return ResponseObject(True, data=[admin.toDict() for admin in admins])
@app.post(f'{APP_PREFIX}/api/admins/add')
def API_AddAdmin():
"""Add a new admin user"""
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '')
email = data.get('email', '').strip()
if not username or not password:
return ResponseObject(False, "Username and password are required")
success, msg = DashboardAdmins.addAdmin(username, password, email)
if success:
DashboardLogger.log(str(request.url), str(request.remote_addr),
Message=f"Admin created: {username} by {session.get('admin_username', 'unknown')}")
return ResponseObject(success, msg)
@app.post(f'{APP_PREFIX}/api/admins/update')
def API_UpdateAdmin():
"""Update admin details (username, email)"""
data = request.get_json()
adminId = data.get('id')
username = data.get('username')
email = data.get('email')
if not adminId:
return ResponseObject(False, "Admin ID is required")
success, msg = DashboardAdmins.updateAdmin(adminId, username, email)
if success:
DashboardLogger.log(str(request.url), str(request.remote_addr),
Message=f"Admin updated: ID {adminId} by {session.get('admin_username', 'unknown')}")
return ResponseObject(success, msg)
@app.post(f'{APP_PREFIX}/api/admins/changePassword')
def API_ChangeAdminPassword():
"""Change password for current logged-in admin"""
data = request.get_json()
currentPassword = data.get('currentPassword', '')
newPassword = data.get('newPassword', '')
adminId = session.get('admin_id')
if not adminId:
return ResponseObject(False, "Not authenticated")
if not currentPassword or not newPassword:
return ResponseObject(False, "Current password and new password are required")
success, msg = DashboardAdmins.changePassword(adminId, currentPassword, newPassword)
if success:
DashboardLogger.log(str(request.url), str(request.remote_addr),
Message=f"Password changed: {session.get('admin_username', 'unknown')}")
return ResponseObject(success, msg)
@app.post(f'{APP_PREFIX}/api/admins/resetPassword')
def API_ResetAdminPassword():
"""Reset password for any admin (admin action)"""
data = request.get_json()
adminId = data.get('id')
newPassword = data.get('newPassword', '')
if not adminId or not newPassword:
return ResponseObject(False, "Admin ID and new password are required")
success, msg = DashboardAdmins.resetPassword(adminId, newPassword)
if success:
admin = DashboardAdmins.getAdminById(adminId)
DashboardLogger.log(str(request.url), str(request.remote_addr),
Message=f"Password reset for: {admin['username'] if admin else adminId} by {session.get('admin_username', 'unknown')}")
return ResponseObject(success, msg)
@app.post(f'{APP_PREFIX}/api/admins/delete')
def API_DeleteAdmin():
"""Delete an admin user"""
data = request.get_json()
adminId = data.get('id')
if not adminId:
return ResponseObject(False, "Admin ID is required")
# Prevent self-deletion
if adminId == session.get('admin_id'):
return ResponseObject(False, "Cannot delete your own account")
admin = DashboardAdmins.getAdminById(adminId)
success, msg = DashboardAdmins.deleteAdmin(adminId)
if success:
DashboardLogger.log(str(request.url), str(request.remote_addr),
Message=f"Admin deleted: {admin['username'] if admin else adminId} by {session.get('admin_username', 'unknown')}")
return ResponseObject(success, msg)
@app.get(f'{APP_PREFIX}/api/admins/current')
def API_GetCurrentAdmin():
"""Get current logged-in admin info"""
adminId = session.get('admin_id')
if not adminId:
return ResponseObject(False, "Not authenticated")
admin = DashboardAdmins.getAdminById(adminId)
if admin:
# Don't return sensitive data
return ResponseObject(True, data={
'id': admin['id'],
'username': admin['username'],
'email': admin['email'],
'enable_totp': admin['enable_totp']
})
return ResponseObject(False, "Admin not found")
# =====================================================
# End Admin Management API Endpoints
# =====================================================
@app.get(f'{APP_PREFIX}/api/getWireguardConfigurations')
def API_getWireguardConfigurations():
InitWireguardConfigurationsList()

View File

@ -0,0 +1,351 @@
"""
Dashboard Admins - Multiple admin users management
"""
import bcrypt
import sqlalchemy as db
from datetime import datetime
from typing import Optional, List
from dataclasses import dataclass, asdict
@dataclass
class AdminUser:
"""Represents an admin user"""
id: int
username: str
email: str
created_at: str
last_login: Optional[str]
enable_totp: bool = False
totp_verified: bool = False
def toDict(self) -> dict:
return asdict(self)
class DashboardAdmins:
"""Manages multiple admin users in the database"""
def __init__(self, engine, dbType: str = 'sqlite'):
self.engine = engine
self.dbType = dbType
self.dbMetadata = db.MetaData()
self._createTable()
def _createTable(self):
"""Create the DashboardAdmins table if it doesn't exist"""
self.adminsTable = db.Table(
'DashboardAdmins',
self.dbMetadata,
db.Column("id", db.Integer, primary_key=True, autoincrement=True),
db.Column("username", db.String(255), nullable=False, unique=True),
db.Column("password", db.String(255), nullable=False),
db.Column("email", db.String(255), nullable=True, default=""),
db.Column("enable_totp", db.Boolean, default=False),
db.Column("totp_verified", db.Boolean, default=False),
db.Column("totp_key", db.String(255), nullable=True),
db.Column("created_at",
(db.DATETIME if self.dbType == 'sqlite' else db.TIMESTAMP),
server_default=db.func.now()),
db.Column("last_login",
(db.DATETIME if self.dbType == 'sqlite' else db.TIMESTAMP),
nullable=True)
)
self.dbMetadata.create_all(self.engine)
def _hashPassword(self, plainPassword: str) -> str:
"""Hash a password using bcrypt"""
return bcrypt.hashpw(plainPassword.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def _checkPassword(self, plainPassword: str, hashedPassword: str) -> bool:
"""Verify a password against its hash"""
try:
return bcrypt.checkpw(plainPassword.encode('utf-8'), hashedPassword.encode('utf-8'))
except Exception:
return False
def getAdminCount(self) -> int:
"""Get total number of admins"""
with self.engine.connect() as conn:
result = conn.execute(
db.select(db.func.count()).select_from(self.adminsTable)
).scalar()
return result or 0
def getAllAdmins(self) -> List[AdminUser]:
"""Get all admin users (without passwords)"""
admins = []
with self.engine.connect() as conn:
result = conn.execute(
self.adminsTable.select().order_by(self.adminsTable.c.id)
).fetchall()
for row in result:
admins.append(AdminUser(
id=row.id,
username=row.username,
email=row.email or "",
created_at=row.created_at.strftime("%Y-%m-%d %H:%M:%S") if row.created_at else "",
last_login=row.last_login.strftime("%Y-%m-%d %H:%M:%S") if row.last_login else None,
enable_totp=row.enable_totp or False,
totp_verified=row.totp_verified or False
))
return admins
def getAdminByUsername(self, username: str) -> Optional[dict]:
"""Get admin by username (includes password hash for auth)"""
with self.engine.connect() as conn:
result = conn.execute(
self.adminsTable.select().where(
self.adminsTable.c.username == username
)
).fetchone()
if result:
return {
'id': result.id,
'username': result.username,
'password': result.password,
'email': result.email or "",
'enable_totp': result.enable_totp or False,
'totp_verified': result.totp_verified or False,
'totp_key': result.totp_key,
'created_at': result.created_at,
'last_login': result.last_login
}
return None
def getAdminById(self, adminId: int) -> Optional[dict]:
"""Get admin by ID"""
with self.engine.connect() as conn:
result = conn.execute(
self.adminsTable.select().where(
self.adminsTable.c.id == adminId
)
).fetchone()
if result:
return {
'id': result.id,
'username': result.username,
'email': result.email or "",
'enable_totp': result.enable_totp or False,
'totp_verified': result.totp_verified or False,
'totp_key': result.totp_key,
'created_at': result.created_at,
'last_login': result.last_login
}
return None
def authenticate(self, username: str, password: str) -> tuple[bool, Optional[dict], str]:
"""
Authenticate an admin user
Returns: (success, admin_data, message)
"""
admin = self.getAdminByUsername(username)
if not admin:
return False, None, "Invalid username or password"
if not self._checkPassword(password, admin['password']):
return False, None, "Invalid username or password"
# Update last login
self.updateLastLogin(admin['id'])
return True, admin, "Login successful"
def addAdmin(self, username: str, password: str, email: str = "") -> tuple[bool, str]:
"""Add a new admin user"""
# Check if username already exists
if self.getAdminByUsername(username):
return False, "Username already exists"
# Validate username
if len(username) < 3:
return False, "Username must be at least 3 characters"
# Validate password
if len(password) < 4:
return False, "Password must be at least 4 characters"
try:
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.insert().values(
username=username,
password=self._hashPassword(password),
email=email,
enable_totp=False,
totp_verified=False,
created_at=datetime.now()
)
)
return True, "Admin created successfully"
except Exception as e:
return False, f"Error creating admin: {str(e)}"
def updateAdmin(self, adminId: int, username: str = None, email: str = None) -> tuple[bool, str]:
"""Update admin details (not password)"""
admin = self.getAdminById(adminId)
if not admin:
return False, "Admin not found"
updates = {}
if username is not None and username != admin['username']:
# Check if new username is taken
existing = self.getAdminByUsername(username)
if existing and existing['id'] != adminId:
return False, "Username already taken"
updates['username'] = username
if email is not None:
updates['email'] = email
if not updates:
return True, "No changes to apply"
try:
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.update()
.where(self.adminsTable.c.id == adminId)
.values(**updates)
)
return True, "Admin updated successfully"
except Exception as e:
return False, f"Error updating admin: {str(e)}"
def changePassword(self, adminId: int, currentPassword: str, newPassword: str) -> tuple[bool, str]:
"""Change admin password (requires current password)"""
admin = self.getAdminById(adminId)
if not admin:
return False, "Admin not found"
# Get full admin record with password
with self.engine.connect() as conn:
result = conn.execute(
self.adminsTable.select().where(self.adminsTable.c.id == adminId)
).fetchone()
if not result:
return False, "Admin not found"
if not self._checkPassword(currentPassword, result.password):
return False, "Current password is incorrect"
if len(newPassword) < 4:
return False, "New password must be at least 4 characters"
try:
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.update()
.where(self.adminsTable.c.id == adminId)
.values(password=self._hashPassword(newPassword))
)
return True, "Password changed successfully"
except Exception as e:
return False, f"Error changing password: {str(e)}"
def resetPassword(self, adminId: int, newPassword: str) -> tuple[bool, str]:
"""Reset admin password (admin action, no current password needed)"""
admin = self.getAdminById(adminId)
if not admin:
return False, "Admin not found"
if len(newPassword) < 4:
return False, "Password must be at least 4 characters"
try:
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.update()
.where(self.adminsTable.c.id == adminId)
.values(password=self._hashPassword(newPassword))
)
return True, "Password reset successfully"
except Exception as e:
return False, f"Error resetting password: {str(e)}"
def deleteAdmin(self, adminId: int) -> tuple[bool, str]:
"""Delete an admin user"""
# Cannot delete if only one admin remains
if self.getAdminCount() <= 1:
return False, "Cannot delete the last admin"
admin = self.getAdminById(adminId)
if not admin:
return False, "Admin not found"
try:
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.delete().where(self.adminsTable.c.id == adminId)
)
return True, f"Admin '{admin['username']}' deleted successfully"
except Exception as e:
return False, f"Error deleting admin: {str(e)}"
def updateLastLogin(self, adminId: int):
"""Update last login timestamp"""
try:
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.update()
.where(self.adminsTable.c.id == adminId)
.values(last_login=datetime.now())
)
except Exception:
pass # Non-critical error
def updateTOTP(self, adminId: int, enable: bool, totpKey: str = None, verified: bool = False) -> tuple[bool, str]:
"""Update TOTP settings for an admin"""
admin = self.getAdminById(adminId)
if not admin:
return False, "Admin not found"
try:
updates = {
'enable_totp': enable,
'totp_verified': verified
}
if totpKey:
updates['totp_key'] = totpKey
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.update()
.where(self.adminsTable.c.id == adminId)
.values(**updates)
)
return True, "TOTP updated successfully"
except Exception as e:
return False, f"Error updating TOTP: {str(e)}"
def migrateFromConfig(self, username: str, passwordHash: str,
enableTotp: bool = False, totpKey: str = None) -> tuple[bool, str]:
"""
Migrate admin from wg-dashboard.ini config to database.
Used for first-time migration.
"""
# Check if any admins exist
if self.getAdminCount() > 0:
return False, "Admins already exist in database"
try:
with self.engine.begin() as conn:
conn.execute(
self.adminsTable.insert().values(
username=username,
password=passwordHash, # Already hashed
email="",
enable_totp=enableTotp,
totp_verified=enableTotp, # If TOTP was enabled, it was verified
totp_key=totpKey,
created_at=datetime.now()
)
)
return True, f"Admin '{username}' migrated successfully"
except Exception as e:
return False, f"Error migrating admin: {str(e)}"

View File

@ -18,6 +18,20 @@ import {WireguardConfigurationsStore} from "@/stores/WireguardConfigurationsStor
const dashboardStore = DashboardConfigurationStore()
const wireguardStore = WireguardConfigurationsStore()
const route = useRoute()
// Find current configuration to get Override Peer Settings
const currentConfig = wireguardStore.Configurations.find(
x => x.Name === route.params.id
);
// Get Override Peer Settings (if they exist and have values)
const override = currentConfig?.Info?.OverridePeerSettings || {};
// Get global defaults
const globalDefaults = dashboardStore.Configuration.Peers;
// Initialize peerData with Override settings, falling back to global defaults
const peerData = ref({
bulkAdd: false,
bulkAddAmount: 0,
@ -25,10 +39,18 @@ const peerData = ref({
allowed_ips: [],
private_key: "",
public_key: "",
DNS: dashboardStore.Configuration.Peers.peer_global_dns,
endpoint_allowed_ip: dashboardStore.Configuration.Peers.peer_endpoint_allowed_ip,
keepalive: parseInt(dashboardStore.Configuration.Peers.peer_keep_alive),
mtu: parseInt(dashboardStore.Configuration.Peers.peer_mtu),
// DNS: Override if set, otherwise global default
DNS: override.DNS || globalDefaults.peer_global_dns,
// Endpoint Allowed IPs: Override if set, otherwise global default
endpoint_allowed_ip: override.EndpointAllowedIPs || globalDefaults.peer_endpoint_allowed_ip,
// Persistent Keepalive: Override if set (and > 0), otherwise global default
keepalive: (override.PersistentKeepalive && parseInt(override.PersistentKeepalive) > 0)
? parseInt(override.PersistentKeepalive)
: parseInt(globalDefaults.peer_keep_alive),
// MTU: Override if set (and > 0), otherwise global default
mtu: (override.MTU && parseInt(override.MTU) > 0)
? parseInt(override.MTU)
: parseInt(globalDefaults.peer_mtu),
preshared_key: "",
preshared_key_bulkAdd: false,
advanced_security: "off",
@ -37,7 +59,6 @@ const peerData = ref({
const availableIp = ref([])
const saving = ref(false)
const route = useRoute()
await fetchGet("/api/getAvailableIPs/" + route.params.id, {}, (res) => {
if (res.status){
availableIp.value = res.data;

View File

@ -31,10 +31,11 @@ export default {
allowed_ips: [],
private_key: "",
public_key: "",
DNS: this.dashboardStore.Configuration.Peers.peer_global_dns,
endpoint_allowed_ip: this.dashboardStore.Configuration.Peers.peer_endpoint_allowed_ip,
keepalive: parseInt(this.dashboardStore.Configuration.Peers.peer_keep_alive),
mtu: parseInt(this.dashboardStore.Configuration.Peers.peer_mtu),
// Will be set in mounted() with Override Peer Settings priority
DNS: "",
endpoint_allowed_ip: "",
keepalive: 0,
mtu: 0,
preshared_key: "",
preshared_key_bulkAdd: false,
advanced_security: "off",
@ -46,11 +47,40 @@ export default {
}
},
mounted() {
// Load available IPs
fetchGet("/api/getAvailableIPs/" + this.$route.params.id, {}, (res) => {
if (res.status){
this.availableIp = res.data;
}
})
// Find current configuration to get Override Peer Settings
const currentConfig = this.store.Configurations.find(
x => x.Name === this.$route.params.id
);
// Get Override Peer Settings (if they exist and have values)
const override = currentConfig?.Info?.OverridePeerSettings || {};
// Get global defaults
const globalDefaults = this.dashboardStore.Configuration.Peers;
// Apply Override settings with fallback to global defaults
// DNS: Override if set, otherwise global default
this.data.DNS = override.DNS || globalDefaults.peer_global_dns;
// Endpoint Allowed IPs: Override if set, otherwise global default
this.data.endpoint_allowed_ip = override.EndpointAllowedIPs || globalDefaults.peer_endpoint_allowed_ip;
// MTU: Override if set (and > 0), otherwise global default
this.data.mtu = (override.MTU && parseInt(override.MTU) > 0)
? parseInt(override.MTU)
: parseInt(globalDefaults.peer_mtu);
// Persistent Keepalive: Override if set (and > 0), otherwise global default
this.data.keepalive = (override.PersistentKeepalive && parseInt(override.PersistentKeepalive) > 0)
? parseInt(override.PersistentKeepalive)
: parseInt(globalDefaults.peer_keep_alive);
},
setup(){
const store = WireguardConfigurationsStore();

View File

@ -0,0 +1,484 @@
<script>
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
import {fetchGet, fetchPost} from "@/utilities/fetch.js";
import LocaleText from "@/components/text/localeText.vue";
export default {
name: "dashboardAdminUsers",
components: {LocaleText},
setup(){
const store = DashboardConfigurationStore();
return {store};
},
data(){
return {
admins: [],
loading: true,
showAddModal: false,
showEditModal: false,
showResetPasswordModal: false,
showChangePasswordModal: false,
// Add admin form
newAdmin: {
username: '',
password: '',
confirmPassword: '',
email: ''
},
// Edit admin form
editAdmin: {
id: null,
username: '',
email: ''
},
// Reset password form
resetPassword: {
id: null,
username: '',
newPassword: '',
confirmPassword: ''
},
// Change own password form
changePassword: {
currentPassword: '',
newPassword: '',
confirmPassword: ''
},
currentAdmin: null,
saving: false
}
},
mounted() {
this.loadAdmins();
this.loadCurrentAdmin();
},
methods: {
async loadAdmins() {
this.loading = true;
await fetchGet("/api/admins", {}, (res) => {
if(res.status){
this.admins = res.data;
} else {
this.store.newMessage("Server", res.message, "danger");
}
this.loading = false;
});
},
async loadCurrentAdmin() {
await fetchGet("/api/admins/current", {}, (res) => {
if(res.status){
this.currentAdmin = res.data;
}
});
},
// Add Admin
openAddModal() {
this.newAdmin = { username: '', password: '', confirmPassword: '', email: '' };
this.showAddModal = true;
},
async addAdmin() {
if (!this.newAdmin.username || !this.newAdmin.password) {
this.store.newMessage("Error", "Username and password are required", "danger");
return;
}
if (this.newAdmin.password !== this.newAdmin.confirmPassword) {
this.store.newMessage("Error", "Passwords do not match", "danger");
return;
}
this.saving = true;
await fetchPost("/api/admins/add", {
username: this.newAdmin.username,
password: this.newAdmin.password,
email: this.newAdmin.email
}, (res) => {
if(res.status){
this.store.newMessage("Server", "Admin created successfully", "success");
this.showAddModal = false;
this.loadAdmins();
} else {
this.store.newMessage("Server", res.message, "danger");
}
this.saving = false;
});
},
// Edit Admin
openEditModal(admin) {
this.editAdmin = {
id: admin.id,
username: admin.username,
email: admin.email || ''
};
this.showEditModal = true;
},
async updateAdmin() {
if (!this.editAdmin.username) {
this.store.newMessage("Error", "Username is required", "danger");
return;
}
this.saving = true;
await fetchPost("/api/admins/update", {
id: this.editAdmin.id,
username: this.editAdmin.username,
email: this.editAdmin.email
}, (res) => {
if(res.status){
this.store.newMessage("Server", "Admin updated successfully", "success");
this.showEditModal = false;
this.loadAdmins();
} else {
this.store.newMessage("Server", res.message, "danger");
}
this.saving = false;
});
},
// Reset Password (for other admins)
openResetPasswordModal(admin) {
this.resetPassword = {
id: admin.id,
username: admin.username,
newPassword: '',
confirmPassword: ''
};
this.showResetPasswordModal = true;
},
async resetAdminPassword() {
if (!this.resetPassword.newPassword) {
this.store.newMessage("Error", "New password is required", "danger");
return;
}
if (this.resetPassword.newPassword !== this.resetPassword.confirmPassword) {
this.store.newMessage("Error", "Passwords do not match", "danger");
return;
}
this.saving = true;
await fetchPost("/api/admins/resetPassword", {
id: this.resetPassword.id,
newPassword: this.resetPassword.newPassword
}, (res) => {
if(res.status){
this.store.newMessage("Server", "Password reset successfully", "success");
this.showResetPasswordModal = false;
} else {
this.store.newMessage("Server", res.message, "danger");
}
this.saving = false;
});
},
// Change Own Password
openChangePasswordModal() {
this.changePassword = {
currentPassword: '',
newPassword: '',
confirmPassword: ''
};
this.showChangePasswordModal = true;
},
async changeOwnPassword() {
if (!this.changePassword.currentPassword || !this.changePassword.newPassword) {
this.store.newMessage("Error", "All fields are required", "danger");
return;
}
if (this.changePassword.newPassword !== this.changePassword.confirmPassword) {
this.store.newMessage("Error", "New passwords do not match", "danger");
return;
}
this.saving = true;
await fetchPost("/api/admins/changePassword", {
currentPassword: this.changePassword.currentPassword,
newPassword: this.changePassword.newPassword
}, (res) => {
if(res.status){
this.store.newMessage("Server", "Password changed successfully", "success");
this.showChangePasswordModal = false;
} else {
this.store.newMessage("Server", res.message, "danger");
}
this.saving = false;
});
},
// Delete Admin
async deleteAdmin(admin) {
if (!confirm(`Are you sure you want to delete admin "${admin.username}"?`)) {
return;
}
await fetchPost("/api/admins/delete", { id: admin.id }, (res) => {
if(res.status){
this.store.newMessage("Server", res.message, "success");
this.loadAdmins();
} else {
this.store.newMessage("Server", res.message, "danger");
}
});
},
isCurrentAdmin(admin) {
return this.currentAdmin && this.currentAdmin.id === admin.id;
},
formatDate(dateStr) {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleString();
}
}
}
</script>
<template>
<div class="card rounded-3">
<div class="card-header d-flex align-items-center">
<h6 class="my-2">
<i class="bi bi-people-fill me-2"></i>
<LocaleText t="Admin Users"></LocaleText>
</h6>
<span class="badge bg-primary ms-2">{{ admins.length }}</span>
</div>
<div class="card-body d-flex flex-column gap-2">
<!-- Action buttons -->
<div class="d-flex gap-2 mb-2">
<button class="btn bg-primary-subtle text-primary-emphasis border-1 border-primary-subtle rounded-3 shadow-sm"
@click="openAddModal()">
<i class="bi bi-person-plus-fill me-2"></i>
<LocaleText t="Add Admin"></LocaleText>
</button>
<button class="btn bg-secondary-subtle text-secondary-emphasis border-1 border-secondary-subtle rounded-3 shadow-sm"
@click="openChangePasswordModal()">
<i class="bi bi-key-fill me-2"></i>
<LocaleText t="Change My Password"></LocaleText>
</button>
</div>
<!-- Loading state -->
<div v-if="loading" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Admin list -->
<div v-else class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th><LocaleText t="Username"></LocaleText></th>
<th><LocaleText t="Email"></LocaleText></th>
<th><LocaleText t="Created"></LocaleText></th>
<th><LocaleText t="Last Login"></LocaleText></th>
<th><LocaleText t="TOTP"></LocaleText></th>
<th class="text-end"><LocaleText t="Actions"></LocaleText></th>
</tr>
</thead>
<tbody>
<tr v-for="admin in admins" :key="admin.id"
:class="{'table-active': isCurrentAdmin(admin)}">
<td>
<i class="bi bi-person-fill me-1"></i>
{{ admin.username }}
<span v-if="isCurrentAdmin(admin)" class="badge bg-success ms-1">You</span>
</td>
<td>{{ admin.email || '-' }}</td>
<td><small>{{ formatDate(admin.created_at) }}</small></td>
<td><small>{{ formatDate(admin.last_login) }}</small></td>
<td>
<span v-if="admin.enable_totp" class="badge bg-success">
<i class="bi bi-shield-check"></i> Enabled
</span>
<span v-else class="badge bg-secondary">Disabled</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary"
@click="openEditModal(admin)"
title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-warning"
@click="openResetPasswordModal(admin)"
title="Reset Password">
<i class="bi bi-key"></i>
</button>
<button class="btn btn-outline-danger"
@click="deleteAdmin(admin)"
:disabled="isCurrentAdmin(admin) || admins.length <= 1"
title="Delete">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Add Admin Modal -->
<Teleport to="body">
<div v-if="showAddModal" class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-person-plus-fill me-2"></i>Add New Admin
</h5>
<button type="button" class="btn-close" @click="showAddModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Username <span class="text-danger">*</span></label>
<input type="text" class="form-control" v-model="newAdmin.username"
placeholder="Enter username" :disabled="saving">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" v-model="newAdmin.email"
placeholder="Enter email (optional)" :disabled="saving">
</div>
<div class="mb-3">
<label class="form-label">Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" v-model="newAdmin.password"
placeholder="Enter password" :disabled="saving">
</div>
<div class="mb-3">
<label class="form-label">Confirm Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" v-model="newAdmin.confirmPassword"
placeholder="Confirm password" :disabled="saving">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showAddModal = false" :disabled="saving">
Cancel
</button>
<button type="button" class="btn btn-primary" @click="addAdmin()" :disabled="saving">
<span v-if="saving" class="spinner-border spinner-border-sm me-1"></span>
Add Admin
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Edit Admin Modal -->
<Teleport to="body">
<div v-if="showEditModal" class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pencil me-2"></i>Edit Admin
</h5>
<button type="button" class="btn-close" @click="showEditModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Username <span class="text-danger">*</span></label>
<input type="text" class="form-control" v-model="editAdmin.username"
placeholder="Enter username" :disabled="saving">
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" v-model="editAdmin.email"
placeholder="Enter email (optional)" :disabled="saving">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showEditModal = false" :disabled="saving">
Cancel
</button>
<button type="button" class="btn btn-primary" @click="updateAdmin()" :disabled="saving">
<span v-if="saving" class="spinner-border spinner-border-sm me-1"></span>
Save Changes
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Reset Password Modal -->
<Teleport to="body">
<div v-if="showResetPasswordModal" class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-key me-2"></i>Reset Password for {{ resetPassword.username }}
</h5>
<button type="button" class="btn-close" @click="showResetPasswordModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">New Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" v-model="resetPassword.newPassword"
placeholder="Enter new password" :disabled="saving">
</div>
<div class="mb-3">
<label class="form-label">Confirm Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" v-model="resetPassword.confirmPassword"
placeholder="Confirm new password" :disabled="saving">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showResetPasswordModal = false" :disabled="saving">
Cancel
</button>
<button type="button" class="btn btn-warning" @click="resetAdminPassword()" :disabled="saving">
<span v-if="saving" class="spinner-border spinner-border-sm me-1"></span>
Reset Password
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- Change Own Password Modal -->
<Teleport to="body">
<div v-if="showChangePasswordModal" class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5)">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-key-fill me-2"></i>Change Your Password
</h5>
<button type="button" class="btn-close" @click="showChangePasswordModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Current Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" v-model="changePassword.currentPassword"
placeholder="Enter current password" :disabled="saving">
</div>
<div class="mb-3">
<label class="form-label">New Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" v-model="changePassword.newPassword"
placeholder="Enter new password" :disabled="saving">
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password <span class="text-danger">*</span></label>
<input type="password" class="form-control" v-model="changePassword.confirmPassword"
placeholder="Confirm new password" :disabled="saving">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showChangePasswordModal = false" :disabled="saving">
Cancel
</button>
<button type="button" class="btn btn-primary" @click="changeOwnPassword()" :disabled="saving">
<span v-if="saving" class="spinner-border spinner-border-sm me-1"></span>
Change Password
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.table th {
font-weight: 600;
font-size: 0.85rem;
}
.table td {
vertical-align: middle;
}
</style>

View File

@ -11,6 +11,7 @@ import AccountSettingsMFA from "@/components/settingsComponent/accountSettingsMF
import AccountSettingsInputUsername from "@/components/settingsComponent/accountSettingsInputUsername.vue";
import DashboardEmailSettings from "@/components/settingsComponent/dashboardEmailSettings.vue";
import DashboardWebHooks from "@/components/settingsComponent/dashboardWebHooks.vue";
import DashboardAdminUsers from "@/components/settingsComponent/dashboardAdminUsers.vue";
const dashboardConfigurationStore = DashboardConfigurationStore()
@ -47,34 +48,9 @@ const dashboardConfigurationStore = DashboardConfigurationStore()
<DashboardIPPortInput></DashboardIPPortInput>
</div>
</div>
<div class="card rounded-3">
<div class="card-header">
<h6 class="my-2">
<i class="bi bi-people-fill me-2"></i>
<LocaleText t="Account Settings"></LocaleText>
</h6>
</div>
<div class="card-body d-flex flex-column gap-3">
<div>
<AccountSettingsInputUsername targetData="username"
title="Username"
></AccountSettingsInputUsername>
</div>
<hr>
<div>
<AccountSettingsInputPassword
targetData="password">
</AccountSettingsInputPassword>
</div>
<hr>
<div>
<h6 >
<LocaleText t="Multi-Factor Authentication (MFA)"></LocaleText>
</h6>
<AccountSettingsMFA v-if="!dashboardConfigurationStore.getActiveCrossServer()"></AccountSettingsMFA>
</div>
</div>
</div>
<!-- Admin Users Management (Multiple Admins) -->
<DashboardAdminUsers></DashboardAdminUsers>
<DashboardAPIKeys></DashboardAPIKeys>
<DashboardEmailSettings></DashboardEmailSettings>