diff --git a/apps/fluux/src/App.tsx b/apps/fluux/src/App.tsx index 4fcada4e..f8346938 100644 --- a/apps/fluux/src/App.tsx +++ b/apps/fluux/src/App.tsx @@ -10,6 +10,7 @@ import { useToastStore } from './stores/toastStore' import { UnlockEncryptionDialog } from './components/UnlockEncryptionDialog' import { IdentityChoiceDialog } from './components/IdentityChoiceDialog' import { RestorePassphraseDialog } from './components/RestorePassphraseDialog' +import { useWebUnlockDialogStore } from './stores/webUnlockDialogStore' import { detectRenderLoop } from '@/utils/renderLoopDetector' import { LoginScreen } from './components/LoginScreen' import { ChatLayout } from './components/ChatLayout' @@ -154,7 +155,9 @@ function App() { // Used to distinguish initial page load reconnect from wake-from-sleep reconnect const [hasBeenOnline, setHasBeenOnline] = useState(false) - const [showWebUnlockDialog, setShowWebUnlockDialog] = useState(false) + const showWebUnlockDialog = useWebUnlockDialogStore((s) => s.isOpen) + const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog) + const closeWebUnlockDialog = useWebUnlockDialogStore((s) => s.closeWebUnlockDialog) // Set when auto-init detects a server-side OpenPGP identity for this // account but no local key. Forces the user through IdentityChoiceDialog // instead of the standard unlock dialog (which would otherwise reach @@ -226,7 +229,7 @@ function App() { } } if (isKeyLocked()) { - setShowWebUnlockDialog(true) + openWebUnlockDialog() } }) } else if (status !== 'connecting') { @@ -237,7 +240,7 @@ function App() { setIsAutoReconnecting(false) } } - }, [status, client, jid]) + }, [status, client, jid, openWebUnlockDialog]) // --- Identity-choice handlers (web first-login safety net) --- // Each resolves `pendingIdentityChoice` with one explicit recovery @@ -395,7 +398,7 @@ function App() { {showWebUnlockDialog && ( setShowWebUnlockDialog(false)} + onClose={() => closeWebUnlockDialog()} /> )} {pendingIdentityChoice && ( diff --git a/apps/fluux/src/components/BackupPassphraseDialog.tsx b/apps/fluux/src/components/BackupPassphraseDialog.tsx index 7bb1106a..bbcc41c4 100644 --- a/apps/fluux/src/components/BackupPassphraseDialog.tsx +++ b/apps/fluux/src/components/BackupPassphraseDialog.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Copy, Check, AlertTriangle, Loader2, RefreshCw } from 'lucide-react' import { generateBackupPassphrase, generateBackupCode, USE_V6_KEYS } from '@/e2ee/passphraseGenerator' +import { SaveToPasswordManagerButton } from './SaveToPasswordManagerButton' // Draw a fresh passphrase in the user's UI language. 8 words × // 11 bits (BIP-39) = 88 bits, which matches the acceptability gate @@ -218,6 +219,12 @@ export function BackupPassphraseDialog({ ? t('settings.encryption.backupCopied') : t('settings.encryption.backupCopy')} + + + ) +} + function EncryptionIcon({ state, peerName, @@ -212,6 +239,10 @@ function EncryptionIcon({ ) } + if (state.kind === 'keyLocked') { + return + } + if (state.kind === 'rejected') { return (
diff --git a/apps/fluux/src/components/ChatView.tsx b/apps/fluux/src/components/ChatView.tsx index 7754101f..afd4b81c 100644 --- a/apps/fluux/src/components/ChatView.tsx +++ b/apps/fluux/src/components/ChatView.tsx @@ -15,6 +15,7 @@ import { MessageBubble, MessageList as MessageListComponent, shouldShowAvatar, b import { FindOnPageBar } from './conversation/FindOnPageBar' import { useFindOnPage, type FindOnPageHandle } from '@/hooks/useFindOnPage' import { useConversationEncryptionState, type ConversationEncryptionState } from '@/hooks/useConversationEncryptionState' +import { useWebUnlockDialogStore } from '@/stores/webUnlockDialogStore' import { ChristmasAnimation } from './ChristmasAnimation' import { ChatHeader } from './ChatHeader' import { MessageComposer, type ReplyInfo, type EditInfo, type MessageComposerHandle, type PendingAttachment } from './MessageComposer' @@ -945,6 +946,7 @@ function MessageInput({ }) { const { t } = useTranslation() const { sendMessage, sendChatState, isArchived, unarchiveConversation, setDraft, getDraft, clearDraft, clearFirstNewMessageId } = useChatActive() + const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog) // Draft persistence - saves on conversation change, restores on load const [text, setText] = useConversationDraft({ @@ -983,6 +985,19 @@ function MessageInput({ } const handleSend = async (text: string): Promise => { + // Refuse to send while the local OpenPGP key is locked for an + // encrypted conversation. Without the guard, the file upload would + // happen plaintext-or-ciphertext-with-no-recipient (depending on + // `encryptAttachment` below) and the send would fail at encrypt + // time, leaving an orphaned upload on the server. Opening the + // unlock dialog routes the user to the passphrase prompt; the + // typed text stays in the composer (we return `false` here, which + // skips the `setText('')` clear in MessageComposer). + if (encryptionState.kind === 'keyLocked') { + openWebUnlockDialog() + return false + } + // Unarchive conversation if archived (user is actively chatting) // and switch to Messages view to see it in the main list if (type === 'chat' && isArchived(conversationId)) { diff --git a/apps/fluux/src/components/SaveToPasswordManagerButton.tsx b/apps/fluux/src/components/SaveToPasswordManagerButton.tsx new file mode 100644 index 00000000..dc4df151 --- /dev/null +++ b/apps/fluux/src/components/SaveToPasswordManagerButton.tsx @@ -0,0 +1,81 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { KeyRound, Check } from 'lucide-react' +import { saveCredentialToManager } from '@/utils/saveCredentialToManager' +import { USE_V6_KEYS } from '@/e2ee/passphraseGenerator' +import { isTauri } from '@/utils/tauri' + +interface SaveToPasswordManagerButtonProps { + /** Stable credential identifier; matches the dialog's hidden username so save and autofill share an entry. */ + id: string + /** Human-readable label persisted in the PM entry list. Selected in the user's UI language. */ + name: string + /** Passphrase to save. Button is disabled while this is null. */ + passphrase: string | null + /** External disable (e.g. while the dialog is publishing). */ + disabled?: boolean +} + +/** + * Explicit "save to password manager" affordance for Tauri. + * + * Web is intentionally not handled here — the hidden form inputs + + * `autoComplete="new-password"` already drive the browser PM detection on + * form submission, so a button would be redundant. In Tauri the embedded + * webview doesn't reliably trigger that detection, hence this explicit path. + * + * Gated on `USE_V6_KEYS`: V4 backup codes don't fit the PM model because + * the restore flow uses a formatted input with `autoComplete="off"`, so we + * don't offer to save them. Returns null in V4 mode and on the web. + */ +export function SaveToPasswordManagerButton({ + id, + name, + passphrase, + disabled, +}: SaveToPasswordManagerButtonProps) { + const { t } = useTranslation() + const [feedback, setFeedback] = useState<'saved' | 'fallback' | null>(null) + + const handleClick = useCallback(async () => { + if (!passphrase) return + const outcome = await saveCredentialToManager({ id, name, password: passphrase }) + if (outcome === 'saved') { + setFeedback('saved') + } else { + try { + await navigator.clipboard.writeText(passphrase) + } catch { + // Clipboard may also be unavailable (no user gesture, secure context, etc.); + // we still show the fallback hint so the user knows the save didn't go through. + } + setFeedback('fallback') + } + setTimeout(() => setFeedback(null), 2000) + }, [id, name, passphrase]) + + if (!isTauri() || !USE_V6_KEYS) return null + + const isFallback = feedback === 'fallback' + const isSaved = feedback === 'saved' + + return ( + + ) +} diff --git a/apps/fluux/src/components/UnlockEncryptionDialog.tsx b/apps/fluux/src/components/UnlockEncryptionDialog.tsx index 889010c2..4ceadf99 100644 --- a/apps/fluux/src/components/UnlockEncryptionDialog.tsx +++ b/apps/fluux/src/components/UnlockEncryptionDialog.tsx @@ -108,6 +108,11 @@ export function UnlockEncryptionDialog({ client, onClose }: UnlockEncryptionDial return (
{ mouseDownTargetRef.current = e.target @@ -127,10 +132,10 @@ export function UnlockEncryptionDialog({ client, onClose }: UnlockEncryptionDial {/* Hidden username distinguishes this entry from the XMPP login in password managers. */}
-

+

{loading ? ' ' : title}

-

{loading ? ' ' : body}

+

{loading ? ' ' : body}

diff --git a/apps/fluux/src/components/conversation/EncryptedPlaceholder.tsx b/apps/fluux/src/components/conversation/EncryptedPlaceholder.tsx new file mode 100644 index 00000000..791d46f9 --- /dev/null +++ b/apps/fluux/src/components/conversation/EncryptedPlaceholder.tsx @@ -0,0 +1,61 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { Lock, LockOpen } from 'lucide-react' +import { useWebKeyLocked } from '@/hooks/useWebKeyLocked' +import { useWebUnlockDialogStore } from '@/stores/webUnlockDialogStore' + +export interface EncryptedPlaceholderProps { + /** + * `true` when the failure was almost certainly a wrong-key situation + * (we tried to decrypt and the cipher rejected the unlocked private + * key). The placeholder still renders, but the click affordance is + * dropped — unlocking again won't help. + * + * Left optional for now; consumers don't yet distinguish. + */ + hardFailure?: boolean +} + +/** + * Rendered inside a message bubble when {@link BaseMessage.encryptedPayload} + * is set — meaning the SDK received an E2EE-claimed stanza but could not + * decrypt it. Two visual states: + * + * - **Locked** (`useWebKeyLocked()` is true): a click prompts for the + * session passphrase. Once unlocked, the SDK's + * `retryPendingDecrypts()` re-runs and the placeholder is replaced + * by the real body on success. + * - **Unlocked**: the cipher rejected the unlocked key (revoked + * identity, wrong recipient, corrupt payload). The placeholder is + * static — clicking again won't help. + */ +export const EncryptedPlaceholder = memo(function EncryptedPlaceholder( + _props: EncryptedPlaceholderProps, +) { + const { t } = useTranslation() + const locked = useWebKeyLocked() + const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog) + + if (locked) { + return ( + + ) + } + + return ( +
+
+ ) +}) diff --git a/apps/fluux/src/components/conversation/MessageBubble.tsx b/apps/fluux/src/components/conversation/MessageBubble.tsx index 87a48e0d..26a6800b 100644 --- a/apps/fluux/src/components/conversation/MessageBubble.tsx +++ b/apps/fluux/src/components/conversation/MessageBubble.tsx @@ -12,6 +12,7 @@ import { Avatar } from '../Avatar' import { AvatarLightbox } from '../AvatarLightbox' import { MessageToolbar } from './MessageToolbar' import { MessageBody } from './MessageBody' +import { EncryptedPlaceholder } from './EncryptedPlaceholder' import { MessageReactions } from './MessageReactions' import { scrollToMessage, isActionMessage } from './messageGrouping' import { MessageAttachments } from '../MessageAttachments' @@ -446,25 +447,30 @@ export const MessageBubble = memo(function MessageBubble({ {/* Collapsible wrapper for long messages */} - {/* Message body (SDK already strips OOB URL from body for non-XEP-0428 clients) */} - + {/* Encrypted-payload placeholder takes precedence over body text + so the SDK's English fallback string never reaches the UI. */} + {message.encryptedPayload ? ( + + ) : ( + + )} {/* File attachments (image, video, audio, text preview, document card) - hidden for retracted */} {!message.isRetracted && } diff --git a/apps/fluux/src/components/settings-components/EncryptionSettings.tsx b/apps/fluux/src/components/settings-components/EncryptionSettings.tsx index 9e3e45fe..49be5eb2 100644 --- a/apps/fluux/src/components/settings-components/EncryptionSettings.tsx +++ b/apps/fluux/src/components/settings-components/EncryptionSettings.tsx @@ -12,7 +12,7 @@ import { RestorePassphraseDialog } from '@/components/RestorePassphraseDialog' import { ExternalKeyExportDialog } from '@/components/ExternalKeyExportDialog' import { IdentityChoiceDialog } from '@/components/IdentityChoiceDialog' import { OwnKeyConflictBanner } from '@/components/OwnKeyConflictBanner' -import { UnlockEncryptionDialog } from '@/components/UnlockEncryptionDialog' +import { useWebUnlockDialogStore } from '@/stores/webUnlockDialogStore' import { KeyPickerDialog } from '@/components/KeyPickerDialog' import type { KeyBundle } from '@/e2ee/OpenPGPPluginBase' import { @@ -101,7 +101,7 @@ export function EncryptionSettings() { const [showExternalExportDialog, setShowExternalExportDialog] = useState(false) const [showImportFileDialog, setShowImportFileDialog] = useState(false) const [pendingImportFileArmored, setPendingImportFileArmored] = useState(null) - const [showUnlockDialog, setShowUnlockDialog] = useState(false) + const openWebUnlockDialog = useWebUnlockDialogStore((s) => s.openWebUnlockDialog) const [pendingKeyPicker, setPendingKeyPicker] = useState<{ candidates: KeyBundle[] backupMessage: string @@ -210,7 +210,7 @@ export function EncryptionSettings() { // state. await registerE2EEPlugins(client) if (!bareJid) { - if (isKeyLocked()) setShowUnlockDialog(true) + if (isKeyLocked()) openWebUnlockDialog() return } const plugin = client.e2ee?.getPlugin('openpgp') as @@ -232,7 +232,7 @@ export function EncryptionSettings() { } } if (isKeyLocked()) { - setShowUnlockDialog(true) + openWebUnlockDialog() } return } @@ -298,7 +298,7 @@ export function EncryptionSettings() { } finally { setIsToggling(false) } - }, [openpgpEnabled, online, client, jid, setOpenpgpEnabled, addToast, t]) + }, [openpgpEnabled, online, client, jid, setOpenpgpEnabled, addToast, t, openWebUnlockDialog]) // --- Identity choice dialog handlers (silent-fork prevention) --- // Each handler resolves the `pendingIdentityChoice` state with one of @@ -798,7 +798,7 @@ export function EncryptionSettings() { {t('settings.encryption.lockedBannerBody')}