decouple OTP secrets from instances with a dedicated database table and CRUD API endpoints

This commit is contained in:
CPTN Cosmo 2026-04-25 17:29:53 +02:00
parent c15f965a2b
commit b8202abba5
5 changed files with 317 additions and 52 deletions

BIN
data/instances.db Normal file

Binary file not shown.

165
main.py
View file

@ -58,29 +58,58 @@ def init_db():
) )
''') ''')
# Check if instances table has user_id, if not, add it (simple migration) c.execute('''
c.execute("PRAGMA table_info(instances)") CREATE TABLE IF NOT EXISTS otp_secrets (
columns = [col[1] for col in c.fetchall()] id INTEGER PRIMARY KEY AUTOINCREMENT,
if "user_id" not in columns: name TEXT NOT NULL,
print("Migrating instances table to include user_id...") encrypted_secret TEXT NOT NULL,
# SQLite doesn't support adding a column with a foreign key constraint easily, user_id INTEGER NOT NULL,
# so we'll just add the column for now. FOREIGN KEY (user_id) REFERENCES users (id)
c.execute('ALTER TABLE instances ADD COLUMN user_id INTEGER') )
''')
# Create instances table first if it doesn't exist
c.execute(''' c.execute('''
CREATE TABLE IF NOT EXISTS instances ( CREATE TABLE IF NOT EXISTS instances (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
ip TEXT NOT NULL, ip TEXT NOT NULL,
port INTEGER NOT NULL, port INTEGER NOT NULL,
encrypted_secret TEXT NOT NULL, encrypted_secret TEXT,
otp_secret_id INTEGER,
last_status TEXT DEFAULT 'Unknown', last_status TEXT DEFAULT 'Unknown',
last_checked REAL DEFAULT 0, last_checked REAL DEFAULT 0,
user_id INTEGER, user_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users (id) FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (otp_secret_id) REFERENCES otp_secrets (id)
) )
''') ''')
# Now check for column migrations
c.execute("PRAGMA table_info(instances)")
columns = [col[1] for col in c.fetchall()]
if "user_id" not in columns:
print("Migrating instances table to include user_id...")
c.execute('ALTER TABLE instances ADD COLUMN user_id INTEGER REFERENCES users(id)')
if "otp_secret_id" not in columns:
print("Migrating instances table to include otp_secret_id...")
c.execute('ALTER TABLE instances ADD COLUMN otp_secret_id INTEGER REFERENCES otp_secrets(id)')
# Migration: Move existing encrypted_secret to otp_secrets table
c.execute('SELECT id, name, encrypted_secret, user_id FROM instances WHERE encrypted_secret IS NOT NULL')
instances_to_migrate = c.fetchall()
for inst_id, inst_name, secret, user_id in instances_to_migrate:
if secret and user_id:
# Create a new OTP secret entry
secret_name = f"Migrated ({inst_name})"
c.execute('INSERT INTO otp_secrets (name, encrypted_secret, user_id) VALUES (?, ?, ?)',
(secret_name, secret, user_id))
otp_secret_id = c.lastrowid
# Update instance to point to the new OTP secret
c.execute('UPDATE instances SET otp_secret_id = ? WHERE id = ?', (otp_secret_id, inst_id))
# Create or update default admin if no users exist or only one user exists # Create or update default admin if no users exist or only one user exists
c.execute('SELECT COUNT(*) FROM users') c.execute('SELECT COUNT(*) FROM users')
count = c.fetchone()[0] count = c.fetchone()[0]
@ -243,17 +272,25 @@ async def change_own_username(request: Request):
raise HTTPException(status_code=400, detail="Username already exists") raise HTTPException(status_code=400, detail="Username already exists")
return {"status": "ok"} return {"status": "ok"}
class OTPSecretCreate(BaseModel):
name: str
secret: str
class OTPSecretUpdate(BaseModel):
name: str
secret: Optional[str] = None
class InstanceCreate(BaseModel): class InstanceCreate(BaseModel):
name: str name: str
ip: str ip: str
port: int = 4646 port: int = 4646
secret: str otp_secret_id: int
class InstanceUpdate(BaseModel): class InstanceUpdate(BaseModel):
name: str name: str
ip: str ip: str
port: int = 4646 port: int = 4646
secret: Optional[str] = None otp_secret_id: int
def clean_secret(secret_input): def clean_secret(secret_input):
secret_clean = secret_input.strip() secret_clean = secret_input.strip()
@ -267,13 +304,90 @@ def clean_secret(secret_input):
pass pass
return secret_clean.replace(" ", "").upper() return secret_clean.replace(" ", "").upper()
# OTP Secret Management
@app.get("/api/otp-secrets", dependencies=[Depends(is_authenticated)])
def get_otp_secrets(request: Request):
user_id = request.session.get("user_id")
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute('SELECT id, name FROM otp_secrets WHERE user_id = ?', (user_id,))
secrets = [dict(row) for row in c.fetchall()]
conn.close()
return secrets
@app.post("/api/otp-secrets", dependencies=[Depends(is_authenticated)])
def create_otp_secret(secret_data: OTPSecretCreate, request: Request):
user_id = request.session.get("user_id")
cleaned = clean_secret(secret_data.secret)
encrypted = fernet.encrypt(cleaned.encode()).decode()
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''
INSERT INTO otp_secrets (name, encrypted_secret, user_id)
VALUES (?, ?, ?)
''', (secret_data.name, encrypted, user_id))
conn.commit()
conn.close()
return {"status": "ok"}
@app.put("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
def update_otp_secret(id: int, secret_data: OTPSecretUpdate, request: Request):
user_id = request.session.get("user_id")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Verify ownership
c.execute('SELECT id FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
if not c.fetchone():
conn.close()
raise HTTPException(status_code=404, detail="OTP Secret not found")
if secret_data.secret:
cleaned = clean_secret(secret_data.secret)
encrypted = fernet.encrypt(cleaned.encode()).decode()
c.execute('''
UPDATE otp_secrets SET name=?, encrypted_secret=?
WHERE id=? AND user_id=?
''', (secret_data.name, encrypted, id, user_id))
else:
c.execute('''
UPDATE otp_secrets SET name=?
WHERE id=? AND user_id=?
''', (secret_data.name, id, user_id))
conn.commit()
conn.close()
return {"status": "ok"}
@app.delete("/api/otp-secrets/{id}", dependencies=[Depends(is_authenticated)])
def delete_otp_secret(id: int, request: Request):
user_id = request.session.get("user_id")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Check if used by any instances
c.execute('SELECT COUNT(*) FROM instances WHERE otp_secret_id=?', (id,))
if c.fetchone()[0] > 0:
conn.close()
raise HTTPException(status_code=400, detail="Cannot delete secret because it is in use by one or more instances")
c.execute('DELETE FROM otp_secrets WHERE id=? AND user_id=?', (id, user_id))
conn.commit()
conn.close()
return {"status": "ok"}
async def poll_instances(): async def poll_instances():
while True: while True:
try: try:
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()
c.execute('SELECT * FROM instances') c.execute('''
SELECT i.*, s.encrypted_secret
FROM instances i
JOIN otp_secrets s ON i.otp_secret_id = s.id
''')
instances = c.fetchall() instances = c.fetchall()
conn.close() conn.close()
@ -332,7 +446,12 @@ def get_instances(request: Request):
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
c = conn.cursor() c = conn.cursor()
c.execute('SELECT id, name, ip, port, last_status, last_checked FROM instances WHERE user_id = ?', (user_id,)) c.execute('''
SELECT i.id, i.name, i.ip, i.port, i.last_status, i.last_checked, i.otp_secret_id, s.name as otp_secret_name
FROM instances i
LEFT JOIN otp_secrets s ON i.otp_secret_id = s.id
WHERE i.user_id = ?
''', (user_id,))
instances = [dict(row) for row in c.fetchall()] instances = [dict(row) for row in c.fetchall()]
conn.close() conn.close()
return instances return instances
@ -340,14 +459,12 @@ def get_instances(request: Request):
@app.post("/api/instances", dependencies=[Depends(is_authenticated)]) @app.post("/api/instances", dependencies=[Depends(is_authenticated)])
def create_instance(inst: InstanceCreate, request: Request): def create_instance(inst: InstanceCreate, request: Request):
user_id = request.session.get("user_id") user_id = request.session.get("user_id")
cleaned = clean_secret(inst.secret)
encrypted = fernet.encrypt(cleaned.encode()).decode()
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
c = conn.cursor() c = conn.cursor()
c.execute(''' c.execute('''
INSERT INTO instances (name, ip, port, encrypted_secret, user_id) INSERT INTO instances (name, ip, port, otp_secret_id, user_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
''', (inst.name, inst.ip, inst.port, encrypted, user_id)) ''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, user_id))
conn.commit() conn.commit()
conn.close() conn.close()
return {"status": "ok"} return {"status": "ok"}
@ -364,18 +481,10 @@ def update_instance(id: int, inst: InstanceUpdate, request: Request):
conn.close() conn.close()
raise HTTPException(status_code=404, detail="Instance not found") raise HTTPException(status_code=404, detail="Instance not found")
if inst.secret:
cleaned = clean_secret(inst.secret)
encrypted = fernet.encrypt(cleaned.encode()).decode()
c.execute(''' c.execute('''
UPDATE instances SET name=?, ip=?, port=?, encrypted_secret=? UPDATE instances SET name=?, ip=?, port=?, otp_secret_id=?
WHERE id=? AND user_id=? WHERE id=? AND user_id=?
''', (inst.name, inst.ip, inst.port, encrypted, id, user_id)) ''', (inst.name, inst.ip, inst.port, inst.otp_secret_id, id, user_id))
else:
c.execute('''
UPDATE instances SET name=?, ip=?, port=?
WHERE id=? AND user_id=?
''', (inst.name, inst.ip, inst.port, id, user_id))
conn.commit() conn.commit()
conn.close() conn.close()

View file

@ -24,21 +24,36 @@ document.addEventListener('DOMContentLoaded', () => {
const showAddUserBtn = document.getElementById('show-add-user-btn'); const showAddUserBtn = document.getElementById('show-add-user-btn');
const addUserModal = document.getElementById('add-user-modal'); const addUserModal = document.getElementById('add-user-modal');
const modalTitle = document.querySelector('#add-instance-modal h2'); const modalTitle = document.querySelector('#add-instance-modal h2');
const secretInput = document.getElementById('secret'); const otpModalTitle = document.getElementById('otp-modal-title');
const otpSaveBtn = document.getElementById('otp-save-btn');
const otpSecretForm = document.getElementById('otp-secret-form');
const showAddOtpBtn = document.getElementById('show-add-otp-btn');
const addOtpModal = document.getElementById('add-otp-secret-modal');
const otpSecretIdSelect = document.getElementById('otp-secret-id');
const otpSecretsList = document.getElementById('otp-secrets-list');
let config = { firewall_host_ip: null }; let config = { firewall_host_ip: null };
let currentUser = null; let currentUser = null;
let editId = null; let editId = null;
let editOtpId = null;
let instancesData = []; let instancesData = [];
let otpSecretsData = [];
const openModal = (modal) => modal.classList.add('active'); const openModal = (modal) => modal.classList.add('active');
const closeModal = (modal) => { const closeModal = (modal) => {
modal.classList.remove('active'); modal.classList.remove('active');
editId = null; editId = null;
editOtpId = null;
if (modal.id === 'add-instance-modal') {
addForm.reset(); addForm.reset();
modalTitle.textContent = 'Add Instance'; modalTitle.textContent = 'Add Instance';
secretInput.placeholder = 'Base32 Secret or otpauth:// URL'; } else if (modal.id === 'add-otp-secret-modal') {
secretInput.required = true; otpSecretForm.reset();
otpModalTitle.textContent = 'Add OTP Secret';
otpSaveBtn.textContent = 'Add Secret';
document.getElementById('otp-secret').required = true;
document.getElementById('otp-secret').placeholder = 'Base32 Secret or otpauth:// URL';
}
}; };
document.querySelectorAll('.btn-close').forEach(btn => { document.querySelectorAll('.btn-close').forEach(btn => {
@ -55,10 +70,17 @@ document.addEventListener('DOMContentLoaded', () => {
showAddBtn.addEventListener('click', () => { showAddBtn.addEventListener('click', () => {
editId = null; editId = null;
modalTitle.textContent = 'Add Instance'; modalTitle.textContent = 'Add Instance';
secretInput.placeholder = 'Base32 Secret or otpauth:// URL'; populateOTPSelect();
secretInput.required = true;
openModal(addInstanceModal); openModal(addInstanceModal);
}); });
showAddOtpBtn.addEventListener('click', () => {
editOtpId = null;
otpModalTitle.textContent = 'Add OTP Secret';
otpSaveBtn.textContent = 'Add Secret';
document.getElementById('otp-secret').required = true;
document.getElementById('otp-secret').placeholder = 'Base32 Secret or otpauth:// URL';
openModal(addOtpModal);
});
showAddUserBtn.addEventListener('click', () => openModal(addUserModal)); showAddUserBtn.addEventListener('click', () => openModal(addUserModal));
const showLogin = () => { const showLogin = () => {
@ -83,6 +105,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (item.dataset.tab === 'admin') fetchUsers(); if (item.dataset.tab === 'admin') fetchUsers();
if (item.dataset.tab === 'instances') fetchInstances(); if (item.dataset.tab === 'instances') fetchInstances();
if (item.dataset.tab === 'otp-secrets') fetchOTPSecrets();
}); });
}); });
@ -119,6 +142,17 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}; };
const fetchOTPSecrets = async () => {
try {
const res = await fetch('/api/otp-secrets');
if (res.status === 401) return showLogin();
otpSecretsData = await res.json();
renderOTPSecrets(otpSecretsData);
} catch (e) {
console.error('Failed to fetch OTP secrets:', e);
}
};
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
const res = await fetch('/api/users'); const res = await fetch('/api/users');
@ -152,6 +186,7 @@ document.addEventListener('DOMContentLoaded', () => {
<h3>${escapeHtml(inst.name)}</h3> <h3>${escapeHtml(inst.name)}</h3>
<div class="instance-meta"> <div class="instance-meta">
<span>${escapeHtml(inst.ip)}:${inst.port}</span> <span>${escapeHtml(inst.ip)}:${inst.port}</span>
<span>OTP: ${escapeHtml(inst.otp_secret_name || 'None')}</span>
<span class="status-badge ${statusClass}">${escapeHtml(inst.last_status)}</span> <span class="status-badge ${statusClass}">${escapeHtml(inst.last_status)}</span>
<span>Last checked: ${timeAgo}</span> <span>Last checked: ${timeAgo}</span>
</div> </div>
@ -172,6 +207,29 @@ document.addEventListener('DOMContentLoaded', () => {
}).join(''); }).join('');
}; };
const renderOTPSecrets = (secrets) => {
if (secrets.length === 0) {
otpSecretsList.innerHTML = '<div class="empty-state">No OTP secrets configured yet.</div>';
return;
}
otpSecretsList.innerHTML = secrets.map(sec => `
<div class="otp-secret-item">
<div class="otp-secret-info">
<h3>${escapeHtml(sec.name)}</h3>
</div>
<div class="otp-secret-actions">
<button class="btn btn-icon" onclick="editOTPSecret(${sec.id})" title="Edit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<button class="delete-btn" onclick="deleteOTPSecret(${sec.id})" title="Delete">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2-2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
</button>
</div>
</div>
`).join('');
};
const renderUsers = (users) => { const renderUsers = (users) => {
usersList.innerHTML = users.map(user => ` usersList.innerHTML = users.map(user => `
<div class="user-item"> <div class="user-item">
@ -195,12 +253,11 @@ document.addEventListener('DOMContentLoaded', () => {
const name = document.getElementById('name').value; const name = document.getElementById('name').value;
const ip = document.getElementById('ip').value; const ip = document.getElementById('ip').value;
const port = parseInt(document.getElementById('port').value, 10); const port = parseInt(document.getElementById('port').value, 10);
const secret = document.getElementById('secret').value; const otp_secret_id = parseInt(otpSecretIdSelect.value, 10);
const url = editId ? `/api/instances/${editId}` : '/api/instances'; const url = editId ? `/api/instances/${editId}` : '/api/instances';
const method = editId ? 'PUT' : 'POST'; const method = editId ? 'PUT' : 'POST';
const body = { name, ip, port }; const body = { name, ip, port, otp_secret_id };
if (secret) body.secret = secret;
try { try {
const res = await fetch(url, { const res = await fetch(url, {
@ -228,13 +285,72 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('name').value = inst.name; document.getElementById('name').value = inst.name;
document.getElementById('ip').value = inst.ip; document.getElementById('ip').value = inst.ip;
document.getElementById('port').value = inst.port; document.getElementById('port').value = inst.port;
secretInput.value = ''; populateOTPSelect(inst.otp_secret_id);
secretInput.required = false;
secretInput.placeholder = 'Leave blank to keep current secret';
modalTitle.textContent = 'Edit Instance'; modalTitle.textContent = 'Edit Instance';
openModal(addInstanceModal); openModal(addInstanceModal);
}; };
const populateOTPSelect = (selectedId = null) => {
otpSecretIdSelect.innerHTML = '<option value="" disabled selected>Select an OTP secret</option>' +
otpSecretsData.map(sec => `<option value="${sec.id}" ${sec.id === selectedId ? 'selected' : ''}>${escapeHtml(sec.name)}</option>`).join('');
};
otpSecretForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('otp-name').value;
const secret = document.getElementById('otp-secret').value;
const url = editOtpId ? `/api/otp-secrets/${editOtpId}` : '/api/otp-secrets';
const method = editOtpId ? 'PUT' : 'POST';
const body = { name };
if (secret) body.secret = secret;
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.ok) {
closeModal(addOtpModal);
fetchOTPSecrets();
} else {
const data = await res.json();
alert(data.detail || `Failed to ${editOtpId ? 'update' : 'add'} OTP secret`);
}
} catch (e) {
console.error('Error saving OTP secret', e);
}
});
window.editOTPSecret = (id) => {
const sec = otpSecretsData.find(s => s.id === id);
if (!sec) return;
editOtpId = id;
document.getElementById('otp-name').value = sec.name;
document.getElementById('otp-secret').value = '';
document.getElementById('otp-secret').required = false;
document.getElementById('otp-secret').placeholder = 'Leave blank to keep current secret';
otpModalTitle.textContent = 'Edit OTP Secret';
otpSaveBtn.textContent = 'Save Changes';
openModal(addOtpModal);
};
window.deleteOTPSecret = async (id) => {
if (!confirm('Are you sure you want to delete this OTP secret?')) return;
try {
const res = await fetch(`/api/otp-secrets/${id}`, { method: 'DELETE' });
if (res.status === 401) return showLogin();
if (res.ok) fetchOTPSecrets();
else {
const data = await res.json();
alert(data.detail || 'Failed to delete OTP secret');
}
} catch (e) {
console.error('Error deleting OTP secret', e);
}
};
loginForm.addEventListener('submit', async (e) => { loginForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const username = document.getElementById('login-username').value; const username = document.getElementById('login-username').value;
@ -254,6 +370,7 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('login-password').value = ''; document.getElementById('login-password').value = '';
fetchConfig(); fetchConfig();
fetchInstances(); fetchInstances();
fetchOTPSecrets();
} else { } else {
loginError.style.display = 'block'; loginError.style.display = 'block';
} }
@ -419,6 +536,7 @@ document.addEventListener('DOMContentLoaded', () => {
hideLogin(); hideLogin();
fetchConfig(); fetchConfig();
fetchInstances(); fetchInstances();
fetchOTPSecrets();
} else { } else {
showLogin(); showLogin();
} }

View file

@ -38,6 +38,7 @@
<nav id="main-nav" class="main-nav" style="display: none;"> <nav id="main-nav" class="main-nav" style="display: none;">
<button class="nav-item active" data-tab="instances">Instances</button> <button class="nav-item active" data-tab="instances">Instances</button>
<button class="nav-item" data-tab="otp-secrets">OTP Secrets</button>
<button class="nav-item" id="nav-admin" data-tab="admin" style="display: none;">Admin</button> <button class="nav-item" id="nav-admin" data-tab="admin" style="display: none;">Admin</button>
<button class="nav-item" data-tab="profile">Profile</button> <button class="nav-item" data-tab="profile">Profile</button>
</nav> </nav>
@ -66,6 +67,21 @@
</div> </div>
</div> </div>
<div id="otp-secrets-section" class="tab-content">
<div class="card otp-secrets-card">
<div class="instances-header">
<h2>OTP Secrets</h2>
<button class="btn btn-primary btn-sm" id="show-add-otp-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
Add Secret
</button>
</div>
<div id="otp-secrets-list" class="otp-secrets-list">
<!-- OTP Secrets injected via JS -->
</div>
</div>
</div>
<div id="admin-section" class="tab-content"> <div id="admin-section" class="tab-content">
<div class="card user-management-card"> <div class="card user-management-card">
<div class="instances-header"> <div class="instances-header">
@ -132,14 +148,36 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="secret">OTP Secret</label> <label for="otp-secret-id">OTP Secret</label>
<input type="password" id="secret" placeholder="Base32 Secret or otpauth:// URL" required> <select id="otp-secret-id" required>
<option value="" disabled selected>Select an OTP secret</option>
</select>
</div> </div>
<button type="submit" class="btn btn-primary">Add Instance</button> <button type="submit" class="btn btn-primary">Add Instance</button>
</form> </form>
</div> </div>
</div> </div>
<div id="add-otp-secret-modal" class="modal">
<div class="modal-content card">
<div class="modal-header">
<h2 id="otp-modal-title">Add OTP Secret</h2>
<button class="btn-close">&times;</button>
</div>
<form id="otp-secret-form">
<div class="form-group">
<label for="otp-name">Name</label>
<input type="text" id="otp-name" placeholder="e.g. Main Account" required>
</div>
<div class="form-group">
<label for="otp-secret">Secret</label>
<input type="password" id="otp-secret" placeholder="Base32 Secret or otpauth:// URL" required>
</div>
<button type="submit" class="btn btn-primary" id="otp-save-btn">Add Secret</button>
</form>
</div>
</div>
<div id="add-user-modal" class="modal"> <div id="add-user-modal" class="modal">
<div class="modal-content card"> <div class="modal-content card">
<div class="modal-header"> <div class="modal-header">

View file

@ -162,7 +162,7 @@ label {
font-weight: 500; font-weight: 500;
} }
input { input, select {
width: 100%; width: 100%;
background-color: var(--bg-color); background-color: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -174,7 +174,7 @@ input {
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
} }
input:focus { input:focus, select:focus {
outline: none; outline: none;
border-color: var(--primary); border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
@ -377,14 +377,14 @@ input:focus {
color: var(--status-online); color: var(--status-online);
} }
.users-list { .users-list, .otp-secrets-list {
margin-top: 2rem; margin-top: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.user-item { .user-item, .otp-secret-item {
background-color: var(--bg-color); background-color: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
@ -394,12 +394,12 @@ input:focus {
align-items: center; align-items: center;
} }
.user-info h3 { .user-info h3, .otp-secret-info h3 {
font-size: 1rem; font-size: 1rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.user-actions { .user-actions, .otp-secret-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
} }