Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import CommandProvider from './components/commands/CommandProvider';
import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
import DictationHotkeyManager from './components/DictationHotkeyManager';
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
import KeyringConsentOverlay from './components/keyring/KeyringConsentOverlay';
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog';
import OpenhumanLinkModal from './components/OpenhumanLinkModal';
Expand Down Expand Up @@ -107,6 +108,7 @@ function App() {
{!onMobile && <DictationHotkeyManager />}
{!onMobile && <LocalAIDownloadSnackbar />}
{!onMobile && <AppUpdatePrompt />}
<KeyringConsentOverlay />
<SecretPromptDialog />
</ServiceBlockingGate>
</CommandProvider>
Expand Down
8 changes: 7 additions & 1 deletion app/src/components/__tests__/BottomTabBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean
onboardingCompleted: true,
chatOnboardingCompleted: true,
analyticsEnabled: false,
localState: { encryptionKey: null, onboardingTasks: null },
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
keyringStatus: {
available: true,
failureReason: null,
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
isBootstrapping: false,
Expand Down
139 changes: 139 additions & 0 deletions app/src/components/keyring/KeyringConsentOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { useState } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import { useCoreState } from '../../providers/CoreStateProvider';
import { decideKeyringConsent, retryKeyringProbe } from '../../services/keyringApi';

const KeyringConsentOverlay = () => {
const { t } = useT();
const { snapshot } = useCoreState();
const [isRetrying, setIsRetrying] = useState(false);
const [isConsenting, setIsConsenting] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [error, setError] = useState<string | null>(null);

const keyringStatus = snapshot.keyringStatus;
const needsConsent = keyringStatus.activeMode === 'consent_pending';

if (!needsConsent) {
return null;
}

const handleConsent = async () => {
setIsConsenting(true);
setError(null);
try {
await decideKeyringConsent('local_encrypted');
} catch {
setError(t('keyring.consent.error'));
} finally {
setIsConsenting(false);
}
};

const handleDecline = async () => {
setIsConsenting(true);
setError(null);
try {
await decideKeyringConsent('declined');
} catch {
setError(t('keyring.consent.error'));
} finally {
setIsConsenting(false);
}
};

const handleRetry = async () => {
setIsRetrying(true);
setError(null);
try {
await retryKeyringProbe();
} catch {
setError(t('keyring.consent.retryFailed'));
} finally {
setIsRetrying(false);
}
};

const failureReason = keyringStatus.failureReason;

return (
<div className="fixed inset-0 z-[10000] bg-stone-950/80 backdrop-blur-sm flex items-center justify-center p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="keyring-consent-title"
className="w-full max-w-lg rounded-2xl border border-amber-500/30 bg-stone-900 p-6 shadow-2xl">
<div className="flex items-center gap-3 mb-4">
Comment thread
senamakel marked this conversation as resolved.
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/20">
<svg
className="h-5 w-5 text-amber-400"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"
/>
</svg>
</div>
<h2 id="keyring-consent-title" className="text-lg font-semibold text-white">
{t('keyring.consent.title')}
</h2>
</div>

<p className="text-sm text-stone-300">{t('keyring.consent.description')}</p>

{failureReason && (
<p className="mt-2 text-xs text-stone-400">
{t('keyring.consent.reasonPrefix')} {failureReason}
</p>
)}

<button
type="button"
onClick={() => setShowDetails(!showDetails)}
className="mt-3 text-xs text-ocean-400 hover:text-ocean-300 underline">
{showDetails ? t('keyring.consent.hideDetails') : t('keyring.consent.showDetails')}
</button>

{showDetails && (
<div className="mt-2 rounded-lg bg-stone-800/60 p-3 text-xs text-stone-400 leading-relaxed">
<p className="font-medium text-stone-300 mb-1">{t('keyring.consent.tradeoffTitle')}</p>
<p>{t('keyring.consent.tradeoffBody')}</p>
</div>
)}

{error && <p className="mt-3 text-sm text-coral-300">{error}</p>}

<div className="mt-5 flex flex-wrap gap-3">
<button
type="button"
onClick={handleConsent}
disabled={isConsenting || isRetrying}
className="rounded-lg bg-ocean-500 px-4 py-2 text-sm font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{isConsenting ? t('common.loading') : t('keyring.consent.consentButton')}
</button>
<button
type="button"
onClick={handleRetry}
disabled={isRetrying || isConsenting}
className="rounded-lg border border-stone-600 px-4 py-2 text-sm text-stone-100 hover:bg-stone-800 disabled:opacity-60">
{isRetrying ? t('keyring.consent.retrying') : t('keyring.consent.retryButton')}
</button>
<button
type="button"
onClick={handleDecline}
disabled={isConsenting || isRetrying}
className="rounded-lg border border-stone-700 px-4 py-2 text-sm text-stone-400 hover:bg-stone-800 disabled:opacity-60">
{t('keyring.consent.declineButton')}
</button>
</div>
</div>
</div>
);
};

export default KeyringConsentOverlay;
16 changes: 14 additions & 2 deletions app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ describe('oauthAuthReadiness', () => {
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, onboardingTasks: null },
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
keyringStatus: {
available: true,
failureReason: null,
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
teams: [],
Expand Down Expand Up @@ -112,7 +118,13 @@ describe('oauthAuthReadiness', () => {
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, onboardingTasks: null },
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
keyringStatus: {
available: true,
failureReason: null,
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
},
teams: [],
Expand Down
160 changes: 160 additions & 0 deletions app/src/components/settings/panels/SecurityPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { useState } from 'react';

import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { decideKeyringConsent, retryKeyringProbe } from '../../../services/keyringApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';

const MODE_BADGE: Record<string, { label: string; className: string }> = {
os_keyring: {
label: 'keyring.settings.mode.osKeychain',
className:
'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30',
},
local_encrypted: {
label: 'keyring.settings.mode.encryptedFile',
className:
'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30',
},
consent_pending: {
label: 'keyring.settings.mode.consentPending',
className:
'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800',
},
declined: {
label: 'keyring.settings.mode.declined',
className:
'bg-coral-50 dark:bg-coral-500/10 text-coral-700 dark:text-coral-300 border-coral-200 dark:border-coral-500/30',
},
};

const SecurityPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { snapshot } = useCoreState();
const { t } = useT();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const keyringStatus = snapshot.keyringStatus;
const modeBadge = MODE_BADGE[keyringStatus.activeMode] ?? MODE_BADGE.consent_pending;

const handleRetryProbe = async () => {
setIsLoading(true);
setError(null);
try {
await retryKeyringProbe();
} catch {
setError(t('keyring.settings.retryFailed'));
} finally {
setIsLoading(false);
}
};

const handleConsentChange = async (mode: 'local_encrypted' | 'declined') => {
setIsLoading(true);
setError(null);
try {
await decideKeyringConsent(mode);
} catch {
setError(t('keyring.consent.error'));
} finally {
setIsLoading(false);
}
};
Comment thread
senamakel marked this conversation as resolved.

return (
<div>
<SettingsHeader
title={t('keyring.settings.title')}
onBack={navigateBack}
breadcrumbs={breadcrumbs}
/>

<div className="space-y-6 p-4">
{/* Storage mode */}
<section>
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
{t('keyring.settings.storageMode')}
</h3>
<div className="flex items-center gap-3">
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${modeBadge.className}`}>
{t(modeBadge.label)}
</span>
<span className="text-xs text-stone-500 dark:text-stone-400">
{t('keyring.settings.backend')}: {keyringStatus.backendName}
</span>
</div>
</section>

{/* Availability */}
<section>
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
{t('keyring.settings.availability')}
</h3>
<div className="rounded-lg bg-stone-100 dark:bg-stone-800/60 p-4">
<div className="flex items-center gap-2 mb-2">
<div
className={`h-2 w-2 rounded-full ${keyringStatus.available ? 'bg-sage-500' : 'bg-amber-500'}`}
/>
<span className="text-sm text-stone-700 dark:text-stone-200">
{keyringStatus.available
? t('keyring.settings.available')
: t('keyring.settings.unavailable')}
</span>
</div>
{keyringStatus.failureReason && (
<p className="text-xs text-stone-500 dark:text-stone-400 ml-4">
{keyringStatus.failureReason}
</p>
)}
<button
type="button"
onClick={handleRetryProbe}
disabled={isLoading}
className="mt-3 rounded-lg border border-stone-300 dark:border-stone-600 px-3 py-1.5 text-xs text-stone-700 dark:text-stone-200 hover:bg-stone-200 dark:hover:bg-stone-700 disabled:opacity-60">
{isLoading ? t('keyring.consent.retrying') : t('keyring.settings.retryButton')}
</button>
</div>
</section>

{/* Consent management (only when keyring is unavailable) */}
{!keyringStatus.available && (
<section>
<h3 className="text-sm font-medium text-stone-700 dark:text-stone-200 mb-3">
{t('keyring.settings.consentTitle')}
</h3>
<p className="text-xs text-stone-500 dark:text-stone-400 mb-3">
{t('keyring.settings.consentDescription')}
</p>
<div className="flex flex-wrap gap-2">
{keyringStatus.activeMode !== 'local_encrypted' && (
<button
type="button"
onClick={() => handleConsentChange('local_encrypted')}
disabled={isLoading}
className="rounded-lg bg-ocean-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-600 disabled:opacity-60">
{t('keyring.settings.grantConsent')}
</button>
)}
{keyringStatus.activeMode !== 'declined' && (
<button
type="button"
onClick={() => handleConsentChange('declined')}
disabled={isLoading}
className="rounded-lg border border-stone-300 dark:border-stone-600 px-3 py-1.5 text-xs text-stone-700 dark:text-stone-200 hover:bg-stone-200 dark:hover:bg-stone-700 disabled:opacity-60">
{t('keyring.settings.revokeConsent')}
</button>
)}
</div>
</section>
)}

{error && <p className="text-sm text-coral-400">{error}</p>}
</div>
</div>
);
};

export default SecurityPanel;
8 changes: 7 additions & 1 deletion app/src/lib/coreState/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ function makeSnapshot(overrides: Partial<CoreAppSnapshot> = {}): CoreAppSnapshot
chatOnboardingCompleted: false,
analyticsEnabled: false,
meetAutoOrchestratorHandoff: false,
localState: { encryptionKey: null, onboardingTasks: null },
localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null },
keyringStatus: {
available: true,
failureReason: null,
activeMode: 'os_keyring',
backendName: 'os',
},
runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null },
...overrides,
};
Expand Down
Loading
Loading