Standard password manager
This commit is contained in:
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
error_reporting(0);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
set_exception_handler(function($e) {
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => 'Server error: ' . $e->getMessage()]);
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit(0);
|
||||||
|
|
||||||
|
$db_path = __DIR__ . '/vault.db';
|
||||||
|
$db = new SQLite3($db_path);
|
||||||
|
$db->enableExceptions(true);
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
salt TEXT NOT NULL,
|
||||||
|
encryption_key TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(user_id, name)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS vault_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
site TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
encrypted_password TEXT NOT NULL,
|
||||||
|
iv TEXT NOT NULL,
|
||||||
|
encryption_method TEXT DEFAULT 'server',
|
||||||
|
folder TEXT DEFAULT 'All',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
|
// Fix missing columns (existing databases)
|
||||||
|
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN encryption_method TEXT DEFAULT 'server'"); } catch (Exception $e) {}
|
||||||
|
try { $db->exec("ALTER TABLE vault_entries ADD COLUMN folder TEXT DEFAULT 'All'"); } catch (Exception $e) {}
|
||||||
|
|
||||||
|
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||||
|
$path = str_replace('/password-manager/api.php', '', $path);
|
||||||
|
$path = str_replace('/api.php', '', $path);
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
|
||||||
|
function genKey($pwd, $salt) { return hash_pbkdf2('sha256', $pwd, $salt, 100000, 32, true); }
|
||||||
|
|
||||||
|
function authenticate($db) {
|
||||||
|
$headers = getallheaders();
|
||||||
|
$token = str_replace('Bearer ', '', $headers['Authorization'] ?? '');
|
||||||
|
if (!$token) { http_response_code(401); echo json_encode(['error'=>'No token']); exit; }
|
||||||
|
$decoded = base64_decode($token);
|
||||||
|
if (!$decoded) { http_response_code(401); echo json_encode(['error'=>'Bad token']); exit; }
|
||||||
|
$parts = explode(':', $decoded);
|
||||||
|
if (count($parts) !== 3) { http_response_code(401); echo json_encode(['error'=>'Token format']); exit; }
|
||||||
|
$uid = intval($parts[0]);
|
||||||
|
$key = base64_decode($parts[2]);
|
||||||
|
$st = $db->prepare('SELECT id, encryption_key FROM users WHERE id=:id');
|
||||||
|
$st->bindValue(':id', $uid, SQLITE3_INTEGER);
|
||||||
|
$user = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
||||||
|
if (!$user || $user['encryption_key'] !== base64_encode($key)) { http_response_code(401); echo json_encode(['error'=>'Invalid session']); exit; }
|
||||||
|
return ['userId' => $uid];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (true) {
|
||||||
|
// Auth
|
||||||
|
case ($path === '/register' && $method === 'POST'):
|
||||||
|
$u = trim($input['username'] ?? '');
|
||||||
|
$p = $input['masterPassword'] ?? '';
|
||||||
|
if (strlen($u) < 3 || strlen($p) < 8) { http_response_code(400); echo json_encode(['error'=>'Min 3/8 chars']); break; }
|
||||||
|
$st = $db->prepare('SELECT id FROM users WHERE username=:u');
|
||||||
|
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
||||||
|
if ($st->execute()->fetchArray()) { http_response_code(409); echo json_encode(['error'=>'Username exists']); break; }
|
||||||
|
$salt = bin2hex(random_bytes(32));
|
||||||
|
$hash = hash_pbkdf2('sha256', $p, $salt, 100000);
|
||||||
|
$key = genKey($p, $salt);
|
||||||
|
$st = $db->prepare('INSERT INTO users (username, password_hash, salt, encryption_key) VALUES (:u, :h, :s, :k)');
|
||||||
|
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':h', $hash, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':s', $salt, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':k', base64_encode($key), SQLITE3_TEXT);
|
||||||
|
$st->execute();
|
||||||
|
$uid = $db->lastInsertRowID();
|
||||||
|
// Insert default folders
|
||||||
|
$defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal'];
|
||||||
|
$stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)');
|
||||||
|
foreach ($defaultFolders as $name) {
|
||||||
|
$stFolder->bindValue(':uid', $uid, SQLITE3_INTEGER);
|
||||||
|
$stFolder->bindValue(':name', $name, SQLITE3_TEXT);
|
||||||
|
$stFolder->execute();
|
||||||
|
}
|
||||||
|
$token = base64_encode($uid . ':' . bin2hex(random_bytes(16)) . ':' . base64_encode($key));
|
||||||
|
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$uid,'salt'=>$salt]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ($path === '/login' && $method === 'POST'):
|
||||||
|
$u = trim($input['username'] ?? '');
|
||||||
|
$p = $input['masterPassword'] ?? '';
|
||||||
|
$st = $db->prepare('SELECT * FROM users WHERE username=:u');
|
||||||
|
$st->bindValue(':u', $u, SQLITE3_TEXT);
|
||||||
|
$user = $st->execute()->fetchArray(SQLITE3_ASSOC);
|
||||||
|
if (!$user) { http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break; }
|
||||||
|
if (!hash_equals($user['password_hash'], hash_pbkdf2('sha256', $p, $user['salt'], 100000))) {
|
||||||
|
http_response_code(401); echo json_encode(['error'=>'Invalid credentials']); break;
|
||||||
|
}
|
||||||
|
$key = genKey($p, $user['salt']);
|
||||||
|
$token = base64_encode($user['id'] . ':' . bin2hex(random_bytes(16)) . ':' . base64_encode($key));
|
||||||
|
// Ensure default folders exist for existing users (idempotent)
|
||||||
|
$defaultFolders = ['All', 'Social', 'Banking', 'Work', 'Personal'];
|
||||||
|
$stFolder = $db->prepare('INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)');
|
||||||
|
foreach ($defaultFolders as $name) {
|
||||||
|
$stFolder->bindValue(':uid', $user['id'], SQLITE3_INTEGER);
|
||||||
|
$stFolder->bindValue(':name', $name, SQLITE3_TEXT);
|
||||||
|
$stFolder->execute();
|
||||||
|
}
|
||||||
|
echo json_encode(['message'=>'OK','token'=>$token,'userId'=>$user['id'],'salt'=>$user['salt']]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Folders
|
||||||
|
case ($path === '/folders' && $method === 'GET'):
|
||||||
|
$auth = authenticate($db);
|
||||||
|
$st = $db->prepare('SELECT name FROM folders WHERE user_id=:uid ORDER BY name');
|
||||||
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$res = $st->execute();
|
||||||
|
$folders = [];
|
||||||
|
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
|
||||||
|
$folders[] = $row['name'];
|
||||||
|
}
|
||||||
|
echo json_encode($folders);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ($path === '/folders' && $method === 'POST'):
|
||||||
|
$auth = authenticate($db);
|
||||||
|
$name = trim($input['name'] ?? '');
|
||||||
|
if (!$name) { http_response_code(400); echo json_encode(['error'=>'Folder name required']); break; }
|
||||||
|
if (strtolower($name) === 'all') { http_response_code(400); echo json_encode(['error'=>'Cannot use "All"']); break; }
|
||||||
|
$st = $db->prepare('INSERT INTO folders (user_id, name) VALUES (:uid, :name)');
|
||||||
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$st->bindValue(':name', $name, SQLITE3_TEXT);
|
||||||
|
try { $st->execute(); }
|
||||||
|
catch (Exception $e) { http_response_code(409); echo json_encode(['error'=>'Folder already exists']); break; }
|
||||||
|
echo json_encode(['message'=>'Folder created', 'name'=>$name]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case (preg_match('/^\/folders\/(.+)$/', $path, $m) && $method === 'DELETE'):
|
||||||
|
$auth = authenticate($db);
|
||||||
|
$folderName = urldecode($m[1]);
|
||||||
|
if ($folderName === 'All') { http_response_code(400); echo json_encode(['error'=>'Cannot delete All']); break; }
|
||||||
|
$st = $db->prepare('DELETE FROM folders WHERE user_id=:uid AND name=:name');
|
||||||
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$st->bindValue(':name', $folderName, SQLITE3_TEXT);
|
||||||
|
$st->execute();
|
||||||
|
if ($db->changes() === 0) { http_response_code(404); echo json_encode(['error'=>'Folder not found']); break; }
|
||||||
|
// Update entries that had this folder to 'All'
|
||||||
|
$stUp = $db->prepare("UPDATE vault_entries SET folder='All' WHERE user_id=:uid AND folder=:f");
|
||||||
|
$stUp->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$stUp->bindValue(':f', $folderName, SQLITE3_TEXT);
|
||||||
|
$stUp->execute();
|
||||||
|
echo json_encode(['message'=>'Deleted']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Entries
|
||||||
|
case ($path === '/entries' && $method === 'GET'):
|
||||||
|
$auth = authenticate($db);
|
||||||
|
$q = $_GET['search'] ?? '';
|
||||||
|
if ($q) {
|
||||||
|
$st = $db->prepare('SELECT * FROM vault_entries WHERE user_id=:uid AND (site LIKE :q OR username LIKE :q) ORDER BY updated_at DESC');
|
||||||
|
$st->bindValue(':q', "%$q%", SQLITE3_TEXT);
|
||||||
|
} else {
|
||||||
|
$st = $db->prepare('SELECT * FROM vault_entries WHERE user_id=:uid ORDER BY updated_at DESC');
|
||||||
|
}
|
||||||
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$res = $st->execute();
|
||||||
|
$entries = [];
|
||||||
|
while ($r = $res->fetchArray(SQLITE3_ASSOC)) {
|
||||||
|
$entries[] = [
|
||||||
|
'id' => $r['id'],
|
||||||
|
'site' => $r['site'],
|
||||||
|
'username' => $r['username'],
|
||||||
|
'encrypted_password' => $r['encrypted_password'],
|
||||||
|
'iv' => $r['iv'],
|
||||||
|
'encryption_method' => $r['encryption_method'] ?? 'server',
|
||||||
|
'folder' => $r['folder'] ?? 'All',
|
||||||
|
'created_at' => $r['created_at'],
|
||||||
|
'updated_at' => $r['updated_at']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
echo json_encode($entries);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ($path === '/entries' && $method === 'POST'):
|
||||||
|
$auth = authenticate($db);
|
||||||
|
$site = trim($input['site'] ?? '');
|
||||||
|
$username = trim($input['username'] ?? '');
|
||||||
|
$folder = trim($input['folder'] ?? 'All');
|
||||||
|
$encPwd = $input['encrypted_password'] ?? '';
|
||||||
|
$iv = $input['iv'] ?? '';
|
||||||
|
if (empty($site) || empty($encPwd)) { http_response_code(400); echo json_encode(['error'=>'Site & password required']); break; }
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$st = $db->prepare('INSERT INTO vault_entries (user_id, site, username, encrypted_password, iv, encryption_method, folder, created_at, updated_at) VALUES (:uid,:s,:u,:e,:i,:m,:f,:c,:c)');
|
||||||
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$st->bindValue(':s', $site, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':u', $username, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':e', $encPwd, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':i', $iv, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':m', 'client', SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':f', $folder, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':c', $now, SQLITE3_TEXT);
|
||||||
|
$st->execute();
|
||||||
|
echo json_encode(['id'=>$db->lastInsertRowID(), 'site'=>$site, 'username'=>$username, 'folder'=>$folder]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'PUT'):
|
||||||
|
$auth = authenticate($db);
|
||||||
|
$site = trim($input['site'] ?? '');
|
||||||
|
$username = trim($input['username'] ?? '');
|
||||||
|
$encPwd = $input['encrypted_password'] ?? '';
|
||||||
|
$iv = $input['iv'] ?? '';
|
||||||
|
$folder = trim($input['folder'] ?? 'All');
|
||||||
|
if (empty($site) || empty($encPwd)) { http_response_code(400); echo json_encode(['error'=>'Site & password required']); break; }
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$st = $db->prepare('UPDATE vault_entries SET site=:s, username=:u, encrypted_password=:e, iv=:i, folder=:f, updated_at=:c WHERE id=:id AND user_id=:uid');
|
||||||
|
$st->bindValue(':s', $site, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':u', $username, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':e', $encPwd, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':i', $iv, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':f', $folder, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':c', $now, SQLITE3_TEXT);
|
||||||
|
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
||||||
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$st->execute();
|
||||||
|
echo json_encode(['message'=>'Updated']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case (preg_match('/^\/entries\/(\d+)$/', $path, $m) && $method === 'DELETE'):
|
||||||
|
$auth = authenticate($db);
|
||||||
|
$st = $db->prepare('DELETE FROM vault_entries WHERE id=:id AND user_id=:uid');
|
||||||
|
$st->bindValue(':id', $m[1], SQLITE3_INTEGER);
|
||||||
|
$st->bindValue(':uid', $auth['userId'], SQLITE3_INTEGER);
|
||||||
|
$st->execute();
|
||||||
|
echo json_encode(['message'=>'Deleted']);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error'=>'Not found']);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->close();
|
||||||
|
?>
|
||||||
+426
@@ -0,0 +1,426 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0a0e17;
|
||||||
|
--bg2: #131a24;
|
||||||
|
--text: #e1e8f0;
|
||||||
|
--text2: #8899aa;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--border: rgba(255,255,255,0.06);
|
||||||
|
--card: rgba(18,25,35,0.85);
|
||||||
|
--input: #10161f;
|
||||||
|
--vault-bg: rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
.light {
|
||||||
|
--bg: #f4f6f9;
|
||||||
|
--bg2: #e9ecf1;
|
||||||
|
--text: #1a1f2e;
|
||||||
|
--text2: #5a6475;
|
||||||
|
--card: rgba(255,255,255,0.9);
|
||||||
|
--input: #ffffff;
|
||||||
|
--border: rgba(0,0,0,0.08);
|
||||||
|
--vault-bg: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body {
|
||||||
|
background: linear-gradient(145deg, var(--bg) 0%, var(--bg2) 100%);
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 1.2rem;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed; top: 1rem; right: 1rem; z-index: 9999;
|
||||||
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
padding: 0.7rem 1.2rem; border-radius: 0.8rem;
|
||||||
|
font-size: 0.85rem; animation: slideIn 0.3s ease; color: #fff;
|
||||||
|
}
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
.toast.success { background: var(--success); }
|
||||||
|
@keyframes slideIn {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault {
|
||||||
|
width: 96%; max-width: 1700px;
|
||||||
|
background: var(--vault-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 2rem; padding: 1.8rem; margin: 0.5rem auto;
|
||||||
|
box-shadow: 0 30px 50px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem; margin-bottom: 0.6rem;
|
||||||
|
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
h1 span {
|
||||||
|
background: var(--accent); padding: 0.2rem 0.7rem;
|
||||||
|
border-radius: 3rem; font-size: 0.8rem; color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
.auth-section {
|
||||||
|
background: rgba(0,0,0,0.25); border-radius: 1.5rem;
|
||||||
|
padding: 1.3rem; margin-bottom: 1rem; border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.auth-tabs { display: flex; gap: 1rem; margin-bottom: 0.8rem; }
|
||||||
|
.auth-tab {
|
||||||
|
background: none; border: none; color: var(--text2);
|
||||||
|
padding: 0.4rem 0.8rem; cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent; font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
flex: 1; min-width: 130px; background: var(--input);
|
||||||
|
border: 1px solid #2d3748; padding: 0.65rem 1rem;
|
||||||
|
border-radius: 2rem; color: var(--text); font-size: 0.85rem;
|
||||||
|
outline: none; font-family: inherit;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: linear-gradient(135deg, #2563eb, #1d4ed8);
|
||||||
|
border: none; color: #fff; font-weight: 600;
|
||||||
|
padding: 0.65rem 1.3rem; border-radius: 2rem;
|
||||||
|
cursor: pointer; font-size: 0.85rem; transition: 0.2s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.15); transform: scale(1.02); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||||
|
.btn-outline { background: transparent; border: 1px solid #475569; color: var(--text2); }
|
||||||
|
.btn-sm { padding: 0.35rem 0.9rem; font-size: 0.75rem; }
|
||||||
|
.btn-xs { padding: 0.2rem 0.5rem; font-size: 0.7rem; }
|
||||||
|
.btn-danger { background: var(--danger); }
|
||||||
|
|
||||||
|
.input-group { display: flex; gap: 0.5rem; margin: 0.7rem 0; flex-wrap: wrap; align-items: center; }
|
||||||
|
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.7rem; }
|
||||||
|
|
||||||
|
.view-toggle { display: flex; gap: 0.2rem; background: rgba(0,0,0,0.3); padding: 0.2rem; border-radius: 2rem; }
|
||||||
|
.view-btn {
|
||||||
|
background: none; border: none; color: var(--text2);
|
||||||
|
padding: 0.35rem 0.7rem; border-radius: 2rem; cursor: pointer;
|
||||||
|
font-size: 0.75rem; transition: 0.2s;
|
||||||
|
}
|
||||||
|
.view-btn.active { background: var(--accent); color: #fff; }
|
||||||
|
.view-btn:hover:not(.active) { background: rgba(255,255,255,0.08); }
|
||||||
|
|
||||||
|
.toggles-row { display: flex; gap: 1.2rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.toggle-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--text2); }
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative; width: 36px; height: 20px;
|
||||||
|
background: #334155; border-radius: 10px; cursor: pointer; transition: 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-switch.active { background: var(--accent); }
|
||||||
|
.toggle-switch::after {
|
||||||
|
content: ''; position: absolute; top: 2px; left: 2px;
|
||||||
|
width: 16px; height: 16px; background: #fff; border-radius: 50%; transition: 0.2s;
|
||||||
|
}
|
||||||
|
.toggle-switch.active::after { left: 18px; }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
background: var(--bg2); padding: 0.25rem 0.8rem;
|
||||||
|
border-radius: 2rem; font-size: 0.75rem; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Strength */
|
||||||
|
.strength-bar { height: 4px; border-radius: 2px; transition: 0.3s; margin-top: 0.2rem; }
|
||||||
|
.s0 { background: var(--danger); width: 20%; }
|
||||||
|
.s1 { background: var(--warning); width: 40%; }
|
||||||
|
.s2 { background: #eab308; width: 60%; }
|
||||||
|
.s3 { background: #84cc16; width: 80%; }
|
||||||
|
.s4 { background: var(--success); width: 100%; }
|
||||||
|
|
||||||
|
/* Folders */
|
||||||
|
.folders-bar {
|
||||||
|
display: flex; gap: 0.4rem; margin-bottom: 0.8rem;
|
||||||
|
flex-wrap: wrap; align-items: center;
|
||||||
|
padding: 0.4rem 0.6rem; background: rgba(0,0,0,0.2); border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.folder-chip {
|
||||||
|
background: var(--bg2); border: 1px solid var(--border);
|
||||||
|
color: var(--text2); padding: 0.3rem 0.8rem; border-radius: 2rem;
|
||||||
|
cursor: pointer; font-size: 0.78rem; transition: 0.2s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.folder-chip:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.folder-chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.folder-count {
|
||||||
|
background: rgba(0,0,0,0.3); padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 1rem; margin-left: 0.3rem; font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
.folder-delete-btn {
|
||||||
|
background: transparent; border: none; color: var(--danger);
|
||||||
|
cursor: pointer; font-size: 0.7rem; margin-left: 0.2rem; opacity: 0.7;
|
||||||
|
}
|
||||||
|
.folder-delete-btn:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.folder-add-btn {
|
||||||
|
background: transparent; border: 1px dashed #475569;
|
||||||
|
color: var(--text2); padding: 0.3rem 0.6rem; border-radius: 2rem;
|
||||||
|
cursor: pointer; font-size: 0.75rem; transition: 0.2s;
|
||||||
|
}
|
||||||
|
.folder-add-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Add form folder select */
|
||||||
|
#addFolderSelect {
|
||||||
|
min-width: 110px; max-width: 150px;
|
||||||
|
background: var(--input); border: 1px solid #2d3748;
|
||||||
|
color: var(--text); padding: 0.5rem 0.8rem; border-radius: 2rem;
|
||||||
|
font-size: 0.8rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entries */
|
||||||
|
#entriesContainer.grid-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
#entriesContainer.list-view { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
|
#entriesContainer.compact-view { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
|
#entriesContainer.table-view { overflow-x: auto; }
|
||||||
|
#entriesContainer.table-view table { width: 100%; border-collapse: collapse; }
|
||||||
|
#entriesContainer.table-view th {
|
||||||
|
text-align: left; padding: 0.4rem 0.6rem;
|
||||||
|
color: var(--text2); font-size: 0.75rem; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
#entriesContainer.table-view td {
|
||||||
|
padding: 0.4rem 0.6rem; font-size: 0.8rem;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card, .entry-row, .entry-compact, .table-row-drag {
|
||||||
|
cursor: grab; user-select: none;
|
||||||
|
}
|
||||||
|
.entry-card.drag-over, .entry-row.drag-over, .entry-compact.drag-over, .table-row-drag.drag-over {
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
box-shadow: 0 0 15px rgba(59,130,246,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1); /* brighter border */
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
padding: 1rem 2.5rem 1rem 1rem;
|
||||||
|
transition: 0.2s;
|
||||||
|
position: relative;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card:hover { border-color: rgba(255,255,255,0.15); transform: translateY(-2px); }
|
||||||
|
.card-site { font-weight: 700; font-size: 0.9rem; color: var(--text); margin-bottom: 0.2rem; }
|
||||||
|
.card-user { color: var(--text2); font-size: 0.75rem; margin-bottom: 0.3rem; }
|
||||||
|
.card-folder {
|
||||||
|
font-size: 0.65rem; color: var(--accent); margin-bottom: 0.3rem;
|
||||||
|
background: rgba(59,130,246,0.15); display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem; border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.card-password {
|
||||||
|
background: var(--bg2); padding: 0.3rem 0.5rem; border-radius: 0.6rem;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
font-family: monospace; font-size: 0.75rem; gap: 0.2rem;
|
||||||
|
}
|
||||||
|
.entry-row {
|
||||||
|
background: var(--card); border: 1px solid var(--border);
|
||||||
|
border-radius: 0.8rem; padding: 0.7rem 2.8rem 0.7rem 0.9rem;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
flex-wrap: wrap; gap: 0.4rem; position: relative;
|
||||||
|
}
|
||||||
|
.entry-compact {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 0.35rem 2.8rem 0.35rem 0.7rem;
|
||||||
|
background: var(--card); border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--border); font-size: 0.8rem; position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btns { position: absolute; top: 4px; right: 6px; display: flex; gap: 4px; z-index: 1; }
|
||||||
|
.delete-btn {
|
||||||
|
width: 20px; height: 20px; background: transparent; color: var(--danger);
|
||||||
|
border: none; cursor: pointer; font-size: 0.9rem; font-weight: 700;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.delete-btn:hover { color: #fff; transform: scale(1.2); }
|
||||||
|
.light .delete-btn:hover { color: #000 !important; }
|
||||||
|
.edit-btn {
|
||||||
|
width: 20px; height: 20px; background: transparent; color: var(--accent);
|
||||||
|
border: none; cursor: pointer; font-size: 0.75rem;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.edit-btn:hover { color: #fff; transform: scale(1.2); }
|
||||||
|
.light .edit-btn:hover { color: #000 !important; }
|
||||||
|
|
||||||
|
.entry-info { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; flex: 1; }
|
||||||
|
.entry-site { font-weight: 700; color: var(--text); }
|
||||||
|
.entry-user { color: var(--text2); }
|
||||||
|
.entry-folder {
|
||||||
|
font-size: 0.7rem; color: var(--accent);
|
||||||
|
background: rgba(59,130,246,0.15); padding: 0.1rem 0.5rem; border-radius: 1rem;
|
||||||
|
}
|
||||||
|
.password-field {
|
||||||
|
display: flex; align-items: center; gap: 0.3rem;
|
||||||
|
background: var(--bg2); padding: 0.2rem 0.5rem; border-radius: 2rem;
|
||||||
|
}
|
||||||
|
.password-text { font-family: monospace; color: var(--text2); font-size: 0.8rem; }
|
||||||
|
.icon-btn {
|
||||||
|
background: none; border: 1px solid #475569; color: var(--text2);
|
||||||
|
border-radius: 2rem; padding: 0.2rem 0.5rem; font-size: 0.65rem; cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: #334155; }
|
||||||
|
|
||||||
|
.search-box { flex: 1; min-width: 160px; }
|
||||||
|
.idle-warning {
|
||||||
|
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
||||||
|
background: rgba(0,0,0,0.95); color: #fff; padding: 2rem;
|
||||||
|
border-radius: 2rem; z-index: 10000; text-align: center; display: none;
|
||||||
|
}
|
||||||
|
.idle-warning.show { display: block; }
|
||||||
|
|
||||||
|
/* Edit Modal */
|
||||||
|
.edit-modal {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.7); display: flex; justify-content: center;
|
||||||
|
align-items: center; z-index: 1001; display: none;
|
||||||
|
}
|
||||||
|
.edit-modal.show { display: flex; }
|
||||||
|
.edit-box {
|
||||||
|
background: var(--bg2); border-radius: 1.5rem; padding: 1.5rem;
|
||||||
|
min-width: 380px; max-width: 90%;
|
||||||
|
}
|
||||||
|
.edit-box h3 { margin-bottom: 1rem; color: var(--text); }
|
||||||
|
.edit-box label { color: var(--text2); font-size: 0.8rem; display: block; margin-bottom: 0.2rem; }
|
||||||
|
.edit-box input, .edit-box select { width: 100%; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* Custom modals (folder add/delete) */
|
||||||
|
.custom-modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.6); display: flex; justify-content: center;
|
||||||
|
align-items: center; z-index: 10001; display: none;
|
||||||
|
}
|
||||||
|
.custom-modal-overlay.show { display: flex; }
|
||||||
|
.custom-modal {
|
||||||
|
background: var(--bg2); border: 1px solid var(--border);
|
||||||
|
border-radius: 1.2rem; padding: 1.5rem; min-width: 300px; max-width: 90%;
|
||||||
|
}
|
||||||
|
.custom-modal h3 { margin-bottom: 1rem; color: var(--text); }
|
||||||
|
.custom-modal input { width: 100%; margin-bottom: 1rem; }
|
||||||
|
.custom-modal .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* Confirm popup near element */
|
||||||
|
.custom-confirm {
|
||||||
|
position: fixed; background: var(--bg2); border: 1px solid var(--accent);
|
||||||
|
border-radius: 0.8rem; padding: 0.7rem 1rem; z-index: 9999;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5); display: none;
|
||||||
|
font-size: 0.8rem; color: var(--text); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.custom-confirm.show { display: block; }
|
||||||
|
.custom-confirm .confirm-text { margin-bottom: 0.5rem; }
|
||||||
|
.custom-confirm .confirm-btns { display: flex; gap: 0.4rem; }
|
||||||
|
.custom-confirm .confirm-yes {
|
||||||
|
background: var(--danger); color: #fff; border: none;
|
||||||
|
padding: 0.3rem 0.8rem; border-radius: 1.5rem; cursor: pointer; font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.custom-confirm .confirm-no {
|
||||||
|
background: #334155; color: #fff; border: none;
|
||||||
|
padding: 0.3rem 0.8rem; border-radius: 1.5rem; cursor: pointer; font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zigzag toast */
|
||||||
|
/* .toast-zigzag { */
|
||||||
|
/* position: fixed; z-index: 9998; padding: 0.5rem 0.9rem; */
|
||||||
|
/* border-radius: 0.5rem; font-size: 0.78rem; font-weight: 600; */
|
||||||
|
/* pointer-events: none; animation: zigzagUp 1.4s ease-out forwards; white-space: nowrap; */
|
||||||
|
/* } */
|
||||||
|
/* .toast-zigzag.success { background: rgba(34,197,94,0.95); color: #fff; } */
|
||||||
|
/* .toast-zigzag.error { background: rgba(239,68,68,0.95); color: #fff; } */
|
||||||
|
/* @keyframes zigzagUp { */
|
||||||
|
/* 0% { opacity: 1; transform: translate(0,0) scale(1); } */
|
||||||
|
/* 15% { opacity: 1; transform: translate(-12px,-12px) scale(1.05); } */
|
||||||
|
/* 30% { opacity: 0.9; transform: translate(14px,-24px) scale(1); } */
|
||||||
|
/* 50% { opacity: 0.7; transform: translate(-10px,-38px) scale(0.95); } */
|
||||||
|
/* 70% { opacity: 0.4; transform: translate(10px,-52px) scale(0.9); } */
|
||||||
|
/* 100% { opacity: 0; transform: translate(0,-68px) scale(0.8); } */
|
||||||
|
/* } */
|
||||||
|
/* Zigzag toast – smaller over time, more transparent */
|
||||||
|
.toast-zigzag {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 9998;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: zigzagUp 1.2s ease-out forwards;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toast-zigzag.success {
|
||||||
|
background: rgba(34, 197, 94, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.toast-zigzag.error {
|
||||||
|
background: rgba(239, 68, 68, 0.95);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zigzagUp {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translate(-10px, -14px) scale(0.9);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 0.65;
|
||||||
|
transform: translate(12px, -28px) scale(0.75);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: translate(-8px, -42px) scale(0.6);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(0, -60px) scale(0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media(max-width:700px) {
|
||||||
|
.vault {
|
||||||
|
background: rgba(20, 30, 45, 0.25); /* a bit more visible */
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
}
|
||||||
|
.entry-row { flex-direction: column; }
|
||||||
|
#entriesContainer.grid-view { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||||||
|
.toolbar { flex-direction: column; }
|
||||||
|
.folders-bar { flex-direction: column; align-items: stretch; }
|
||||||
|
}
|
||||||
|
/* Table view improvements */
|
||||||
|
#entriesContainer.table-view {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#entriesContainer.table-view table {
|
||||||
|
min-width: 600px; /* Ensures columns don't squash too much */
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#entriesContainer.table-view th,
|
||||||
|
#entriesContainer.table-view td {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Limit password cell width and show ellipsis */
|
||||||
|
#entriesContainer.table-view td.password-cell {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🔐 Vault</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
<div class="idle-warning" id="idleWarning"><h3>⏰ Auto-lock</h3><p>Vault locks in <span id="idleCountdown">30</span>s</p><button class="btn btn-sm" onclick="resetIdle()">Stay Unlocked</button></div>
|
||||||
|
|
||||||
|
<div class="edit-modal" id="editModal">
|
||||||
|
<div class="edit-box">
|
||||||
|
<h3>✏️ Edit Entry</h3>
|
||||||
|
<form onsubmit="return false">
|
||||||
|
<label>Website / App</label><input type="text" id="editSite" placeholder="example.com" autocomplete="off">
|
||||||
|
<label style="margin-top:.6rem">Username / Email</label><input type="text" id="editUsername" placeholder="user@example.com" autocomplete="off">
|
||||||
|
<label style="margin-top:.6rem">Password</label>
|
||||||
|
<div style="position:relative">
|
||||||
|
<input type="password" id="editPassword" placeholder="Password" style="width:100%;padding-right:40px" autocomplete="new-password">
|
||||||
|
<button type="button" onclick="toggleEditPassword()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:.9rem">👁️</button>
|
||||||
|
</div>
|
||||||
|
<label style="margin-top:.6rem">Folder</label>
|
||||||
|
<select id="editFolder" style="width:100%;margin-bottom:.4rem"></select>
|
||||||
|
<input type="hidden" id="editId">
|
||||||
|
<div style="display:flex;gap:.5rem;margin-top:1rem">
|
||||||
|
<button class="btn" onclick="saveEdit()">💾 Save</button>
|
||||||
|
<button class="btn btn-outline" onclick="closeEdit()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vault">
|
||||||
|
<h1>🔐 Vault <span>XAMPP</span><button class="btn btn-outline btn-xs" onclick="toggleTheme()" style="margin-left:auto">🌓</button></h1>
|
||||||
|
|
||||||
|
<div id="authSection" class="auth-section">
|
||||||
|
<div class="auth-tabs"><button class="auth-tab active" onclick="switchTab('login')">Login</button><button class="auth-tab" onclick="switchTab('register')">Register</button></div>
|
||||||
|
<div id="loginForm">
|
||||||
|
<form onsubmit="return false" class="input-group" autocomplete="off">
|
||||||
|
<input type="text" id="loginUsername" placeholder="Username" autocomplete="username">
|
||||||
|
<input type="password" id="loginPassword" placeholder="Master Password" autocomplete="current-password">
|
||||||
|
<button class="btn" id="loginBtn" onclick="login()">🔓 Unlock</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="registerForm" class="hidden">
|
||||||
|
<form onsubmit="return false" class="input-group" autocomplete="off">
|
||||||
|
<input type="text" id="regUsername" placeholder="Username (min 3)" autocomplete="username">
|
||||||
|
<input type="password" id="regPassword" placeholder="Password (min 8)" autocomplete="new-password">
|
||||||
|
<button class="btn" id="registerBtn" onclick="register()">✨ Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="vaultSection" class="hidden">
|
||||||
|
<div style="display:flex;justify-content:space-between;margin-bottom:.5rem;align-items:center;flex-wrap:wrap;gap:.4rem">
|
||||||
|
<span class="status-badge" id="connectionStatus">🟢 Connected</span>
|
||||||
|
<span id="entryCount" style="color:var(--text2);font-size:.8rem"></span>
|
||||||
|
<div style="display:flex;gap:.4rem;align-items:center">
|
||||||
|
<select id="autoLockTimer" onchange="setAutoLock()" style="font-size:.7rem;padding:.3rem .5rem;background:var(--input);border:1px solid #2d3748;color:var(--text);border-radius:1.5rem">
|
||||||
|
<option value="0">No lock</option><option value="1">1 min</option><option value="5" selected>5 min</option><option value="15">15 min</option><option value="30">30 min</option><option value="60">1 hour</option>
|
||||||
|
</select>
|
||||||
|
<span id="currentUser" style="color:var(--text2);font-size:.8rem"></span>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="doLogout()">🔒 Lock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit="return false" class="input-group" autocomplete="off">
|
||||||
|
<input type="text" id="siteInput" placeholder="Website *" autocomplete="off">
|
||||||
|
<input type="text" id="usernameInput" placeholder="Username (optional)" autocomplete="off">
|
||||||
|
<div style="flex:1;min-width:130px;position:relative">
|
||||||
|
<input type="password" id="passwordInput" placeholder="Password *" autocomplete="new-password" style="width:100%" oninput="checkStrength()">
|
||||||
|
<div class="strength-bar s0" id="strengthBar"></div>
|
||||||
|
</div>
|
||||||
|
<select id="addFolderSelect"></select>
|
||||||
|
<button type="button" class="btn" id="addBtn" onclick="addEntry()">➕ Add</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-box"><input type="text" id="searchInput" placeholder="🔍 Search..." oninput="searchEntries()" autocomplete="off"></div>
|
||||||
|
<div style="display:flex;gap:.4rem;align-items:center;flex-wrap:wrap">
|
||||||
|
<div class="view-toggle" id="viewToggle">
|
||||||
|
<button class="view-btn active" data-view="grid">🟫 Grid</button>
|
||||||
|
<button class="view-btn" data-view="compact">📝 Compact</button>
|
||||||
|
<button class="view-btn" data-view="table">📊 Table</button>
|
||||||
|
<button class="view-btn" data-view="list">📋 List</button>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="openGen()">🎲</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggles-row" style="margin-bottom:.5rem;padding:.3rem .7rem;background:rgba(0,0,0,0.2);border-radius:1rem">
|
||||||
|
<span class="toggle-item"><span>👁️ View</span><div class="toggle-switch active" id="showViewBtnToggle" onclick="toggleViewBtn()"></div></span>
|
||||||
|
<span class="toggle-item"><span>📧 Email</span><div class="toggle-switch active" id="showEmailToggle" onclick="toggleShowEmail()"></div></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="folders-bar" id="foldersBar"></div>
|
||||||
|
|
||||||
|
<div id="entriesContainer" class="grid-view"><div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">📭 No entries</div></div>
|
||||||
|
<div style="margin-top:.6rem;text-align:right"><button class="btn btn-outline btn-sm" onclick="exportPasswords()">📤 Export</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="genModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:none;justify-content:center;align-items:center;z-index:1000">
|
||||||
|
<div style="background:var(--bg2);border-radius:1.5rem;padding:1.5rem;min-width:320px;max-width:90%">
|
||||||
|
<h3 style="margin-bottom:.8rem;color:var(--text)">🎲 Generator</h3>
|
||||||
|
<div style="background:var(--input);padding:.8rem;border-radius:1rem;font-family:monospace;text-align:center;color:#4ade80;margin:.6rem 0;word-break:break-all" id="genPreview">Click Generate</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:.6rem;margin:.8rem 0"><span>Length:</span><input type="range" id="pwdLen" min="8" max="64" value="16" oninput="onLenChange()" style="flex:1"><span id="lenVal" style="background:var(--input);padding:.2rem .6rem;border-radius:1rem;min-width:30px;text-align:center">16</span></div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:.5rem;margin:.5rem 0">
|
||||||
|
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useUpper" checked onchange="genPwd()"> A-Z</label>
|
||||||
|
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useLower" checked onchange="genPwd()"> a-z</label>
|
||||||
|
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useNum" checked onchange="genPwd()"> 0-9</label>
|
||||||
|
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useSym" checked onchange="genPwd()"> !@#$</label>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:.4rem;margin-top:.8rem"><button class="btn" onclick="genPwd()" style="flex:1">🔄</button><button class="btn" onclick="useGen()" style="flex:1">✅ Use</button><button class="btn btn-outline" onclick="closeGen()">Cancel</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
const API = '/password-manager/api.php';
|
||||||
|
let token = sessionStorage.getItem('authToken');
|
||||||
|
let curUser = sessionStorage.getItem('currentUsername');
|
||||||
|
let view = localStorage.getItem('vaultView') || 'grid';
|
||||||
|
let showView = localStorage.getItem('showViewBtn') !== 'false';
|
||||||
|
let showMail = localStorage.getItem('showEmail') !== 'false';
|
||||||
|
let dark = localStorage.getItem('darkTheme') !== 'false';
|
||||||
|
let lockMin = parseInt(localStorage.getItem('autoLockMinutes') || '5');
|
||||||
|
let order = JSON.parse(localStorage.getItem('entryOrder') || '[]');
|
||||||
|
let selectedFolder = localStorage.getItem('selectedFolder') || 'All';
|
||||||
|
let entries = [];
|
||||||
|
let folders = ['All']; // Will be populated from server
|
||||||
|
let genPwdVal = '';
|
||||||
|
let cryptoKey = null;
|
||||||
|
let idleT, warnT, countT;
|
||||||
|
let draggedId = null;
|
||||||
|
|
||||||
|
// ==================== TOAST ====================
|
||||||
|
function toast(m, t) { t = t || 'success'; const c = document.getElementById('toastContainer'); const d = document.createElement('div'); d.className = 'toast ' + t; d.textContent = m; c.appendChild(d); setTimeout(() => d.remove(), 3000); }
|
||||||
|
function showZigzagToast(element, message, type) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast-zigzag ' + (type || 'success');
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
toast.style.left = rect.left + 'px';
|
||||||
|
toast.style.top = rect.top + 'px';
|
||||||
|
setTimeout(() => toast.remove(), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== THEME ====================
|
||||||
|
function applyTheme() { document.body.classList.toggle('light', !dark); }
|
||||||
|
function toggleTheme() { dark = !dark; localStorage.setItem('darkTheme', dark); applyTheme(); }
|
||||||
|
|
||||||
|
// ==================== CRYPTO ====================
|
||||||
|
function checkStrength() { const p = document.getElementById('passwordInput').value; const b = document.getElementById('strengthBar'); let s = 0; if (p.length >= 8) s++; if (p.length >= 12) s++; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; if (/\d/.test(p)) s++; if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; b.className = 'strength-bar s' + Math.min(4, s); }
|
||||||
|
async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); }
|
||||||
|
async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plain)); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)) }; }
|
||||||
|
async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } }
|
||||||
|
|
||||||
|
// ==================== AUTO-LOCK ====================
|
||||||
|
function setAutoLock() { lockMin = parseInt(document.getElementById('autoLockTimer').value); localStorage.setItem('autoLockMinutes', lockMin); resetIdle(); }
|
||||||
|
function resetIdle() { clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); document.getElementById('idleWarning').classList.remove('show'); if (lockMin > 0 && token) { const lm = lockMin * 60000; warnT = setTimeout(() => { document.getElementById('idleWarning').classList.add('show'); let cd = 30; document.getElementById('idleCountdown').textContent = cd; countT = setInterval(() => { cd--; document.getElementById('idleCountdown').textContent = cd; if (cd <= 0) { clearInterval(countT); doLogout(); } }, 1000); }, Math.max(0, lm - 30000)); idleT = setTimeout(() => doLogout(), lm); } }
|
||||||
|
|
||||||
|
// ==================== USERNAME ====================
|
||||||
|
function saveUsername() { const f = document.getElementById('usernameInput'); if (f && f.value.trim()) localStorage.setItem('savedUsername', f.value.trim()); }
|
||||||
|
function loadUsername() { const s = localStorage.getItem('savedUsername'); const f = document.getElementById('usernameInput'); if (s && f) f.value = s; }
|
||||||
|
|
||||||
|
// ==================== FOLDERS (server-backed) ====================
|
||||||
|
async function loadFolders() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/folders', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||||
|
if (r.ok) {
|
||||||
|
folders = await r.json();
|
||||||
|
if (!folders.includes('All')) folders.unshift('All');
|
||||||
|
} else {
|
||||||
|
folders = ['All'];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
folders = ['All'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addFolderToServer(name) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
await loadFolders();
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const d = await r.json();
|
||||||
|
toast('❌ ' + (d.error || 'Error'), 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) { toast('⚠️ Connection error', 'error'); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolderFromServer(name) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/folders/' + encodeURIComponent(name), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
await loadFolders();
|
||||||
|
if (selectedFolder === name) {
|
||||||
|
selectedFolder = 'All';
|
||||||
|
localStorage.setItem('selectedFolder', 'All');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const d = await r.json();
|
||||||
|
toast('❌ ' + (d.error || 'Error'), 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e) { toast('⚠️ Connection error', 'error'); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFolders() {
|
||||||
|
const bar = document.getElementById('foldersBar');
|
||||||
|
if (!bar) return;
|
||||||
|
|
||||||
|
// Ensure every entry has a string folder
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (!e.folder || typeof e.folder !== 'string') e.folder = 'All';
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {};
|
||||||
|
entries.forEach(e => {
|
||||||
|
const f = e.folder;
|
||||||
|
counts[f] = (counts[f] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
folders.forEach(f => {
|
||||||
|
const count = counts[f] || 0;
|
||||||
|
html += `<span class="folder-chip${selectedFolder === f ? ' active' : ''}" onclick="selectFolder('${esc(f)}')">📁 ${esc(f)}<span class="folder-count">${count}</span>${f !== 'All' ? `<button class="folder-delete-btn" onclick="event.stopPropagation();showDeleteFolderConfirm('${esc(f)}')">✕</button>` : ''}</span>`;
|
||||||
|
});
|
||||||
|
html += `<button class="folder-add-btn" onclick="showAddFolderModal()">+ New</button>`;
|
||||||
|
bar.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateAddFolderSelect() {
|
||||||
|
const select = document.getElementById('addFolderSelect');
|
||||||
|
if (!select) return;
|
||||||
|
select.innerHTML = '';
|
||||||
|
folders.forEach(f => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = f;
|
||||||
|
option.textContent = '📁 ' + f;
|
||||||
|
if (f === selectedFolder) option.selected = true;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFolder(f) {
|
||||||
|
selectedFolder = f;
|
||||||
|
localStorage.setItem('selectedFolder', f);
|
||||||
|
renderFolders();
|
||||||
|
populateAddFolderSelect();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddFolderModal() {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'custom-modal-overlay show';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="custom-modal">
|
||||||
|
<h3>📁 New Folder</h3>
|
||||||
|
<input type="text" id="newFolderName" placeholder="Folder name">
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-outline btn-sm" id="cancelAddFolder">Cancel</button>
|
||||||
|
<button class="btn btn-sm" id="confirmAddFolder">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.getElementById('cancelAddFolder').onclick = () => overlay.remove();
|
||||||
|
document.getElementById('confirmAddFolder').onclick = async () => {
|
||||||
|
const name = document.getElementById('newFolderName').value.trim();
|
||||||
|
if (!name) { toast('Enter a name', 'error'); return; }
|
||||||
|
const success = await addFolderToServer(name);
|
||||||
|
if (success) {
|
||||||
|
renderFolders();
|
||||||
|
populateAddFolderSelect();
|
||||||
|
overlay.remove();
|
||||||
|
toast('📁 Folder created!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDeleteFolderConfirm(folderName) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'custom-modal-overlay show';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="custom-modal">
|
||||||
|
<h3>🗑️ Delete Folder</h3>
|
||||||
|
<p style="color:var(--text2);margin-bottom:1rem;">Delete "${folderName}"? Entries will move to "All".</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-outline btn-sm" id="cancelDeleteFolder">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-danger" id="confirmDeleteFolder">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
document.getElementById('cancelDeleteFolder').onclick = () => overlay.remove();
|
||||||
|
document.getElementById('confirmDeleteFolder').onclick = async () => {
|
||||||
|
const success = await deleteFolderFromServer(folderName);
|
||||||
|
if (success) {
|
||||||
|
renderFolders();
|
||||||
|
populateAddFolderSelect();
|
||||||
|
render();
|
||||||
|
overlay.remove();
|
||||||
|
toast('📁 Folder deleted');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== INIT ====================
|
||||||
|
function init() {
|
||||||
|
document.querySelectorAll('.view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
||||||
|
document.getElementById('showViewBtnToggle').classList.toggle('active', showView);
|
||||||
|
document.getElementById('showEmailToggle').classList.toggle('active', showMail);
|
||||||
|
document.getElementById('autoLockTimer').value = lockMin;
|
||||||
|
applyTheme();
|
||||||
|
document.getElementById('usernameInput').style.display = showMail ? '' : 'none';
|
||||||
|
loadUsername();
|
||||||
|
const sl = localStorage.getItem('savedLoginUser');
|
||||||
|
if (sl) document.getElementById('loginUsername').value = sl;
|
||||||
|
document.getElementById('viewToggle').addEventListener('click', e => {
|
||||||
|
if (e.target.classList.contains('view-btn')) {
|
||||||
|
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
e.target.classList.add('active');
|
||||||
|
view = e.target.dataset.view;
|
||||||
|
localStorage.setItem('vaultView', view);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleViewBtn() { showView = !showView; localStorage.setItem('showViewBtn', showView); document.getElementById('showViewBtnToggle').classList.toggle('active', showView); render(); }
|
||||||
|
function toggleShowEmail() { showMail = !showMail; localStorage.setItem('showEmail', showMail); document.getElementById('showEmailToggle').classList.toggle('active', showMail); document.getElementById('usernameInput').style.display = showMail ? '' : 'none'; render(); }
|
||||||
|
|
||||||
|
// ==================== GENERATOR ====================
|
||||||
|
function openGen() { document.getElementById('genModal').style.display = 'flex'; genPwd(); }
|
||||||
|
function closeGen() { document.getElementById('genModal').style.display = 'none'; }
|
||||||
|
function onLenChange() { document.getElementById('lenVal').textContent = document.getElementById('pwdLen').value; genPwd(); }
|
||||||
|
function genPwd() { const l = parseInt(document.getElementById('pwdLen').value); let c = ''; if (document.getElementById('useUpper').checked) c += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (document.getElementById('useLower').checked) c += 'abcdefghijklmnopqrstuvwxyz'; if (document.getElementById('useNum').checked) c += '0123456789'; if (document.getElementById('useSym').checked) c += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!c) { document.getElementById('genPreview').textContent = 'Select option'; return; } let p = ''; for (let i = 0; i < l; i++) p += c.charAt(Math.floor(Math.random() * c.length)); genPwdVal = p; document.getElementById('genPreview').textContent = p; }
|
||||||
|
function useGen() { if (!genPwdVal) genPwd(); document.getElementById('passwordInput').value = genPwdVal; checkStrength(); navigator.clipboard.writeText(genPwdVal); toast('🎲 Copied!'); closeGen(); }
|
||||||
|
|
||||||
|
// ==================== AUTH ====================
|
||||||
|
function switchTab(t) { document.querySelectorAll('.auth-tab').forEach(x => x.classList.remove('active')); event.target.classList.add('active'); document.getElementById('loginForm').classList.toggle('hidden', t !== 'login'); document.getElementById('registerForm').classList.toggle('hidden', t !== 'register'); }
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const u = document.getElementById('loginUsername').value.trim();
|
||||||
|
const p = document.getElementById('loginPassword').value;
|
||||||
|
if (!u || !p) { toast('Fill all fields', 'error'); return; }
|
||||||
|
document.getElementById('loginBtn').disabled = true;
|
||||||
|
localStorage.setItem('savedLoginUser', u);
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
token = d.token; curUser = u; cryptoKey = await deriveKey(p, d.salt);
|
||||||
|
sessionStorage.setItem('authToken', token); sessionStorage.setItem('currentUsername', u);
|
||||||
|
await loadFolders();
|
||||||
|
toast('✅ Login!'); showVault(); loadEntries();
|
||||||
|
} else { toast('❌ ' + (d.error || 'Invalid'), 'error'); document.getElementById('loginPassword').value = ''; }
|
||||||
|
} catch (e) { toast('⚠️ Connection error', 'error'); }
|
||||||
|
finally { document.getElementById('loginBtn').disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register() {
|
||||||
|
const u = document.getElementById('regUsername').value.trim();
|
||||||
|
const p = document.getElementById('regPassword').value;
|
||||||
|
if (u.length < 3) { toast('Username min 3', 'error'); return; }
|
||||||
|
if (p.length < 8) { toast('Password min 8', 'error'); return; }
|
||||||
|
document.getElementById('registerBtn').disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (r.ok) {
|
||||||
|
token = d.token; curUser = u; cryptoKey = await deriveKey(p, d.salt);
|
||||||
|
sessionStorage.setItem('authToken', token); sessionStorage.setItem('currentUsername', u);
|
||||||
|
await loadFolders();
|
||||||
|
toast('✅ Created!'); showVault(); loadEntries();
|
||||||
|
} else { toast('❌ ' + (d.error || 'Failed'), 'error'); }
|
||||||
|
} catch (e) { toast('⚠️ Connection error', 'error'); }
|
||||||
|
finally { document.getElementById('registerBtn').disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLogout() {
|
||||||
|
saveUsername();
|
||||||
|
clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT);
|
||||||
|
document.getElementById('idleWarning').classList.remove('show');
|
||||||
|
token = null; curUser = null; entries = []; cryptoKey = null; folders = ['All'];
|
||||||
|
sessionStorage.clear();
|
||||||
|
document.getElementById('authSection').classList.remove('hidden');
|
||||||
|
document.getElementById('vaultSection').classList.add('hidden');
|
||||||
|
document.getElementById('loginPassword').value = '';
|
||||||
|
document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showVault() {
|
||||||
|
document.getElementById('authSection').classList.add('hidden');
|
||||||
|
document.getElementById('vaultSection').classList.remove('hidden');
|
||||||
|
document.getElementById('currentUser').textContent = '👤 ' + curUser;
|
||||||
|
document.getElementById('usernameInput').style.display = showMail ? '' : 'none';
|
||||||
|
document.getElementById('autoLockTimer').value = lockMin;
|
||||||
|
loadUsername();
|
||||||
|
renderFolders();
|
||||||
|
populateAddFolderSelect();
|
||||||
|
resetIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ENTRIES ====================
|
||||||
|
function applyOrder(list) { if (!list || !list.length) return []; if (!order || !order.length) return list; const map = new Map(list.filter(e => e && e.id).map(e => [e.id, e])); const ord = []; order.forEach(id => { if (map.has(id)) { ord.push(map.get(id)); map.delete(id); } }); map.forEach(e => ord.push(e)); return ord; }
|
||||||
|
|
||||||
|
async function loadEntries(q) {
|
||||||
|
try {
|
||||||
|
let url = API + '/entries';
|
||||||
|
if (q) url += '?search=' + encodeURIComponent(q);
|
||||||
|
const r = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
|
||||||
|
if (r.ok) {
|
||||||
|
const raw = await r.json();
|
||||||
|
entries = [];
|
||||||
|
for (const e of raw) {
|
||||||
|
if (e.encryption_method === 'client') {
|
||||||
|
const pw = await decryptPwd(e.encrypted_password, e.iv);
|
||||||
|
entries.push({ id: e.id, site: e.site, username: e.username, password: pw, folder: e.folder || 'All' });
|
||||||
|
} else {
|
||||||
|
entries.push({ id: e.id, site: e.site, username: e.username, password: e.password || '', folder: e.folder || 'All' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries = applyOrder(entries);
|
||||||
|
document.getElementById('connectionStatus').textContent = '🟢 Connected';
|
||||||
|
document.getElementById('entryCount').textContent = '(' + entries.length + ' entries)';
|
||||||
|
renderFolders();
|
||||||
|
populateAddFolderSelect();
|
||||||
|
render();
|
||||||
|
} else if (r.status === 401) { toast('Session expired', 'error'); doLogout(); }
|
||||||
|
} catch (e) { document.getElementById('connectionStatus').textContent = '🔴 Error'; toast('Connection error', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredEntries() {
|
||||||
|
if (selectedFolder === 'All') return entries;
|
||||||
|
return entries.filter(e => (e.folder || 'All') === selectedFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const c = document.getElementById('entriesContainer');
|
||||||
|
c.className = '';
|
||||||
|
c.classList.add(view + '-view');
|
||||||
|
const filtered = getFilteredEntries();
|
||||||
|
if (!filtered.length) { c.innerHTML = '<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">📭 No entries</div>'; return; }
|
||||||
|
if (view === 'table') {
|
||||||
|
let h = '<table><thead><tr><th>Site</th>' +
|
||||||
|
(showMail ? '<th>User</th>' : '') +
|
||||||
|
'<th>Password</th><th>Folder</th><th>Actions</th></tr></thead><tbody>';
|
||||||
|
filtered.forEach(e => {
|
||||||
|
h += '<tr class="table-row-drag" draggable="true" data-id="' + e.id + '">' +
|
||||||
|
'<td>🌐 ' + esc(e.site) + '</td>' +
|
||||||
|
(showMail ? '<td>👤 ' + esc(e.username) + '</td>' : '') +
|
||||||
|
'<td class="password-cell"><span id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span></td>' +
|
||||||
|
'<td><span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span></td>' +
|
||||||
|
'<td class="actions-cell">' +
|
||||||
|
(showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button> ' : '') +
|
||||||
|
'<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button> ' +
|
||||||
|
'<button class="edit-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✏️</button> ' +
|
||||||
|
'<button class="delete-btn" data-id="' + e.id + '" style="position:static;display:inline-flex;vertical-align:middle;">✕</button>' +
|
||||||
|
'</td>' +
|
||||||
|
'</tr>';
|
||||||
|
});
|
||||||
|
h += '</tbody></table>';
|
||||||
|
c.innerHTML = h;
|
||||||
|
}else { c.innerHTML = filtered.map(e => (view === 'grid' ? gridC(e) : view === 'compact' ? compC(e) : listC(e))).join(''); }
|
||||||
|
attachEvents();
|
||||||
|
setupDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function gridC(e) { return '<div class="entry-card" draggable="true" data-id="' + e.id + '"><div class="action-btns"><button class="edit-btn" data-id="' + e.id + '">✏️</button><button class="delete-btn" data-id="' + e.id + '">✕</button></div><div class="card-site">🌐 ' + esc(e.site) + '</div>' + (showMail ? '<div class="card-user">👤 ' + esc(e.username) + '</div>' : '') + '<div class="card-folder">📁 ' + esc(e.folder || 'All') + '</div><div class="card-password"><span id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span><div>' + (showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>' : '') + '<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div></div></div>'; }
|
||||||
|
function listC(e) { return '<div class="entry-row" draggable="true" data-id="' + e.id + '"><div class="action-btns"><button class="edit-btn" data-id="' + e.id + '">✏️</button><button class="delete-btn" data-id="' + e.id + '">✕</button></div><div class="entry-info"><span class="entry-site">🌐 ' + esc(e.site) + '</span>' + (showMail ? '<span class="entry-user">👤 ' + esc(e.username) + '</span>' : '') + '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span><div class="password-field"><span class="password-text" id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span>' + (showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>' : '') + '<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div></div></div>'; }
|
||||||
|
function compC(e) { return '<div class="entry-compact" draggable="true" data-id="' + e.id + '"><div class="action-btns"><button class="edit-btn" data-id="' + e.id + '">✏️</button><button class="delete-btn" data-id="' + e.id + '">✕</button></div><span>🌐 ' + esc(e.site) + '</span>' + (showMail ? '<span>👤 ' + esc(e.username) + '</span>' : '') + '<span class="entry-folder">📁 ' + esc(e.folder || 'All') + '</span><span id="p-' + e.id + '" data-pw="' + esc(e.password) + '">••••••••</span>' + (showView ? '<button class="icon-btn toggle-p" data-id="' + e.id + '">👁️</button>' : '') + '<button class="icon-btn copy-p" data-id="' + e.id + '">📋</button></div>'; }
|
||||||
|
|
||||||
|
// ==================== EVENTS ====================
|
||||||
|
function showConfirm(btn, message, callback) {
|
||||||
|
const id = btn.dataset.id;
|
||||||
|
const existing = document.querySelector('.custom-confirm');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
const confirm = document.createElement('div');
|
||||||
|
confirm.className = 'custom-confirm show';
|
||||||
|
confirm.innerHTML = '<div class="confirm-text">' + message + '</div><div class="confirm-btns"><button class="confirm-yes">Yes</button><button class="confirm-no">No</button></div>';
|
||||||
|
document.body.appendChild(confirm);
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
confirm.style.top = (rect.top - 60) + 'px';
|
||||||
|
let leftPos = rect.left - confirm.offsetWidth + rect.width;
|
||||||
|
if (leftPos < 10) leftPos = 10;
|
||||||
|
confirm.style.left = leftPos + 'px';
|
||||||
|
confirm.querySelector('.confirm-yes').onclick = () => { confirm.remove(); callback(id); showZigzagToast(btn, '🗑️ Deleted!', 'error'); };
|
||||||
|
confirm.querySelector('.confirm-no').onclick = () => confirm.remove();
|
||||||
|
setTimeout(() => { document.addEventListener('click', function closeConfirm(e) { if (!confirm.contains(e.target) && e.target !== btn) { confirm.remove(); document.removeEventListener('click', closeConfirm); } }); }, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachEvents() {
|
||||||
|
document.querySelectorAll('.delete-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); showConfirm(this, 'Delete this entry?', id => delEntry(id)); });
|
||||||
|
document.querySelectorAll('.edit-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); openEdit(this.dataset.id); });
|
||||||
|
document.querySelectorAll('.toggle-p').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); const el = document.getElementById('p-' + this.dataset.id); el.textContent = el.textContent === '••••••••' ? el.dataset.pw : '••••••••'; });
|
||||||
|
document.querySelectorAll('.copy-p').forEach(b => b.onclick = async function(ev) { ev.stopPropagation(); const el = document.getElementById('p-' + this.dataset.id); try { await navigator.clipboard.writeText(el.dataset.pw); this.textContent = '✓'; const btn = this; setTimeout(() => { btn.textContent = '📋'; }, 1000); showZigzagToast(this, '📋 Copied!', 'success'); } catch (e) { showZigzagToast(this, 'Failed', 'error'); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDrag() {
|
||||||
|
const c = document.getElementById('entriesContainer'); if (!c) return;
|
||||||
|
c.querySelectorAll('[draggable="true"]').forEach(el => {
|
||||||
|
el.ondragstart = function(e) { draggedId = this.dataset.id; this.style.opacity = '0.5'; e.dataTransfer.setData('text/plain', this.dataset.id); e.dataTransfer.effectAllowed = 'move'; };
|
||||||
|
el.ondragend = function(e) { this.style.opacity = '1'; draggedId = null; c.querySelectorAll('.drag-over').forEach(x => x.classList.remove('drag-over')); };
|
||||||
|
el.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dataset.id !== draggedId) this.classList.add('drag-over'); };
|
||||||
|
el.ondragleave = function(e) { this.classList.remove('drag-over'); };
|
||||||
|
el.ondrop = function(e) { e.preventDefault(); e.stopPropagation(); this.classList.remove('drag-over'); const fromId = parseInt(e.dataTransfer.getData('text/plain')); const toId = parseInt(this.dataset.id); if (!fromId || !toId || fromId === toId) return; let fi = -1, ti = -1; for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id === fromId) fi = i; if (entries[i] && entries[i].id === toId) ti = i; } if (fi > -1 && ti > -1 && fi !== ti) { const moved = entries.splice(fi, 1)[0]; entries.splice(ti, 0, moved); order = entries.map(e => e.id); localStorage.setItem('entryOrder', JSON.stringify(order)); render(); } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EDIT ====================
|
||||||
|
function openEdit(id) {
|
||||||
|
let e = null;
|
||||||
|
for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id == id) { e = entries[i]; break; } }
|
||||||
|
if (!e) return;
|
||||||
|
const folderSelect = document.getElementById('editFolder');
|
||||||
|
folderSelect.innerHTML = '';
|
||||||
|
folders.forEach(f => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = f;
|
||||||
|
option.textContent = '📁 ' + f;
|
||||||
|
if (f === (e.folder || 'All')) option.selected = true;
|
||||||
|
folderSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
document.getElementById('editId').value = id;
|
||||||
|
document.getElementById('editSite').value = e.site;
|
||||||
|
document.getElementById('editUsername').value = e.username;
|
||||||
|
document.getElementById('editPassword').value = e.password;
|
||||||
|
document.getElementById('editPassword').type = 'password';
|
||||||
|
document.getElementById('editModal').classList.add('show');
|
||||||
|
}
|
||||||
|
function closeEdit() { document.getElementById('editModal').classList.remove('show'); }
|
||||||
|
function toggleEditPassword() { const f = document.getElementById('editPassword'); f.type = f.type === 'password' ? 'text' : 'password'; }
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
const id = document.getElementById('editId').value;
|
||||||
|
const site = document.getElementById('editSite').value.trim();
|
||||||
|
const username = document.getElementById('editUsername').value.trim();
|
||||||
|
const password = document.getElementById('editPassword').value;
|
||||||
|
const folder = document.getElementById('editFolder').value;
|
||||||
|
if (!site || !password) { toast('Site and password required', 'error'); return; }
|
||||||
|
try {
|
||||||
|
const enc = await encryptPwd(password);
|
||||||
|
const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) });
|
||||||
|
if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); }
|
||||||
|
else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
|
||||||
|
} catch (e) { toast('⚠️ Error', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ADD / DELETE ====================
|
||||||
|
async function addEntry() {
|
||||||
|
const site = document.getElementById('siteInput').value.trim();
|
||||||
|
const user = document.getElementById('usernameInput').value.trim();
|
||||||
|
const pass = document.getElementById('passwordInput').value;
|
||||||
|
if (!site || !pass) { toast('❌ Site and password required', 'error'); return; }
|
||||||
|
if (user) localStorage.setItem('savedUsername', user);
|
||||||
|
document.getElementById('addBtn').disabled = true;
|
||||||
|
try {
|
||||||
|
const enc = await encryptPwd(pass);
|
||||||
|
const folder = document.getElementById('addFolderSelect').value;
|
||||||
|
const r = await fetch(API + '/entries', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder }) });
|
||||||
|
if (r.ok) { document.getElementById('siteInput').value = ''; document.getElementById('passwordInput').value = ''; document.getElementById('strengthBar').className = 'strength-bar s0'; toast('✅ Saved!'); loadEntries(); }
|
||||||
|
else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); }
|
||||||
|
} catch (e) { toast('⚠️ Error', 'error'); }
|
||||||
|
finally { document.getElementById('addBtn').disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delEntry(id) { try { const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } }); if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); toast('🗑️ Deleted!'); loadEntries(); } } catch (e) { toast('Error', 'error'); } }
|
||||||
|
|
||||||
|
// ==================== UTILS ====================
|
||||||
|
function searchEntries() { loadEntries(document.getElementById('searchInput').value); }
|
||||||
|
function exportPasswords() { if (!confirm('Export plain text?')) return; const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json'; a.click(); URL.revokeObjectURL(a.href); toast('Exported!'); }
|
||||||
|
function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||||
|
|
||||||
|
// ==================== STARTUP ====================
|
||||||
|
init();
|
||||||
|
applyTheme();
|
||||||
|
if (token && curUser) {
|
||||||
|
// Re-derive key from session? Can't without password, so we need to re-authenticate.
|
||||||
|
// For now, just show vault if token exists but warn user they must log in again.
|
||||||
|
sessionStorage.clear();
|
||||||
|
token = null;
|
||||||
|
curUser = null;
|
||||||
|
document.getElementById('authSection').classList.remove('hidden');
|
||||||
|
document.getElementById('vaultSection').classList.add('hidden');
|
||||||
|
}
|
||||||
|
['click', 'keypress', 'scroll', 'mousemove'].forEach(e => document.addEventListener(e, () => { if (token) resetIdle(); }));
|
||||||
|
document.addEventListener('keypress', e => { if (e.key === 'Enter') { if (document.getElementById('passwordInput') === document.activeElement) addEntry(); else if (document.getElementById('loginPassword') === document.activeElement) login(); else if (document.getElementById('regPassword') === document.activeElement) register(); } });
|
||||||
|
document.getElementById('genModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGen(); });
|
||||||
|
document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); });
|
||||||
Binary file not shown.
Reference in New Issue
Block a user