mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: implement device login approval system
Add a complete device authentication approval flow that allows users to approve login requests from new devices on their already-authenticated devices. Core features: - Create authentication requests when logging in from new devices - Display pending requests with device info, IP address, and fingerprint phrases - Approve or deny requests from web interface with real-time notifications - Support multiple auth request types (authenticate & unlock, unlock only) - Automatic expiration and cleanup of stale requests Backend changes: - Add auth_requests table with proper indexes for efficient queries - Implement full CRUD API for authentication requests - Add notification hub integration for real-time updates - Add device fingerprint phrase generation for security verification Frontend changes: - Add AuthRequestApprovalDialog component for approving/denying requests - Add PendingAuthRequestsPanel component to display and manage pending requests - Integrate panels into Security and Settings pages - Add fingerprint wordlist for generating human-readable verification phrases - Update i18n translations for all supported languages Security considerations: - Access code verification to prevent unauthorized access - Device fingerprint validation for additional security layer - IP address and country tracking for audit purposes - Automatic expiration of old requests (15 minutes) - Only most recent request per device can be approved Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+96
-3
@@ -3,6 +3,7 @@ import { useLocation } from 'wouter';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AppAuthenticatedShell from '@/components/AppAuthenticatedShell';
|
||||
import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import AuthRequestApprovalDialog from '@/components/AuthRequestApprovalDialog';
|
||||
import AuthViews from '@/components/AuthViews';
|
||||
import NotFoundPage from '@/components/NotFoundPage';
|
||||
import PublicSendPage from '@/components/PublicSendPage';
|
||||
@@ -22,6 +23,12 @@ import {
|
||||
saveSession,
|
||||
stripProfileSecrets,
|
||||
} from '@/lib/api/auth';
|
||||
import {
|
||||
encryptSessionUserKeyForAuthRequest,
|
||||
isPendingAuthRequest,
|
||||
listPendingAuthRequests,
|
||||
respondToAuthRequest,
|
||||
} from '@/lib/api/auth-requests';
|
||||
import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin';
|
||||
import { getDomainRules, saveDomainRules } from '@/lib/api/domains';
|
||||
import { getSends } from '@/lib/api/send';
|
||||
@@ -74,7 +81,7 @@ import {
|
||||
createDemoMainRoutesProps,
|
||||
} from '@/lib/demo';
|
||||
import type { AdminBackupSettings } from '@/lib/api/backup';
|
||||
import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||
import type { AdminInvite, AdminUser, AppPhase, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||
import type { VaultCoreSnapshot } from '@/lib/vault-cache';
|
||||
|
||||
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||
@@ -94,6 +101,8 @@ const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.fil
|
||||
const SETTINGS_HOME_ROUTE = '/settings';
|
||||
const SETTINGS_ACCOUNT_ROUTE = '/settings/account';
|
||||
const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules';
|
||||
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
|
||||
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
|
||||
const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const;
|
||||
const APP_ROUTE_PATHS = [
|
||||
'/',
|
||||
@@ -102,7 +111,8 @@ const APP_ROUTE_PATHS = [
|
||||
'/sends',
|
||||
'/admin',
|
||||
'/logs',
|
||||
'/security/devices',
|
||||
LEGACY_DEVICE_MANAGEMENT_ROUTE,
|
||||
DEVICE_MANAGEMENT_ROUTE,
|
||||
'/backup',
|
||||
'/settings',
|
||||
SETTINGS_ACCOUNT_ROUTE,
|
||||
@@ -213,6 +223,8 @@ export default function App() {
|
||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
|
||||
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
|
||||
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||
@@ -1060,6 +1072,52 @@ export default function App() {
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const pendingAuthRequestsQuery = useQuery({
|
||||
queryKey: ['auth-requests-pending', vaultCacheKey || session?.email],
|
||||
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: 15_000,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
|
||||
const latestPendingAuthRequest = pendingAuthRequests[0] || null;
|
||||
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId;
|
||||
|
||||
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||
if (!session) throw new Error(t('txt_vault_key_unavailable'));
|
||||
setAuthRequestSubmittingId(authRequest.id);
|
||||
try {
|
||||
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||
key,
|
||||
masterPasswordHash: null,
|
||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||
requestApproved: true,
|
||||
});
|
||||
setAuthRequestDialogDismissedId(null);
|
||||
pushToast('success', t('txt_auth_request_approved'));
|
||||
await pendingAuthRequestsQuery.refetch();
|
||||
} finally {
|
||||
setAuthRequestSubmittingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||
setAuthRequestSubmittingId(authRequest.id);
|
||||
try {
|
||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||
requestApproved: false,
|
||||
});
|
||||
setAuthRequestDialogDismissedId(null);
|
||||
pushToast('success', t('txt_auth_request_denied'));
|
||||
await pendingAuthRequestsQuery.refetch();
|
||||
} finally {
|
||||
setAuthRequestSubmittingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise<void> {
|
||||
const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains);
|
||||
const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains);
|
||||
@@ -1509,7 +1567,7 @@ export default function App() {
|
||||
if (location === '/sends') return t('nav_sends');
|
||||
if (location === '/admin') return t('nav_admin_panel');
|
||||
if (location === '/logs') return t('nav_log_center');
|
||||
if (location === '/security/devices') return t('nav_device_management');
|
||||
if (location === LEGACY_DEVICE_MANAGEMENT_ROUTE || location === DEVICE_MANAGEMENT_ROUTE) return t('nav_device_management');
|
||||
if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules');
|
||||
if (location === '/backup') return t('nav_backup_strategy');
|
||||
if (isImportRoute) return t('nav_import_export');
|
||||
@@ -1518,6 +1576,16 @@ export default function App() {
|
||||
return t('nav_my_vault');
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'app') return;
|
||||
if (!hashPath.startsWith('/')) return;
|
||||
if (normalizedHashPath !== DEVICE_MANAGEMENT_ROUTE && normalizedHashPath !== LEGACY_DEVICE_MANAGEMENT_ROUTE) return;
|
||||
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
|
||||
window.history.replaceState(null, '', DEVICE_MANAGEMENT_ROUTE);
|
||||
}
|
||||
if (location !== DEVICE_MANAGEMENT_ROUTE) navigate(DEVICE_MANAGEMENT_ROUTE);
|
||||
}, [phase, hashPath, normalizedHashPath, location, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||
}, [phase, location, isPublicSendRoute, navigate]);
|
||||
@@ -1624,6 +1692,13 @@ export default function App() {
|
||||
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
||||
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||
pendingAuthRequests,
|
||||
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching,
|
||||
onRefreshPendingAuthRequests: async () => {
|
||||
await pendingAuthRequestsQuery.refetch();
|
||||
},
|
||||
onApproveAuthRequest: approveAuthRequest,
|
||||
onDenyAuthRequest: denyAuthRequest,
|
||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||
@@ -1868,6 +1943,24 @@ export default function App() {
|
||||
}}
|
||||
disableTotpSubmitting={disableTotpSubmitting}
|
||||
/>
|
||||
<AuthRequestApprovalDialog
|
||||
open={authRequestDialogOpen}
|
||||
authRequest={latestPendingAuthRequest}
|
||||
submitting={!!authRequestSubmittingId}
|
||||
onApprove={() => {
|
||||
if (!latestPendingAuthRequest) return;
|
||||
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||
});
|
||||
}}
|
||||
onDeny={() => {
|
||||
if (!latestPendingAuthRequest) return;
|
||||
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
|
||||
});
|
||||
}}
|
||||
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ function isAdminProfile(profile: Profile | null): boolean {
|
||||
return String(profile?.role || '').toLowerCase() === 'admin';
|
||||
}
|
||||
|
||||
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
|
||||
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
|
||||
|
||||
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||
@@ -55,7 +58,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
|
||||
const dataActive = props.location === '/backup' || props.isImportRoute;
|
||||
const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs';
|
||||
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
||||
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
|
||||
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
|
||||
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
|
||||
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -177,7 +181,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
|
||||
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
|
||||
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
|
||||
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -222,7 +226,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
|
||||
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
|
||||
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
|
||||
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
@@ -116,6 +116,11 @@ export interface AppMainRoutesProps {
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
@@ -153,6 +158,7 @@ export interface AppMainRoutesProps {
|
||||
|
||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||
const deviceManagementRoutePaths = ['/security/devices', '/settings/security/device-management'] as const;
|
||||
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
|
||||
const importPageContent = (
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
@@ -269,6 +275,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
@@ -287,7 +298,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className="mobile-settings-link">
|
||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
@@ -327,32 +338,39 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<LoadingState card lines={4} />
|
||||
) : null}
|
||||
</Route>
|
||||
<Route path="/security/devices">
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SecurityDevicesPage
|
||||
devices={props.authorizedDevices}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
error={props.authorizedDevicesError}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onTrustPermanently={props.onTrustDevicePermanently}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
onRemoveAll={props.onRemoveAllDevices}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
{deviceManagementRoutePaths.map((path) => (
|
||||
<Route key={path} path={path}>
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SecurityDevicesPage
|
||||
devices={props.authorizedDevices}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
error={props.authorizedDevicesError}
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
onRenameDevice={props.onRenameAuthorizedDevice}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onTrustPermanently={props.onTrustDevicePermanently}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
onRemoveAll={props.onRemoveAllDevices}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
))}
|
||||
<Route path="/settings/domain-rules">
|
||||
<div className="stack domain-rules-route">
|
||||
{props.mobileLayout && (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ShieldCheck, ShieldX } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AuthRequest } from '@/lib/types';
|
||||
|
||||
interface AuthRequestApprovalDialogProps {
|
||||
open: boolean;
|
||||
authRequest: AuthRequest | null;
|
||||
submitting: boolean;
|
||||
onApprove: () => void;
|
||||
onDeny: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export default function AuthRequestApprovalDialog(props: AuthRequestApprovalDialogProps) {
|
||||
const authRequest = props.authRequest;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={props.open && !!authRequest}
|
||||
title={t('txt_approve_device_login')}
|
||||
message={t('txt_auth_request_approve_message')}
|
||||
confirmText={props.submitting ? t('txt_approving') : t('txt_approve')}
|
||||
cancelText={t('txt_later')}
|
||||
confirmDisabled={props.submitting || !authRequest}
|
||||
cancelDisabled={props.submitting}
|
||||
onConfirm={props.onApprove}
|
||||
onCancel={props.onClose}
|
||||
afterActions={(
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger dialog-btn"
|
||||
disabled={props.submitting || !authRequest}
|
||||
onClick={props.onDeny}
|
||||
>
|
||||
<ShieldX size={14} className="btn-icon" />
|
||||
{t('txt_deny')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{authRequest && (
|
||||
<div className="auth-request-details">
|
||||
<div className="auth-request-device">
|
||||
<ShieldCheck size={18} />
|
||||
<div>
|
||||
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
|
||||
<small>{authRequest.requestDeviceIdentifier}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-request-kv">
|
||||
<span>{t('txt_created')}</span>
|
||||
<strong>{formatDateTime(authRequest.creationDate)}</strong>
|
||||
</div>
|
||||
{authRequest.requestIpAddress && (
|
||||
<div className="auth-request-kv">
|
||||
<span>{t('txt_ip_address')}</span>
|
||||
<strong>{authRequest.requestIpAddress}</strong>
|
||||
</div>
|
||||
)}
|
||||
<div className="auth-request-fingerprint">
|
||||
<span>{t('txt_fingerprint_phrase')}</span>
|
||||
<strong>{authRequest.fingerprintPhrase || t('txt_dash')}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { RefreshCw, ShieldCheck, ShieldX } from 'lucide-preact';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AuthRequest } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface PendingAuthRequestsPanelProps {
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
className?: string;
|
||||
loadingVariant?: 'placeholder' | 'compact';
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? t('txt_dash') : date.toLocaleString();
|
||||
}
|
||||
|
||||
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
|
||||
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
|
||||
|
||||
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||
if (authRequestSubmittingId) return;
|
||||
setAuthRequestSubmittingId(authRequest.id);
|
||||
try {
|
||||
await props.onApproveAuthRequest(authRequest);
|
||||
} finally {
|
||||
setAuthRequestSubmittingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
|
||||
if (authRequestSubmittingId) return;
|
||||
setAuthRequestSubmittingId(authRequest.id);
|
||||
try {
|
||||
await props.onDenyAuthRequest(authRequest);
|
||||
} finally {
|
||||
setAuthRequestSubmittingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={props.className || 'card settings-module'}>
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_pending_device_logins')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={props.pendingAuthRequestsLoading}
|
||||
onClick={() => void props.onRefreshPendingAuthRequests()}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="account-passkeys-list">
|
||||
{props.pendingAuthRequestsLoading && props.pendingAuthRequests.length === 0 ? (
|
||||
props.loadingVariant === 'compact' ? (
|
||||
<LoadingState lines={2} compact />
|
||||
) : (
|
||||
<div className="settings-module-placeholder">
|
||||
<RefreshCw size={20} />
|
||||
<span>{t('txt_loading')}</span>
|
||||
</div>
|
||||
)
|
||||
) : props.pendingAuthRequests.length === 0 ? (
|
||||
<div className="settings-module-placeholder">
|
||||
<ShieldCheck size={20} />
|
||||
<span>{t('txt_no_pending_device_logins')}</span>
|
||||
</div>
|
||||
) : (
|
||||
props.pendingAuthRequests.map((authRequest) => (
|
||||
<div key={authRequest.id} className="account-passkey-row auth-request-row">
|
||||
<div className="account-passkey-main">
|
||||
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
|
||||
<small>{authRequest.requestDeviceIdentifier}</small>
|
||||
<small>{t('txt_created_value', { value: formatDateTime(authRequest.creationDate) })}</small>
|
||||
</div>
|
||||
<span className="auth-request-fingerprint-inline">
|
||||
{authRequest.fingerprintPhrase || t('txt_dash')}
|
||||
</span>
|
||||
<div className="actions account-passkey-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary small"
|
||||
disabled={!!authRequestSubmittingId}
|
||||
onClick={() => void approveAuthRequest(authRequest)}
|
||||
>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{authRequestSubmittingId === authRequest.id ? t('txt_approving') : t('txt_approve')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={!!authRequestSubmittingId}
|
||||
onClick={() => void denyAuthRequest(authRequest)}
|
||||
>
|
||||
<ShieldX size={14} className="btn-icon" />
|
||||
{t('txt_deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,20 @@ import { useState } from 'preact/hooks';
|
||||
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AuthorizedDevice } from '@/lib/types';
|
||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||
import type { AuthRequest, AuthorizedDevice } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface SecurityDevicesPageProps {
|
||||
devices: AuthorizedDevice[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||
onTrustPermanently: (device: AuthorizedDevice) => void;
|
||||
@@ -72,6 +78,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="stack">
|
||||
<PendingAuthRequestsPanel
|
||||
className="card"
|
||||
loadingVariant="compact"
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
/>
|
||||
|
||||
<section className="card">
|
||||
<div className="section-head">
|
||||
<div>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { AccountPasskeyCredential, Profile } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
|
||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
||||
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
@@ -22,6 +23,11 @@ interface SettingsPageProps {
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -219,13 +225,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
return t('txt_prf_not_supported');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
async function changeLocale(next: Locale): Promise<void> {
|
||||
if (next === getLocale()) return;
|
||||
setSelectedLocale(next);
|
||||
@@ -504,6 +503,14 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PendingAuthRequestsPanel
|
||||
pendingAuthRequests={props.pendingAuthRequests}
|
||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
||||
/>
|
||||
|
||||
<section className="settings-module sensitive-actions-module">
|
||||
<div className="sensitive-actions-grid">
|
||||
<div className="sensitive-action">
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { base64ToBytes, bytesToBase64, hkdfExpand, toBufferSource } from '@/lib/crypto';
|
||||
import { EFFLongWordList } from '@/lib/fingerprint-wordlist';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AuthRequest, ListResponse, SessionState } from '@/lib/types';
|
||||
import type { AuthedFetch } from './shared';
|
||||
import { parseErrorMessage, parseJson } from './shared';
|
||||
|
||||
function readResponseProperty<T>(source: Record<string, any>, camel: string, pascal: string, fallback: T): T {
|
||||
return (source[camel] ?? source[pascal] ?? fallback) as T;
|
||||
}
|
||||
|
||||
function normalizeAuthRequest(raw: Record<string, any>): AuthRequest {
|
||||
return {
|
||||
id: String(readResponseProperty(raw, 'id', 'Id', '')),
|
||||
publicKey: String(readResponseProperty(raw, 'publicKey', 'PublicKey', '')),
|
||||
requestDeviceType: readResponseProperty(raw, 'requestDeviceType', 'RequestDeviceType', null),
|
||||
requestDeviceTypeValue: readResponseProperty(raw, 'requestDeviceTypeValue', 'RequestDeviceTypeValue', null),
|
||||
requestDeviceIdentifier: String(readResponseProperty(raw, 'requestDeviceIdentifier', 'RequestDeviceIdentifier', '')),
|
||||
requestIpAddress: readResponseProperty(raw, 'requestIpAddress', 'RequestIpAddress', null),
|
||||
requestCountryName: readResponseProperty(raw, 'requestCountryName', 'RequestCountryName', null),
|
||||
key: readResponseProperty(raw, 'key', 'Key', null),
|
||||
creationDate: String(readResponseProperty(raw, 'creationDate', 'CreationDate', '')),
|
||||
requestApproved: readResponseProperty(raw, 'requestApproved', 'RequestApproved', null),
|
||||
responseDate: readResponseProperty(raw, 'responseDate', 'ResponseDate', null),
|
||||
deviceId: readResponseProperty(raw, 'deviceId', 'DeviceId', null),
|
||||
requestDeviceId: readResponseProperty(raw, 'requestDeviceId', 'RequestDeviceId', null),
|
||||
};
|
||||
}
|
||||
|
||||
async function withFingerprintPhrase(email: string, request: AuthRequest): Promise<AuthRequest> {
|
||||
if (!request.publicKey) return request;
|
||||
try {
|
||||
return {
|
||||
...request,
|
||||
fingerprintPhrase: await getFingerprintPhrase(email, base64ToBytes(request.publicKey)),
|
||||
};
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPendingAuthRequests(authedFetch: AuthedFetch, email: string): Promise<AuthRequest[]> {
|
||||
const resp = await authedFetch('/api/auth-requests/pending');
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_requests_load_failed')));
|
||||
const body = await parseJson<ListResponse<Record<string, any>> & { Data?: Record<string, any>[] }>(resp);
|
||||
const rows = (body?.data || body?.Data || []).map(normalizeAuthRequest);
|
||||
return Promise.all(rows.map((row) => withFingerprintPhrase(email, row)));
|
||||
}
|
||||
|
||||
export async function respondToAuthRequest(
|
||||
authedFetch: AuthedFetch,
|
||||
requestId: string,
|
||||
payload: {
|
||||
key?: string | null;
|
||||
masterPasswordHash?: string | null;
|
||||
deviceIdentifier: string;
|
||||
requestApproved: boolean;
|
||||
}
|
||||
): Promise<AuthRequest> {
|
||||
const resp = await authedFetch(`/api/auth-requests/${encodeURIComponent(requestId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_auth_request_update_failed')));
|
||||
const body = await parseJson<Record<string, any>>(resp);
|
||||
if (!body) throw new Error(t('txt_auth_request_update_failed'));
|
||||
return normalizeAuthRequest(body);
|
||||
}
|
||||
|
||||
export function isPendingAuthRequest(request: AuthRequest): boolean {
|
||||
if (!request.id || !request.creationDate) return false;
|
||||
if (request.responseDate) return false;
|
||||
const createdAt = new Date(request.creationDate).getTime();
|
||||
if (!Number.isFinite(createdAt)) return true;
|
||||
return Date.now() - createdAt < 15 * 60 * 1000;
|
||||
}
|
||||
|
||||
export async function encryptSessionUserKeyForAuthRequest(session: SessionState, authRequest: AuthRequest): Promise<string> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
if (!authRequest.publicKey) throw new Error(t('txt_auth_request_missing_public_key'));
|
||||
|
||||
const userKeyBytes = new Uint8Array(64);
|
||||
userKeyBytes.set(base64ToBytes(session.symEncKey), 0);
|
||||
userKeyBytes.set(base64ToBytes(session.symMacKey), 32);
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
'spki',
|
||||
toBufferSource(base64ToBytes(authRequest.publicKey)),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-1' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
const encryptedBytes = new Uint8Array(await crypto.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
publicKey,
|
||||
toBufferSource(userKeyBytes)
|
||||
));
|
||||
return `4.${bytesToBase64(encryptedBytes)}`;
|
||||
}
|
||||
|
||||
export async function getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
|
||||
const keyFingerprint = new Uint8Array(await crypto.subtle.digest('SHA-256', toBufferSource(publicKey)));
|
||||
const userFingerprint = await hkdfExpand(keyFingerprint, email.toLowerCase(), 32);
|
||||
return hashPhrase(userFingerprint).join('-');
|
||||
}
|
||||
|
||||
function hashPhrase(hash: Uint8Array, minimumEntropy = 64): string[] {
|
||||
const entropyPerWord = Math.log(EFFLongWordList.length) / Math.log(2);
|
||||
let numWords = Math.ceil(minimumEntropy / entropyPerWord);
|
||||
if (numWords * entropyPerWord > hash.length * 4) {
|
||||
throw new Error('Output entropy of hash function is too small');
|
||||
}
|
||||
|
||||
let hashNumber = 0n;
|
||||
for (const byte of hash) {
|
||||
hashNumber = (hashNumber * 256n) + BigInt(byte);
|
||||
}
|
||||
|
||||
const phrase: string[] = [];
|
||||
const wordCount = BigInt(EFFLongWordList.length);
|
||||
while (numWords > 0) {
|
||||
const remainder = Number(hashNumber % wordCount);
|
||||
hashNumber /= wordCount;
|
||||
phrase.push(EFFLongWordList[remainder]);
|
||||
numWords -= 1;
|
||||
}
|
||||
return phrase;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1172,7 +1172,22 @@ const en: Record<string, string> = {
|
||||
"txt_target": "Target",
|
||||
"txt_time": "Time",
|
||||
"txt_time_range": "Time range",
|
||||
"txt_remove_domain": "Remove domain"
|
||||
"txt_remove_domain": "Remove domain",
|
||||
"txt_approve_device_login": "Approve device login",
|
||||
"txt_auth_request_approve_message": "Unlock Bitwarden on your device or approve from the web app. Before approving, make sure the fingerprint phrase matches the one below.",
|
||||
"txt_approve": "Approve",
|
||||
"txt_approving": "Approving...",
|
||||
"txt_deny": "Deny",
|
||||
"txt_later": "Later",
|
||||
"txt_pending_device_logins": "Pending device logins",
|
||||
"txt_no_pending_device_logins": "No pending device logins",
|
||||
"txt_fingerprint_phrase": "Fingerprint phrase",
|
||||
"txt_auth_requests_load_failed": "Failed to load device login requests",
|
||||
"txt_auth_request_update_failed": "Failed to update device login request",
|
||||
"txt_auth_request_approved": "Device login approved",
|
||||
"txt_auth_request_denied": "Device login denied",
|
||||
"txt_auth_request_missing_public_key": "Device login request is missing a public key",
|
||||
"txt_ip_address": "IP address"
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -1172,7 +1172,22 @@ const es: Record<string, string> = {
|
||||
"txt_target": "Destino",
|
||||
"txt_time": "Hora",
|
||||
"txt_time_range": "Rango de tiempo",
|
||||
"txt_remove_domain": "Quitar dominio"
|
||||
"txt_remove_domain": "Quitar dominio",
|
||||
"txt_approve_device_login": "Aprobar inicio de sesión con dispositivo",
|
||||
"txt_auth_request_approve_message": "Desbloquee Bitwarden en su dispositivo o apruebe desde la aplicación web. Antes de aprobar, asegúrese de que la frase de huella coincida con la siguiente.",
|
||||
"txt_fingerprint_phrase": "Frase de huella",
|
||||
"txt_ip_address": "Dirección IP",
|
||||
"txt_approve": "Aprobar",
|
||||
"txt_approving": "Aprobando...",
|
||||
"txt_deny": "Denegar",
|
||||
"txt_later": "Más tarde",
|
||||
"txt_pending_device_logins": "Inicios de sesión con dispositivo pendientes",
|
||||
"txt_no_pending_device_logins": "No hay inicios de sesión con dispositivo pendientes",
|
||||
"txt_auth_requests_load_failed": "No se pudieron cargar las solicitudes de inicio de sesión con dispositivo",
|
||||
"txt_auth_request_update_failed": "No se pudo actualizar la solicitud de inicio de sesión con dispositivo",
|
||||
"txt_auth_request_approved": "Inicio de sesión con dispositivo aprobado",
|
||||
"txt_auth_request_denied": "Inicio de sesión con dispositivo denegado",
|
||||
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
|
||||
};
|
||||
|
||||
export default es;
|
||||
|
||||
@@ -1172,7 +1172,22 @@ const ru: Record<string, string> = {
|
||||
"txt_target": "Цель",
|
||||
"txt_time": "Время",
|
||||
"txt_time_range": "Период",
|
||||
"txt_remove_domain": "Удалить домен"
|
||||
"txt_remove_domain": "Удалить домен",
|
||||
"txt_approve_device_login": "Подтвердить вход с устройства",
|
||||
"txt_auth_request_approve_message": "Разблокируйте Bitwarden на устройстве или подтвердите вход через веб-приложение. Перед подтверждением убедитесь, что фраза отпечатка совпадает с указанной ниже.",
|
||||
"txt_fingerprint_phrase": "Фраза отпечатка",
|
||||
"txt_ip_address": "IP-адрес",
|
||||
"txt_approve": "Подтвердить",
|
||||
"txt_approving": "Подтверждение...",
|
||||
"txt_deny": "Отклонить",
|
||||
"txt_later": "Позже",
|
||||
"txt_pending_device_logins": "Ожидающие входы с устройств",
|
||||
"txt_no_pending_device_logins": "Нет ожидающих входов с устройств",
|
||||
"txt_auth_requests_load_failed": "Не удалось загрузить запросы входа с устройств",
|
||||
"txt_auth_request_update_failed": "Не удалось обновить запрос входа с устройства",
|
||||
"txt_auth_request_approved": "Вход с устройства подтвержден",
|
||||
"txt_auth_request_denied": "Вход с устройства отклонен",
|
||||
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -1172,7 +1172,22 @@ const zhCN: Record<string, string> = {
|
||||
"txt_target": "目标",
|
||||
"txt_time": "时间",
|
||||
"txt_time_range": "时间范围",
|
||||
"txt_remove_domain": "移除域名"
|
||||
"txt_remove_domain": "移除域名",
|
||||
"txt_approve_device_login": "批准设备登录",
|
||||
"txt_auth_request_approve_message": "解锁您设备上的 Bitwarden,或通过网页 App 批准。批准前,请确保指纹短语与下面的相匹配。",
|
||||
"txt_approve": "批准",
|
||||
"txt_approving": "正在批准...",
|
||||
"txt_deny": "拒绝",
|
||||
"txt_later": "稍后",
|
||||
"txt_pending_device_logins": "待处理设备登录",
|
||||
"txt_no_pending_device_logins": "没有待处理设备登录",
|
||||
"txt_fingerprint_phrase": "指纹短语",
|
||||
"txt_auth_requests_load_failed": "加载设备登录请求失败",
|
||||
"txt_auth_request_update_failed": "更新设备登录请求失败",
|
||||
"txt_auth_request_approved": "已批准设备登录",
|
||||
"txt_auth_request_denied": "已拒绝设备登录",
|
||||
"txt_auth_request_missing_public_key": "设备登录请求缺少公钥",
|
||||
"txt_ip_address": "IP 地址"
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -1172,7 +1172,22 @@ const zhTW: Record<string, string> = {
|
||||
"txt_target": "目標",
|
||||
"txt_time": "時間",
|
||||
"txt_time_range": "時間範圍",
|
||||
"txt_remove_domain": "移除域名"
|
||||
"txt_remove_domain": "移除域名",
|
||||
"txt_approve_device_login": "批准裝置登入",
|
||||
"txt_auth_request_approve_message": "解鎖您裝置上的 Bitwarden,或透過網頁 App 批准。批准前,請確保指紋短語與下面的相符。",
|
||||
"txt_fingerprint_phrase": "指紋短語",
|
||||
"txt_ip_address": "IP 位址",
|
||||
"txt_approve": "批准",
|
||||
"txt_approving": "正在批准...",
|
||||
"txt_deny": "拒絕",
|
||||
"txt_later": "稍後",
|
||||
"txt_pending_device_logins": "待處理裝置登入",
|
||||
"txt_no_pending_device_logins": "沒有待處理裝置登入",
|
||||
"txt_auth_requests_load_failed": "載入裝置登入請求失敗",
|
||||
"txt_auth_request_update_failed": "更新裝置登入請求失敗",
|
||||
"txt_auth_request_approved": "已批准裝置登入",
|
||||
"txt_auth_request_denied": "已拒絕裝置登入",
|
||||
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
|
||||
};
|
||||
|
||||
export default zhTW;
|
||||
|
||||
@@ -338,6 +338,23 @@ export interface AccountPasskeyCredential {
|
||||
revisionDate?: string;
|
||||
}
|
||||
|
||||
export interface AuthRequest {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
requestDeviceType?: string | null;
|
||||
requestDeviceTypeValue?: number | null;
|
||||
requestDeviceIdentifier: string;
|
||||
requestIpAddress?: string | null;
|
||||
requestCountryName?: string | null;
|
||||
key?: string | null;
|
||||
creationDate: string;
|
||||
requestApproved?: boolean | null;
|
||||
responseDate?: string | null;
|
||||
deviceId?: string | null;
|
||||
requestDeviceId?: string | null;
|
||||
fingerprintPhrase?: string;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyAssertionOptionsResponse {
|
||||
options: PublicKeyCredentialRequestOptions;
|
||||
token: string;
|
||||
|
||||
@@ -389,3 +389,83 @@
|
||||
.mobile-sidebar-head {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
.auth-request-details {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
.auth-request-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: color-mix(in srgb, var(--primary) 7%, var(--panel));
|
||||
}
|
||||
|
||||
.auth-request-device svg {
|
||||
color: var(--primary-strong);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.auth-request-device div,
|
||||
.account-passkey-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-request-device strong,
|
||||
.auth-request-device small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-request-device small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-request-kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.auth-request-kv span,
|
||||
.auth-request-fingerprint span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-request-kv strong {
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.auth-request-fingerprint {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.auth-request-fingerprint strong,
|
||||
.auth-request-fingerprint-inline {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.auth-request-row {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.auth-request-fingerprint-inline {
|
||||
color: var(--muted-strong);
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user