Harden auth and API: username change, 2FA fixes, input validation
- Add POST /api/auth/change-username (password + OTP when 2FA is on, format/reserved-name checks, uniqueness) and a Change Username modal in Account.vue; use the real username for the 2FA provisioning URI. - Stop creating the built-in admin/password user; the initial admin is seeded only from OVPMON_INITIAL_ADMIN_USER/PASSWORD. - Reject 2FA-pending temporary tokens on all protected routes (Flask token_required, Profiler verify_token); only /api/auth/verify-2fa accepts them. - Stop logging the OTP and TOTP secret in enable_2fa. - Profiler: validate profile username (pattern + realpath checks in schema, router, pki and generator) to prevent path traversal. - Restrict CORS to the panel origin in Profiler and Flask APIs. - UI: header username no longer sticks to the hardcoded Admin fallback; it is synced from /user/me and updated after a rename. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
14ffd64801
commit
11c1b6379b
9 files changed
+241
-16
No files matched your search
+78
-10
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
# Enable CORS for all routes with specific headers support
|
# Enable CORS for all routes with specific headers support
|
||||||
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True)
|
CORS(app, resources={r"/api/*": {"origins": ["https://213.226.125.13:8088"]}}, supports_credentials=True)
|
||||||
|
|
||||||
class OpenVPNAPI:
|
class OpenVPNAPI:
|
||||||
def get_config_value(self, section, key, fallback=None):
|
def get_config_value(self, section, key, fallback=None):
|
||||||
@@ -67,19 +67,23 @@ class OpenVPNAPI:
|
|||||||
return self.db_manager.get_connection()
|
return self.db_manager.get_connection()
|
||||||
|
|
||||||
def ensure_default_admin(self):
|
def ensure_default_admin(self):
|
||||||
"""Create a default admin user if no users exist"""
|
"""Create the initial admin only from OVPMON_INITIAL_ADMIN_USER/PASSWORD env (no built-in defaults)"""
|
||||||
conn = self.get_db_connection()
|
conn = self.get_db_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
try:
|
try:
|
||||||
cursor.execute("SELECT COUNT(*) FROM users")
|
cursor.execute("SELECT COUNT(*) FROM users")
|
||||||
if cursor.fetchone()[0] == 0:
|
if cursor.fetchone()[0] == 0:
|
||||||
# Default: admin / password
|
user = os.getenv('OVPMON_INITIAL_ADMIN_USER')
|
||||||
password_hash = bcrypt.hashpw('password'.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
pw = os.getenv('OVPMON_INITIAL_ADMIN_PASSWORD')
|
||||||
cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", ('admin', password_hash))
|
if not user or not pw:
|
||||||
|
logger.error("No users exist and OVPMON_INITIAL_ADMIN_USER/OVPMON_INITIAL_ADMIN_PASSWORD are not set: admin NOT created")
|
||||||
|
return
|
||||||
|
password_hash = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||||
|
cursor.execute("INSERT INTO users (username, password_hash) VALUES (?, ?)", (user, password_hash))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
logger.info("Default admin user created (admin/password)")
|
logger.info("Initial admin user created from environment")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error ensuring default admin: {e}")
|
logger.error(f"Error ensuring initial admin: {e}")
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -617,7 +621,9 @@ def token_required(f):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
|
data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
|
||||||
# In a real app, you might want to verify user still exists in DB
|
# Temporary 2FA-pending tokens are valid only for /api/auth/verify-2fa
|
||||||
|
if data.get('is_2fa_pending'):
|
||||||
|
return jsonify({'success': False, 'error': '2FA verification required'}), 401
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
return jsonify({'success': False, 'error': 'Token has expired'}), 401
|
return jsonify({'success': False, 'error': 'Token has expired'}), 401
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -720,7 +726,10 @@ def setup_2fa():
|
|||||||
|
|
||||||
secret = pyotp.random_base32()
|
secret = pyotp.random_base32()
|
||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
provisioning_uri = totp.provisioning_uri(name="admin", issuer_name="OpenVPN-Monitor")
|
_c = api.get_db_connection()
|
||||||
|
_row = _c.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
|
_c.close()
|
||||||
|
provisioning_uri = totp.provisioning_uri(name=(_row[0] if _row else "user"), issuer_name="OpenVPN-Monitor")
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
@@ -739,7 +748,7 @@ def enable_2fa():
|
|||||||
if not secret or not otp:
|
if not secret or not otp:
|
||||||
return jsonify({'success': False, 'error': 'Missing data'}), 400
|
return jsonify({'success': False, 'error': 'Missing data'}), 400
|
||||||
|
|
||||||
logger.info(f"Attempting 2FA activation. User OTP: {otp}, Secret: {secret}")
|
logger.info("Attempting 2FA activation")
|
||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
|
|
||||||
# Adding valid_window=1 to allow ±30 seconds clock drift
|
# Adding valid_window=1 to allow ±30 seconds clock drift
|
||||||
@@ -834,6 +843,65 @@ def change_password():
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
USERNAME_RE = __import__('re').compile(r'^[A-Za-z][A-Za-z0-9_.-]{2,31}$')
|
||||||
|
RESERVED_USERNAMES = {'admin', 'administrator', 'root', 'user', 'test', 'guest'}
|
||||||
|
|
||||||
|
def _current_user_id():
|
||||||
|
token = request.headers['Authorization'].split(' ')[1]
|
||||||
|
return jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])['user_id']
|
||||||
|
|
||||||
|
@app.route('/api/auth/change-username', methods=['POST'])
|
||||||
|
@token_required
|
||||||
|
def change_username():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
new_username = (data.get('new_username') or '').strip()
|
||||||
|
current_password = data.get('current_password')
|
||||||
|
otp = data.get('otp')
|
||||||
|
|
||||||
|
if not new_username or not current_password:
|
||||||
|
return jsonify({'success': False, 'error': 'Missing data'}), 400
|
||||||
|
if not USERNAME_RE.match(new_username) or new_username.lower() in RESERVED_USERNAMES:
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid username (3-32 chars, letters/digits/._-, must start with a letter, reserved names not allowed)'}), 400
|
||||||
|
|
||||||
|
ip = request.remote_addr
|
||||||
|
if not api.check_rate_limit(ip):
|
||||||
|
return jsonify({'success': False, 'error': 'Too many attempts. Try again in 15 minutes.'}), 429
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = _current_user_id()
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'success': False, 'error': 'Token is invalid'}), 401
|
||||||
|
|
||||||
|
conn = api.get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT password_hash, totp_secret, is_2fa_enabled FROM users WHERE id = ?", (user_id,))
|
||||||
|
user = cursor.fetchone()
|
||||||
|
if not user or not bcrypt.checkpw(current_password.encode('utf-8'), user[0].encode('utf-8')):
|
||||||
|
api.record_login_attempt(ip, False)
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid current password'}), 401
|
||||||
|
if user[2]:
|
||||||
|
if not otp or not pyotp.TOTP(user[1]).verify(str(otp), valid_window=1):
|
||||||
|
api.record_login_attempt(ip, False)
|
||||||
|
return jsonify({'success': False, 'error': 'Invalid 2FA code'}), 401
|
||||||
|
|
||||||
|
cursor.execute("SELECT 1 FROM users WHERE lower(username) = lower(?) AND id != ?", (new_username, user_id))
|
||||||
|
if cursor.fetchone():
|
||||||
|
return jsonify({'success': False, 'error': 'Username is already taken'}), 409
|
||||||
|
try:
|
||||||
|
cursor.execute("UPDATE users SET username = ? WHERE id = ?", (new_username, user_id))
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({'success': False, 'error': 'Username is already taken'}), 409
|
||||||
|
|
||||||
|
logger.info(f"Username changed for user ID: {user_id}")
|
||||||
|
return jsonify({'success': True, 'username': new_username})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error changing username: {e}")
|
||||||
|
return jsonify({'success': False, 'error': 'Internal server error'}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
# --- USER ROUTES ---
|
# --- USER ROUTES ---
|
||||||
|
|
||||||
@app.route('/api/v1/user/me', methods=['GET'])
|
@app.route('/api/v1/user/me', methods=['GET'])
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ app = FastAPI(
|
|||||||
# Enable CORS
|
# Enable CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["https://213.226.125.13:8088"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(system.router, prefix="/api", tags=["System"])
|
app.include_router(system.router, prefix="/api", tags=["System"])
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ def create_profile(
|
|||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=400, detail="User already exists")
|
raise HTTPException(status_code=400, detail="User already exists")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pki.validate_username(profile_in.username)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid username")
|
||||||
|
|
||||||
# Build PKI
|
# Build PKI
|
||||||
try:
|
try:
|
||||||
pki.build_client(profile_in.username, db)
|
pki.build_client(profile_in.username, db)
|
||||||
@@ -89,6 +94,9 @@ def create_profile(
|
|||||||
client_conf_dir = "client-config"
|
client_conf_dir = "client-config"
|
||||||
os.makedirs(client_conf_dir, exist_ok=True)
|
os.makedirs(client_conf_dir, exist_ok=True)
|
||||||
file_path = os.path.join(client_conf_dir, f"{profile_in.username}.ovpn")
|
file_path = os.path.join(client_conf_dir, f"{profile_in.username}.ovpn")
|
||||||
|
base_real = os.path.realpath(client_conf_dir)
|
||||||
|
if not os.path.realpath(file_path).startswith(base_real + os.sep):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid username")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
generator.generate_client_config(db, profile_in.username, file_path)
|
generator.generate_client_config(db, profile_in.username, file_path)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class ConfigResponse(BaseModel):
|
|||||||
|
|
||||||
# --- User Profile Schemas ---
|
# --- User Profile Schemas ---
|
||||||
class UserProfileBase(BaseModel):
|
class UserProfileBase(BaseModel):
|
||||||
username: str
|
username: str = Field(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||||
|
|
||||||
class UserProfileCreate(UserProfileBase):
|
class UserProfileCreate(UserProfileBase):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
|||||||
return config_content
|
return config_content
|
||||||
|
|
||||||
def generate_client_config(db: Session, username: str, output_path: str):
|
def generate_client_config(db: Session, username: str, output_path: str):
|
||||||
|
from .pki import validate_username
|
||||||
|
validate_username(username)
|
||||||
|
base = os.path.realpath(os.path.dirname(output_path) or ".")
|
||||||
|
if os.path.realpath(output_path) != os.path.join(base, os.path.basename(output_path)) or os.path.basename(output_path) != f"{username}.ovpn":
|
||||||
|
raise ValueError("Invalid output path")
|
||||||
settings = get_system_settings(db)
|
settings = get_system_settings(db)
|
||||||
pki = get_pki_settings(db)
|
pki = get_pki_settings(db)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ from datetime import datetime
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
import re
|
||||||
|
USERNAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
||||||
|
|
||||||
|
def validate_username(username: str) -> str:
|
||||||
|
if not isinstance(username, str) or not USERNAME_RE.match(username) or ".." in username:
|
||||||
|
raise ValueError("Invalid username")
|
||||||
|
return username
|
||||||
|
|
||||||
EASY_RSA_DIR = os.path.join(os.getcwd(), "easy-rsa")
|
EASY_RSA_DIR = os.path.join(os.getcwd(), "easy-rsa")
|
||||||
PKI_DIR = os.path.join(EASY_RSA_DIR, "pki")
|
PKI_DIR = os.path.join(EASY_RSA_DIR, "pki")
|
||||||
INDEX_PATH = os.path.join(PKI_DIR, "index.txt")
|
INDEX_PATH = os.path.join(PKI_DIR, "index.txt")
|
||||||
@@ -170,11 +178,13 @@ def clear_pki(db: Session):
|
|||||||
return "PKI directory did not exist, but User DB and Client profiles were wiped."
|
return "PKI directory did not exist, but User DB and Client profiles were wiped."
|
||||||
|
|
||||||
def build_client(username: str, db: Session):
|
def build_client(username: str, db: Session):
|
||||||
|
validate_username(username)
|
||||||
env = _get_easyrsa_env(db)
|
env = _get_easyrsa_env(db)
|
||||||
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
_run_easyrsa(["build-client-full", username, "nopass"], env)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def revoke_client(username: str, db: Session):
|
def revoke_client(username: str, db: Session):
|
||||||
|
validate_username(username)
|
||||||
env = _get_easyrsa_env(db)
|
env = _get_easyrsa_env(db)
|
||||||
_run_easyrsa(["revoke", username], env)
|
_run_easyrsa(["revoke", username], env)
|
||||||
_run_easyrsa(["gen-crl"], env)
|
_run_easyrsa(["gen-crl"], env)
|
||||||
|
|||||||
@@ -43,7 +43,15 @@ async def verify_token(authorization: str = Header(None)):
|
|||||||
# print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...")
|
# print(f"[AUTH] Decoding token with SECRET_KEY starting with: {SECRET_KEY[:3]}...")
|
||||||
|
|
||||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||||
|
if payload.get("is_2fa_pending"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="2FA verification required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
error_detail = str(e)
|
error_detail = str(e)
|
||||||
|
|||||||
+24
-2
@@ -99,7 +99,7 @@
|
|||||||
|
|
||||||
<div class="user-profile ms-2">
|
<div class="user-profile ms-2">
|
||||||
<div class="user-avatar-small bg-primary text-white">
|
<div class="user-avatar-small bg-primary text-white">
|
||||||
{{ username[0]?.toUpperCase() || 'A' }}
|
{{ username[0]?.toUpperCase() || 'U' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-meta d-none d-md-block">
|
<div class="user-meta d-none d-md-block">
|
||||||
<span class="username">{{ username }}</span>
|
<span class="username">{{ username }}</span>
|
||||||
@@ -123,9 +123,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch, computed } from 'vue';
|
import { ref, onMounted, watch, computed } from 'vue';
|
||||||
import { useAppConfig } from './composables/useAppConfig';
|
import { useAppConfig } from './composables/useAppConfig';
|
||||||
|
import { useApi } from './composables/useApi';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
const { loadConfig, isLoaded } = useAppConfig();
|
const { loadConfig, isLoaded } = useAppConfig();
|
||||||
|
const { apiClient } = useApi();
|
||||||
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
|
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
|
||||||
const isDark = ref(false);
|
const isDark = ref(false);
|
||||||
const refreshKey = ref(0);
|
const refreshKey = ref(0);
|
||||||
@@ -135,7 +137,24 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const isAuthenticated = computed(() => route.name !== 'Login');
|
const isAuthenticated = computed(() => route.name !== 'Login');
|
||||||
const username = ref(localStorage.getItem('ovpmon_user') || 'Admin');
|
const username = ref(localStorage.getItem('ovpmon_user') || '');
|
||||||
|
|
||||||
|
// Keep the header name in sync with the server (login, rename, stale session)
|
||||||
|
const syncUsername = async () => {
|
||||||
|
if (!localStorage.getItem('ovpmon_token')) {
|
||||||
|
username.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
username.value = localStorage.getItem('ovpmon_user') || '';
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/user/me');
|
||||||
|
if (res.data?.username) {
|
||||||
|
username.value = res.data.username;
|
||||||
|
localStorage.setItem('ovpmon_user', res.data.username);
|
||||||
|
}
|
||||||
|
} catch (e) { /* 401 is handled by the interceptor */ }
|
||||||
|
};
|
||||||
|
const onUserChanged = () => { username.value = localStorage.getItem('ovpmon_user') || ''; };
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('ovpmon_token');
|
localStorage.removeItem('ovpmon_token');
|
||||||
@@ -162,10 +181,13 @@ const refreshPage = () => {
|
|||||||
// Close sidebar on route change
|
// Close sidebar on route change
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
|
if (route.name !== 'Login') syncUsername();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
|
window.addEventListener('ovpmon-user-changed', onUserChanged);
|
||||||
|
if (route.name !== 'Login') syncUsername();
|
||||||
|
|
||||||
// Init Theme
|
// Init Theme
|
||||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="showPwModal">
|
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="showPwModal">
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-action btn-action-secondary py-2 fw-bold btn-account-action mt-2" @click="showUserModal">
|
||||||
|
Change Username
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,6 +167,55 @@
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
|
|
||||||
|
<!-- Username Change Modal -->
|
||||||
|
<BaseModal id="usernameChangeModal" title="Change Account Username" ref="userModal">
|
||||||
|
<template #body>
|
||||||
|
<form @submit.prevent="handleChangeUsername" id="userForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-bold text-muted text-uppercase">New Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
v-model="usernameForm.new_username"
|
||||||
|
placeholder="3-32 chars: letters, digits, . _ -"
|
||||||
|
pattern="[A-Za-z][A-Za-z0-9_.\-]{2,31}"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-bold text-muted text-uppercase">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
v-model="usernameForm.current_password"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2" v-if="isEnabled">
|
||||||
|
<label class="form-label small fw-bold text-muted text-uppercase">2FA Code</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
v-model="usernameForm.otp"
|
||||||
|
placeholder="6-digit code"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<button type="button" class="btn-action btn-action-secondary" @click="userModal.hide()">Cancel</button>
|
||||||
|
<button type="submit" form="userForm" class="btn-action btn-action-save" :disabled="userLoading">
|
||||||
|
<span v-if="userLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Update Username
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -179,6 +231,7 @@ const { apiClient } = useApi();
|
|||||||
|
|
||||||
// UI Refs
|
// UI Refs
|
||||||
const pwModal = ref(null);
|
const pwModal = ref(null);
|
||||||
|
const userModal = ref(null);
|
||||||
const confirmModal = ref(null);
|
const confirmModal = ref(null);
|
||||||
const disable2FAModal = ref(null);
|
const disable2FAModal = ref(null);
|
||||||
|
|
||||||
@@ -234,6 +287,57 @@ const handleChangePassword = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Username Logic
|
||||||
|
const userLoading = ref(false);
|
||||||
|
const usernameForm = reactive({ new_username: '', current_password: '', otp: '' });
|
||||||
|
|
||||||
|
const showUserModal = () => {
|
||||||
|
usernameForm.new_username = '';
|
||||||
|
usernameForm.current_password = '';
|
||||||
|
usernameForm.otp = '';
|
||||||
|
userModal.value.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeUsername = async () => {
|
||||||
|
if (!/^[A-Za-z][A-Za-z0-9_.-]{2,31}$/.test(usernameForm.new_username)) {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Error!',
|
||||||
|
text: 'Username must be 3-32 chars, start with a letter, and contain only letters, digits, . _ -',
|
||||||
|
icon: 'error',
|
||||||
|
confirmButtonColor: '#EC7C31'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
userLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('../auth/change-username', {
|
||||||
|
new_username: usernameForm.new_username,
|
||||||
|
current_password: usernameForm.current_password,
|
||||||
|
otp: usernameForm.otp || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
try { localStorage.setItem('ovpmon_user', res.data.username); } catch (e) { /* ignore */ }
|
||||||
|
window.dispatchEvent(new Event('ovpmon-user-changed'));
|
||||||
|
userModal.value.hide();
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Success!',
|
||||||
|
text: 'Username updated. Use the new username the next time you sign in.',
|
||||||
|
icon: 'success',
|
||||||
|
confirmButtonColor: '#1652B8'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
Swal.fire({
|
||||||
|
title: 'Failed',
|
||||||
|
text: err.response?.data?.error || 'Failed to update username',
|
||||||
|
icon: 'error',
|
||||||
|
confirmButtonColor: '#cf222e'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
userLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 2FA Logic
|
// 2FA Logic
|
||||||
const isEnabled = ref(false);
|
const isEnabled = ref(false);
|
||||||
const step2 = ref(false);
|
const step2 = ref(false);
|
||||||
|
|||||||
Reference in new issue
Block a user