mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-13 13:06:52 +00:00
fix(startup): enforce keychain consent before credential access
Keep credential-store operations behind a per-process consent gate, prevent duplicate native grant requests, and defer legacy token migration until explicit consent. Harden the RTL/sidebar, custom window controls, bidi copy, and queue editor fixes. Refs #17
This commit is contained in:
@@ -25,7 +25,7 @@ import { getPlatformInfo } from '../utils/platform';
|
||||
import { isTransferLocked } from '../utils/downloadActions';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { localePluralVariant } from '../i18n/locales';
|
||||
import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/locales';
|
||||
import {
|
||||
canSubmitMetadataRows,
|
||||
appendRequestUrlsAfterVersion,
|
||||
@@ -116,6 +116,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [
|
||||
|
||||
export const AddDownloadsModal = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
|
||||
const { addToast } = useToast();
|
||||
const {
|
||||
isAddModalOpen,
|
||||
@@ -1247,7 +1248,9 @@ export const AddDownloadsModal = () => {
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className="add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none"
|
||||
className={`add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none ${
|
||||
isRtl ? 'add-download-links-input--rtl' : ''
|
||||
}`}
|
||||
placeholder={t($ => $.addDownloads.pastePlaceholder)}
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { KeyRound, ShieldAlert } from 'lucide-react';
|
||||
@@ -20,16 +20,23 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
|
||||
const platform = usePlatformInfo();
|
||||
const [isGranting, setIsGranting] = useState(false);
|
||||
const [grantRequestPending, setGrantRequestPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const grantRequestRef = useRef<Promise<PairingTokenHydration> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showKeychainModal || isGranting) return;
|
||||
if (!showKeychainModal || isGranting || grantRequestPending) return;
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') dismissKeychainPrompt(consentVersion);
|
||||
if (event.key !== 'Escape') return;
|
||||
if (consentVersion.trim()) {
|
||||
dismissKeychainPrompt(consentVersion);
|
||||
} else {
|
||||
useSettingsStore.getState().setShowKeychainModal(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [consentVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
|
||||
}, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]);
|
||||
|
||||
if (!showKeychainModal) {
|
||||
return null;
|
||||
@@ -56,6 +63,10 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
: t($ => $.keychain.grantLabelDefault);
|
||||
|
||||
const handleGrant = async () => {
|
||||
// A native credential-store call cannot be cancelled from the webview.
|
||||
// Keep the request identity until it settles so a UI timeout cannot
|
||||
// launch a second OS prompt while the first one is still outstanding.
|
||||
if (grantRequestRef.current) return;
|
||||
setIsGranting(true);
|
||||
setError(null);
|
||||
|
||||
@@ -79,9 +90,21 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
return true;
|
||||
};
|
||||
const grantRequest = invoke('grant_keychain_access');
|
||||
grantRequestRef.current = grantRequest;
|
||||
setGrantRequestPending(true);
|
||||
void grantRequest.then(
|
||||
() => {
|
||||
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
|
||||
setGrantRequestPending(false);
|
||||
},
|
||||
() => {
|
||||
if (grantRequestRef.current === grantRequest) grantRequestRef.current = null;
|
||||
setGrantRequestPending(false);
|
||||
}
|
||||
);
|
||||
// A native credential-store call cannot be cancelled by the webview. Keep
|
||||
// a late successful result useful even if the UI timeout has already
|
||||
// restored the Later/retry controls.
|
||||
// returned control to the explanation.
|
||||
grantRequest.then(applyPersistentGrant).catch(() => undefined);
|
||||
|
||||
try {
|
||||
@@ -106,14 +129,22 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
};
|
||||
|
||||
const handleLater = () => {
|
||||
dismissKeychainPrompt(consentVersion);
|
||||
if (consentVersion.trim()) {
|
||||
dismissKeychainPrompt(consentVersion);
|
||||
} else {
|
||||
// A modal opened by an early user action can render before the async
|
||||
// app-version lookup completes. Do not persist a dismissal for an
|
||||
// unknown build; startup must make the final consent decision once the
|
||||
// identity is known.
|
||||
useSettingsStore.getState().setShowKeychainModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget && !isGranting) handleLater();
|
||||
if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -168,17 +199,17 @@ export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = (
|
||||
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
|
||||
<button
|
||||
onClick={handleLater}
|
||||
disabled={isGranting}
|
||||
disabled={isGranting || grantRequestPending}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.keychain.later)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGrant}
|
||||
disabled={isGranting}
|
||||
disabled={isGranting || grantRequestPending}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
>
|
||||
{isGranting ? t($ => $.keychain.enabling) : grantLabel}
|
||||
{isGranting || grantRequestPending ? t($ => $.keychain.enabling) : grantLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { usePlatformInfo } from '../utils/platform';
|
||||
import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls';
|
||||
import { normalizeCustomProxy } from '../store/useDownloadStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { localeDirection, resolveAppLocale } from '../i18n';
|
||||
|
||||
const settingsTabs: { type: SettingsTab; icon: typeof Download }[] = [
|
||||
{ type: 'downloads', icon: Download },
|
||||
@@ -252,9 +253,12 @@ const CategoryFolderInput = ({
|
||||
};
|
||||
|
||||
export default function SettingsView() {
|
||||
const { t } = useTranslation();
|
||||
const { i18n, t } = useTranslation();
|
||||
const settings = useSettingsStore();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl';
|
||||
const isSidebarOnRight = settings.sidebarPosition === 'right'
|
||||
|| (settings.sidebarPosition === 'auto' && isRtl);
|
||||
const platform = usePlatformInfo();
|
||||
const platformName =
|
||||
platform.os === 'macos'
|
||||
@@ -680,7 +684,9 @@ runEngineChecks(false);
|
||||
|
||||
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
|
||||
<div className="settings-toolbar">
|
||||
<div className="settings-tab-strip flex items-stretch gap-1">
|
||||
<div className={`settings-tab-strip flex items-stretch gap-1 ${
|
||||
isSidebarOnRight ? 'settings-tab-strip--sidebar-right' : ''
|
||||
}`}>
|
||||
{settingsTabs.map(tab => (
|
||||
<TabButton key={tab.type} {...tab} label={tabLabels[tab.type]} />
|
||||
))}
|
||||
|
||||
@@ -46,6 +46,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
const renameQueueCancelRef = useRef<string | null>(null);
|
||||
const renamingQueueIdRef = useRef<string | null>(null);
|
||||
const editingQueueNameRef = useRef('');
|
||||
const rejectedAddQueueNameRef = useRef<string | null>(null);
|
||||
const rejectedRenameRef = useRef<{ queueId: string; name: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCloseMenu = () => setContextMenu(null);
|
||||
@@ -174,9 +176,17 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
if (!addQueue(normalizedName)) {
|
||||
if (trigger === 'blur' && rejectedAddQueueNameRef.current === normalizedName) {
|
||||
rejectedAddQueueNameRef.current = null;
|
||||
setNewQueueName('');
|
||||
setIsAddingQueue(false);
|
||||
return;
|
||||
}
|
||||
rejectedAddQueueNameRef.current = normalizedName;
|
||||
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
rejectedAddQueueNameRef.current = null;
|
||||
addQueueSubmitRef.current = true;
|
||||
setNewQueueName('');
|
||||
setIsAddingQueue(false);
|
||||
@@ -202,9 +212,23 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
if (!renameQueue(queueId, normalizedName)) {
|
||||
if (
|
||||
trigger === 'blur'
|
||||
&& rejectedRenameRef.current?.queueId === queueId
|
||||
&& rejectedRenameRef.current.name === normalizedName
|
||||
) {
|
||||
rejectedRenameRef.current = null;
|
||||
renamingQueueIdRef.current = null;
|
||||
editingQueueNameRef.current = '';
|
||||
setEditingQueueName('');
|
||||
setRenamingQueueId(null);
|
||||
return;
|
||||
}
|
||||
rejectedRenameRef.current = { queueId, name: normalizedName };
|
||||
addToast({ message: t($ => $.sidebar.queueNameExists), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
rejectedRenameRef.current = null;
|
||||
renameQueueSubmitRef.current = true;
|
||||
renamingQueueIdRef.current = null;
|
||||
setRenamingQueueId(null);
|
||||
@@ -226,6 +250,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
value={editingQueueName}
|
||||
onChange={e => {
|
||||
editingQueueNameRef.current = e.target.value;
|
||||
rejectedRenameRef.current = null;
|
||||
setEditingQueueName(e.target.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
@@ -352,7 +377,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
placeholder={t($ => $.actions.queueName)}
|
||||
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
|
||||
value={newQueueName}
|
||||
onChange={e => setNewQueueName(e.target.value)}
|
||||
onChange={e => {
|
||||
rejectedAddQueueNameRef.current = null;
|
||||
setNewQueueName(e.target.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleAddQueueSubmit();
|
||||
if (e.key === 'Escape') {
|
||||
@@ -371,6 +399,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
onClick={() => {
|
||||
addQueueSubmitRef.current = false;
|
||||
addQueueCancelRef.current = false;
|
||||
rejectedAddQueueNameRef.current = null;
|
||||
setIsAddingQueue(true);
|
||||
setNewQueueName('');
|
||||
}}
|
||||
@@ -454,6 +483,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
if (q) {
|
||||
renameQueueSubmitRef.current = false;
|
||||
renameQueueCancelRef.current = null;
|
||||
rejectedRenameRef.current = null;
|
||||
renamingQueueIdRef.current = q.id;
|
||||
editingQueueNameRef.current = q.name;
|
||||
setEditingQueueName(q.name);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Minus, Square, X } from 'lucide-react';
|
||||
import { Maximize2, Minus, X } from 'lucide-react';
|
||||
import type { PointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -58,7 +58,7 @@ export function WindowControls({ side }: WindowControlsProps) {
|
||||
void appWindow.toggleMaximize();
|
||||
}}
|
||||
>
|
||||
<Square size={8} strokeWidth={3} />
|
||||
<Maximize2 size={9} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user