|
|
|
@@ -1,33 +1,39 @@
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
|
import {
|
|
|
|
|
Plus, ChevronUp, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert, MoreVertical,
|
|
|
|
|
Mail, Globe, Server, Database, Cloud, Shield, Lock, Wifi, CreditCard, Building2, Printer,
|
|
|
|
|
Smartphone, KeyRound, HardDrive, Router, UserCog,
|
|
|
|
|
Plus, ChevronUp, ChevronLeft, Trash2, Eye, EyeOff, Copy, Pencil, Save, X, RefreshCw, ShieldAlert,
|
|
|
|
|
MoreVertical, Mail, Globe, Server, Database, Cloud, Shield, Lock, Wifi, CreditCard, Building2, Printer,
|
|
|
|
|
Smartphone, KeyRound, HardDrive, Router, UserCog, Folder, FolderPlus, Share2, UserPlus,
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
import { useUi } from '../../context/ui-context.jsx';
|
|
|
|
|
import { useAction } from '../../hooks/useAsync.js';
|
|
|
|
|
import { useAction, useAsync } from '../../hooks/useAsync.js';
|
|
|
|
|
import { useDebouncedValue } from '../../hooks/useDebouncedValue.js';
|
|
|
|
|
import {
|
|
|
|
|
getVaultStatus, createVaultPin, unlockVault, changeVaultPin, forgotVaultPin, setVaultToken,
|
|
|
|
|
listVaultEntries, getVaultEntry, createVaultEntry, updateVaultEntry, deleteVaultEntry,
|
|
|
|
|
listVaultGroups, createVaultGroup, updateVaultGroup, deleteVaultGroup,
|
|
|
|
|
listEntryShares, addEntryShare, removeEntryShare,
|
|
|
|
|
} from '../../services/vault.js';
|
|
|
|
|
import { listUsers } from '../../services/users.js';
|
|
|
|
|
import { Button } from '../../components/Button.jsx';
|
|
|
|
|
import { Field } from '../../components/Field.jsx';
|
|
|
|
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
|
|
|
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
|
|
|
|
import { Flyout, FlyoutItem } from '../../components/Flyout.jsx';
|
|
|
|
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
|
|
|
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
|
|
|
|
import { BRAND_ICONS } from '../../data/brandIcons.js';
|
|
|
|
|
|
|
|
|
|
const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '' };
|
|
|
|
|
const EMPTY_FORM = { title: '', url: '', username: '', password: '', notes: '', iconKey: '', groupId: '' };
|
|
|
|
|
const EMPTY_GROUP_FORM = { name: '', iconKey: '' };
|
|
|
|
|
const AUTO_LOCK_MS = 3 * 60 * 1000; // 3 minutos: salir solo del menu si queda abierto sin atencion
|
|
|
|
|
|
|
|
|
|
// Catalogo fijo de iconos genericos (debe coincidir con backend/src/modules/vault/service.js#ICON_KEYS) --
|
|
|
|
|
// a proposito NO se usa ningun servicio externo de favicon (ej. google/s2/favicons): revelaria a
|
|
|
|
|
// terceros que urls internas tiene guardadas cada usuario, inaceptable para un repositorio de
|
|
|
|
|
// credenciales. El usuario elige uno de esta lista, o uno de marca (BRAND_ICONS), o se deja
|
|
|
|
|
// "Auto" (inicial del nombre).
|
|
|
|
|
// "Auto" (inicial del nombre). Los grupos solo usan este catalogo generico (nunca marcas: un
|
|
|
|
|
// grupo no es un sitio puntual).
|
|
|
|
|
const ICONS = {
|
|
|
|
|
mail: Mail, globe: Globe, server: Server, database: Database, cloud: Cloud, shield: Shield,
|
|
|
|
|
lock: Lock, wifi: Wifi, card: CreditCard, building: Building2, printer: Printer,
|
|
|
|
@@ -52,6 +58,11 @@ function EntryIcon({ iconKey, title, size = 22 }) {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function GroupIcon({ iconKey, size = 22 }) {
|
|
|
|
|
const Icon = iconKey ? ICONS[iconKey] : null;
|
|
|
|
|
return <div className="vault-card-icon">{Icon ? <Icon size={size} /> : <Folder size={size} />}</div>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function randomPassword(length = 20) {
|
|
|
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*';
|
|
|
|
|
const bytes = crypto.getRandomValues(new Uint32Array(length));
|
|
|
|
@@ -67,8 +78,11 @@ export default function ContrasenasPage() {
|
|
|
|
|
const [checking, setChecking] = useState(true);
|
|
|
|
|
const [hasPinSet, setHasPinSet] = useState(null);
|
|
|
|
|
const [entries, setEntries] = useState(null);
|
|
|
|
|
const [groups, setGroups] = useState(null);
|
|
|
|
|
const [activeGroupId, setActiveGroupId] = useState(null);
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const debouncedSearch = useDebouncedValue(search, 300);
|
|
|
|
|
const { data: users } = useAsync(listUsers, []);
|
|
|
|
|
|
|
|
|
|
const [pin, setPin] = useState('');
|
|
|
|
|
const [pinConfirm, setPinConfirm] = useState('');
|
|
|
|
@@ -82,6 +96,12 @@ export default function ContrasenasPage() {
|
|
|
|
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
|
|
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
|
const [showBrandIcons, setShowBrandIcons] = useState(false);
|
|
|
|
|
const [shares, setShares] = useState([]);
|
|
|
|
|
const [sharingWith, setSharingWith] = useState('');
|
|
|
|
|
|
|
|
|
|
const [groupPopupOpen, setGroupPopupOpen] = useState(false);
|
|
|
|
|
const [editingGroup, setEditingGroup] = useState(null);
|
|
|
|
|
const [groupForm, setGroupForm] = useState(EMPTY_GROUP_FORM);
|
|
|
|
|
|
|
|
|
|
const [changingPin, setChangingPin] = useState(false);
|
|
|
|
|
const [pinChangeForm, setPinChangeForm] = useState({ current: '', next: '', next2: '' });
|
|
|
|
@@ -93,10 +113,15 @@ export default function ContrasenasPage() {
|
|
|
|
|
setLocked(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadGroups() {
|
|
|
|
|
setGroups(await listVaultGroups());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function checkAccess() {
|
|
|
|
|
setChecking(true);
|
|
|
|
|
try {
|
|
|
|
|
await loadEntries();
|
|
|
|
|
await loadGroups();
|
|
|
|
|
} catch {
|
|
|
|
|
setLocked(true);
|
|
|
|
|
try {
|
|
|
|
@@ -146,6 +171,7 @@ export default function ContrasenasPage() {
|
|
|
|
|
await unlockVault(pin);
|
|
|
|
|
setPin(''); setPinConfirm('');
|
|
|
|
|
await loadEntries();
|
|
|
|
|
await loadGroups();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setPinError(err.message);
|
|
|
|
|
} finally {
|
|
|
|
@@ -174,24 +200,32 @@ export default function ContrasenasPage() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openEntry(entry) {
|
|
|
|
|
async function openEntry(entry) {
|
|
|
|
|
setSelected(entry);
|
|
|
|
|
setForm({ title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '', iconKey: entry.icon_key || '' });
|
|
|
|
|
setForm({
|
|
|
|
|
title: entry.title, url: entry.url || '', username: entry.username || '', password: '', notes: '',
|
|
|
|
|
iconKey: entry.icon_key || '', groupId: entry.group_id ? String(entry.group_id) : '',
|
|
|
|
|
});
|
|
|
|
|
setRevealedPassword(null);
|
|
|
|
|
setPasswordVisible(false);
|
|
|
|
|
setEditing(false);
|
|
|
|
|
setCreating(false);
|
|
|
|
|
setShowBrandIcons(!!BRAND_ICONS[entry.icon_key]);
|
|
|
|
|
setSharingWith('');
|
|
|
|
|
const isOwner = entry.is_owner !== false;
|
|
|
|
|
setShares(isOwner ? await listEntryShares(entry.id) : []);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCreate() {
|
|
|
|
|
setSelected(null);
|
|
|
|
|
setForm(EMPTY_FORM);
|
|
|
|
|
setForm({ ...EMPTY_FORM, groupId: activeGroupId ? String(activeGroupId) : '' });
|
|
|
|
|
setRevealedPassword(null);
|
|
|
|
|
setPasswordVisible(false);
|
|
|
|
|
setEditing(true);
|
|
|
|
|
setCreating(true);
|
|
|
|
|
setShowBrandIcons(false);
|
|
|
|
|
setShares([]);
|
|
|
|
|
setSharingWith('');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closePopup() {
|
|
|
|
@@ -222,16 +256,17 @@ export default function ContrasenasPage() {
|
|
|
|
|
|
|
|
|
|
async function save() {
|
|
|
|
|
if (!form.title.trim()) return;
|
|
|
|
|
const payload = { ...form, groupId: form.groupId ? Number(form.groupId) : null };
|
|
|
|
|
await runAction(async () => {
|
|
|
|
|
if (creating) {
|
|
|
|
|
await createVaultEntry(form);
|
|
|
|
|
await createVaultEntry(payload);
|
|
|
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
|
|
|
} else {
|
|
|
|
|
await updateVaultEntry(selected.id, form);
|
|
|
|
|
await updateVaultEntry(selected.id, payload);
|
|
|
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
|
|
|
}
|
|
|
|
|
closePopup();
|
|
|
|
|
await loadEntries();
|
|
|
|
|
await Promise.all([loadEntries(), loadGroups()]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -246,7 +281,7 @@ export default function ContrasenasPage() {
|
|
|
|
|
label: t('common_delete'), variant: 'danger',
|
|
|
|
|
onClick: () => runAction(async () => {
|
|
|
|
|
await deleteVaultEntry(entry.id);
|
|
|
|
|
await loadEntries();
|
|
|
|
|
await Promise.all([loadEntries(), loadGroups()]);
|
|
|
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
@@ -259,6 +294,73 @@ export default function ContrasenasPage() {
|
|
|
|
|
pushToast({ tone: 'success', message: t('vault_copied') });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function addShare() {
|
|
|
|
|
if (!sharingWith) return;
|
|
|
|
|
await runAction(async () => {
|
|
|
|
|
setShares(await addEntryShare(selected.id, Number(sharingWith)));
|
|
|
|
|
setSharingWith('');
|
|
|
|
|
await loadEntries(); // el icono de compartida de la tarjeta usa share_count de la lista principal
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function removeShare(userId) {
|
|
|
|
|
runAction(async () => {
|
|
|
|
|
setShares(await removeEntryShare(selected.id, userId));
|
|
|
|
|
await loadEntries();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCreateGroup() {
|
|
|
|
|
setEditingGroup(null);
|
|
|
|
|
setGroupForm(EMPTY_GROUP_FORM);
|
|
|
|
|
setGroupPopupOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openEditGroup(group) {
|
|
|
|
|
setEditingGroup(group);
|
|
|
|
|
setGroupForm({ name: group.name, iconKey: group.icon_key || '' });
|
|
|
|
|
setGroupPopupOpen(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeGroupPopup() {
|
|
|
|
|
setGroupPopupOpen(false);
|
|
|
|
|
setEditingGroup(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveGroup() {
|
|
|
|
|
if (!groupForm.name.trim()) return;
|
|
|
|
|
await runAction(async () => {
|
|
|
|
|
if (editingGroup) {
|
|
|
|
|
await updateVaultGroup(editingGroup.id, groupForm);
|
|
|
|
|
} else {
|
|
|
|
|
await createVaultGroup(groupForm);
|
|
|
|
|
}
|
|
|
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
|
|
|
closeGroupPopup();
|
|
|
|
|
await loadGroups();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function confirmDeleteGroup(group) {
|
|
|
|
|
openDialog({
|
|
|
|
|
tone: 'warning',
|
|
|
|
|
title: t('common_confirm_delete_title'),
|
|
|
|
|
message: t('vault_group_delete_confirm_msg'),
|
|
|
|
|
actions: [
|
|
|
|
|
{ label: t('common_cancel'), variant: 'standard' },
|
|
|
|
|
{
|
|
|
|
|
label: t('common_delete'), variant: 'danger',
|
|
|
|
|
onClick: () => runAction(async () => {
|
|
|
|
|
await deleteVaultGroup(group.id);
|
|
|
|
|
if (activeGroupId === group.id) setActiveGroupId(null);
|
|
|
|
|
await Promise.all([loadGroups(), loadEntries()]);
|
|
|
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (checking) return <div className="text-muted">{t('common_loading')}</div>;
|
|
|
|
|
|
|
|
|
|
if (locked) {
|
|
|
|
@@ -300,6 +402,17 @@ export default function ContrasenasPage() {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const query = search.trim();
|
|
|
|
|
const inGroupView = !query && activeGroupId !== null;
|
|
|
|
|
const activeGroup = inGroupView ? (groups || []).find((g) => g.id === activeGroupId) : null;
|
|
|
|
|
const displayedGroups = !query && activeGroupId === null ? (groups || []) : [];
|
|
|
|
|
const displayedEntries = query
|
|
|
|
|
? entries
|
|
|
|
|
: inGroupView
|
|
|
|
|
? (entries || []).filter((e) => e.group_id === activeGroupId)
|
|
|
|
|
: (entries || []).filter((e) => !e.group_id);
|
|
|
|
|
const canEditSelected = creating || (selected && selected.is_owner !== false);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="toolbar">
|
|
|
|
@@ -307,28 +420,68 @@ export default function ContrasenasPage() {
|
|
|
|
|
<SearchInput value={search} onChange={setSearch} />
|
|
|
|
|
<div style={{ flex: 1 }} />
|
|
|
|
|
<Button variant="standard" icon={RefreshCw} onClick={() => setChangingPin(true)}>{t('vault_change_pin')}</Button>
|
|
|
|
|
{activeGroupId === null && (
|
|
|
|
|
<Button variant="standard" icon={FolderPlus} onClick={openCreateGroup}>{t('vault_group_new')}</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button variant="accent" icon={Plus} onClick={openCreate}>{t('vault_new_entry')}</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{entries.length === 0 && (
|
|
|
|
|
<div className="text-muted">{search.trim() ? t('common_search_no_results') : t('common_empty')}</div>
|
|
|
|
|
{inGroupView && activeGroup && (
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
|
|
|
|
<button className="icon-btn" title={t('vault_group_back')} onClick={() => setActiveGroupId(null)}><ChevronLeft size={18} /></button>
|
|
|
|
|
<GroupIcon iconKey={activeGroup.icon_key} size={16} />
|
|
|
|
|
<h3 style={{ margin: 0 }}>{activeGroup.name}</h3>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{displayedGroups.length === 0 && displayedEntries.length === 0 && (
|
|
|
|
|
<div className="text-muted">{query ? t('common_search_no_results') : t('common_empty')}</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="vault-grid">
|
|
|
|
|
{entries.map((entry) => (
|
|
|
|
|
<div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}>
|
|
|
|
|
{displayedGroups.map((group) => (
|
|
|
|
|
<div key={`g${group.id}`} className="vault-card" onClick={() => setActiveGroupId(group.id)}>
|
|
|
|
|
<div className="vault-card-menu" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<Flyout trigger={({ onClick }) => (
|
|
|
|
|
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical size={16} /></button>
|
|
|
|
|
)}>
|
|
|
|
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDelete(entry)}>{t('common_delete')}</FlyoutItem>
|
|
|
|
|
<FlyoutItem icon={Pencil} onClick={() => openEditGroup(group)}>{t('common_edit')}</FlyoutItem>
|
|
|
|
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDeleteGroup(group)}>{t('common_delete')}</FlyoutItem>
|
|
|
|
|
</Flyout>
|
|
|
|
|
</div>
|
|
|
|
|
<EntryIcon iconKey={entry.icon_key} title={entry.title} />
|
|
|
|
|
<div className="vault-card-title">{entry.title}</div>
|
|
|
|
|
{entry.url && <div className="vault-card-url">{entry.url}</div>}
|
|
|
|
|
<GroupIcon iconKey={group.icon_key} />
|
|
|
|
|
<div className="vault-card-title">{group.name}</div>
|
|
|
|
|
<div className="vault-card-url">{group.entry_count} {t('vault_group_entries_count')}</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{displayedEntries.map((entry) => {
|
|
|
|
|
const isOwner = entry.is_owner !== false;
|
|
|
|
|
return (
|
|
|
|
|
<div key={entry.id} className="vault-card" onClick={() => openEntry(entry)}>
|
|
|
|
|
{isOwner ? (
|
|
|
|
|
<div className="vault-card-menu" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<Flyout trigger={({ onClick }) => (
|
|
|
|
|
<button className="icon-btn row-menu-btn" onClick={onClick}><MoreVertical size={16} /></button>
|
|
|
|
|
)}>
|
|
|
|
|
<FlyoutItem icon={Trash2} danger onClick={() => confirmDelete(entry)}>{t('common_delete')}</FlyoutItem>
|
|
|
|
|
</Flyout>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="vault-card-shared-badge" title={`${t('vault_shared_by')} ${entry.owner_name}`}>
|
|
|
|
|
<Share2 size={13} strokeWidth={2.5} />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{isOwner && entry.share_count > 0 && (
|
|
|
|
|
<span className="vault-card-shared-badge" title={t('vault_share_title')}>
|
|
|
|
|
<Share2 size={13} strokeWidth={2.5} />
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
<EntryIcon iconKey={entry.icon_key} title={entry.title} />
|
|
|
|
|
<div className="vault-card-title">{entry.title}</div>
|
|
|
|
|
{entry.url && <div className="vault-card-url">{entry.url}</div>}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DetailPopup
|
|
|
|
@@ -338,6 +491,12 @@ export default function ContrasenasPage() {
|
|
|
|
|
width={480}
|
|
|
|
|
height={640}
|
|
|
|
|
>
|
|
|
|
|
{!creating && selected && selected.is_owner === false && (
|
|
|
|
|
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>
|
|
|
|
|
{t('vault_shared_by')} {selected.owner_name}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{editing && (
|
|
|
|
|
<Field label={t('vault_field_icon')}>
|
|
|
|
|
<div className="vault-icon-picker">
|
|
|
|
@@ -400,6 +559,16 @@ export default function ContrasenasPage() {
|
|
|
|
|
</Field>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{editing && canEditSelected && (
|
|
|
|
|
<Field label={t('vault_field_group')}>
|
|
|
|
|
<ComboBox
|
|
|
|
|
options={[{ value: '', label: t('vault_group_none') }, ...(groups || []).map((g) => ({ value: String(g.id), label: g.name }))]}
|
|
|
|
|
value={form.groupId}
|
|
|
|
|
onChange={(v) => setForm((f) => ({ ...f, groupId: v }))}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Field label={t('vault_field_title')}>
|
|
|
|
|
<input className="input" value={form.title} disabled={!editing} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} />
|
|
|
|
|
</Field>
|
|
|
|
@@ -441,15 +610,81 @@ export default function ContrasenasPage() {
|
|
|
|
|
<textarea className="textarea" rows={3} disabled={!editing} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
|
|
|
|
</Field>
|
|
|
|
|
|
|
|
|
|
{(editing || canEditSelected) && (
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
|
|
|
{editing ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button variant="accent" icon={Save} onClick={save} disabled={!form.title.trim()}>{t('common_save')}</Button>
|
|
|
|
|
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!creating && canEditSelected && (
|
|
|
|
|
<div style={{ marginTop: 20 }}>
|
|
|
|
|
<h4 style={{ display: 'flex', alignItems: 'center', gap: 6 }}><Share2 size={15} /> {t('vault_share_title')}</h4>
|
|
|
|
|
<Field label={t('vault_share_add')}>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
<ComboBox
|
|
|
|
|
options={(users || []).filter((u) => !shares.some((s) => s.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }))}
|
|
|
|
|
value={sharingWith}
|
|
|
|
|
onChange={setSharingWith}
|
|
|
|
|
placeholder={t('vault_share_placeholder')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button variant="standard" icon={UserPlus} onClick={addShare} disabled={!sharingWith}>{t('vault_share_add')}</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</Field>
|
|
|
|
|
{shares.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('vault_share_empty')}</div>}
|
|
|
|
|
{shares.map((s) => (
|
|
|
|
|
<div key={s.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
|
|
|
|
<span style={{ flex: 1, fontSize: '.82rem' }}>{s.name} <span className="text-muted">· {s.email}</span></span>
|
|
|
|
|
<button className="icon-btn" onClick={() => removeShare(s.user_id)}><Trash2 size={15} /></button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DetailPopup>
|
|
|
|
|
|
|
|
|
|
<DetailPopup
|
|
|
|
|
open={groupPopupOpen}
|
|
|
|
|
title={editingGroup ? editingGroup.name : t('vault_group_new')}
|
|
|
|
|
onClose={closeGroupPopup}
|
|
|
|
|
width={380}
|
|
|
|
|
height={300}
|
|
|
|
|
>
|
|
|
|
|
<Field label={t('vault_group_name')}>
|
|
|
|
|
<input className="input" autoFocus value={groupForm.name} onChange={(e) => setGroupForm((f) => ({ ...f, name: e.target.value }))} />
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label={t('vault_field_icon')}>
|
|
|
|
|
<div className="vault-icon-picker">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`vault-icon-option ${!groupForm.iconKey ? 'selected' : ''}`}
|
|
|
|
|
title={t('vault_icon_auto')}
|
|
|
|
|
onClick={() => setGroupForm((f) => ({ ...f, iconKey: '' }))}
|
|
|
|
|
>
|
|
|
|
|
<Folder size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
{Object.entries(ICONS).map(([key, Icon]) => (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
key={key}
|
|
|
|
|
className={`vault-icon-option ${groupForm.iconKey === key ? 'selected' : ''}`}
|
|
|
|
|
onClick={() => setGroupForm((f) => ({ ...f, iconKey: key }))}
|
|
|
|
|
>
|
|
|
|
|
<Icon size={18} />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</Field>
|
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
|
|
|
{editing ? (
|
|
|
|
|
<>
|
|
|
|
|
<Button variant="accent" icon={Save} onClick={save} disabled={!form.title.trim()}>{t('common_save')}</Button>
|
|
|
|
|
<Button variant="standard" icon={X} onClick={cancelEdit}>{t('common_cancel')}</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<Button variant="standard" icon={Pencil} onClick={startEdit}>{t('common_edit')}</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button variant="accent" icon={Save} onClick={saveGroup} disabled={!groupForm.name.trim()}>{t('common_save')}</Button>
|
|
|
|
|
<Button variant="standard" icon={X} onClick={closeGroupPopup}>{t('common_cancel')}</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</DetailPopup>
|
|
|
|
|
|
|
|
|
|