mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat(notifications): enhance NotificationsHub with device status updates and logout notifications
This commit is contained in:
+24
-5
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import AuthViews from '@/components/AuthViews';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
@@ -87,7 +87,7 @@ import {
|
||||
} from '@/lib/export-formats';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||
import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||
|
||||
interface PendingTotp {
|
||||
email: string;
|
||||
@@ -296,6 +296,9 @@ function buildPublicSendUrl(origin: string, accessId: string, keyPart: string):
|
||||
}
|
||||
|
||||
const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||
|
||||
interface WebVaultSignalRInvocation {
|
||||
type?: number;
|
||||
@@ -371,11 +374,12 @@ export default function App() {
|
||||
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
const [mobileLayout, setMobileLayout] = useState(false);
|
||||
const [decryptedFolders, setDecryptedFolders] = useState<Folder[]>([]);
|
||||
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||
|
||||
useEffect(() => {
|
||||
const syncInviteFromUrl = () => {
|
||||
@@ -1031,6 +1035,7 @@ export default function App() {
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
reconnectAttempts = 0;
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
try {
|
||||
socket?.send(`{"protocol":"json","version":1}${SIGNALR_RECORD_SEPARATOR}`);
|
||||
} catch {
|
||||
@@ -1045,6 +1050,16 @@ export default function App() {
|
||||
const frames = parseSignalRTextFrames(event.data);
|
||||
for (const frame of frames) {
|
||||
if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue;
|
||||
const updateType = Number(frame.arguments?.[0]?.Type || 0);
|
||||
if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) {
|
||||
logoutNow();
|
||||
return;
|
||||
}
|
||||
if (updateType === SIGNALR_UPDATE_TYPE_DEVICE_STATUS) {
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
continue;
|
||||
}
|
||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
||||
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
||||
void silentRefreshVaultRef.current();
|
||||
@@ -1053,6 +1068,7 @@ export default function App() {
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
socket = null;
|
||||
void refreshAuthorizedDevicesRef.current();
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
@@ -1084,6 +1100,8 @@ export default function App() {
|
||||
await authorizedDevicesQuery.refetch();
|
||||
}
|
||||
|
||||
refreshAuthorizedDevicesRef.current = refreshAuthorizedDevices;
|
||||
|
||||
async function revokeDeviceTrustAction(device: AuthorizedDevice) {
|
||||
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||
await authorizedDevicesQuery.refetch();
|
||||
@@ -1751,7 +1769,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) {
|
||||
const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' });
|
||||
const payload = bytes.slice();
|
||||
const blob = new Blob([payload], { type: mimeType || 'application/octet-stream' });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
@@ -1967,7 +1986,7 @@ export default function App() {
|
||||
title={sidebarToggleTitle}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||
>
|
||||
<Folder size={16} className="btn-icon" />
|
||||
<FolderIcon size={16} className="btn-icon" />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={handleLock}>
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<tr>
|
||||
<th>{t('txt_device')}</th>
|
||||
<th>{t('txt_type')}</th>
|
||||
<th>{t('txt_status')}</th>
|
||||
<th>{t('txt_added')}</th>
|
||||
<th>{t('txt_last_seen')}</th>
|
||||
<th>{t('txt_trusted_until')}</th>
|
||||
@@ -89,6 +90,11 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
<div className="muted-inline">{device.identifier}</div>
|
||||
</td>
|
||||
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||
<td data-label={t('txt_status')}>
|
||||
<span className={`device-status-pill ${device.online ? 'online' : 'offline'}`}>
|
||||
{device.online ? t('txt_online') : t('txt_offline')}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
||||
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
|
||||
<td data-label={t('txt_trusted_until')}>
|
||||
@@ -122,7 +128,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
))}
|
||||
{!props.loading && props.devices.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<td colSpan={7}>
|
||||
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -346,6 +346,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_ssn: "SSN",
|
||||
txt_state_province: "State / Province",
|
||||
txt_status: "Status",
|
||||
txt_online: "Online",
|
||||
txt_offline: "Offline",
|
||||
txt_submit: "Submit",
|
||||
txt_sync: "Sync",
|
||||
txt_sync_vault: "Sync Vault",
|
||||
@@ -624,6 +626,8 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_manage_device_sessions_and_30_day_totp_trusted_sessions: '管理设备会话和 30 天 TOTP 受信状态。',
|
||||
txt_role: '角色',
|
||||
txt_status: '状态',
|
||||
txt_online: '在线',
|
||||
txt_offline: '离线',
|
||||
txt_actions: '操作',
|
||||
txt_type: '类型',
|
||||
txt_revoke_all_trusted: '撤销全部受信任设备',
|
||||
|
||||
@@ -304,6 +304,7 @@ export interface AuthorizedDevice {
|
||||
type: number;
|
||||
creationDate: string | null;
|
||||
revisionDate: string | null;
|
||||
online: boolean;
|
||||
trusted: boolean;
|
||||
trustedTokenCount: number;
|
||||
trustedUntil: string | null;
|
||||
|
||||
@@ -1600,6 +1600,29 @@ input[type='file'].input::file-selector-button:hover {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.device-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 58px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.device-status-pill.online {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.device-status-pill.offline {
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user