Standard password manager

This commit is contained in:
2026-05-06 20:49:51 +01:00
commit a4528eb1f7
6 changed files with 1306 additions and 0 deletions
+273
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+485
View File
@@ -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.
BIN
View File
Binary file not shown.