Quelltext des aktuellen Standes
<?php
$this->layout('layouts/main', [
'page_title' => $page_title,
'current_route'=> $current_route,
'full_width' => true,
]);
$teams = $teams ?? [];
$folders = $folders ?? [];
$teamFolders = $team_folders ?? [];
$allTeamFolders = $all_team_folders ?? [];
$entries = $entries ?? [];
$folderId = $active_folder_id ?? null;
$teamId = $active_team_id ?? null;
$activeTeam = $active_team ?? null;
$searchQuery = $search_query ?? '';
$activeScope = $active_scope ?? null;
$recentIds = $recent_ids ?? [];
$folderCounts = $folder_counts ?? [];
$teamFolderCnt = $team_folder_counts ?? [];
$canWriteTeam = $can_write_active_team ?? false;
$showAll = $show_all ?? false;
$canCreate = $can_create ?? false;
?>
<style>
/* ---- Vault Layout ---- */
a, a:hover { text-decoration: none; }
.v-wrap { display:flex; flex-direction:column; height:calc(100vh - 60px); overflow:hidden; }
/* Body */
.v-body { flex:1; display:flex; overflow:hidden; }
/* Sidebar */
.v-sidebar { width:220px; flex-shrink:0; background:var(--slate-50); border-right:1px solid var(--gray-200);
overflow-y:auto; display:flex; flex-direction:column; }
.v-sidebar-sec { padding:0.7rem 0.875rem 0.2rem; font-size:0.67rem; font-weight:700;
text-transform:uppercase; letter-spacing:0.07em; color:var(--gray-400); }
.v-sidebar-item { display:flex; align-items:center; gap:0.4rem; padding:0.38rem 0.875rem;
font-size:0.83rem; color:var(--gray-700); text-decoration:none; cursor:pointer; transition:background 0.1s;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; border:none; background:none; width:100%; text-align:left; }
.v-sidebar-item:hover { background:var(--color-gray-200); }
.v-sidebar-item.active { background:var(--blue-100); color:var(--blue-700); font-weight:600; }
.v-sidebar-icon { width:14px; height:14px; flex-shrink:0; color:var(--gray-400); }
.v-sidebar-item.active .v-sidebar-icon { color:var(--blue-700); }
.v-sidebar-add { font-size:0.76rem; color:var(--gray-500); padding:0.15rem 0.875rem 0.55rem;
display:flex; align-items:center; gap:0.3rem; cursor:pointer; text-decoration:none; }
.v-sidebar-add:hover { color:var(--blue-600); }
.v-sidebar-divider { height:1px; background:var(--gray-200); margin:0.4rem 0; }
.v-sidebar-folder { display:flex; align-items:center; }
.v-sidebar-folder .v-sidebar-item { flex:1; min-width:0; }
.v-folder-del { display:none; border:none; background:none; cursor:pointer; color:var(--gray-400); font-size:0.75rem; padding:0.2rem 0.4rem; border-radius:0.25rem; flex-shrink:0; line-height:1; }
.v-folder-del:hover { color:var(--red-600); background:var(--red-100); }
.v-sidebar-folder:hover .v-folder-del { display:block; }
.v-folder-manage { text-decoration:none; }
.v-folder-manage:hover { color:var(--blue-600); background:var(--blue-100); }
.v-sidebar-folder:hover .v-folder-manage { display:block; }
/* Main */
.v-main { flex:1; display:flex; flex-direction:column; overflow:hidden; }
.v-toolbar { display:flex; align-items:center; gap:0.75rem; padding:0.6rem 1.25rem;
border-bottom:1px solid var(--gray-200); background:var(--color-bg); flex-shrink:0; }
.v-toolbar-title { font-size:0.82rem; font-weight:600; color:var(--gray-700); flex:1; }
.v-toolbar-tools { display:flex; align-items:center; gap:0.55rem; flex-shrink:0; }
/* Inline-Suche im Header (im Emoji-Modus wird <i> durch span.emoji-icon ersetzt –
Selektor muss beide Varianten treffen, sonst rutscht das Icon über das Feld) */
.v-head-search { position:relative; display:inline-block; }
.v-head-search > i.fa-search,
.v-head-search > .emoji-icon:first-child { position:absolute; left:9px; top:50%; transform:translateY(-50%); color:var(--gray-400); font-size:0.75rem; line-height:1; pointer-events:none; }
.v-head-search input { width:200px; padding-left:1.7rem; }
/* Natives Lösch-X des Suchfelds ausblenden (sonst doppeltes X neben dem eigenen) */
.v-head-search input::-webkit-search-cancel-button { -webkit-appearance:none; appearance:none; }
.v-head-search-clear { position:absolute; right:7px; top:50%; transform:translateY(-50%); color:var(--gray-400); text-decoration:none; font-size:1.05rem; line-height:1; }
.v-head-search-clear:hover { color:var(--gray-500); }
/* Entries */
.v-entries { flex:1; overflow-y:auto; padding:0.875rem 1.25rem; }
.v-entry { display:flex; align-items:center; gap:0.75rem; padding:0.6rem 0.875rem;
background:var(--color-bg); border:1px solid var(--gray-200); border-radius:0.6rem; margin-bottom:0.4rem;
color:inherit; transition:box-shadow 0.1s, border-color 0.1s; }
.v-entry { cursor:pointer; }
.v-entry:hover { box-shadow:0 2px 8px rgba(0,0,0,0.06); border-color:var(--gray-300); }
.v-entry-actions a, .v-entry-actions button, .v-entry-actions form { position:relative; z-index:1; }
.v-favicon { width:26px; height:26px; border-radius:5px; background:var(--gray-100);
display:flex; align-items:center; justify-content:center; flex-shrink:0; overflow:hidden; }
.v-favicon img { width:18px; height:18px; object-fit:contain; }
.v-favicon-ph { font-size:11px; color:var(--gray-400); font-weight:700; }
.v-entry-info { flex:1; min-width:0; }
.v-entry-title { font-size:0.875rem; font-weight:600; color:var(--gray-900);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.v-entry-sub { font-size:0.75rem; color:var(--gray-400);
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.v-entry-badge { display:inline-block; font-size:0.6rem; font-weight:700;
padding:0.1rem 0.3rem; border-radius:3px; margin-left:0.3rem; vertical-align:middle; }
.v-badge-totp { background:var(--amber-100); color:var(--amber-800); }
.v-badge-team { background:var(--violet-100); color:var(--violet-700); }
.v-entry-actions { display:flex; gap:0.3rem; flex-shrink:0; }
.v-empty { text-align:center; padding:2.5rem 1rem; color:var(--gray-400); }
/* Kleine Ordner-/Export-Modals: Basis aus app.css (.modal-overlay/.modal-card),
lokal nur Zustands-Toggle und kompakte Breite */
.v-modal-overlay { display:none; }
.v-modal-overlay.open { display:flex; }
.v-modal-overlay .modal-card { max-width:400px; }
/* Mobile folder nav */
.v-mobile-nav { display:none; }
@media(max-width:768px){
.v-sidebar { display:none; }
.v-body { display:block; }
/* Natürliches Seitenscrollen statt eingesperrter 100vh-Box */
.v-wrap { height:auto; overflow:visible; }
.v-body, .v-main, .v-table-scroll { overflow:visible; }
.v-table-scroll .data-table thead th { position:static; box-shadow:none; }
.v-mobile-nav {
display:flex; align-items:center; gap:0.4rem; overflow-x:auto; padding:0.5rem 0.875rem;
background:var(--color-bg); border-bottom:1px solid var(--gray-200); flex-shrink:0;
scrollbar-width:none; -webkit-overflow-scrolling:touch;
}
.v-mobile-nav::-webkit-scrollbar { display:none; }
.v-mobile-nav-item {
display:inline-flex; align-items:center; gap:0.3rem; white-space:nowrap;
padding:0.3rem 0.7rem; border-radius:9999px; font-size:0.78rem; font-weight:600;
border:1px solid var(--gray-200); background:var(--color-bg); color:var(--gray-700); text-decoration:none;
flex-shrink:0;
}
.v-mobile-nav-item.active { background:var(--color-primary-deep); color:var(--color-on-accent); border-color:var(--color-primary-deep); }
.v-mobile-nav-sep { color:var(--gray-300); font-size:0.7rem; flex-shrink:0; }
}
/* ---- Redesign: KPI, Filter, Tabelle, Sidebar-Suche/Collapse ---- */
.v-head { flex-shrink:0; }
/* Sidebar-Suche + kollabierbare Sektionen */
.v-sidebar-search { padding:0.55rem 0.7rem 0.35rem; }
.v-sidebar-search input { width:100%; box-sizing:border-box; padding:0.32rem 0.55rem; font-size:0.78rem;
border:1px solid var(--gray-300); border-radius:0.4rem; }
.v-sec-toggle { display:flex; align-items:center; gap:0.3rem; cursor:pointer; user-select:none; }
.v-sec-toggle .v-chev { margin-left:auto; transition:transform 0.15s; font-size:0.62rem; color:var(--gray-400); }
.v-sec.is-collapsed .v-chev { transform:rotate(-90deg); }
.v-sec.is-collapsed .v-sec-body { display:none; }
.v-side-hidden { display:none !important; }
/* Tresor-Liste als Tabelle */
.v-table-scroll { flex:1; overflow-y:auto; padding:0; }
.v-table-scroll .table-wrapper { border:0; border-radius:0; margin:0; box-shadow:none; }
.v-table-scroll .data-table { margin:0; }
.v-load-more { padding:0.7rem; }
/* Import/Export-Aufklappmenü (JS-frei, robust) */
.v-io-menu { position:relative; display:inline-block; }
.v-io-menu > summary { list-style:none; cursor:pointer; }
.v-io-menu > summary::-webkit-details-marker { display:none; }
.v-io-list { position:absolute; right:0; top:calc(100% + 4px); z-index:1100; min-width:200px;
background:var(--color-bg); border:1px solid var(--gray-200); border-radius:0.45rem; box-shadow:0 10px 30px rgba(0,0,0,0.14); padding:0.25rem 0; }
.v-io-list a { display:flex; align-items:center; gap:0.55rem; padding:0.45rem 0.9rem; color:var(--gray-700); font-size:0.85rem; white-space:nowrap; text-decoration:none; }
.v-io-list a:hover { background:var(--gray-100); color:var(--gray-900); }
.content-header, .content-header-actions { overflow:visible; }
/* Sticky Tabellenkopf */
.v-table-scroll .data-table thead th { position:sticky; top:0; z-index:3; background:var(--slate-50); box-shadow:inset 0 -1px 0 var(--gray-200); }
/* Inline-Kopieren in der Zeile */
.v-cell { display:inline-flex; align-items:center; gap:0.35rem; max-width:100%; }
.v-cell-val { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.v-inline-copy { border:none; background:none; color:var(--color-gray-400); cursor:pointer; padding:0 0.15rem; font-size:0.78rem; opacity:0; transition:opacity 0.1s, color 0.1s; }
tr:hover .v-inline-copy { opacity:1; }
.v-inline-copy:hover { color:var(--blue-600); }
.v-inline-copy.copied { color:var(--green-600); opacity:1; }
/* Sidebar ein-/ausblenden */
.v-body.v-sidebar-collapsed .v-sidebar { display:none; }
.v-sidebar-toggle { border:1px solid var(--gray-200); background:var(--gray-50); color:var(--gray-500); border-radius:0.35rem; width:30px; height:26px; cursor:pointer; flex-shrink:0; }
.v-sidebar-toggle:hover { background:var(--slate-100); color:var(--gray-900); }
/* Mobile: Karten-Liste statt Tabelle (Tabelle wird per CSS in Karten transformiert) */
@media(max-width:640px){
.v-head-search input { width:130px; }
.v-table-scroll { padding:0.65rem 0.75rem; }
.v-table-scroll .table-wrapper { border:none; background:none; box-shadow:none; overflow:visible; }
/* app.css erzwingt mobil min-width:640px für .data-table – Karten brauchen volle Bildschirmbreite */
#vTable, #vTable tbody { display:block; width:100%; min-width:0 !important; }
#vTable thead { display:none; }
#vTable tr.v-row { display:flex; flex-wrap:wrap; align-items:center; gap:0.35rem 0.6rem;
background:var(--color-bg); border:1px solid var(--gray-200); border-radius:0.65rem; margin-bottom:0.5rem;
padding:0.65rem 0.75rem; box-shadow:0 1px 2px rgba(0,0,0,0.04); }
#vTable td { display:block; border:none; padding:0; background:none; }
/* Zeile 1: Favicon + Titel + Badges (volle Breite) */
#vTable td:first-child { flex:1 1 100%; min-width:0; }
/* Adresse/Bereich/Geändert entfallen auf Mobile */
#vTable td:nth-child(3), #vTable td:nth-child(4), #vTable td:nth-child(5) { display:none; }
/* Zeile 2: Benutzername links, Aktionen rechts */
#vTable td:nth-child(2) { flex:1 1 auto; min-width:0; font-size:0.8rem; }
#vTable td.v-actions-cell { margin-left:auto; display:flex; align-items:center; gap:0.4rem; }
/* Größere Tap-Targets */
#vTable td.v-actions-cell .btn { width:36px; height:34px; padding:0; display:inline-flex;
align-items:center; justify-content:center; font-size:0.85rem; }
.v-inline-copy { opacity:1; padding:0.35rem 0.45rem; color:var(--gray-400); }
/* Toolbar entschlacken: Sidebar-/Dichte-Umschalter entfallen, Rest darf umbrechen */
.v-toolbar { flex-wrap:wrap; row-gap:0.4rem; padding:0.5rem 0.75rem; }
.v-sidebar-toggle, #vDensity { display:none; }
}
.v-ttitle { display:flex; align-items:center; gap:0.55rem; min-width:0; }
.v-sel { width:15px; height:15px; cursor:pointer; flex-shrink:0; accent-color:var(--blue-600); }
.v-badge-expired { background:var(--red-100); color:var(--red-700); }
.v-badge-expiring { background:var(--orange-100); color:var(--orange-700); }
tr.v-row.v-kbd-active > td { background:var(--blue-50) !important; }
tr.v-row.v-dragging { opacity:0.5; }
.v-sidebar-item.v-drop-hover { background:var(--blue-100); outline:2px dashed var(--blue-600); outline-offset:-2px; }
.v-recent { display:flex; align-items:center; gap:0.4rem; padding:0.45rem 1.25rem 0; flex-wrap:wrap; }
.v-recent-label { font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--gray-400); }
.v-recent-chip { display:inline-flex; align-items:center; gap:0.3rem; padding:0.2rem 0.6rem; border-radius:9999px;
border:1px solid var(--gray-200); background:var(--color-bg); color:var(--gray-700); font-size:0.76rem; cursor:pointer; }
.v-recent-chip:hover { border-color:var(--blue-300); background:var(--blue-50); }
.v-chev-btn { border:none; background:none; cursor:pointer; color:var(--gray-400); font-size:0.6rem; padding:0 0.25rem; flex-shrink:0; }
.v-fcount { margin-left:auto; font-size:0.68rem; color:var(--gray-400); flex-shrink:0; }
.v-ttitle .v-entry-title { font-weight:600; font-size:0.875rem; }
.v-tsub { color:var(--gray-500); }
.data-table.vlt-dense td, .data-table.vlt-dense th { padding-top:0.28rem; padding-bottom:0.28rem; }
.data-table td.v-actions-cell { white-space:nowrap; text-align:right; }
.vlt-hidden-row { display:none !important; }
.v-load-more { text-align:center; padding:0.6rem 0 0.2rem; }
</style>
<div class="v-wrap">
<!-- Content Header -->
<div class="content-header" style="flex-shrink:0;">
<div>
<h1><?= $this->te('Passwörter') ?></h1>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
<li class="breadcrumb-item active"><?= $this->te('Vault') ?></li>
</ol>
</div>
<div class="content-header-actions">
<form method="GET" action="/vault" class="v-head-search">
<i class="fas fa-search"></i>
<input type="search" name="q" id="vSearchInput" class="form-control form-control-sm" placeholder="<?= $this->te('Durchsuchen…') ?>"
value="<?= $this->e($searchQuery) ?>" autocomplete="off" data-act-input="vaultSearchAusFeld">
<a href="#" class="v-head-search-clear" id="vSearchClear" title="<?= $this->te('Zurücksetzen') ?>"
style="<?= $searchQuery === '' ? 'display:none;' : '' ?>" data-act="vaultSearchClear">×</a>
</form>
<button type="button" class="btn btn-primary btn-sm" data-act="openCreateModal"><i class="fas fa-plus"></i> <?= $this->te('Neuer Eintrag') ?></button>
<details class="v-io-menu">
<summary class="btn btn-default btn-sm"><i class="fas fa-exchange-alt"></i> <?= $this->te('Import/Export') ?> <i class="fas fa-caret-down"></i></summary>
<div class="v-io-list">
<a href="/vault/import<?= $teamId !== null ? '?team_id=' . (int)$teamId : '' ?>"><i class="fas fa-file-import"></i> <?= $this->te('Importieren (CSV/KDBX)') ?></a>
<a href="/vault/export"><i class="fas fa-file-export"></i> <?= $this->te('Exportieren (CSV)') ?></a>
<a href="#" data-open="kdbxExportModal"><i class="fas fa-file-archive"></i> <?= $this->te('Exportieren (KDBX, verschlüsselt)') ?></a>
</div>
</details>
<a href="/vault/pin" class="btn btn-default btn-sm" title="<?= $this->te('Tresor-PIN / Master-Passwort') ?>"><i class="fas fa-lock"></i> PIN</a>
</div>
</div>
<!-- Mobile folder navigation (only visible on small screens) -->
<div class="v-mobile-nav">
<a href="/vault" class="v-mobile-nav-item <?= $teamId === null && $folderId === null && $searchQuery === '' && $activeScope === null ? 'active' : '' ?>"><?= $this->te('Alle') ?></a>
<a href="/vault?scope=personal" class="v-mobile-nav-item <?= $activeScope === 'personal' ? 'active' : '' ?>"><?= $this->te('Persönlich') ?></a>
<?php foreach ($folders as $folder): ?>
<a href="/vault?folder=<?= $folder['id'] ?>"
class="v-mobile-nav-item <?= ($teamId === null && $folderId === (int)$folder['id']) ? 'active' : '' ?>">
<?= $this->e($folder['name']) ?>
</a>
<?php endforeach; ?>
<?php if (!empty($teams)): ?>
<span class="v-mobile-nav-sep">|</span>
<?php foreach ($teams as $team): ?>
<a href="/vault?team_id=<?= $team['id'] ?>"
class="v-mobile-nav-item <?= ($teamId === (int)$team['id'] && $folderId === null) ? 'active' : '' ?>">
<?= $this->e($team['name']) ?>
</a>
<?php if ($teamId === (int)$team['id']): ?>
<?php foreach ($teamFolders as $tf): ?>
<a href="/vault?team_id=<?= $team['id'] ?>&folder=<?= $tf['id'] ?>"
class="v-mobile-nav-item <?= ($folderId === (int)$tf['id']) ? 'active' : '' ?>">
› <?= $this->e($tf['name']) ?>
</a>
<?php endforeach; ?>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
<?php if ($teamId !== null): ?>
<a href="#" class="v-mobile-nav-item"
data-open="teamFolderModal"><?= $this->te('+ Ordner') ?></a>
<?php else: ?>
<a href="#" class="v-mobile-nav-item"
data-open="folderModal"><?= $this->te('+ Ordner') ?></a>
<?php endif; ?>
</div>
<!-- Body -->
<div class="v-body">
<!-- Sidebar -->
<aside class="v-sidebar">
<?php if (!empty($folders) || !empty($teams)): ?>
<div class="v-sidebar-search">
<input type="search" id="vSideSearch" placeholder="<?= $this->te('Ordner/Team filtern…') ?>" autocomplete="off" data-act-input="vaultSeitenfilterAusFeld">
</div>
<?php endif; ?>
<div class="v-sec" id="vSecPersonal">
<div class="v-sidebar-sec v-sec-toggle" data-toggle-parent="is-collapsed">
<?= $this->te('Mein Tresor') ?> <span class="v-chev">▾</span>
</div>
<div class="v-sec-body">
<a href="/vault"
class="v-sidebar-item <?= $teamId === null && $folderId === null && $searchQuery === '' && $activeScope === null ? 'active' : '' ?>">
<svg class="v-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/>
</svg>
<?= $this->te('Alle Einträge') ?>
</a>
<a href="/vault?scope=personal"
class="v-sidebar-item <?= $activeScope === 'personal' ? 'active' : '' ?>"
title="<?= $this->te('Nur persönliche Einträge (ohne Team-Tresore)') ?>">
<svg class="v-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
</svg>
<?= $this->te('Meine Einträge') ?>
</a>
<?php foreach ($folders as $folder): ?>
<div class="v-sidebar-folder js-side-item">
<a href="/vault?folder=<?= $folder['id'] ?>"
class="v-sidebar-item <?= ($teamId === null && $folderId === (int)$folder['id']) ? 'active' : '' ?>"
style="padding-left:<?= 0.875 + 0.9 * (int)($folder['depth'] ?? 0) ?>rem;"
title="<?= $this->e($folder['path_label'] ?? $folder['name']) ?>">
<svg class="v-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
<?= $this->e($folder['name']) ?>
<?php if (!empty($folderCounts[(int)$folder['id']])): ?><span class="v-fcount"><?= (int)$folderCounts[(int)$folder['id']] ?></span><?php endif; ?>
</a>
<form method="POST" action="/vault/folders/<?= $folder['id'] ?>/delete" style="margin:0;"
data-confirm="Ordner löschen? Einträge bleiben erhalten.">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="v-folder-del" title="<?= $this->te('Ordner löschen') ?>">✕</button>
</form>
</div>
<?php endforeach; ?>
<a href="#" class="v-sidebar-add"
data-open="folderModal">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
<?= $this->te('Ordner anlegen') ?>
</a>
</div>
</div>
<?php if (!empty($teams)): ?>
<div class="v-sidebar-divider"></div>
<div class="v-sec" id="vSecTeams">
<div class="v-sidebar-sec v-sec-toggle" data-toggle-parent="is-collapsed">
<?= $this->te('Teams') ?> <span style="font-weight:600;color:var(--gray-400);">(<?= count($teams) ?>)</span> <span class="v-chev">▾</span>
</div>
<div class="v-sec-body">
<?php foreach ($teams as $team): ?>
<div class="v-sidebar-folder js-side-item">
<a href="/vault?team_id=<?= $team['id'] ?>"
class="v-sidebar-item <?= ($teamId === (int)$team['id'] && $folderId === null) ? 'active' : '' ?>">
<svg class="v-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<?= $this->e($team['name']) ?>
<?php if (!(bool)($team['tvk_ready'] ?? false)): ?>
<span class="status-pill status-pill--warning" style="margin-left:auto;font-size:0.6rem;padding:0.05rem 0.35rem;"><?= $this->te('Pending') ?></span>
<?php endif; ?>
</a>
<a href="/vault/teams/<?= (int)$team['id'] ?>" class="v-folder-del v-folder-manage"
title="<?= $this->te('Team-Tresor verwalten (Ordner, Mitglieder)') ?>"><i class="fas fa-cog"></i></a>
</div>
<?php if ($teamId === (int)$team['id']): ?>
<?php foreach ($teamFolders as $tf): ?>
<div class="v-sidebar-folder">
<a href="/vault?team_id=<?= $team['id'] ?>&folder=<?= $tf['id'] ?>"
class="v-sidebar-item <?= ($folderId === (int)$tf['id']) ? 'active' : '' ?>"
style="padding-left:<?= 1.75 + 0.9 * (int)($tf['depth'] ?? 0) ?>rem;"
title="<?= $this->e($tf['path_label'] ?? $tf['name']) ?>">
<svg class="v-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
<?= $this->e($tf['name']) ?>
<?php if (!empty($teamFolderCnt[(int)$tf['id']])): ?><span class="v-fcount"><?= (int)$teamFolderCnt[(int)$tf['id']] ?></span><?php endif; ?>
</a>
<?php if ($canWriteTeam): ?>
<button type="button" class="v-folder-del v-folder-manage" title="<?= $this->te('Ordner verschieben') ?>"
data-act="openTeamFolderMove" data-args='[<?= (int)$tf['id'] ?>]'><i class="fas fa-exchange-alt"></i></button>
<form method="POST" action="/vault/teams/<?= (int)$team['id'] ?>/folders/<?= (int)$tf['id'] ?>/delete" style="margin:0;"
data-confirm="Team-Ordner löschen? Einträge bleiben erhalten.">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="_return" value="vault">
<button type="submit" class="v-folder-del" title="<?= $this->te('Team-Ordner löschen') ?>">✕</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
<a href="#" class="v-sidebar-add" style="padding-left:1.75rem;"
data-open="teamFolderModal">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
<?= $this->te('Ordner anlegen') ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="v-sidebar-divider"></div>
<div class="v-sec">
<div class="v-sidebar-sec"><?= $this->te('Werkzeuge') ?></div>
<a href="/vault/health" class="v-sidebar-item">
<svg class="v-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
<?= $this->te('Passwort-Gesundheit') ?>
</a>
<a href="/vault/trash" class="v-sidebar-item">
<svg class="v-sidebar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
<?= $this->te('Papierkorb') ?>
</a>
</div>
</aside>
<!-- Main content -->
<div class="v-main">
<div class="v-toolbar">
<button type="button" class="v-sidebar-toggle" title="<?= $this->te('Seitenleiste ein-/ausblenden') ?>" data-act="vaultToggleSidebar"><i class="fas fa-bars"></i></button>
<span class="v-toolbar-title">
<?php if ($searchQuery !== ''): ?>
<i class="fas fa-search" style="color:var(--gray-400);"></i> <?= $this->te('Suchergebnisse') ?>
<?php elseif ($activeTeam): ?>
<i class="fas fa-users" style="color:var(--gray-400);"></i> <?= $this->e($activeTeam['name']) ?>
<?php elseif ($folderId): ?>
<i class="fas fa-folder" style="color:var(--gray-400);"></i> <?= $this->te('Ordner') ?>
<?php elseif ($activeScope === 'personal'): ?>
<i class="fas fa-user" style="color:var(--gray-400);"></i> <?= $this->te('Meine Einträge') ?>
<?php else: ?>
<i class="fas fa-key" style="color:var(--gray-400);"></i> <?= $this->te('Alle Einträge') ?>
<?php endif; ?>
<span id="vCount" style="font-weight:400;color:var(--gray-500);margin-left:0.3rem;">(<?= count($entries) ?>)</span>
</span>
<div id="vBulkBar" style="display:none;align-items:center;gap:0.45rem;flex-shrink:0;">
<span id="vBulkCount" style="font-size:0.78rem;font-weight:700;color:var(--blue-700);white-space:nowrap;"></span>
<button type="button" class="btn btn-default btn-xs" data-act="vaultBulkMove"><i class="fas fa-exchange-alt"></i> <?= $this->te('Verschieben') ?></button>
<button type="button" class="btn btn-danger btn-xs" data-act="vaultBulkDelete"><i class="fas fa-trash"></i> <?= $this->te('Löschen') ?></button>
<button type="button" class="btn btn-default btn-xs" title="<?= $this->te('Auswahl aufheben') ?>" data-act="vaultSelAll" data-args='[false]'>×</button>
</div>
<div class="v-toolbar-tools">
<label class="mb-0" style="display:inline-flex;align-items:center;gap:0.3rem;font-size:0.78rem;white-space:nowrap;color:var(--gray-500);">
<input type="checkbox" id="vTotpOnly" data-act-change="vaultApplyTable"> <?= $this->te('nur 2FA') ?>
</label>
<select id="vSort" class="form-control form-control-sm" style="width:auto;" title="<?= $this->te('Sortierung') ?>" data-act-change="vaultApplyTable">
<option value="title-asc"><?= $this->te('Titel A–Z') ?></option>
<option value="title-desc"><?= $this->te('Titel Z–A') ?></option>
<option value="user-asc"><?= $this->te('Benutzer A–Z') ?></option>
</select>
<button type="button" id="vDensity" class="btn btn-default btn-xs" title="<?= $this->te('Kompakte Ansicht umschalten') ?>" data-act="vaultToggleDensity"><i class="fas fa-compress"></i></button>
</div>
</div>
<?php
// „Zuletzt verwendet"-Chips (nur in der Alle-Einträge-Ansicht)
$recentChips = [];
if ($showAll && !empty($recentIds)) {
$byId = [];
foreach ($entries as $e) { $byId[(int)$e['id']] = $e; }
foreach ($recentIds as $rid => $usedAt) {
if (isset($byId[$rid])) { $recentChips[] = $byId[$rid]; }
if (count($recentChips) >= 8) { break; }
}
}
?>
<?php if (!empty($recentChips)): ?>
<div class="v-recent">
<span class="v-recent-label"><i class="fas fa-history" style="margin-right:0.2rem;"></i><?= $this->te('Zuletzt:') ?></span>
<?php foreach ($recentChips as $rc):
$rcTeam = $rc['team_id'] !== null ? (int)$rc['team_id'] : null;
$rcBase = $rcTeam !== null ? '/vault/teams/' . $rcTeam . '/entries/' . (int)$rc['id'] : '/vault/entries/' . (int)$rc['id'];
?>
<button type="button" class="v-recent-chip"
data-act="openEntryModal" data-args='["<?= $this->e($rcBase) ?>?json=1","<?= $this->e($rcBase) ?>/edit","<?= $this->e($rcBase) ?>/delete"]'>
<?= $this->e(mb_strimwidth((string)$rc['title'], 0, 28, '…')) ?>
</button>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="v-table-scroll">
<?php if (empty($entries)): ?>
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-lock"></i></div>
<?php if ($searchQuery): ?>
<?= $this->t('Keine Treffer für „{suche}"', ['suche' => $this->e($searchQuery)]) ?>
<?php else: ?>
<div><?= $this->te('Keine Einträge') ?></div>
<a href="/vault/entries/create" class="btn btn-primary btn-sm" style="margin-top:0.6rem;"><i class="fas fa-plus"></i> <?= $this->te('Ersten Eintrag anlegen') ?></a>
<?php endif; ?>
</div>
<?php else: ?>
<div class="table-wrapper">
<table class="data-table" id="vTable">
<thead>
<tr>
<th scope="col"><span style="display:inline-flex;align-items:center;gap:0.55rem;"><input type="checkbox" id="vSelAll" class="v-sel" title="<?= $this->te('Alle (gefilterten) auswählen') ?>" data-act="vaultAlleAusKaestchen"> <?= $this->te('Titel') ?></span></th>
<th scope="col"><?= $this->te('Benutzername') ?></th>
<th scope="col"><?= $this->te('Adresse') ?></th>
<th scope="col"><?= $this->te('Bereich') ?></th>
<th scope="col"><?= $this->te('Geändert') ?></th>
<th scope="col" class="text-right"><?= $this->te('Aktionen') ?></th>
</tr>
</thead>
<tbody id="vTbody">
<?php foreach ($entries as $entry):
$isTeamEntry = !empty($entry['team_id']) || ($entry['_source'] ?? '') === 'team';
$entryTeamId = $entry['_team_id'] ?? ($entry['team_id'] ?? null);
if ($isTeamEntry && $entryTeamId) {
$showUrl = '/vault/teams/' . $entryTeamId . '/entries/' . $entry['id'];
$editUrl = '/vault/teams/' . $entryTeamId . '/entries/' . $entry['id'] . '/edit';
$deleteUrl = '/vault/teams/' . $entryTeamId . '/entries/' . $entry['id'] . '/delete';
} else {
$showUrl = '/vault/entries/' . $entry['id'];
$editUrl = '/vault/entries/' . $entry['id'] . '/edit';
$deleteUrl = '/vault/entries/' . $entry['id'] . '/delete';
}
$jsonUrl = $showUrl . '?json=1';
$_avTitle = $entry['title'] ?? '?';
$_avHue = crc32((string)$_avTitle) % 360;
$_user = (string)($entry['username'] ?? '');
$_url = (string)($entry['url'] ?? '');
$_favDom = strtolower(trim((string)($entry['favicon_domain'] ?? '')));
$_favUrl = ($favicon_urls ?? [])[$_favDom] ?? null;
?>
<tr class="clickable v-row" draggable="true"
data-id="<?= (int)$entry['id'] ?>"
data-title="<?= $this->e(mb_strtolower((string)$_avTitle)) ?>"
data-user="<?= $this->e(mb_strtolower($_user)) ?>"
data-url="<?= $this->e(mb_strtolower($_url)) ?>"
data-totp="<?= !empty($entry['has_totp']) ? '1' : '0' ?>"
data-folder="<?= $entry['folder_id'] !== null ? (int)$entry['folder_id'] : '' ?>"
data-teamid="<?= $entry['team_id'] !== null ? (int)$entry['team_id'] : '' ?>"
data-expired="<?= (!empty($entry['expires_at']) && strtotime((string)$entry['expires_at']) < time()) ? '1' : '0' ?>"
data-act="openEntryModal" data-args='["<?= $this->e($jsonUrl) ?>","<?= $this->e($editUrl) ?>","<?= $this->e($deleteUrl) ?>"]'>
<td>
<div class="v-ttitle">
<input type="checkbox" class="v-sel v-sel-row" value="<?= (int)$entry['id'] ?>"
data-act="vaultSelChanged">
<?php if ($_favDom !== ''): ?>
<span class="v-favicon">
<img src="/vault/entries/<?= (int)$entry['id'] ?>/favicon" alt="" loading="lazy"
data-favicon-fallback>
<span class="v-favicon-ph" hidden data-bg="hsl(<?= $_avHue ?>,52%,90%)"
style="color:hsl(<?= $_avHue ?>,55%,38%);"><?= $this->e(mb_strtoupper(mb_substr((string)$_avTitle, 0, 1))) ?></span>
</span>
<?php else: ?>
<span class="v-favicon" style="background:hsl(<?= $_avHue ?>,52%,90%);">
<span class="v-favicon-ph" style="color:hsl(<?= $_avHue ?>,55%,38%);"><?= $this->e(mb_strtoupper(mb_substr((string)$_avTitle, 0, 1))) ?></span>
</span>
<?php endif; ?>
<span class="v-entry-title"><?= $this->e($entry['title']) ?></span>
<?php if (!empty($entry['has_totp'])): ?>
<span class="v-entry-badge v-badge-totp">2FA</span>
<?php endif; ?>
<?php if (!empty($entry['expires_at'])):
$_expTs = strtotime((string)$entry['expires_at']); ?>
<?php if ($_expTs !== false && $_expTs < time()): ?>
<span class="v-entry-badge v-badge-expired" title="<?= $this->te('Abgelaufen am {datum}', ['datum' => date('d.m.Y', $_expTs)]) ?>"><?= $this->te('Abgelaufen') ?></span>
<?php elseif ($_expTs !== false && $_expTs < time() + 14 * 86400): ?>
<span class="v-entry-badge v-badge-expiring" title="<?= $this->te('Läuft ab am {datum}', ['datum' => date('d.m.Y', $_expTs)]) ?>"><?= $this->te('Läuft ab') ?></span>
<?php endif; ?>
<?php endif; ?>
</div>
</td>
<td class="v-tsub"><?php if ($_user !== ''): ?><span class="v-cell"><span class="v-cell-val"><?= $this->e($_user) ?></span><button type="button" class="v-inline-copy" data-copy="<?= $this->e($_user) ?>" title="<?= $this->te('Benutzername kopieren') ?>" ><i class="fas fa-copy"></i></button></span><?php else: ?>—<?php endif; ?></td>
<td class="v-tsub" style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="<?= $this->e($_url) ?>"><?= $_url !== '' ? $this->e($_url) : '—' ?></td>
<td>
<?php if ($isTeamEntry && !empty($entry['_team_name'])): ?>
<span class="v-entry-badge v-badge-team"><?= $this->e($entry['_team_name']) ?></span>
<?php elseif ($isTeamEntry): ?>
<span class="v-entry-badge v-badge-team"><?= $this->te('Team') ?></span>
<?php else: ?>
<span class="v-tsub"><?= $this->te('Persönlich') ?></span>
<?php endif; ?>
</td>
<td class="v-tsub" style="white-space:nowrap;"><?= !empty($entry['updated_at']) ? date('d.m.Y', strtotime((string)$entry['updated_at'])) : '—' ?></td>
<td class="v-actions-cell" data-stop>
<button type="button" class="btn btn-xs btn-default" title="<?= $this->te('Passwort kopieren') ?>"
data-act="vaultCopyPassword" data-args='[<?= (int)$entry['id'] ?>]'><i class="fas fa-key"></i></button>
<button type="button" class="btn btn-xs btn-default" title="<?= $this->te('Aktionen') ?>"
data-json="<?= $this->e($jsonUrl) ?>" data-edit="<?= $this->e($editUrl) ?>"
data-del="<?= $this->e($deleteUrl) ?>" data-id="<?= (int)$entry['id'] ?>"
data-team="<?= $entry['team_id'] !== null ? (int)$entry['team_id'] : '' ?>"
data-folder="<?= $entry['folder_id'] !== null ? (int)$entry['folder_id'] : '' ?>"
data-act="vaultRowMenu"><i class="fas fa-ellipsis-v"></i></button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="v-load-more" id="vLoadMore" style="display:none;">
<button type="button" class="btn btn-default btn-sm" data-act="vaultShowMore"><?= $this->te('Mehr anzeigen') ?></button>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
<script<?= $this->scriptNonce() ?>>
/* Tresor-Liste: Sortierung, 2FA-Filter, Dichte, Paginierung, Sidebar-Filter (clientseitig) */
(function () {
var PAGE = 50;
var vPageLimit = PAGE;
var vQuery = '';
var vQueryTimer = null;
// Passt eine Zeile zu 2FA-Filter + Livesuche (Titel/Benutzer/URL)?
function vRowMatches(r) {
var totpOnly = document.getElementById('vTotpOnly') && document.getElementById('vTotpOnly').checked;
if (totpOnly && r.getAttribute('data-totp') !== '1') return false;
if (vQuery === '') return true;
return (r.getAttribute('data-title') || '').indexOf(vQuery) !== -1
|| (r.getAttribute('data-user') || '').indexOf(vQuery) !== -1
|| (r.getAttribute('data-url') || '').indexOf(vQuery) !== -1;
}
window.vaultApplyTable = function () {
var tbody = document.getElementById('vTbody');
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr.v-row'));
var sortEl = document.getElementById('vSort');
var sort = sortEl ? sortEl.value : 'title-asc';
var key = sort.indexOf('user') === 0 ? 'user' : 'title';
var desc = sort.indexOf('desc') !== -1;
rows.sort(function (a, b) {
var av = a.getAttribute('data-' + key) || '', bv = b.getAttribute('data-' + key) || '';
var cmp = av.localeCompare(bv, 'de');
return desc ? -cmp : cmp;
});
rows.forEach(function (r) { tbody.appendChild(r); });
var shown = 0, matched = 0;
rows.forEach(function (r) {
if (!vRowMatches(r)) { r.classList.add('vlt-hidden-row'); return; }
matched++;
if (shown < vPageLimit) { r.classList.remove('vlt-hidden-row'); shown++; }
else { r.classList.add('vlt-hidden-row'); }
});
var cnt = document.getElementById('vCount'); if (cnt) cnt.textContent = '(' + matched + ')';
var lm = document.getElementById('vLoadMore'); if (lm) lm.style.display = (matched > shown) ? '' : 'none';
};
window.vaultShowMore = function () { vPageLimit += PAGE; vaultApplyTable(); };
window.vaultRowMatchesFilter = vRowMatches;
// Livesuche: filtert die geladene Liste beim Tippen (Titel/Benutzer/URL).
// Enter sendet weiterhin die serverseitige Suche (findet auch Notizen
// und durchsucht immer persönlich + alle Teams).
window.vaultSearchInput = function (q) {
var clear = document.getElementById('vSearchClear');
if (clear) clear.style.display = (q || '') !== '' ? '' : 'none';
clearTimeout(vQueryTimer);
vQueryTimer = setTimeout(function () {
vQuery = (q || '').toLowerCase().trim();
vPageLimit = PAGE;
vaultApplyTable();
}, 150);
};
// Der Verteiler übergibt das Element; diese Felder reichten früher ihren Wert.
window.vaultSearchAusFeld = function (el) { vaultSearchInput(el.value); };
window.vaultSeitenfilterAusFeld = function (el) { vaultFilterSidebar(el.value); };
window.vaultAlleAusKaestchen = function (el) { vaultSelAll(el.checked); };
window.vaultSearchClear = function () {
// Aktive Server-Suche (?q=…) → komplett zurück zur Übersicht
try {
if (new URLSearchParams(window.location.search).get('q')) {
window.location.href = '/vault';
return false;
}
} catch (e) {}
var inp = document.getElementById('vSearchInput');
if (inp) { inp.value = ''; inp.focus(); }
vaultSearchInput('');
return false;
};
window.vaultToggleDensity = function () {
var t = document.getElementById('vTable'); if (!t) return;
t.classList.toggle('vlt-dense');
try { localStorage.setItem('vaultDense', t.classList.contains('vlt-dense') ? '1' : '0'); } catch (e) {}
};
window.vaultFilterSidebar = function (q) {
q = (q || '').toLowerCase().trim();
document.querySelectorAll('.js-side-item').forEach(function (it) {
var t = it.textContent.toLowerCase();
it.classList.toggle('v-side-hidden', q !== '' && t.indexOf(q) === -1);
});
};
window.vaultToggleSidebar = function () {
var body = document.querySelector('.v-body');
if (!body) return;
body.classList.toggle('v-sidebar-collapsed');
try { localStorage.setItem('vaultSidebar', body.classList.contains('v-sidebar-collapsed') ? '0' : '1'); } catch (e) {}
};
// ── Massen-Auswahl ────────────────────────────────────────────────
window.vaultSelIds = function () {
return Array.prototype.slice.call(document.querySelectorAll('.v-sel-row:checked'))
.map(function (c) { return c.value; });
};
window.vaultSelChanged = function () {
var n = vaultSelIds().length;
var bar = document.getElementById('vBulkBar');
if (bar) bar.style.display = n ? 'flex' : 'none';
var c = document.getElementById('vBulkCount');
if (c) c.textContent = n + ' ausgewählt';
};
// Wählt alle Einträge, die dem aktuellen Filter (2FA + Livesuche) entsprechen –
// auch die per Paginierung ausgeblendeten (wichtig, um z. B. 200 Stück zu verschieben).
window.vaultSelAll = function (on) {
document.querySelectorAll('tr.v-row').forEach(function (r) {
var cb = r.querySelector('.v-sel-row');
if (!cb) return;
cb.checked = on && window.vaultRowMatchesFilter(r);
});
var all = document.getElementById('vSelAll');
if (all && !on) all.checked = false;
vaultSelChanged();
};
window.vaultBulkDelete = function () {
var ids = vaultSelIds();
if (!ids.length) return;
if (!confirm(ids.length + ' Einträge in den Papierkorb verschieben?')) return;
fetch('/vault/entries/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
body: '_csrf=' + encodeURIComponent(vaultCsrf) + '&ids=' + encodeURIComponent(ids.join(','))
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.message) alert(data.message);
location.reload();
})
.catch(function (err) { alert('Verbindungsfehler: ' + err.message); });
};
// Aufklappmenüs (details.v-io-menu) bei Klick außerhalb schließen
document.addEventListener('click', function (ev) {
document.querySelectorAll('details.v-io-menu[open]').forEach(function (d) {
if (!d.contains(ev.target)) d.removeAttribute('open');
});
});
document.addEventListener('DOMContentLoaded', function () {
try { if (localStorage.getItem('vaultDense') !== '0') { var t = document.getElementById('vTable'); if (t) t.classList.add('vlt-dense'); } } catch (e) {}
try { if (localStorage.getItem('vaultSidebar') === '0') { var b = document.querySelector('.v-body'); if (b) b.classList.add('v-sidebar-collapsed'); } } catch (e) {}
if (document.getElementById('vTbody')) vaultApplyTable();
// Endlos-Laden: nächste Einträge automatisch nachladen, sobald das
// Listenende in Sichtweite kommt (der Button bleibt als Fallback).
var lm = document.getElementById('vLoadMore');
if (lm && 'IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting && lm.style.display !== 'none') vaultShowMore();
});
}, { rootMargin: '300px' }).observe(lm);
}
});
})();
</script>
<!-- Modal: KDBX-Export -->
<div class="modal-overlay v-modal-overlay" id="kdbxExportModal"
data-close-self>
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Verschlüsselter Export (KDBX)') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close"
data-close="kdbxExportModal">×</button>
</div>
<form method="POST" action="/vault/export-kdbx">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="target_team_id" value="<?= $teamId !== null ? (int)$teamId : '' ?>">
<div class="modal-body">
<p class="form-hint" style="margin:0 0 0.8rem;">
<?= $teamId !== null && $activeTeam
? $this->t('Exportiert den Team-Tresor „{name}" als KeePass-Datei (KDBX 4). Die Datei wird mit dem hier gewählten Master-Passwort verschlüsselt.', ['name' => $this->e($activeTeam['name'])])
: $this->te('Exportiert deinen persönlichen Tresor als KeePass-Datei (KDBX 4). Die Datei wird mit dem hier gewählten Master-Passwort verschlüsselt.') ?>
</p>
<div class="form-group">
<label for="kdbxExportPassword"><?= $this->te('Master-Passwort') ?> <span class="text-danger">*</span></label>
<input type="password" id="kdbxExportPassword" name="kdbx_password" class="form-control"
placeholder="<?= $this->te('Master-Passwort (min. 8 Zeichen)') ?>" minlength="8" required autocomplete="new-password">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-close="kdbxExportModal"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary"><?= $this->te('Exportieren') ?></button>
</div>
</form>
</div>
</div>
<!-- Modal: persönlicher Ordner -->
<div class="modal-overlay v-modal-overlay" id="folderModal"
data-close-self>
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Ordner anlegen') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close"
data-close="folderModal">×</button>
</div>
<form method="POST" action="/vault/folders">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="modal-body">
<div class="form-group">
<label for="folderModalName"><?= $this->te('Ordner-Name') ?> <span class="text-danger">*</span></label>
<input type="text" id="folderModalName" name="name" class="form-control" placeholder="<?= $this->te('Ordner-Name') ?>" required autofocus>
</div>
<?php if (!empty($folders)): ?>
<div class="form-group">
<label for="folderModalParent"><?= $this->te('Übergeordneter Ordner') ?></label>
<select id="folderModalParent" name="parent_id" class="form-control">
<option value=""><?= $this->te('— Kein übergeordneter Ordner —') ?></option>
<?php foreach ($folders as $f): ?>
<option value="<?= (int)$f['id'] ?>" <?= ($teamId === null && $folderId === (int)$f['id']) ? 'selected' : '' ?>>
<?= $this->e($f['path_label'] ?? $f['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-close="folderModal"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary"><?= $this->te('Anlegen') ?></button>
</div>
</form>
</div>
</div>
<?php if ($teamId !== null): ?>
<div class="modal-overlay v-modal-overlay" id="teamFolderModal"
data-close-self>
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Team-Ordner anlegen') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close"
data-close="teamFolderModal">×</button>
</div>
<form method="POST" action="/vault/teams/<?= (int)$teamId ?>/folders">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<div class="modal-body">
<div class="form-group">
<label for="teamFolderModalName"><?= $this->te('Ordner-Name') ?> <span class="text-danger">*</span></label>
<input type="text" id="teamFolderModalName" name="name" class="form-control" placeholder="<?= $this->te('Ordner-Name') ?>" required autofocus>
</div>
<?php if (!empty($teamFolders)): ?>
<div class="form-group">
<label for="teamFolderModalParent"><?= $this->te('Übergeordneter Ordner') ?></label>
<select id="teamFolderModalParent" name="parent_id" class="form-control">
<option value=""><?= $this->te('— Kein übergeordneter Ordner —') ?></option>
<?php foreach ($teamFolders as $tf): ?>
<option value="<?= (int)$tf['id'] ?>" <?= ($folderId === (int)$tf['id']) ? 'selected' : '' ?>>
<?= $this->e($tf['path_label'] ?? $tf['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-close="teamFolderModal"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary"><?= $this->te('Anlegen') ?></button>
</div>
</form>
</div>
</div>
<?php if ($canWriteTeam): ?>
<!-- Modal: Team-Ordner an anderen Eltern-Ordner hängen -->
<div class="modal-overlay v-modal-overlay" id="teamFolderMoveModal"
data-close-self>
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Ordner verschieben') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close"
data-close="teamFolderMoveModal">×</button>
</div>
<form method="POST" id="tfMoveForm" action="">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="_return" value="vault">
<div class="modal-body">
<div class="form-group">
<label for="tfMoveParent"><?= $this->te('Neuer übergeordneter Ordner') ?></label>
<select name="parent_id" id="tfMoveParent" class="form-control"></select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default"
data-close="teamFolderMoveModal"><?= $this->te('Abbrechen') ?></button>
<button type="submit" class="btn btn-primary"><?= $this->te('Verschieben') ?></button>
</div>
</form>
</div>
</div>
<script<?= $this->scriptNonce() ?>>
var _tfFolders = <?= json_encode(array_values(array_map(fn($f) => [
'id' => (int)$f['id'],
'parent' => $f['parent_id'] !== null ? (int)$f['parent_id'] : null,
'label' => $f['path_label'] ?? $f['name'],
], $teamFolders)), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
function openTeamFolderMove(folderId) {
document.getElementById('tfMoveForm').action =
'/vault/teams/<?= (int)$teamId ?>/folders/' + folderId + '/move';
// Den Ordner selbst und alle Nachfahren als Ziel ausschließen (Zyklus)
var children = {};
_tfFolders.forEach(function (f) {
var p = f.parent === null ? 0 : f.parent;
(children[p] = children[p] || []).push(f.id);
});
var excluded = {};
var stack = [folderId];
while (stack.length) {
var c = stack.pop();
excluded[c] = true;
(children[c] || []).forEach(function (k) { stack.push(k); });
}
var curParent = null;
_tfFolders.forEach(function (f) { if (f.id === folderId) curParent = f.parent; });
var sel = document.getElementById('tfMoveParent');
sel.innerHTML = '<option value="">— Oberste Ebene —</option>';
_tfFolders.forEach(function (f) {
if (excluded[f.id]) return;
var o = document.createElement('option');
o.value = String(f.id);
o.textContent = f.label;
if (curParent !== null && f.id === curParent) o.selected = true;
sel.appendChild(o);
});
document.getElementById('teamFolderMoveModal').classList.add('open');
}
</script>
<?php endif; ?>
<?php endif; ?>
<!-- ================================================================ -->
<!-- Modal: Eintrag verschieben -->
<!-- ================================================================ -->
<div class="modal-overlay vm-overlay" id="moveModal" data-close-self>
<div class="modal-card">
<div class="modal-header">
<h2><?= $this->te('Eintrag verschieben') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" data-act="closeMoveModal">×</button>
</div>
<div class="modal-body">
<p class="form-hint" id="moveModalHint" style="margin:0 0 0.9rem;"><?= $this->te('Ziel auswählen') ?></p>
<div class="form-group">
<label for="moveTargetContext"><?= $this->te('Zielbereich') ?></label>
<select id="moveTargetContext" data-act-change="moveFillFolders" class="form-control">
<option value=""><?= $this->te('— Mein persönlicher Tresor —') ?></option>
<?php foreach ($teams as $team): ?>
<option value="<?= (int)$team['id'] ?>">👥 <?= $this->e($team['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="moveTargetFolder"><?= $this->te('Zielordner') ?> <span class="vm-opt"><?= $this->te('(optional)') ?></span></label>
<select id="moveTargetFolder" class="form-control">
<option value=""><?= $this->te('— Kein Ordner (Stammverzeichnis) —') ?></option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-act="closeMoveModal"><?= $this->te('Abbrechen') ?></button>
<button type="button" class="btn btn-primary" id="moveSubmitBtn" data-act="submitMove">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 9l7-7 7 7M5 15l7 7 7-7"/></svg>
<?= $this->te('Verschieben') ?>
</button>
</div>
</div>
</div>
<style>
/* ── Vault-Modals (Verschieben/Anlegen): Basis aus app.css (.modal-overlay/.modal-card),
hier nur Zustands-Toggle, Vault-Stapelreihenfolge und Maße ── */
.vm-overlay { display:none; z-index:2500; padding:1rem; }
.vm-overlay.open { display:flex; }
#moveModal .modal-card { max-width:440px; }
#createModal .modal-card { max-width:580px; }
/* Formular-Spezifika der Vault-Modals */
.vm-opt { font-weight:400; color:var(--gray-400); }
.vm-row-2 { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; }
@media(max-width:480px) { .vm-row-2 { grid-template-columns:1fr; } }
.vm-pw-row { display:flex; gap:.4rem; }
.vm-pw-row .form-control { flex:1; }
.vm-pw-btn { padding:.5rem .7rem; border:1px solid var(--gray-300); border-radius:.45rem;
background:var(--gray-50); color:var(--gray-700); cursor:pointer; font-size:.82rem; white-space:nowrap;
display:flex; align-items:center; gap:.3rem; }
.vm-pw-btn:hover { background:var(--gray-200); }
.vm-gen-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; font-size:.75rem;
color:var(--gray-500); background:var(--slate-50); border:1px solid var(--gray-200); border-radius:.4rem; padding:.4rem .65rem; margin-top:.35rem; }
.vm-gen-bar input[type=number] { width:52px; padding:.2rem .4rem; border:1px solid var(--gray-300);
border-radius:.3rem; font-size:.78rem; }
.vm-gen-bar label { display:flex; align-items:center; gap:.25rem; cursor:pointer; }
.vm-url-list { display:flex; flex-direction:column; gap:.35rem; }
.vm-url-row { display:flex; gap:.4rem; }
.vm-url-row .form-control { flex:1; }
.vm-url-del { padding:.5rem .55rem; border:1px solid var(--red-300); background:var(--red-100); color:var(--red-600);
border-radius:.45rem; cursor:pointer; font-size:.8rem; }
.vm-url-del:hover { background:var(--red-200); }
.vm-url-add { font-size:.78rem; color:var(--blue-600); cursor:pointer; background:none; border:none;
padding:.2rem 0; text-decoration:none; display:inline-flex; align-items:center; gap:.25rem; }
.vm-url-add:hover { text-decoration:underline; }
</style>
<!-- ================================================================ -->
<!-- Modal: Neuen Eintrag erstellen -->
<!-- ================================================================ -->
<div class="modal-overlay vm-overlay" id="createModal" data-close-self>
<div class="modal-card">
<div class="modal-header">
<h2 id="cmTitleHead"><?= $this->te('Neuen Eintrag erstellen') ?></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" data-act="closeCreateModal">×</button>
</div>
<div class="modal-body">
<form id="createEntryForm" method="POST" action="" data-act-submit="submitCreateForm">
<input type="hidden" name="_csrf" id="createCsrf" value="<?= $this->csrf() ?>">
<input type="hidden" name="_method" id="cmMethod" value="">
<input type="hidden" name="create_team_id" id="createTeamId" value="<?= $teamId !== null ? (int)$teamId : '' ?>">
<input type="hidden" name="create_folder_id" id="createFolderId" value="<?= $folderId !== null ? (int)$folderId : '' ?>">
<?php $writableTeams = $writable_teams ?? []; ?>
<?php if (!empty($writableTeams)): ?>
<!-- Ziel-Tresor frei wählbar; vorbelegt ist der gerade geöffnete. -->
<div class="form-group" id="cmVaultGroup">
<label for="cm_vault"><?= $this->te('Tresor') ?></label>
<select id="cm_vault" class="form-control" data-act-change="vaultZielAusFeld">
<option value=""><?= $this->te('Persönlicher Tresor') ?></option>
<?php foreach ($writableTeams as $wt): ?>
<option value="<?= (int)$wt['id'] ?>"<?= $teamId === (int)$wt['id'] ? ' selected' : '' ?>>
<?= $this->e($wt['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="form-group">
<label for="cm_title"><?= $this->te('Titel') ?> <span class="text-danger">*</span></label>
<input type="text" id="cm_title" name="title" class="form-control" required autofocus placeholder="<?= $this->te('z.B. GitHub, Office 365, Router…') ?>">
</div>
<div class="form-group">
<label><?= $this->te('URL(s)') ?></label>
<div class="vm-url-list" id="cmUrlList">
<div class="vm-url-row">
<input type="url" name="urls[]" class="form-control" placeholder="https://example.com">
</div>
</div>
<button type="button" class="vm-url-add" data-act="cmAddUrl">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
<?= $this->te('Weitere URL') ?>
</button>
</div>
<div class="vm-row-2">
<div class="form-group">
<label for="cm_username"><?= $this->te('Benutzername / E-Mail') ?></label>
<input type="text" id="cm_username" name="username" class="form-control" placeholder="user@example.com" autocomplete="off">
</div>
<div class="form-group">
<label for="cm_folder"><?= $this->te('Ordner') ?></label>
<select id="cm_folder" name="folder_id" class="form-control">
<option value=""><?= $this->te('— Kein Ordner —') ?></option>
<?php foreach ($folders as $folder): ?>
<option value="<?= $folder['id'] ?>" <?= $folderId === (int)$folder['id'] ? 'selected' : '' ?>>
<?= $this->e($folder['path_label'] ?? $folder['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group">
<label for="cm_password"><?= $this->te('Passwort') ?> <span class="text-danger">*</span></label>
<div class="vm-pw-row">
<input type="password" id="cm_password" name="password" class="form-control" required autocomplete="new-password" placeholder="<?= $this->te('Sicheres Passwort') ?>">
<button aria-label="<?= $this->te('Passwort anzeigen') ?>" type="button" class="vm-pw-btn" data-act="cmTogglePw">
<svg id="cmPwEye" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
<button type="button" class="vm-pw-btn" data-act="cmGeneratePw" title="<?= $this->te('Generieren') ?>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38"/></svg>
</button>
</div>
<div class="vm-gen-bar">
<span><?= $this->te('Länge:') ?></span>
<input type="number" id="cmGenLen" value="20" min="8" max="128">
<label><input type="checkbox" id="cmGenUpper" checked> A–Z</label>
<label><input type="checkbox" id="cmGenNum" checked> 0–9</label>
<label><input type="checkbox" id="cmGenSym" checked> !@#$</label>
</div>
</div>
<div class="form-group">
<label for="cm_totp"><?= $this->te('TOTP-Secret') ?> <span class="vm-opt"><?= $this->te('(optional)') ?></span></label>
<input type="text" id="cm_totp" name="totp" class="form-control" placeholder="<?= $this->te('Base32-Secret, z.B. JBSWY3DPEHPK3PXP') ?>" autocomplete="off" spellcheck="false">
<span class="form-hint"><?= $this->te('Einrichtungsschlüssel aus dem Authenticator-QR-Code (nicht der 6-stellige Code).') ?></span>
</div>
<div class="form-group">
<label for="cm_expires"><?= $this->te('Ablaufdatum') ?> <span class="vm-opt"><?= $this->te('(optional – Erinnerung zur Passwort-Rotation)') ?></span></label>
<input type="date" id="cm_expires" name="expires_at" class="form-control" style="max-width:200px;">
</div>
<div class="form-group">
<label><?= $this->te('Zusatzfelder') ?> <span class="vm-opt"><?= $this->te('(optional, verschlüsselt)') ?></span></label>
<div class="vm-url-list" id="cmFieldList"></div>
<button type="button" class="vm-url-add" data-act="cmAddField">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 5v14M5 12h14"/></svg>
<?= $this->te('Feld hinzufügen') ?>
</button>
</div>
<div class="form-group">
<label for="cm_notes"><?= $this->te('Notizen') ?> <span class="vm-opt"><?= $this->te('(optional)') ?></span></label>
<textarea id="cm_notes" name="notes" class="form-control" style="min-height:72px;" placeholder="<?= $this->te('Optionale Anmerkungen…') ?>"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-act="closeCreateModal"><?= $this->te('Abbrechen') ?></button>
<button type="button" class="btn btn-primary" id="createSubmitBtn" data-act="cmAbsenden">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v14a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
<?= $this->te('Speichern') ?>
</button>
</div>
</div>
</div>
<script<?= $this->scriptNonce() ?>>
var vaultPersonalFolders = <?php echo json_encode(array_values(array_map(fn($f) => ['id' => (int)$f['id'], 'name' => $f['path_label'] ?? $f['name']], $folders)), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;
var vaultTeamFolders = <?php echo json_encode(array_map(fn($tf) => array_values(array_map(fn($f) => ['id' => (int)$f['id'], 'name' => $f['path_label'] ?? $f['name']], $tf)), $allTeamFolders), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;
var vaultCsrf = <?php echo json_encode($this->csrf(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); ?>;
var _moveEntryId = null;
var _bulkMoveIds = null;
function openMoveModal(entryId, srcTeamId, srcFolderId) {
_moveEntryId = entryId;
_bulkMoveIds = null;
var ctx = document.getElementById('moveTargetContext');
ctx.value = srcTeamId !== null ? String(srcTeamId) : '';
moveFillFolders();
if (srcFolderId !== null) {
document.getElementById('moveTargetFolder').value = String(srcFolderId);
}
document.getElementById('moveModalHint').textContent = 'Ziel auswählen';
document.getElementById('moveModal').classList.add('open');
}
// Massen-Verschieben: nutzt dasselbe Modal, sendet an /vault/entries/bulk-move
function vaultBulkMove() {
var ids = vaultSelIds();
if (!ids.length) return;
_moveEntryId = null;
_bulkMoveIds = ids;
document.getElementById('moveTargetContext').value = '';
moveFillFolders();
document.getElementById('moveModalHint').textContent = ids.length + ' Einträge verschieben – Ziel auswählen';
document.getElementById('moveModal').classList.add('open');
}
function closeMoveModal() {
document.getElementById('moveModal').classList.remove('open');
_moveEntryId = null;
_bulkMoveIds = null;
}
function moveFillFolders() {
var ctx = document.getElementById('moveTargetContext').value;
var sel = document.getElementById('moveTargetFolder');
var teamId = ctx !== '' ? parseInt(ctx, 10) : null;
sel.innerHTML = '<option value="">— Kein Ordner —</option>';
var list = teamId !== null ? (vaultTeamFolders[teamId] || []) : vaultPersonalFolders;
list.forEach(function(f) {
var opt = document.createElement('option');
opt.value = String(f.id);
opt.textContent = f.name;
sel.appendChild(opt);
});
}
function submitMove() {
if (_moveEntryId === null && (_bulkMoveIds === null || !_bulkMoveIds.length)) return;
var targetTeamId = document.getElementById('moveTargetContext').value;
var targetFolderId = document.getElementById('moveTargetFolder').value;
var btn = document.getElementById('moveSubmitBtn');
btn.disabled = true;
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 9l7-7 7 7M5 15l7 7 7-7"/></svg> Verschiebt\u2026';
var body = '_csrf=' + encodeURIComponent(vaultCsrf)
+ '&target_team_id=' + encodeURIComponent(targetTeamId)
+ '&target_folder_id=' + encodeURIComponent(targetFolderId);
var url = '/vault/entries/' + _moveEntryId + '/move';
if (_bulkMoveIds !== null) {
url = '/vault/entries/bulk-move';
body += '&ids=' + encodeURIComponent(_bulkMoveIds.join(','));
}
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json' },
body: body
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
if (_bulkMoveIds !== null && data.message) alert(data.message);
closeMoveModal();
location.reload();
} else {
alert('Fehler: ' + (data.message || 'Unbekannter Fehler'));
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 9l7-7 7 7M5 15l7 7 7-7"/></svg> Verschieben';
}
})
.catch(function(err) {
alert('Verbindungsfehler: ' + err.message);
btn.disabled = false;
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 9l7-7 7 7M5 15l7 7 7-7"/></svg> Verschieben';
});
}
// ── Create Entry Modal ──────────────────────────────────────────────────────
var _createTeamId = <?= $teamId !== null ? (int)$teamId : 'null' ?>;
var _createFolderId = <?= $folderId !== null ? (int)$folderId : 'null' ?>;
/**
* Richtet den Anlege-Dialog auf einen Ziel-Tresor aus: Formular-Ziel, verstecktes
* Feld und Ordner-Auswahl. teamId === null bedeutet persönlicher Tresor.
*/
function vaultApplyCreateTarget(teamId, preselectFolderId) {
var form = document.getElementById('createEntryForm');
var sel = document.getElementById('cm_folder');
var folders;
if (teamId !== null) {
form.action = '/vault/teams/' + teamId + '/entries';
document.getElementById('createTeamId').value = teamId;
folders = vaultTeamFolders[teamId] || [];
} else {
form.action = '/vault/entries';
document.getElementById('createTeamId').value = '';
folders = vaultPersonalFolders || [];
}
// Ordner gehören immer genau einem Tresor – beim Wechsel neu aufbauen.
if (sel) {
sel.innerHTML = '<option value="">— Kein Ordner —</option>';
var ziel = preselectFolderId !== null && preselectFolderId !== '' ? String(preselectFolderId) : null;
folders.forEach(function (f) {
var opt = document.createElement('option');
opt.value = String(f.id);
opt.textContent = f.name;
// Vergleich über Zeichenketten: Die Kennungen kommen je nach
// Datenbanktreiber als Zahl oder als Text aus dem Server.
if (ziel !== null && String(f.id) === ziel) { opt.selected = true; }
sel.appendChild(opt);
});
}
}
/** Auswahl im Dialog: Ziel-Tresor umstellen (Ordner-Vorauswahl entfällt dabei). */
// Zielwahl im Anlegen-Dialog: der Verteiler übergibt das Auswahlfeld.
function vaultZielAusFeld(el) { vaultSwitchCreateTarget(el.value); }
// Speichern-Knopf des Anlegen-Dialogs steht außerhalb des Formulars.
function cmAbsenden() {
var form = document.getElementById('createEntryForm');
if (form) { form.requestSubmit(); }
}
function vaultSwitchCreateTarget(value) {
vaultApplyCreateTarget(value === '' ? null : parseInt(value, 10), null);
}
function openCreateModal() {
vaultApplyCreateTarget(_createTeamId, _createFolderId);
// Auswahlfeld auf den gerade geöffneten Tresor stellen und wieder einblenden
// (das Bearbeiten eines Eintrags blendet es aus).
var vaultSel = document.getElementById('cm_vault');
if (vaultSel) { vaultSel.value = _createTeamId === null ? '' : String(_createTeamId); }
var vaultGroup = document.getElementById('cmVaultGroup');
if (vaultGroup) { vaultGroup.style.display = ''; }
// Zurück in den Anlege-Modus (vedEditInModal überschreibt danach bei Bedarf)
document.getElementById('cmTitleHead').textContent = 'Neuen Eintrag erstellen';
document.getElementById('cmMethod').value = '';
document.getElementById('cm_title').value = '';
document.getElementById('cm_username').value = '';
document.getElementById('cm_password').value = '';
document.getElementById('cm_totp').value = '';
document.getElementById('cm_notes').value = '';
var cmExp = document.getElementById('cm_expires'); if (cmExp) cmExp.value = '';
var cmFields = document.getElementById('cmFieldList'); if (cmFields) cmFields.innerHTML = '';
document.getElementById('cmUrlList').innerHTML = '<div class="vm-url-row"><input type="url" name="urls[]" class="form-control" placeholder="https://example.com"></div>';
document.getElementById('createModal').classList.add('open');
setTimeout(function() { document.getElementById('cm_title').focus(); }, 100);
}
function closeCreateModal() {
document.getElementById('createModal').classList.remove('open');
}
function cmAddUrl() {
var list = document.getElementById('cmUrlList');
var row = document.createElement('div');
row.className = 'vm-url-row';
row.innerHTML = '<input type="url" name="urls[]" class="form-control" placeholder="https://example.com">'
+ '<button type="button" class="vm-url-del" data-remove-closest=".vm-url-row">×</button>';
list.appendChild(row);
row.querySelector('input').focus();
}
function cmTogglePw() {
var inp = document.getElementById('cm_password');
inp.type = inp.type === 'password' ? 'text' : 'password';
}
function cmAddField(name, value, isSecret) {
var list = document.getElementById('cmFieldList');
var row = document.createElement('div');
row.className = 'vm-url-row';
row.style.flexWrap = 'wrap';
row.innerHTML =
'<input type="text" name="cf_name[]" class="form-control" placeholder="Feldname" style="flex:1 1 35%;">' +
'<input type="text" name="cf_value[]" class="form-control" placeholder="Wert" style="flex:1 1 45%;">' +
'<label style="display:inline-flex;align-items:center;gap:0.25rem;font-size:0.75rem;color:#6b7280;">' +
'<input type="checkbox" name="cf_secret[]" value="1"> geheim</label>' +
'<button type="button" class="vm-url-del" data-remove-closest=".vm-url-row">×</button>';
list.appendChild(row);
var inputs = row.querySelectorAll('input');
if (name) inputs[0].value = name;
if (value) inputs[1].value = value;
if (isSecret) inputs[2].checked = true;
return row;
}
function cmGeneratePw() {
var len = parseInt(document.getElementById('cmGenLen').value, 10) || 20;
var upper = document.getElementById('cmGenUpper').checked;
var numbers = document.getElementById('cmGenNum').checked;
var symbols = document.getElementById('cmGenSym').checked;
var chars = 'abcdefghijklmnopqrstuvwxyz';
if (upper) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
if (numbers) chars += '0123456789';
if (symbols) chars += '!@#$%^&*()-_=+[]{}|;:,.<>?';
var pw = '';
var arr = new Uint32Array(len);
window.crypto.getRandomValues(arr);
for (var i = 0; i < len; i++) pw += chars[arr[i] % chars.length];
var inp = document.getElementById('cm_password');
inp.value = pw;
inp.type = 'text';
}
function submitCreateForm(formular, e) {
e.preventDefault();
var btn = document.getElementById('createSubmitBtn');
var form = document.getElementById('createEntryForm');
btn.disabled = true;
btn.textContent = 'Speichert…';
form.submit();
return false;
}
// Escape key closes modals
document.addEventListener('keydown', function(e) {
if (e.key !== 'Escape') return;
if (document.getElementById('createModal').classList.contains('open')) { closeCreateModal(); return; }
if (document.getElementById('moveModal').classList.contains('open')) { closeMoveModal(); return; }
});
</script>
<!-- ================================================================ -->
<!-- Modal: Eintrag Details -->
<!-- ================================================================ -->
<style>
/* Basis aus app.css (.modal-overlay/.modal-card); lokal Zustands-Toggle,
Vault-Stapelreihenfolge und die randlosen Feldzeilen des Detail-Modals */
.ved-overlay { display:none; z-index:3000; padding:1rem; }
.ved-overlay.open { display:flex; }
.ved-overlay .modal-header { gap:0.75rem; padding:1rem 1.25rem; }
.ved-overlay .modal-body { padding:0; }
.ved-overlay .modal-footer { flex-wrap:wrap; gap:0.5rem; padding:0.875rem 1.25rem; }
.ved-favicon { width:32px; height:32px; border-radius:6px; background:var(--gray-100);
display:flex; align-items:center; justify-content:center; flex-shrink:0; overflow:hidden; }
.ved-favicon img { width:22px; height:22px; object-fit:contain; }
.ved-title { flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ved-field { display:flex; align-items:center; padding:0.8rem 1.25rem;
border-bottom:1px solid var(--gray-100); gap:0.75rem; }
.ved-field:last-child { border-bottom:none; }
.ved-field-label { font-size:0.7rem; font-weight:700; text-transform:uppercase;
letter-spacing:0.06em; color:var(--gray-400); min-width:105px; flex-shrink:0; }
.ved-field-value { flex:1; font-size:0.875rem; color:var(--gray-900); word-break:break-all; }
.ved-field-value.mono { font-family:monospace; }
.ved-pw-blur { filter:blur(5px); user-select:none; transition:filter 0.15s; cursor:pointer; }
.ved-copy-btn { display:inline-flex; align-items:center; justify-content:center;
width:28px; height:28px; border:1px solid var(--gray-200); border-radius:0.35rem;
background:var(--gray-50); color:var(--gray-500); cursor:pointer; font-size:12px; flex-shrink:0; }
.ved-copy-btn:hover { background:var(--gray-200); }
.ved-copy-btn.copied { background:var(--green-100); color:var(--green-700); border-color:var(--green-200); }
.ved-show-btn { display:inline-flex; align-items:center; justify-content:center;
width:28px; height:28px; border:1px solid var(--gray-200); border-radius:0.35rem;
background:var(--gray-50); color:var(--gray-500); cursor:pointer; font-size:12px; flex-shrink:0; }
.ved-show-btn:hover { background:var(--gray-200); }
.ved-totp-btn { display:inline-flex; align-items:center; gap:0.3rem;
padding:0.3rem 0.7rem; background:var(--blue-50); color:var(--blue-600);
border:1px solid var(--blue-200); border-radius:0.45rem;
font-size:0.8rem; font-weight:600; cursor:pointer; }
.ved-totp-code { font-size:1.3rem; font-weight:800; letter-spacing:0.12em;
color:var(--blue-600); font-family:monospace; }
.ved-meta { flex:1; font-size:0.72rem; color:var(--gray-400); }
.ved-loading { padding:2.5rem; text-align:center; color:var(--gray-400); }
</style>
<div class="modal-overlay ved-overlay" id="entryDetailModal"
data-close-self>
<div class="modal-card">
<div class="modal-header">
<div class="ved-favicon" id="vedFavicon">
<span id="vedFaviconPh" style="font-size:13px;color:var(--gray-400);font-weight:700;"></span>
</div>
<h2 class="ved-title" id="vedTitle"></h2>
<button aria-label="<?= $this->te('Schließen') ?>" type="button" class="modal-close" data-act="closeEntryModal">×</button>
</div>
<div class="modal-body" id="vedBody">
<div class="ved-loading"><?= $this->te('Lade…') ?></div>
</div>
<div class="modal-footer">
<div class="ved-meta" id="vedMeta"></div>
<button type="button" class="btn btn-default btn-sm" data-act="vedShare" title="<?= $this->te('Einmal-Freigabe erstellen') ?>"><?= $this->te('🔗 Teilen') ?></button>
<button type="button" id="vedEditBtn" class="btn btn-default btn-sm" data-act="vedEditInModal"><?= $this->te('✎ Bearbeiten') ?></button>
<form id="vedDeleteForm" method="POST" action="#" style="margin:0;"
data-confirm="Eintrag in den Papierkorb verschieben?">
<input type="hidden" name="_csrf" value="<?= $this->csrf() ?>">
<button type="submit" class="btn btn-outline-danger btn-sm">
<?= $this->te('🗑 Löschen') ?>
</button>
</form>
</div>
</div>
</div>
<script<?= $this->scriptNonce() ?>>
var _vedTotpUrl = null;
var _vedTotpInterval = null;
var _vedCurrent = null;
function openEntryModal(jsonUrl, editUrl, deleteUrl) {
var overlay = document.getElementById('entryDetailModal');
var body = document.getElementById('vedBody');
// Reset
document.getElementById('vedTitle').textContent = 'Lade…';
document.getElementById('vedFavicon').innerHTML = '<span id="vedFaviconPh" style="font-size:13px;color:#9ca3af;font-weight:700;"></span>';
document.getElementById('vedMeta').textContent = '';
document.getElementById('vedDeleteForm').action = deleteUrl || '#';
body.innerHTML = '<div class="ved-loading">🔒 Lade…</div>';
if (_vedTotpInterval) { clearInterval(_vedTotpInterval); _vedTotpInterval = null; }
_vedTotpUrl = null;
overlay.classList.add('open');
fetch(jsonUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok) {
body.innerHTML = '<div class="ved-loading">Fehler beim Laden.</div>';
return;
}
var e = data.entry;
var editUrl = data.edit_url;
var delUrl = data.delete_url;
_vedTotpUrl = data.totp_url || null;
_vedCurrent = { entry: e, editUrl: editUrl, delUrl: delUrl, jsonUrl: jsonUrl,
shareUrl: data.share_url || null, filesUrl: data.files_url || null,
passkeys: data.passkeys || [], passkeysUrl: data.passkeys_url || null,
fields: data.fields || [], files: data.files || [], history: data.history || [] };
document.getElementById('vedTitle').textContent = e.title || '';
document.getElementById('vedDeleteForm').action = delUrl || '#';
// Avatar: serverseitig gecachtes Favicon (kein externer Call) oder Monogramm
var favWrap = document.getElementById('vedFavicon');
var avHue = vaultHue(e.title || '?');
var avCh = (e.title || '?').charAt(0).toUpperCase();
function vedSetMonogram() {
favWrap.style.background = 'hsl(' + avHue + ',52%,90%)';
favWrap.innerHTML = '<span style="font-size:13px;font-weight:700;color:hsl(' + avHue + ',55%,38%);">' + vedEsc(avCh) + '</span>';
}
if (data.favicon_url) {
favWrap.style.background = '#f3f4f6';
favWrap.innerHTML = '';
var vedImg = document.createElement('img');
vedImg.src = data.favicon_url;
vedImg.alt = '';
vedImg.style.cssText = 'width:22px;height:22px;object-fit:contain;';
vedImg.onerror = vedSetMonogram;
favWrap.appendChild(vedImg);
} else {
vedSetMonogram();
}
// Meta
var meta = [];
if (e.created_at) meta.push('Erstellt: ' + e.created_at.substring(0,16).replace('T',' '));
if (e.updated_at && e.updated_at !== e.created_at) meta.push('Geändert: ' + e.updated_at.substring(0,16).replace('T',' '));
document.getElementById('vedMeta').textContent = meta.join(' · ');
// Build body
var html = '';
// Username
if (e.username) {
html += '<div class="ved-field">'
+ '<div class="ved-field-label">Benutzername</div>'
+ '<div class="ved-field-value" id="vedUsername">' + vedEsc(e.username) + '</div>'
+ '<button class="ved-copy-btn" data-act="vedCopy" data-args="["vedUsername"]" title="Kopieren">📋</button>'
+ '</div>';
}
// Password (farbige Darstellung: Ziffern/Sonderzeichen abgesetzt)
html += '<div class="ved-field">'
+ '<div class="ved-field-label">Passwort</div>'
+ '<div class="ved-field-value mono ved-pw-blur" id="vedPassword" data-pw="' + vedEsc(e.password) + '" data-act="vedTogglePw">' + vedColorPw(e.password) + '</div>'
+ '<button class="ved-show-btn" id="vedPwToggle" data-act="vedTogglePw" title="Anzeigen">👁</button>'
+ '<button class="ved-copy-btn" data-copy="' + vedEsc(e.password) + '" title="Kopieren">📋</button>'
+ '</div>';
// URLs
if (e.url) {
var urls = e.url.split('\n').map(function(u){return u.trim();}).filter(Boolean);
if (urls.length) {
html += '<div class="ved-field" style="align-items:flex-start;">'
+ '<div class="ved-field-label" style="padding-top:0.1rem;">URL' + (urls.length > 1 ? 's' : '') + '</div>'
+ '<div class="ved-field-value" style="flex:1;">';
urls.forEach(function(u) {
var safe = vedSafeUrl(u);
html += '<div style="margin-bottom:0.2rem;">'
+ (safe
? '<a href="' + vedEsc(safe) + '" target="_blank" rel="noopener noreferrer">' + vedEsc(u) + '</a>'
: '<span>' + vedEsc(u) + '</span>')
+ '</div>';
});
html += '</div>'
+ '<button class="ved-copy-btn" data-copy="' + vedEsc(urls[0]) + '" title="Kopieren">📋</button>'
+ '</div>';
}
}
// TOTP
if (e.has_totp) {
html += '<div class="ved-field">'
+ '<div class="ved-field-label">TOTP</div>'
+ '<div class="ved-field-value" id="vedTotpArea">'
+ '<button type="button" class="ved-totp-btn" id="vedTotpBtn" data-act="vedLoadTotp">🔑 Code anzeigen</button>'
+ ' <button type="button" class="ved-totp-btn" data-act="vedShowTotpQr" title="QR-Code fürs Handy">▤ QR</button>'
+ '<div id="vedTotpQr" style="margin-top:0.5rem;"></div>'
+ '</div></div>';
}
// Ablaufdatum
if (e.expires_at) {
var expTs = new Date(e.expires_at + 'T00:00:00').getTime();
var warn = expTs < Date.now();
html += '<div class="ved-field">'
+ '<div class="ved-field-label">Ablauf</div>'
+ '<div class="ved-field-value" style="color:' + (warn ? '#b91c1c' : '#374151') + ';">'
+ vedEsc(e.expires_at.split('-').reverse().join('.')) + (warn ? ' (abgelaufen)' : '') + '</div></div>';
}
// Benutzerdefinierte Felder
(_vedCurrent.fields || []).forEach(function (f, idx) {
var vid = 'vedField' + idx;
var valHtml = f.is_secret
? '<span class="ved-pw-blur" id="' + vid + '" data-plain="' + vedEsc(f.value) + '" data-act="vedRevealField">' + vedEsc(f.value) + '</span>'
: '<span id="' + vid + '">' + vedEsc(f.value) + '</span>';
html += '<div class="ved-field">'
+ '<div class="ved-field-label">' + vedEsc(f.name) + '</div>'
+ '<div class="ved-field-value mono">' + valHtml + '</div>'
+ '<button class="ved-copy-btn" data-copy="' + vedEsc(f.value) + '" title="Kopieren">📋</button>'
+ '</div>';
});
// Notes
if (e.notes) {
html += '<div class="ved-field" style="align-items:flex-start;">'
+ '<div class="ved-field-label" style="padding-top:0.1rem;">Notizen</div>'
+ '<div class="ved-field-value" style="white-space:pre-wrap;font-size:0.875rem;line-height:1.6;">' + vedEsc(e.notes) + '</div>'
+ '</div>';
}
// Anhänge
html += '<div class="ved-field" style="align-items:flex-start;">'
+ '<div class="ved-field-label" style="padding-top:0.3rem;">Anhänge</div>'
+ '<div class="ved-field-value" id="vedFiles" style="flex:1;">' + vedRenderFiles(_vedCurrent.files) + '</div></div>';
// Passkeys (nur über die Browser-Erweiterung angelegt; hier einsehen und entfernen)
if ((_vedCurrent.passkeys || []).length) {
html += '<div class="ved-field" style="align-items:flex-start;">'
+ '<div class="ved-field-label" style="padding-top:0.3rem;">Passkeys</div>'
+ '<div class="ved-field-value" id="vedPasskeys" style="flex:1;">' + vedRenderPasskeys(_vedCurrent.passkeys) + '</div></div>';
}
// Passwort-Historie (einklappbar)
if ((_vedCurrent.history || []).length) {
var hh = '<details style="width:100%;"><summary style="cursor:pointer;font-size:0.8rem;color:#6b7280;">'
+ _vedCurrent.history.length + ' frühere Passwörter</summary>';
_vedCurrent.history.forEach(function (h) {
hh += '<div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.35rem;">'
+ '<span class="ved-pw-blur mono" data-plain="' + vedEsc(h.password) + '" data-act="vedRevealField" style="flex:1;">' + vedEsc(h.password) + '</span>'
+ '<span style="font-size:0.72rem;color:#9ca3af;">' + vedEsc((h.changed_at || '').substring(0,16).replace('T',' ')) + '</span>'
+ '<button class="ved-copy-btn" data-copy="' + vedEsc(h.password) + '" title="Kopieren">📋</button></div>';
});
hh += '</details>';
html += '<div class="ved-field" style="align-items:flex-start;"><div class="ved-field-label" style="padding-top:0.1rem;">Historie</div><div class="ved-field-value">' + hh + '</div></div>';
}
body.innerHTML = html;
})
.catch(function() {
body.innerHTML = '<div class="ved-loading">Verbindungsfehler.</div>';
});
}
function closeEntryModal() {
document.getElementById('entryDetailModal').classList.remove('open');
if (_vedTotpInterval) { clearInterval(_vedTotpInterval); _vedTotpInterval = null; }
}
function vedTogglePw() {
var el = document.getElementById('vedPassword');
var btn = document.getElementById('vedPwToggle');
if (el.classList.contains('ved-pw-blur')) {
el.classList.remove('ved-pw-blur');
btn.innerHTML = '🙈';
btn.title = 'Verbergen';
} else {
el.classList.add('ved-pw-blur');
btn.innerHTML = '👁';
btn.title = 'Anzeigen';
}
}
function vedCopy(fieldId, btn) {
var text = document.getElementById(fieldId).textContent.trim();
vedCopyText(text, btn);
}
function vedCopyText(text, btn) {
navigator.clipboard.writeText(text).then(function() {
if (btn) {
var orig = btn.innerHTML;
btn.classList.add('copied');
btn.textContent = '✓';
setTimeout(function() { btn.classList.remove('copied'); btn.innerHTML = orig; }, 1500);
}
vaultScheduleClipboardClear(text);
});
}
function vedLoadTotp() {
if (!_vedTotpUrl) return;
var area = document.getElementById('vedTotpArea');
var btn = document.getElementById('vedTotpBtn');
if (btn) { btn.disabled = true; btn.textContent = '⏳ Laden…'; }
fetch(_vedTotpUrl)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { area.textContent = 'Fehler: ' + data.error; return; }
area.innerHTML = '<span class="ved-totp-code" id="vedTotpCode">' + vedEsc(data.code) + '</span>'
+ '<span style="font-size:0.75rem;color:#9ca3af;margin-left:0.5rem;">noch <span id="vedTotpTimer">' + data.remaining + '</span>s</span>'
+ '<button class="ved-copy-btn" style="margin-left:0.5rem;" data-copy="' + vedEsc(data.code) + '" title="Kopieren">📋</button>';
vedStartTotpTimer();
})
.catch(function() { area.textContent = 'Fehler beim Laden.'; });
}
function vedStartTotpTimer() {
if (_vedTotpInterval) clearInterval(_vedTotpInterval);
_vedTotpInterval = setInterval(function() {
var timerEl = document.getElementById('vedTotpTimer');
if (!timerEl) { clearInterval(_vedTotpInterval); return; }
var remaining = parseInt(timerEl.textContent) - 1;
if (remaining <= 0) {
clearInterval(_vedTotpInterval);
vedLoadTotp();
} else {
timerEl.textContent = remaining;
}
}, 1000);
}
// Maskiert alle Zeichen, die in HTML-Text und in einfach wie doppelt
// gequoteten Attributen Bedeutung haben – damit bleibt jeder Einsatzort sicher,
// unabhängig davon, welche Anführungszeichen die aufbauende Stelle verwendet.
function vedEsc(s) {
return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''').replace(/`/g,'`');
}
// Deterministischer Farbton aus String (für Avatare)
function vaultHue(s) {
s = String(s || '?');
var h = 0;
for (var i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) % 360; }
return h;
}
// Liefert eine als href sichere URL oder '' (blockiert z. B. javascript:).
function vedSafeUrl(u) {
u = String(u || '').trim();
if (/^https?:\/\//i.test(u)) return u;
if (!/^[a-z][a-z0-9+.\-]*:/i.test(u)) return 'https://' + u; // schemenlos
return '';
}
// Best-effort: Zwischenablage nach Ablauf leeren, sofern unverändert
var VAULT_CLIP_CLEAR_MS = 45000;
function vaultScheduleClipboardClear(value) {
setTimeout(function() {
if (!navigator.clipboard.readText) { navigator.clipboard.writeText('').catch(function(){}); return; }
navigator.clipboard.readText()
.then(function(cur) { if (cur === value) navigator.clipboard.writeText('').catch(function(){}); })
.catch(function(){});
}, VAULT_CLIP_CLEAR_MS);
}
// Der Ereignisverteiler kopiert über diesen Namen.
window.vaultCopyText = function (text, btn) { vedCopyText(text, btn); };
document.addEventListener('click', function (e) {
var d = e.target.closest('[data-delfile]');
if (d) {
var fileId = parseInt(d.getAttribute('data-delfile'), 10);
if (fileId > 0) { vedDeleteFile(fileId); }
}
var p = e.target.closest('[data-delpasskey]');
if (p) {
var passkeyId = parseInt(p.getAttribute('data-delpasskey'), 10);
if (passkeyId > 0) { vedDeletePasskey(passkeyId); }
}
});
// Keyboard: Escape closes modal
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && document.getElementById('entryDetailModal').classList.contains('open')) {
closeEntryModal();
}
});
// Direktlink ?entry=… (z. B. von der früheren Detailseite): Modal automatisch öffnen
document.addEventListener('DOMContentLoaded', function () {
var params = new URLSearchParams(window.location.search);
var entryId = parseInt(params.get('entry') || '', 10);
if (!entryId) return;
var teamId = parseInt(params.get('team_id') || '', 10);
var base = teamId ? '/vault/teams/' + teamId + '/entries/' + entryId : '/vault/entries/' + entryId;
openEntryModal(base + '?json=1', base + '/edit', base + '/delete');
// Parameter aus der URL nehmen, damit Reload/Schließen nicht erneut öffnet
params.delete('entry');
var qs = params.toString();
try { history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : '')); } catch (e) {}
});
// ── Farbige Passwortdarstellung: Ziffern blau, Sonderzeichen rot ──
function vedColorPw(pw) {
return String(pw || '').split('').map(function (c) {
var e = vedEsc(c);
if (/[0-9]/.test(c)) return '<span style="color:#2563eb;">' + e + '</span>';
if (/[^a-zA-Z0-9]/.test(c)) return '<span style="color:#dc2626;">' + e + '</span>';
return e;
}).join('');
}
// Passwort-Anzeige (ent-blurren zeigt gefärbte Variante)
function vedRevealField(el) {
if (el.classList.contains('ved-pw-blur')) el.classList.remove('ved-pw-blur');
else el.classList.add('ved-pw-blur');
}
// ── Anhänge im Modal ──
function vedRenderFiles(files) {
if (!_vedCurrent) return '';
var html = '';
(files || []).forEach(function (f) {
var kb = Math.max(1, Math.round(f.size_bytes / 1024));
html += '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;">'
+ '<a href="' + vedEsc(_vedCurrent.filesUrl + '/' + f.id) + '" style="flex:1;font-size:0.82rem;">📎 ' + vedEsc(f.filename) + '</a>'
+ '<span style="font-size:0.72rem;color:#9ca3af;">' + kb + ' KB</span>'
+ '<button class="ved-copy-btn" title="Löschen" data-delfile="' + vedEsc(f.id) + '">🗑</button></div>';
});
html += '<label class="btn btn-default btn-xs">'
+ '➕ Datei anhängen<input type="file" style="display:none;" data-act-change="vedUploadFile"></label>';
return html;
}
function vedRenderPasskeys(list) {
var html = '';
(list || []).forEach(function (p) {
var who = p.user_display || p.user_name || '';
var used = p.last_used_at ? 'zuletzt ' + vedEsc(String(p.last_used_at).substring(0, 16)) : 'noch nicht verwendet';
html += '<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.3rem;">'
+ '<span style="flex:1;font-size:0.82rem;">🔑 ' + vedEsc(p.rp_id) + (who ? ' <span class="text-muted">(' + vedEsc(who) + ')</span>' : '') + '</span>'
+ '<span class="text-muted" style="font-size:0.72rem;">' + used + '</span>'
+ '<button class="ved-copy-btn" title="Passkey löschen" data-delpasskey="' + vedEsc(p.id) + '">🗑</button></div>';
});
return html || '<span class="text-muted" style="font-size:0.82rem;">Keine Passkeys.</span>';
}
function vedDeletePasskey(passkeyId) {
if (!_vedCurrent || !_vedCurrent.passkeysUrl || !confirm('Passkey löschen? Die Anmeldung damit ist danach nicht mehr möglich.')) return;
fetch(_vedCurrent.passkeysUrl + '/' + passkeyId + '/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
body: '_csrf=' + encodeURIComponent(vaultCsrf)
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { alert(d.message || 'Löschen fehlgeschlagen.'); return; }
_vedCurrent.passkeys = d.passkeys || [];
var host = document.getElementById('vedPasskeys');
if (host) { host.innerHTML = vedRenderPasskeys(_vedCurrent.passkeys); }
})
.catch(function (e) { alert('Verbindungsfehler: ' + e.message); });
}
function vedUploadFile(input) {
if (!input.files.length || !_vedCurrent) return;
var fd = new FormData();
fd.append('_csrf', vaultCsrf);
fd.append('file', input.files[0]);
fetch(_vedCurrent.filesUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }, body: fd })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { alert(d.message || 'Upload fehlgeschlagen.'); return; }
_vedCurrent.files = d.files;
document.getElementById('vedFiles').innerHTML = vedRenderFiles(d.files);
})
.catch(function (e) { alert('Verbindungsfehler: ' + e.message); });
}
function vedDeleteFile(fileId) {
if (!_vedCurrent || !confirm('Anhang löschen?')) return;
fetch(_vedCurrent.filesUrl + '/' + fileId + '/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
body: '_csrf=' + encodeURIComponent(vaultCsrf)
})
.then(function (r) { return r.json(); })
.then(function (d) { if (d.success) { _vedCurrent.files = d.files; document.getElementById('vedFiles').innerHTML = vedRenderFiles(d.files); } });
}
// ── TOTP-QR fürs Handy (otpauth-URL über den TOTP-Endpunkt mit ?secret=1) ──
function vedShowTotpQr() {
if (!_vedCurrent || !_vedTotpUrl) return;
var host = document.getElementById('vedTotpQr');
if (host.dataset.shown === '1') { host.innerHTML = ''; host.dataset.shown = '0'; return; }
fetch(_vedTotpUrl + '?secret=1', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.otpauth) { host.textContent = 'QR nicht verfügbar.'; return; }
var sc = document.createElement('script');
sc.src = '/assets/js/qrcode.min.js';
sc.onload = function () {
host.innerHTML = '<canvas id="vedQrCanvas"></canvas>';
window.QRCode.toCanvas(document.getElementById('vedQrCanvas'), d.otpauth, { width: 150, margin: 1 }, function () {});
host.dataset.shown = '1';
};
sc.onerror = function () { host.textContent = 'QR-Bibliothek nicht ladbar.'; };
host.appendChild(sc);
})
.catch(function () { host.textContent = 'QR nicht verfügbar.'; });
}
// ── Bearbeiten im Modal (füllt das Anlege-Modal als Edit) ──
function vedEditInModal() {
if (!_vedCurrent) return;
var e = _vedCurrent.entry;
closeEntryModal();
openCreateModal();
// Tresor und Ordner des Eintrags übernehmen: Das Formular schickt die
// Ordnerwahl mit, eine leere Wahl bedeutet „kein Ordner".
var eTeam = (e.team_id === null || e.team_id === undefined || e.team_id === '')
? null : parseInt(e.team_id, 10);
vaultApplyCreateTarget(eTeam, (e.folder_id === null || e.folder_id === undefined) ? null : e.folder_id);
document.getElementById('cmTitleHead').textContent = 'Eintrag bearbeiten';
document.getElementById('cmMethod').value = 'PUT';
document.getElementById('createEntryForm').action = _vedCurrent.editUrl.replace(/\/edit$/, '');
// Beim Bearbeiten bleibt der Tresor, in dem der Eintrag liegt – ein Wechsel
// ist ein Verschieben und läuft über die eigene Aktion.
var vaultGroup = document.getElementById('cmVaultGroup');
if (vaultGroup) { vaultGroup.style.display = 'none'; }
document.getElementById('cm_title').value = e.title || '';
document.getElementById('cm_username').value = e.username || '';
document.getElementById('cm_password').value = e.password || '';
document.getElementById('cm_totp').value = e.has_totp ? (e.totp || '') : '';
document.getElementById('cm_notes').value = e.notes || '';
document.getElementById('cm_expires').value = e.expires_at || '';
// URLs
var urlList = document.getElementById('cmUrlList');
var urls = (e.url || '').split('\n').map(function (u) { return u.trim(); }).filter(Boolean);
urlList.innerHTML = '';
(urls.length ? urls : ['']).forEach(function (u, i) {
if (i === 0) {
urlList.innerHTML = '<div class="vm-url-row"><input type="url" name="urls[]" class="form-control" placeholder="https://example.com"></div>';
urlList.querySelector('input').value = u;
} else { cmAddUrl(); urlList.querySelectorAll('input')[i].value = u; }
});
// Zusatzfelder
document.getElementById('cmFieldList').innerHTML = '';
(_vedCurrent.fields || []).forEach(function (f) { cmAddField(f.name, f.value, f.is_secret); });
}
// ── Einmal-Freigabe ──
function vedShare() {
if (!_vedCurrent || !_vedCurrent.shareUrl) return;
var hours = prompt('Freigabe-Link gültig für wie viele Stunden? (1–168)', '24');
if (hours === null) return;
fetch(_vedCurrent.shareUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
body: '_csrf=' + encodeURIComponent(vaultCsrf) + '&ttl_hours=' + encodeURIComponent(parseInt(hours, 10) || 24)
})
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.success) { alert(d.message || 'Freigabe fehlgeschlagen.'); return; }
navigator.clipboard.writeText(d.url).catch(function () {});
window.prompt('Einmal-Link (in Zwischenablage kopiert, gültig ' + d.ttl_hours + 'h, nur EINMAL abrufbar):', d.url);
})
.catch(function (e) { alert('Verbindungsfehler: ' + e.message); });
}
// ── Inline-Passwort aus der Zeile kopieren (On-Demand vom Server) ──
function vaultCopyPassword(entryId, btn) {
var orig = btn.innerHTML;
fetch('/vault/entries/' + entryId + '/password', { headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.error || d.password === undefined) { alert(d.error || 'Fehler.'); return; }
navigator.clipboard.writeText(d.password).then(function () {
btn.innerHTML = '<i class="fas fa-check" style="color:#16a34a;"></i>';
setTimeout(function () { btn.innerHTML = orig; }, 1400);
vaultScheduleClipboardClear(d.password);
});
})
.catch(function (e) { alert('Verbindungsfehler: ' + e.message); });
}
// ── Tastatur: / fokussiert Suche, Pfeile navigieren, Enter öffnet, c kopiert ──
(function () {
var idx = -1;
function visibleRows() {
return Array.prototype.slice.call(document.querySelectorAll('#vTbody tr.v-row:not(.vlt-hidden-row)'));
}
function highlight(rows) {
rows.forEach(function (r, i) { r.classList.toggle('v-kbd-active', i === idx); });
if (idx >= 0 && rows[idx]) rows[idx].scrollIntoView({ block: 'nearest' });
}
document.addEventListener('keydown', function (ev) {
var tag = (ev.target.tagName || '').toLowerCase();
var typing = tag === 'input' || tag === 'textarea' || tag === 'select' || ev.target.isContentEditable;
// Modal offen? Tastatur-Navigation aussetzen
var modalOpen = document.querySelector('.ved-overlay.open, .vm-overlay.open, .v-modal-overlay.open');
if (ev.key === '/' && !typing && !modalOpen) {
ev.preventDefault();
var si = document.getElementById('vSearchInput');
if (si) si.focus();
return;
}
if (typing || modalOpen) return;
var rows = visibleRows();
if (!rows.length) return;
if (ev.key === 'ArrowDown') { ev.preventDefault(); idx = Math.min(rows.length - 1, idx + 1); highlight(rows); }
else if (ev.key === 'ArrowUp') { ev.preventDefault(); idx = Math.max(0, idx - 1); highlight(rows); }
else if (ev.key === 'Enter' && idx >= 0 && rows[idx]) { ev.preventDefault(); rows[idx].click(); }
else if ((ev.key === 'c' || ev.key === 'C') && idx >= 0 && rows[idx]) {
ev.preventDefault();
var cp = rows[idx].querySelector('.v-actions-cell .btn[title="Passwort kopieren"]');
if (cp) cp.click();
}
});
})();
// ── Drag & Drop: Eintrag auf einen Ordner in der Seitenleiste ziehen ──
(function () {
var dragId = null, dragTeam = null;
document.addEventListener('dragstart', function (ev) {
var row = ev.target.closest && ev.target.closest('tr.v-row');
if (!row) return;
dragId = row.getAttribute('data-id');
dragTeam = row.getAttribute('data-teamid') || '';
row.classList.add('v-dragging');
ev.dataTransfer.effectAllowed = 'move';
});
document.addEventListener('dragend', function (ev) {
var row = ev.target.closest && ev.target.closest('tr.v-row');
if (row) row.classList.remove('v-dragging');
});
// Drop-Ziele: persönliche Ordner-Links (?folder=) und Team-Ordner-Links
document.querySelectorAll('.v-sidebar-item[href*="folder="], .v-sidebar-item[href="/vault"], .v-sidebar-item[href="/vault?scope=personal"]').forEach(function (el) {
el.addEventListener('dragover', function (ev) { if (dragId) { ev.preventDefault(); el.classList.add('v-drop-hover'); } });
el.addEventListener('dragleave', function () { el.classList.remove('v-drop-hover'); });
el.addEventListener('drop', function (ev) {
ev.preventDefault();
el.classList.remove('v-drop-hover');
if (!dragId) return;
var href = el.getAttribute('href') || '';
var fm = href.match(/folder=(\d+)/);
var tm = href.match(/team_id=(\d+)/);
var targetFolder = fm ? fm[1] : '';
var targetTeam = tm ? tm[1] : '';
// Nur innerhalb desselben Kontexts per DnD (Team↔Team / persönlich↔persönlich)
if ((dragTeam || '') !== (targetTeam || '')) {
alert('Verschieben zwischen persönlichem und Team-Tresor bitte über „Verschieben" im Menü.');
dragId = null; return;
}
var body = '_csrf=' + encodeURIComponent(vaultCsrf)
+ '&target_team_id=' + encodeURIComponent(targetTeam)
+ '&target_folder_id=' + encodeURIComponent(targetFolder);
fetch('/vault/entries/' + dragId + '/move', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
body: body
}).then(function (r) { return r.json(); }).then(function (d) { if (d.success) location.reload(); else alert(d.message || 'Verschieben fehlgeschlagen.'); });
dragId = null;
});
});
})();
</script>
<!-- Aktionen-Menü (3 Punkte) – ein gemeinsames, fix positioniertes Menü für alle Zeilen -->
<div id="vRowMenu" class="v-row-menu" style="display:none;">
<a href="#" data-act="vRmAction" data-args='["show"]'><i class="fas fa-eye"></i> <?= $this->te('Anzeigen') ?></a>
<a href="#" data-act="vRmAction" data-args='["edit"]'><i class="fas fa-pen"></i> <?= $this->te('Bearbeiten') ?></a>
<a href="#" data-act="vRmAction" data-args='["move"]'><i class="fas fa-exchange-alt"></i> <?= $this->te('Verschieben') ?></a>
<a href="#" class="v-rm-danger" data-act="vRmAction" data-args='["delete"]'><i class="fas fa-trash"></i> <?= $this->te('Löschen') ?></a>
</div>
<style>
.v-row-menu { position:fixed; z-index:4000; min-width:180px; background:var(--color-bg); border:1px solid var(--gray-200);
border-radius:0.45rem; box-shadow:0 10px 30px rgba(0,0,0,0.16); padding:0.25rem 0; }
.v-row-menu a { display:flex; align-items:center; gap:0.55rem; padding:0.5rem 0.9rem; color:var(--gray-700);
font-size:0.85rem; white-space:nowrap; text-decoration:none; }
.v-row-menu a:hover { background:var(--gray-100); color:var(--gray-900); }
.v-row-menu a.v-rm-danger { color:var(--red-600); }
.v-row-menu a.v-rm-danger:hover { background:var(--red-100); }
.v-row-menu a i { width:15px; text-align:center; color:var(--gray-400); }
.v-row-menu a.v-rm-danger i { color:var(--red-600); }
</style>
<script<?= $this->scriptNonce() ?>>
var _rmData = null;
window.vaultRowMenu = function (btn) {
var m = document.getElementById('vRowMenu');
_rmData = {
json: btn.getAttribute('data-json'),
edit: btn.getAttribute('data-edit'),
del: btn.getAttribute('data-del'),
id: parseInt(btn.getAttribute('data-id'), 10),
team: btn.getAttribute('data-team') !== '' ? parseInt(btn.getAttribute('data-team'), 10) : null,
folder: btn.getAttribute('data-folder') !== '' ? parseInt(btn.getAttribute('data-folder'), 10) : null
};
m.style.display = 'block';
var r = btn.getBoundingClientRect();
var mw = m.offsetWidth, mh = m.offsetHeight;
var left = Math.max(8, Math.min(r.right - mw, window.innerWidth - mw - 8));
var top = r.bottom + 4;
if (top + mh > window.innerHeight - 8) top = Math.max(8, r.top - mh - 4);
m.style.left = left + 'px';
m.style.top = top + 'px';
};
function vRmClose() {
document.getElementById('vRowMenu').style.display = 'none';
}
window.vRmAction = function (action) {
if (!_rmData) { vRmClose(); return false; }
var d = _rmData;
vRmClose();
if (action === 'show') {
openEntryModal(d.json, d.edit, d.del);
} else if (action === 'edit') {
window.location.href = d.edit;
} else if (action === 'move') {
openMoveModal(d.id, d.team, d.folder);
} else if (action === 'delete') {
if (confirm('Eintrag in den Papierkorb verschieben?')) {
var f = document.createElement('form');
f.method = 'POST';
f.action = d.del;
var i = document.createElement('input');
i.type = 'hidden'; i.name = '_csrf'; i.value = vaultCsrf;
f.appendChild(i);
document.body.appendChild(f);
f.submit();
}
}
return false;
};
document.addEventListener('click', function (ev) {
var m = document.getElementById('vRowMenu');
if (m.style.display !== 'none' && !m.contains(ev.target) && !ev.target.closest('[data-json]')) vRmClose();
});
document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape') vRmClose(); });
window.addEventListener('scroll', vRmClose, true);
</script>
<?= $this->render('vault/_autolock', ['vault_idle_ttl' => $vault_idle_ttl ?? null]) ?>