mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d399f431b | ||
|
|
bb3f866220 | ||
|
|
39d9df78ea | ||
|
|
a1b12fc447 | ||
|
|
099217062a |
Generated
+670
-410
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -58,7 +58,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tsx": "^4.22.4",
|
"tsx": "^4.22.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^8.1.3",
|
||||||
"wrangler": "^4.105.0"
|
"wrangler": "^4.105.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -419,7 +419,36 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
return !!meta?.searchText.includes(searchQuery);
|
return !!meta?.searchText.includes(searchQuery);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pre-compute group min name for duplicates group ordering
|
||||||
|
const groupMinName = new Map<string, string>();
|
||||||
|
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
|
||||||
|
for (const cipher of next) {
|
||||||
|
const gk = (duplicateSignatureInfo.byId.get(cipher.id) || [])
|
||||||
|
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
|
||||||
|
.sort()[0] || '';
|
||||||
|
if (!gk) continue;
|
||||||
|
const name = cipherMetaById.get(cipher.id)?.name || '';
|
||||||
|
const cur = groupMinName.get(gk);
|
||||||
|
if (!cur || nameCollator.compare(name, cur) < 0) groupMinName.set(gk, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
next.sort((a, b) => {
|
next.sort((a, b) => {
|
||||||
|
// Duplicates view: group by color, sort A-Z within each group
|
||||||
|
if (sidebarFilter.kind === 'duplicates' && duplicateSignatureInfo) {
|
||||||
|
const gk = (id: string) => (duplicateSignatureInfo.byId.get(id) || [])
|
||||||
|
.filter(s => (duplicateSignatureInfo.counts.get(s) || 0) >= 2)
|
||||||
|
.sort()[0] || '';
|
||||||
|
const gA = gk(a.id), gB = gk(b.id);
|
||||||
|
if (gA !== gB) return !gA ? 1 : !gB ? -1 : nameCollator.compare(
|
||||||
|
groupMinName.get(gA) || '', groupMinName.get(gB) || ''
|
||||||
|
) || (gA < gB ? -1 : 1);
|
||||||
|
return nameCollator.compare(
|
||||||
|
cipherMetaById.get(a.id)?.name || '',
|
||||||
|
cipherMetaById.get(b.id)?.name || ''
|
||||||
|
) || String(a.id || '').localeCompare(String(b.id || ''));
|
||||||
|
}
|
||||||
|
|
||||||
const metaA = cipherMetaById.get(a.id);
|
const metaA = cipherMetaById.get(a.id);
|
||||||
const metaB = cipherMetaById.get(b.id);
|
const metaB = cipherMetaById.get(b.id);
|
||||||
if (sortMode === 'edited') {
|
if (sortMode === 'edited') {
|
||||||
@@ -1049,6 +1078,20 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
}
|
}
|
||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
}, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
|
||||||
|
const handleSelectUniqueFromDuplicates = useCallback(() => {
|
||||||
|
const map: Record<string, boolean> = {};
|
||||||
|
const seen = new Set<number>();
|
||||||
|
for (const cipher of filteredCiphers) {
|
||||||
|
const groupIndex = duplicateGroupIndexById.get(cipher.id);
|
||||||
|
if (groupIndex === undefined) continue;
|
||||||
|
if (seen.has(groupIndex)) {
|
||||||
|
map[cipher.id] = true;
|
||||||
|
} else {
|
||||||
|
seen.add(groupIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelectedMap(map);
|
||||||
|
}, [filteredCiphers, duplicateGroupIndexById]);
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
const map: Record<string, boolean> = {};
|
const map: Record<string, boolean> = {};
|
||||||
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
for (const cipher of filteredCiphers) map[cipher.id] = true;
|
||||||
@@ -1163,6 +1206,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
|
|||||||
onSyncVault={handleSyncVault}
|
onSyncVault={handleSyncVault}
|
||||||
onOpenBulkDelete={handleOpenBulkDelete}
|
onOpenBulkDelete={handleOpenBulkDelete}
|
||||||
onSelectDuplicates={handleSelectDuplicates}
|
onSelectDuplicates={handleSelectDuplicates}
|
||||||
|
onSelectUniqueFromDuplicates={handleSelectUniqueFromDuplicates}
|
||||||
onSelectAll={handleSelectAll}
|
onSelectAll={handleSelectAll}
|
||||||
onToggleCreateMenu={handleToggleCreateMenu}
|
onToggleCreateMenu={handleToggleCreateMenu}
|
||||||
onStartCreate={startCreate}
|
onStartCreate={startCreate}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ interface VaultListPanelProps {
|
|||||||
onSyncVault: () => void;
|
onSyncVault: () => void;
|
||||||
onOpenBulkDelete: () => void;
|
onOpenBulkDelete: () => void;
|
||||||
onSelectDuplicates: () => void;
|
onSelectDuplicates: () => void;
|
||||||
|
onSelectUniqueFromDuplicates: () => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onToggleCreateMenu: () => void;
|
onToggleCreateMenu: () => void;
|
||||||
onStartCreate: (type: number) => void;
|
onStartCreate: (type: number) => void;
|
||||||
@@ -319,40 +320,43 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="search-input-wrap">
|
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
||||||
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
|
<div className="duplicate-mode-head-menu mobile-duplicate-toolbar">
|
||||||
<div className="duplicate-mode-head-menu">
|
<div className="mobile-duplicate-mode-select-wrap">
|
||||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
||||||
<>
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
<input
|
</button>
|
||||||
className="search-input"
|
</div>
|
||||||
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
) : (
|
||||||
value={props.searchInput}
|
<div className="search-input-wrap">
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
<input
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
className="search-input"
|
||||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
placeholder={t('txt_search_items_count', { count: props.totalCipherCount })}
|
||||||
onKeyDown={(e) => {
|
value={props.searchInput}
|
||||||
if (e.key !== 'Escape' || !props.searchInput) return;
|
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
e.preventDefault();
|
onCompositionStart={props.onSearchCompositionStart}
|
||||||
props.onClearSearch();
|
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
/>
|
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||||
{!!props.searchInput && (
|
e.preventDefault();
|
||||||
<button
|
props.onClearSearch();
|
||||||
type="button"
|
}}
|
||||||
className="search-clear-btn"
|
/>
|
||||||
aria-label={t('txt_clear_search')}
|
{!!props.searchInput && (
|
||||||
title={t('txt_clear_search_esc')}
|
<button
|
||||||
onClick={props.onClearSearch}
|
type="button"
|
||||||
>
|
className="search-clear-btn"
|
||||||
<X size={14} />
|
aria-label={t('txt_clear_search')}
|
||||||
</button>
|
title={t('txt_clear_search_esc')}
|
||||||
)}
|
onClick={props.onClearSearch}
|
||||||
</>
|
>
|
||||||
)}
|
<X size={14} />
|
||||||
</div>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
|
||||||
<div className="duplicate-mode-head-menu">
|
<div className="duplicate-mode-head-menu">
|
||||||
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
{renderMobileFilterMenu('duplicate', t('txt_duplicate_detection_mode'), duplicateModeSelected, <Copy size={14} />, duplicateModeOptions)}
|
||||||
@@ -387,7 +391,13 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
</button>
|
</button>
|
||||||
{!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu}
|
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout ? (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onSelectUniqueFromDuplicates}>
|
||||||
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
!props.isMobileLayout && props.sidebarFilter !== undefined && createMenu
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AdminInvite,
|
AdminInvite,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
|
AuditLogEntry,
|
||||||
AuthorizedDevice,
|
AuthorizedDevice,
|
||||||
Cipher,
|
Cipher,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -383,6 +384,143 @@ export const DEMO_CIPHERS: Cipher[] = [
|
|||||||
decFingerprint: 'SHA256:demoNodeWardenFingerprint',
|
decFingerprint: 'SHA256:demoNodeWardenFingerprint',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// --- Duplicate detection demo pairs (exact, login-site, login-credentials, password) ---
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-exact-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-work',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Internal VPN',
|
||||||
|
decName: 'Internal VPN',
|
||||||
|
creationDate: '2026-04-10T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-28T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'vpn-user',
|
||||||
|
password: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
decUsername: 'vpn-user',
|
||||||
|
decPassword: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-exact-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-work',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Internal VPN',
|
||||||
|
decName: 'Internal VPN',
|
||||||
|
creationDate: '2026-03-15T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-30T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'vpn-user',
|
||||||
|
password: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
decUsername: 'vpn-user',
|
||||||
|
decPassword: 'vpn-secret-2026', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://vpn.internal.example.com', decUri: 'https://vpn.internal.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-site-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-devops',
|
||||||
|
favorite: false,
|
||||||
|
name: 'AWS Console',
|
||||||
|
decName: 'AWS Console',
|
||||||
|
creationDate: '2026-03-01T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-25T09:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'aws-admin',
|
||||||
|
password: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
decUsername: 'aws-admin',
|
||||||
|
decPassword: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-site-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-devops',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Amazon Web Services',
|
||||||
|
decName: 'Amazon Web Services',
|
||||||
|
creationDate: '2026-02-20T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-20T09:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'aws-admin',
|
||||||
|
password: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
decUsername: 'aws-admin',
|
||||||
|
decPassword: 'aws-secure-password', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://console.aws.amazon.com', decUri: 'https://console.aws.amazon.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-cred-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Personal Blog',
|
||||||
|
decName: 'Personal Blog',
|
||||||
|
creationDate: '2026-01-10T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-15T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'my-account@example.com',
|
||||||
|
password: 'shared-credential', // gitguardian:ignore
|
||||||
|
decUsername: 'my-account@example.com',
|
||||||
|
decPassword: 'shared-credential', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://blog.example.com', decUri: 'https://blog.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-cred-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Forum Account',
|
||||||
|
decName: 'Forum Account',
|
||||||
|
creationDate: '2026-01-15T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-04-18T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'my-account@example.com',
|
||||||
|
password: 'shared-credential', // gitguardian:ignore
|
||||||
|
decUsername: 'my-account@example.com',
|
||||||
|
decPassword: 'shared-credential', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://forum.example.com', decUri: 'https://forum.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-pw-a',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Old Forum',
|
||||||
|
decName: 'Old Forum',
|
||||||
|
creationDate: '2025-06-01T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-03-01T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'legacy-user',
|
||||||
|
password: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
decUsername: 'legacy-user',
|
||||||
|
decPassword: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://old-forum.example.com', decUri: 'https://old-forum.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cipher-dup-pw-b',
|
||||||
|
type: 1,
|
||||||
|
folderId: 'folder-personal',
|
||||||
|
favorite: false,
|
||||||
|
name: 'Legacy CMS',
|
||||||
|
decName: 'Legacy CMS',
|
||||||
|
creationDate: '2025-05-10T08:00:00.000Z',
|
||||||
|
revisionDate: '2026-02-15T10:00:00.000Z',
|
||||||
|
login: {
|
||||||
|
username: 'cms-admin',
|
||||||
|
password: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
decUsername: 'cms-admin',
|
||||||
|
decPassword: 'reused-password-2020', // gitguardian:ignore
|
||||||
|
uris: [{ uri: 'https://cms.example.com', decUri: 'https://cms.example.com', match: null }],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'cipher-archived',
|
id: 'cipher-archived',
|
||||||
type: 1,
|
type: 1,
|
||||||
@@ -575,6 +713,233 @@ export const DEMO_BACKUP_SETTINGS: AdminBackupSettings = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DEMO_AUDIT_LOGS: AuditLogEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-login',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'auth.login.success',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows', location: 'San Francisco, US' }),
|
||||||
|
createdAt: '2026-07-08T14:32:10.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-failed',
|
||||||
|
actorUserId: null,
|
||||||
|
actorEmail: 'unknown@example.com',
|
||||||
|
action: 'auth.login.failed',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '198.51.100.7', reason: 'invalid_password', attemptCount: 3 }),
|
||||||
|
createdAt: '2026-07-08T13:15:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-2fa',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'auth.totp.enabled',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'security',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
|
||||||
|
createdAt: '2026-07-07T09:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-auth-refresh-failed',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'auth.refresh.failed.token_expired',
|
||||||
|
category: 'auth',
|
||||||
|
level: 'error',
|
||||||
|
targetType: null,
|
||||||
|
targetId: 'demo-device-browser',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', device: 'Chrome 125 on Windows' }),
|
||||||
|
createdAt: '2026-07-06T18:45:30.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-security-password',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'user.password.changed',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: DEMO_USER_ID,
|
||||||
|
targetUserEmail: DEMO_PROFILE.email,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'user_initiated' }),
|
||||||
|
createdAt: '2026-07-05T10:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-security-user-banned',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.user.banned',
|
||||||
|
category: 'security',
|
||||||
|
level: 'security',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: 'demo-user-003',
|
||||||
|
targetUserEmail: 'suspended@example.com',
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', reason: 'violation_of_tos' }),
|
||||||
|
createdAt: '2026-07-04T16:20:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-security-user-register',
|
||||||
|
actorUserId: null,
|
||||||
|
actorEmail: 'newuser@example.com',
|
||||||
|
action: 'user.register.completed',
|
||||||
|
category: 'security',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: 'demo-user-004',
|
||||||
|
targetUserEmail: 'newuser@example.com',
|
||||||
|
metadata: JSON.stringify({ ip: '192.0.2.55', invite: 'DEMO-INVITE-2026' }),
|
||||||
|
createdAt: '2026-07-03T08:30:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-device-trusted',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'device.trusted.added',
|
||||||
|
category: 'device',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: 'demo-device-mobile',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ deviceName: 'iPhone', os: 'iOS 18', ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-07-02T12:15:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-device-removed',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'device.removed',
|
||||||
|
category: 'device',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: 'device',
|
||||||
|
targetId: 'demo-device-old',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ deviceName: 'Firefox on Linux', ip: '198.51.100.20', trigger: 'user_initiated' }),
|
||||||
|
createdAt: '2026-07-01T09:45:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-device-all-revoked',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'device.all_trust_revoked',
|
||||||
|
category: 'device',
|
||||||
|
level: 'security',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ ip: '203.0.113.42', trigger: 'password_change' }),
|
||||||
|
createdAt: '2026-07-01T09:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-data-backup',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.backup.run.completed',
|
||||||
|
category: 'data',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260701_030000.zip', size: '1.2 MB', destination: 'Demo WebDAV' }),
|
||||||
|
createdAt: '2026-07-01T03:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-data-restore',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.backup.restore.completed',
|
||||||
|
category: 'data',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ fileName: 'nodewarden_backup_20260628_030000.zip', checksum: 'verified' }),
|
||||||
|
createdAt: '2026-06-30T14:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-data-export',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.export.completed',
|
||||||
|
category: 'data',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ format: 'encrypted_json', totalItems: 24 }),
|
||||||
|
createdAt: '2026-06-28T11:30:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-system-settings',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.settings.updated',
|
||||||
|
category: 'system',
|
||||||
|
level: 'info',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ changedKeys: ['signupsAllowed', 'kdfIterations'], ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-06-25T08:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-system-invite',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.invite.created',
|
||||||
|
category: 'system',
|
||||||
|
level: 'info',
|
||||||
|
targetType: 'invite',
|
||||||
|
targetId: 'DEMO-INVITE-2026',
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ expiresIn: '168h', ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-06-20T10:00:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-log-system-config',
|
||||||
|
actorUserId: DEMO_USER_ID,
|
||||||
|
actorEmail: DEMO_PROFILE.email,
|
||||||
|
action: 'admin.config.updated',
|
||||||
|
category: 'system',
|
||||||
|
level: 'warn',
|
||||||
|
targetType: null,
|
||||||
|
targetId: null,
|
||||||
|
targetUserEmail: null,
|
||||||
|
metadata: JSON.stringify({ changedKeys: ['smtp.host', 'smtp.port'], ip: '203.0.113.42' }),
|
||||||
|
createdAt: '2026-06-18T15:30:00.000Z',
|
||||||
|
object: 'auditLog',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function cloneJson<T>(value: T): T {
|
function cloneJson<T>(value: T): T {
|
||||||
return JSON.parse(JSON.stringify(value)) as T;
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
}
|
}
|
||||||
@@ -1075,6 +1440,38 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onGetRecoveryCode: readonlyString,
|
onGetRecoveryCode: readonlyString,
|
||||||
onGetApiKey: readonlyString,
|
onGetApiKey: readonlyString,
|
||||||
onRotateApiKey: readonlyString,
|
onRotateApiKey: readonlyString,
|
||||||
|
onListAccountPasskeys: async () => [],
|
||||||
|
onCreateAccountPasskey: async () => {
|
||||||
|
await readonly();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onEnableAccountPasskeyDirectUnlock: readonly,
|
||||||
|
onDeleteAccountPasskey: readonly,
|
||||||
|
onLoadAuditLogs: async (filters) => {
|
||||||
|
const limit = Number(filters.limit || 50) || 50;
|
||||||
|
const offset = Number(filters.offset || 0) || 0;
|
||||||
|
let filtered = DEMO_AUDIT_LOGS.filter((log) => {
|
||||||
|
if (filters.category && filters.category !== 'all' && log.category !== filters.category) return false;
|
||||||
|
if (filters.level && filters.level !== 'all' && log.level !== filters.level) return false;
|
||||||
|
if (filters.q) {
|
||||||
|
const q = filters.q.toLowerCase();
|
||||||
|
if (!log.action.toLowerCase().includes(q) && !(log.actorEmail || '').toLowerCase().includes(q)) return false;
|
||||||
|
}
|
||||||
|
if (filters.from && new Date(log.createdAt).getTime() < new Date(filters.from).getTime()) return false;
|
||||||
|
if (filters.to && new Date(log.createdAt).getTime() > new Date(filters.to).getTime()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
const total = filtered.length;
|
||||||
|
const sliced = filtered.slice(offset, offset + limit);
|
||||||
|
return {
|
||||||
|
logs: sliced,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset: offset + sliced.length,
|
||||||
|
hasMore: offset + sliced.length < total,
|
||||||
|
};
|
||||||
|
},
|
||||||
onLockTimeoutChange: readonlyVoid,
|
onLockTimeoutChange: readonlyVoid,
|
||||||
onSessionTimeoutActionChange: readonlyVoid,
|
onSessionTimeoutActionChange: readonlyVoid,
|
||||||
onRefreshAuthorizedDevices: async () => {
|
onRefreshAuthorizedDevices: async () => {
|
||||||
|
|||||||
@@ -427,6 +427,20 @@
|
|||||||
min-width: max(100%, 190px);
|
min-width: max(100%, 190px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head .mobile-duplicate-toolbar {
|
||||||
|
@apply flex min-w-0 items-center gap-1.5;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .mobile-duplicate-mode-select-wrap {
|
||||||
|
max-width: 130px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-head .mobile-duplicate-mode-select-wrap .mobile-vault-filter-trigger {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar.actions {
|
.toolbar.actions {
|
||||||
@apply justify-end overflow-visible pb-0.5;
|
@apply justify-end overflow-visible pb-0.5;
|
||||||
flex-wrap: unset;
|
flex-wrap: unset;
|
||||||
|
|||||||
Reference in New Issue
Block a user