-
Notifications
You must be signed in to change notification settings - Fork 53
feat: add app-wide unlock gate #1076
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,79 @@ | ||||||||||||||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' | ||||||||||||||
| import { useAppContext } from '@workspace/context-react/use-app-context' | ||||||||||||||
| import type { WalletProtectionMode } from '@workspace/db/wallet/wallet' | ||||||||||||||
| import { useAccountActive } from '@workspace/db-react/use-account-active' | ||||||||||||||
| import { useWalletActive } from '@workspace/db-react/use-wallet-active' | ||||||||||||||
| import { toastError } from '@workspace/ui/lib/toast-error' | ||||||||||||||
| import { useVaultUnlockDialog } from '@workspace/vault-react/vault-unlock-provider' | ||||||||||||||
|
|
||||||||||||||
| export type ShellUnlockGateState = { | ||||||||||||||
| isChecking: boolean | ||||||||||||||
| isLocked: boolean | ||||||||||||||
| isUnlocking: boolean | ||||||||||||||
| walletName: string | ||||||||||||||
| walletProtectionMode: WalletProtectionMode | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export type ShellUnlockGate = { | ||||||||||||||
| actions: { | ||||||||||||||
| unlock(): Promise<void> | ||||||||||||||
| } | ||||||||||||||
| state: ShellUnlockGateState | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| function shellUnlockStatusQueryKey(walletId: string) { | ||||||||||||||
| return ['shellUnlockStatus', walletId] as const | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export function useShellUnlockGate(): ShellUnlockGate { | ||||||||||||||
| const account = useAccountActive() | ||||||||||||||
| const context = useAppContext() | ||||||||||||||
| const queryClient = useQueryClient() | ||||||||||||||
| const wallet = useWalletActive() | ||||||||||||||
| const { requestUnlock } = useVaultUnlockDialog() | ||||||||||||||
| const statusQuery = useQuery({ | ||||||||||||||
| queryFn: async () => { | ||||||||||||||
| try { | ||||||||||||||
| await context.vault.requireWalletKey({ walletId: account.walletId }) | ||||||||||||||
| return true | ||||||||||||||
| } catch { | ||||||||||||||
| if (wallet.protectionMode === 'unsecured') { | ||||||||||||||
| await context.vault.unlockWallet({ credential: '', walletId: account.walletId }) | ||||||||||||||
| return true | ||||||||||||||
| } | ||||||||||||||
| return false | ||||||||||||||
| } | ||||||||||||||
| }, | ||||||||||||||
| queryKey: shellUnlockStatusQueryKey(account.walletId), | ||||||||||||||
| retry: false, | ||||||||||||||
| }) | ||||||||||||||
| const unlockMutation = useMutation({ | ||||||||||||||
| mutationFn: async () => | ||||||||||||||
| await requestUnlock({ | ||||||||||||||
| mode: wallet.protectionMode, | ||||||||||||||
| reason: 'generic', | ||||||||||||||
| walletId: account.walletId, | ||||||||||||||
| }), | ||||||||||||||
| onError: (caught) => toastError(caught instanceof Error ? caught.message : `${caught}`), | ||||||||||||||
| onSuccess: async (unlocked) => { | ||||||||||||||
| if (unlocked) { | ||||||||||||||
| await queryClient.invalidateQueries({ queryKey: shellUnlockStatusQueryKey(account.walletId) }) | ||||||||||||||
| } | ||||||||||||||
| }, | ||||||||||||||
| }) | ||||||||||||||
|
|
||||||||||||||
| return { | ||||||||||||||
| actions: { | ||||||||||||||
| unlock: async () => { | ||||||||||||||
| await unlockMutation.mutateAsync().catch(() => undefined) | ||||||||||||||
| }, | ||||||||||||||
| }, | ||||||||||||||
| state: { | ||||||||||||||
| isChecking: statusQuery.isLoading, | ||||||||||||||
| isLocked: statusQuery.data === false, | ||||||||||||||
|
Comment on lines
+72
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Unlock gate fails open on query error instead of showing locked state When How the error propagatesIn the
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||||||||||
| isUnlocking: unlockMutation.isPending, | ||||||||||||||
|
Comment on lines
+72
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: If a TanStack React Query v5 query function throws (and retries are disabled with retry: false), then after the failed attempt settles: - data: undefined [1] - isError: true [1] - isLoading: false [1] Reasoning: In v5, Citations: 🏁 Script executed: # Check if file exists and view the relevant lines
if [ -f "packages/feature-shell/src/data-access/use-shell-unlock-gate.tsx" ]; then
echo "=== File found ==="
wc -l "packages/feature-shell/src/data-access/use-shell-unlock-gate.tsx"
echo ""
echo "=== Lines 60-85 context ==="
sed -n '60,85p' "packages/feature-shell/src/data-access/use-shell-unlock-gate.tsx"
else
echo "File not found at specified path"
# Try to find it
find . -name "use-shell-unlock-gate.tsx" -type f 2>/dev/null
fiRepository: samui-build/samui-wallet Length of output: 682 🏁 Script executed: head -50 "packages/feature-shell/src/data-access/use-shell-unlock-gate.tsx"Repository: samui-build/samui-wallet Length of output: 1740 Fail closed when unlock status is not explicitly
💡 Proposed fix state: {
isChecking: statusQuery.isLoading,
- isLocked: statusQuery.data === false,
+ isLocked: statusQuery.data !== true,
isUnlocking: unlockMutation.isPending,
walletName: wallet.name,
walletProtectionMode: wallet.protectionMode,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| walletName: wallet.name, | ||||||||||||||
| walletProtectionMode: wallet.protectionMode, | ||||||||||||||
| }, | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import { NavLink, Outlet } from 'react-router' | |
| import { ShellUiCommandMenu } from './shell-ui-command-menu.tsx' | ||
| import { ShellUiMenu } from './shell-ui-menu.tsx' | ||
| import { ShellUiMenuActions } from './shell-ui-menu-actions.tsx' | ||
| import { ShellUiUnlockGate } from './shell-ui-unlock-gate.tsx' | ||
| import { ShellUiWarningExperimental } from './shell-ui-warning-experimental.tsx' | ||
|
|
||
| export interface ShellLayoutLink { | ||
|
|
@@ -43,7 +44,9 @@ export function ShellUiLayout() { | |
| </div> | ||
| </header> | ||
| <main className="flex-1 overflow-y-auto p-1 md:p-2 lg:p-4"> | ||
| <Outlet /> | ||
| <ShellUiUnlockGate> | ||
| <Outlet /> | ||
| </ShellUiUnlockGate> | ||
|
Comment on lines
+47
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| </main> | ||
| <footer className="flex items-center justify-between bg-secondary/30 pb-[env(safe-area-inset-bottom)]"> | ||
| {links.map(({ icon, label, to }) => ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { useTranslation } from '@workspace/i18n' | ||
| import { Button } from '@workspace/ui/components/button' | ||
| import { UiCard } from '@workspace/ui/components/ui-card' | ||
| import { UiLoaderFull } from '@workspace/ui/components/ui-loader-full' | ||
| import type { ReactNode } from 'react' | ||
| import { useShellUnlockGate } from '../data-access/use-shell-unlock-gate.tsx' | ||
|
|
||
| export function ShellUiUnlockGate({ children }: { children: ReactNode }) { | ||
| const { t } = useTranslation('shell') | ||
| const { actions, state } = useShellUnlockGate() | ||
|
|
||
| if (state.isChecking) { | ||
| return <UiLoaderFull /> | ||
| } | ||
|
|
||
| if (!state.isLocked) { | ||
| return children | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex min-h-full items-center justify-center p-4"> | ||
| <UiCard | ||
| className="w-full max-w-xs" | ||
| contentProps={{ className: 'space-y-4' }} | ||
| description={t(($) => $.unlockGateDescription, { walletName: state.walletName })} | ||
| title={t(($) => $.unlockGateTitle)} | ||
| > | ||
| <Button disabled={state.isUnlocking} onClick={actions.unlock} type="button"> | ||
| {t(($) => $.unlockGateAction)} | ||
| </Button> | ||
| </UiCard> | ||
| </div> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -218,7 +218,7 @@ export function useVaultUnlockProvider(): VaultUnlockProviderValue { | |||||
| confirmPassword, | ||||||
| confirmPasswordLabel: copy.confirmPasswordLabel, | ||||||
| credential, | ||||||
| credentialInputType: pending?.mode === 'pin' ? 'text' : 'password', | ||||||
| credentialInputType: 'password', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 PIN input type changed to 'password', inconsistent with request unlock dialog The
Suggested change
Was this helpful? React with 👍 or 👎 to provide feedback. |
||||||
| credentialLabel: pending?.mode === 'pin' ? copy.pinLabel : copy.passwordLabel, | ||||||
| description: setupMode ? copy.setupDescription : (pending?.description ?? copy.defaultDescription), | ||||||
| error, | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This gate currently treats every state except
data === falseas unlocked, while only showing a loader forisLoading. In this app, queries are persisted viaPersistQueryClientProvider(packages/feature-shell/src/data-access/shell-providers.tsx), so a previously cachedtruecan be hydrated on startup beforerequireWalletKeyrechecks the new in-memory vault state. That means locked wallets can briefly render routed content (and run child effects) until refetch flips the value, which defeats the purpose of an app-wide lock gate.Useful? React with 👍 / 👎.